]> BookStack Code Mirror - bookstack/blob - resources/js/markdown/inputs/textarea.ts
Merge pull request #5725 from BookStackApp/md_plaintext
[bookstack] / resources / js / markdown / inputs / textarea.ts
1 import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
2 import {MarkdownEditorShortcutMap} from "../shortcuts";
3 import {MarkdownEditorEventMap} from "../dom-handlers";
4 import {debounce} from "../../services/util";
5
6 type UndoStackEntry = {
7     content: string;
8     selection: MarkdownEditorInputSelection;
9 }
10
11 class UndoStack {
12     protected onChangeDebounced: (callback: () => UndoStackEntry) => void;
13
14     protected stack: UndoStackEntry[] = [];
15     protected pointer: number = -1;
16     protected lastActionTime: number = 0;
17
18     constructor() {
19         this.onChangeDebounced = debounce(this.onChange, 1000, false);
20     }
21
22     undo(): UndoStackEntry|null {
23         if (this.pointer < 1) {
24             return null;
25         }
26
27         this.lastActionTime = Date.now();
28         this.pointer -= 1;
29         return this.stack[this.pointer];
30     }
31
32     redo(): UndoStackEntry|null {
33         const atEnd = this.pointer === this.stack.length - 1;
34         if (atEnd) {
35             return null;
36         }
37
38         this.lastActionTime = Date.now();
39         this.pointer++;
40         return this.stack[this.pointer];
41     }
42
43     push(getValueCallback: () => UndoStackEntry): void {
44         // Ignore changes made via undo/redo actions
45         if (Date.now() - this.lastActionTime < 100) {
46             return;
47         }
48
49         this.onChangeDebounced(getValueCallback);
50     }
51
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)
56         }
57
58         this.stack.push(getValueCallback());
59
60         // Limit stack size
61         if (this.stack.length > 50) {
62             this.stack = this.stack.slice(this.stack.length - 50);
63         }
64
65         this.pointer = this.stack.length - 1;
66     }
67 }
68
69 export class TextareaInput implements MarkdownEditorInput {
70
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();
77
78     protected textSizeCache: {x: number; y: number}|null = null;
79
80     constructor(
81         input: HTMLTextAreaElement,
82         shortcuts: MarkdownEditorShortcutMap,
83         events: MarkdownEditorEventMap,
84         onChange: () => void
85     ) {
86         this.input = input;
87         this.shortcuts = shortcuts;
88         this.events = events;
89         this.onChange = onChange;
90
91         this.onKeyDown = this.onKeyDown.bind(this);
92         this.configureLocalShortcuts();
93         this.configureListeners();
94
95         this.input.style.removeProperty("display");
96         this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
97     }
98
99     teardown() {
100         this.eventController.abort('teardown');
101     }
102
103     configureLocalShortcuts(): void {
104         this.shortcuts['Mod-z'] = () => {
105             const undoEntry = this.undoStack.undo();
106             if (undoEntry) {
107                 this.setText(undoEntry.content);
108                 this.setSelection(undoEntry.selection, false);
109             }
110         };
111         this.shortcuts['Mod-y'] = () => {
112             const redoContent = this.undoStack.redo();
113             if (redoContent) {
114                 this.setText(redoContent.content);
115                 this.setSelection(redoContent.selection, false);
116             }
117         }
118     }
119
120     configureListeners(): void {
121         // Keyboard shortcuts
122         this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
123
124         // Shared event listeners
125         for (const [name, listener] of Object.entries(this.events)) {
126             this.input.addEventListener(name, listener, {signal: this.eventController.signal});
127         }
128
129         // Input change handling
130         this.input.addEventListener('input', () => {
131             this.onChange();
132             this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
133         }, {signal: this.eventController.signal});
134     }
135
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();
139         const keyParts = [
140             e.shiftKey ? 'Shift' : null,
141             isApple && e.metaKey ? 'Mod' : null,
142             !isApple && e.ctrlKey ? 'Mod' : null,
143             key,
144         ];
145
146         const keyString = keyParts.filter(Boolean).join('-');
147         if (this.shortcuts[keyString]) {
148             e.preventDefault();
149             this.shortcuts[keyString]();
150         }
151     }
152
153     appendText(text: string): void {
154         this.input.value += `\n${text}`;
155         this.input.dispatchEvent(new Event('input'));
156     }
157
158     eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
159         const eventCoords = this.mouseEventToTextRelativeCoords(event);
160         return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
161     }
162
163     focus(): void {
164         this.input.focus();
165     }
166
167     getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
168         const lines = this.getText().split('\n');
169         let lineStart = 0;
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};
175             }
176             lineStart = lineEnd + 1;
177         }
178
179         return {from: 0, to: 0};
180     }
181
182     getLineText(lineIndex: number): string {
183         const text = this.getText();
184         const lines = text.split("\n");
185         return lines[lineIndex] || '';
186     }
187
188     getSelection(): MarkdownEditorInputSelection {
189         return {from: this.input.selectionStart, to: this.input.selectionEnd};
190     }
191
192     getSelectionText(selection?: MarkdownEditorInputSelection): string {
193         const text = this.getText();
194         const range = selection || this.getSelection();
195         return text.slice(range.from, range.to);
196     }
197
198     getText(): string {
199         return this.input.value;
200     }
201
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});
206     }
207
208     searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
209         const textPosition = this.getText().indexOf(text);
210         if (textPosition > -1) {
211             return this.getLineRangeFromPosition(textPosition);
212         }
213
214         return null;
215     }
216
217     setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void {
218         this.input.selectionStart = selection.from;
219         this.input.selectionEnd = selection.to;
220     }
221
222     setText(text: string, selection?: MarkdownEditorInputSelection): void {
223         this.input.value = text;
224         this.input.dispatchEvent(new Event('input'));
225         if (selection) {
226             this.setSelection(selection, false);
227         }
228     }
229
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);
237         }
238     }
239
240     protected measureTextSize(): {x: number; y: number} {
241         if (this.textSizeCache) {
242             return this.textSizeCache;
243         }
244
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);
256
257         const bounds = el.getBoundingClientRect();
258         el.remove();
259         this.textSizeCache = {
260             x: bounds.width,
261             y: bounds.height / 2,
262         };
263         return this.textSizeCache;
264     }
265
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);
272
273         return Math.floor(textSpace / textWidth);
274     }
275
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', ''));
281
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);
284
285         return {x: xPos, y: yPos};
286     }
287
288     protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection {
289         const textSize = this.measureTextSize();
290         const lineWidth = this.measureLineCharCount(textSize.x);
291
292         const lines = this.getText().split('\n');
293
294         let currY = 0;
295         let currPos = 0;
296         for (const line of lines) {
297             let linePos = 0;
298             const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
299             for (let i = 0; i < wrapCount; i++) {
300                 currY += textSize.y;
301                 if (currY > y) {
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};
305                 }
306
307                 linePos += lineWidth;
308             }
309
310             currPos += line.length + 1;
311         }
312
313         return this.getSelection();
314     }
315 }