1 import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
2 import {MarkdownEditorShortcutMap} from "../shortcuts";
3 import {MarkdownEditorEventMap} from "../dom-handlers";
6 export class TextareaInput implements MarkdownEditorInput {
8 protected input: HTMLTextAreaElement;
9 protected shortcuts: MarkdownEditorShortcutMap;
10 protected events: MarkdownEditorEventMap;
11 protected onChange: () => void;
12 protected eventController = new AbortController();
14 protected textSizeCache: {x: number; y: number}|null = null;
17 input: HTMLTextAreaElement,
18 shortcuts: MarkdownEditorShortcutMap,
19 events: MarkdownEditorEventMap,
23 this.shortcuts = shortcuts;
25 this.onChange = onChange;
27 this.onKeyDown = this.onKeyDown.bind(this);
28 this.configureListeners();
32 this.input.style.removeProperty("display");
36 this.eventController.abort('teardown');
39 configureListeners(): void {
41 this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
43 // Shared event listeners
44 for (const [name, listener] of Object.entries(this.events)) {
45 this.input.addEventListener(name, listener, {signal: this.eventController.signal});
48 // Input change handling
49 this.input.addEventListener('input', () => {
51 }, {signal: this.eventController.signal});
53 this.input.addEventListener('click', (event: MouseEvent) => {
54 const x = event.clientX;
55 const y = event.clientY;
56 const range = this.eventToPosition(event);
57 const text = this.getText().split('');
58 console.log(range, text.slice(0, 20));
62 onKeyDown(e: KeyboardEvent) {
63 const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
64 const key = e.key.length > 1 ? e.key : e.key.toLowerCase();
66 e.shiftKey ? 'Shift' : null,
67 isApple && e.metaKey ? 'Mod' : null,
68 !isApple && e.ctrlKey ? 'Mod' : null,
72 const keyString = keyParts.filter(Boolean).join('-');
73 if (this.shortcuts[keyString]) {
75 this.shortcuts[keyString]();
79 appendText(text: string): void {
80 this.input.value += `\n${text}`;
81 this.input.dispatchEvent(new Event('input'));
84 eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
85 const eventCoords = this.mouseEventToTextRelativeCoords(event);
86 const textSize = this.measureTextSize();
87 const lineWidth = this.measureLineCharCount(textSize.x);
89 const lines = this.getText().split('\n');
95 for (const line of lines) {
97 const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
98 for (let i = 0; i < wrapCount; i++) {
100 if (currY > eventCoords.y) {
101 const targetX = Math.floor(eventCoords.x / textSize.x);
102 const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
103 return {from: maxPos, to: maxPos};
106 linePos += lineWidth;
109 currPos += line.length + 1;
112 return this.getSelection();
119 getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
120 const lines = this.getText().split('\n');
122 for (let i = 0; i < lines.length; i++) {
123 const line = lines[i];
124 const lineEnd = lineStart + line.length;
125 if (position <= lineEnd) {
126 return {from: lineStart, to: lineEnd};
128 lineStart = lineEnd + 1;
131 return {from: 0, to: 0};
134 getLineText(lineIndex: number): string {
135 const text = this.getText();
136 const lines = text.split("\n");
137 return lines[lineIndex] || '';
140 getSelection(): MarkdownEditorInputSelection {
141 return {from: this.input.selectionStart, to: this.input.selectionEnd};
144 getSelectionText(selection?: MarkdownEditorInputSelection): string {
145 const text = this.getText();
146 const range = selection || this.getSelection();
147 return text.slice(range.from, range.to);
151 return this.input.value;
154 getTextAboveView(): string {
155 const scrollTop = this.input.scrollTop;
156 const computedStyles = window.getComputedStyle(this.input);
157 const lines = this.getText().split('\n');
158 const paddingTop = Number(computedStyles.paddingTop.replace('px', ''));
159 const paddingBottom = Number(computedStyles.paddingBottom.replace('px', ''));
161 const avgLineHeight = (this.input.scrollHeight - paddingBottom - paddingTop) / lines.length;
162 const roughLinePos = Math.max(Math.floor((scrollTop - paddingTop) / avgLineHeight), 0);
163 const linesAbove = this.getText().split('\n').slice(0, roughLinePos);
164 return linesAbove.join('\n');
167 searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
168 const textPosition = this.getText().indexOf(text);
169 if (textPosition > -1) {
170 return this.getLineRangeFromPosition(textPosition);
176 setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void {
177 this.input.selectionStart = selection.from;
178 this.input.selectionEnd = selection.to;
181 setText(text: string, selection?: MarkdownEditorInputSelection): void {
182 this.input.value = text;
183 this.input.dispatchEvent(new Event('input'));
185 this.setSelection(selection, false);
189 spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection> | null): void {
190 const text = this.getText();
191 const updatedText = text.slice(0, from) + newText + text.slice(to);
192 this.setText(updatedText);
193 if (selection && selection.from) {
194 const newSelection = {from: selection.from, to: selection.to || selection.from};
195 this.setSelection(newSelection, false);
199 protected measureTextSize(): {x: number; y: number} {
200 if (this.textSizeCache) {
201 return this.textSizeCache;
204 const el = document.createElement("div");
205 el.textContent = `a\nb`;
206 const inputStyles = window.getComputedStyle(this.input)
207 el.style.font = inputStyles.font;
208 el.style.lineHeight = inputStyles.lineHeight;
209 el.style.padding = '0px';
210 el.style.display = 'inline-block';
211 el.style.visibility = 'hidden';
212 el.style.position = 'absolute';
213 el.style.whiteSpace = 'pre';
214 this.input.after(el);
216 const bounds = el.getBoundingClientRect();
218 this.textSizeCache = {
220 y: bounds.height / 2,
222 return this.textSizeCache;
225 protected measureLineCharCount(textWidth: number): number {
226 const inputStyles = window.getComputedStyle(this.input);
227 const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
228 const paddingRight = Number(inputStyles.paddingRight.replace('px', ''));
229 const width = Number(inputStyles.width.replace('px', ''));
230 const textSpace = width - (paddingLeft + paddingRight);
232 return Math.floor(textSpace / textWidth);
235 protected mouseEventToTextRelativeCoords(event: MouseEvent): {x: number; y: number} {
236 const inputBounds = this.input.getBoundingClientRect();
237 const inputStyles = window.getComputedStyle(this.input);
238 const paddingTop = Number(inputStyles.paddingTop.replace('px', ''));
239 const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
241 const xPos = Math.max(event.clientX - (inputBounds.left + paddingLeft), 0);
242 const yPos = Math.max((event.clientY - (inputBounds.top + paddingTop)) + this.input.scrollTop, 0);
244 return {x: xPos, y: yPos};