1 import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
2 import {MarkdownEditorShortcutMap} from "../shortcuts";
3 import {MarkdownEditorEventMap} from "../dom-handlers";
4 import {debounce} from "../../services/util";
6 type UndoStackEntry = {
8 selection: MarkdownEditorInputSelection;
12 protected onChangeDebounced: (callback: () => UndoStackEntry) => void;
14 protected stack: UndoStackEntry[] = [];
15 protected pointer: number = -1;
16 protected lastActionTime: number = 0;
19 this.onChangeDebounced = debounce(this.onChange, 1000, false);
22 undo(): UndoStackEntry|null {
23 if (this.pointer < 1) {
27 this.lastActionTime = Date.now();
29 return this.stack[this.pointer];
32 redo(): UndoStackEntry|null {
33 const atEnd = this.pointer === this.stack.length - 1;
38 this.lastActionTime = Date.now();
40 return this.stack[this.pointer];
43 push(getValueCallback: () => UndoStackEntry): void {
44 // Ignore changes made via undo/redo actions
45 if (Date.now() - this.lastActionTime < 100) {
49 this.onChangeDebounced(getValueCallback);
52 protected onChange(getValueCallback: () => UndoStackEntry) {
53 // Trim the end of the stack from the pointer since we're branching away
54 if (this.pointer !== this.stack.length - 1) {
55 this.stack = this.stack.slice(0, this.pointer)
58 this.stack.push(getValueCallback());
61 if (this.stack.length > 50) {
62 this.stack = this.stack.slice(this.stack.length - 50);
65 this.pointer = this.stack.length - 1;
69 export class TextareaInput implements MarkdownEditorInput {
71 protected input: HTMLTextAreaElement;
72 protected shortcuts: MarkdownEditorShortcutMap;
73 protected events: MarkdownEditorEventMap;
74 protected onChange: () => void;
75 protected eventController = new AbortController();
76 protected undoStack = new UndoStack();
78 protected textSizeCache: {x: number; y: number}|null = null;
81 input: HTMLTextAreaElement,
82 shortcuts: MarkdownEditorShortcutMap,
83 events: MarkdownEditorEventMap,
87 this.shortcuts = shortcuts;
89 this.onChange = onChange;
91 this.onKeyDown = this.onKeyDown.bind(this);
92 this.configureLocalShortcuts();
93 this.configureListeners();
95 this.input.style.removeProperty("display");
96 this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
100 this.eventController.abort('teardown');
103 configureLocalShortcuts(): void {
104 this.shortcuts['Mod-z'] = () => {
105 const undoEntry = this.undoStack.undo();
107 this.setText(undoEntry.content);
108 this.setSelection(undoEntry.selection, false);
111 this.shortcuts['Mod-y'] = () => {
112 const redoContent = this.undoStack.redo();
114 this.setText(redoContent.content);
115 this.setSelection(redoContent.selection, false);
120 configureListeners(): void {
121 // Keyboard shortcuts
122 this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
124 // Shared event listeners
125 for (const [name, listener] of Object.entries(this.events)) {
126 this.input.addEventListener(name, listener, {signal: this.eventController.signal});
129 // Input change handling
130 this.input.addEventListener('input', () => {
132 this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
133 }, {signal: this.eventController.signal});
136 onKeyDown(e: KeyboardEvent) {
137 const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
138 const key = e.key.length > 1 ? e.key : e.key.toLowerCase();
140 e.shiftKey ? 'Shift' : null,
141 isApple && e.metaKey ? 'Mod' : null,
142 !isApple && e.ctrlKey ? 'Mod' : null,
146 const keyString = keyParts.filter(Boolean).join('-');
147 if (this.shortcuts[keyString]) {
149 this.shortcuts[keyString]();
153 appendText(text: string): void {
154 this.input.value += `\n${text}`;
155 this.input.dispatchEvent(new Event('input'));
158 eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
159 const eventCoords = this.mouseEventToTextRelativeCoords(event);
160 return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
167 getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
168 const lines = this.getText().split('\n');
170 for (let i = 0; i < lines.length; i++) {
171 const line = lines[i];
172 const lineEnd = lineStart + line.length;
173 if (position <= lineEnd) {
174 return {from: lineStart, to: lineEnd};
176 lineStart = lineEnd + 1;
179 return {from: 0, to: 0};
182 getLineText(lineIndex: number): string {
183 const text = this.getText();
184 const lines = text.split("\n");
185 return lines[lineIndex] || '';
188 getSelection(): MarkdownEditorInputSelection {
189 return {from: this.input.selectionStart, to: this.input.selectionEnd};
192 getSelectionText(selection?: MarkdownEditorInputSelection): string {
193 const text = this.getText();
194 const range = selection || this.getSelection();
195 return text.slice(range.from, range.to);
199 return this.input.value;
202 getTextAboveView(): string {
203 const scrollTop = this.input.scrollTop;
204 const selection = this.inputPositionToSelection(0, scrollTop);
205 return this.getSelectionText({from: 0, to: selection.to});
208 searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
209 const textPosition = this.getText().indexOf(text);
210 if (textPosition > -1) {
211 return this.getLineRangeFromPosition(textPosition);
217 setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void {
218 this.input.selectionStart = selection.from;
219 this.input.selectionEnd = selection.to;
222 setText(text: string, selection?: MarkdownEditorInputSelection): void {
223 this.input.value = text;
224 this.input.dispatchEvent(new Event('input'));
226 this.setSelection(selection, false);
230 spliceText(from: number, to: number, newText: string, selection: Partial<MarkdownEditorInputSelection> | null): void {
231 const text = this.getText();
232 const updatedText = text.slice(0, from) + newText + text.slice(to);
233 this.setText(updatedText);
234 if (selection && selection.from) {
235 const newSelection = {from: selection.from, to: selection.to || selection.from};
236 this.setSelection(newSelection, false);
240 protected measureTextSize(): {x: number; y: number} {
241 if (this.textSizeCache) {
242 return this.textSizeCache;
245 const el = document.createElement("div");
246 el.textContent = `a\nb`;
247 const inputStyles = window.getComputedStyle(this.input)
248 el.style.font = inputStyles.font;
249 el.style.lineHeight = inputStyles.lineHeight;
250 el.style.padding = '0px';
251 el.style.display = 'inline-block';
252 el.style.visibility = 'hidden';
253 el.style.position = 'absolute';
254 el.style.whiteSpace = 'pre';
255 this.input.after(el);
257 const bounds = el.getBoundingClientRect();
259 this.textSizeCache = {
261 y: bounds.height / 2,
263 return this.textSizeCache;
266 protected measureLineCharCount(textWidth: number): number {
267 const inputStyles = window.getComputedStyle(this.input);
268 const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
269 const paddingRight = Number(inputStyles.paddingRight.replace('px', ''));
270 const width = Number(inputStyles.width.replace('px', ''));
271 const textSpace = width - (paddingLeft + paddingRight);
273 return Math.floor(textSpace / textWidth);
276 protected mouseEventToTextRelativeCoords(event: MouseEvent): {x: number; y: number} {
277 const inputBounds = this.input.getBoundingClientRect();
278 const inputStyles = window.getComputedStyle(this.input);
279 const paddingTop = Number(inputStyles.paddingTop.replace('px', ''));
280 const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
282 const xPos = Math.max(event.clientX - (inputBounds.left + paddingLeft), 0);
283 const yPos = Math.max((event.clientY - (inputBounds.top + paddingTop)) + this.input.scrollTop, 0);
285 return {x: xPos, y: yPos};
288 protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection {
289 const textSize = this.measureTextSize();
290 const lineWidth = this.measureLineCharCount(textSize.x);
292 const lines = this.getText().split('\n');
296 for (const line of lines) {
298 const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
299 for (let i = 0; i < wrapCount; i++) {
302 const targetX = Math.floor(x / textSize.x);
303 const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
304 return {from: maxPos, to: maxPos};
307 linePos += lineWidth;
310 currPos += line.length + 1;
313 return this.getSelection();