]> BookStack Code Mirror - bookstack/blob - resources/js/markdown/inputs/textarea.ts
MD Editor: Worked to improve/fix positioning code
[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
5
6 export class TextareaInput implements MarkdownEditorInput {
7
8     protected input: HTMLTextAreaElement;
9     protected shortcuts: MarkdownEditorShortcutMap;
10     protected events: MarkdownEditorEventMap;
11     protected onChange: () => void;
12     protected eventController = new AbortController();
13
14     protected textSizeCache: {x: number; y: number}|null = null;
15
16     constructor(
17         input: HTMLTextAreaElement,
18         shortcuts: MarkdownEditorShortcutMap,
19         events: MarkdownEditorEventMap,
20         onChange: () => void
21     ) {
22         this.input = input;
23         this.shortcuts = shortcuts;
24         this.events = events;
25         this.onChange = onChange;
26
27         this.onKeyDown = this.onKeyDown.bind(this);
28         this.configureListeners();
29
30         // TODO - Undo/Redo
31
32         this.input.style.removeProperty("display");
33     }
34
35     teardown() {
36         this.eventController.abort('teardown');
37     }
38
39     configureListeners(): void {
40         // Keyboard shortcuts
41         this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
42
43         // Shared event listeners
44         for (const [name, listener] of Object.entries(this.events)) {
45             this.input.addEventListener(name, listener, {signal: this.eventController.signal});
46         }
47
48         // Input change handling
49         this.input.addEventListener('input', () => {
50             this.onChange();
51         }, {signal: this.eventController.signal});
52
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));
59         });
60     }
61
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();
65         const keyParts = [
66             e.shiftKey ? 'Shift' : null,
67             isApple && e.metaKey ? 'Mod' : null,
68             !isApple && e.ctrlKey ? 'Mod' : null,
69             key,
70         ];
71
72         const keyString = keyParts.filter(Boolean).join('-');
73         if (this.shortcuts[keyString]) {
74             e.preventDefault();
75             this.shortcuts[keyString]();
76         }
77     }
78
79     appendText(text: string): void {
80         this.input.value += `\n${text}`;
81         this.input.dispatchEvent(new Event('input'));
82     }
83
84     eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
85         const eventCoords = this.mouseEventToTextRelativeCoords(event);
86         const textSize = this.measureTextSize();
87         const lineWidth = this.measureLineCharCount(textSize.x);
88
89         const lines = this.getText().split('\n');
90
91         // TODO - Check this
92
93         let currY = 0;
94         let currPos = 0;
95         for (const line of lines) {
96             let linePos = 0;
97             const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
98             for (let i = 0; i < wrapCount; i++) {
99                 currY += textSize.y;
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};
104                 }
105
106                 linePos += lineWidth;
107             }
108
109             currPos += line.length + 1;
110         }
111
112         return this.getSelection();
113     }
114
115     focus(): void {
116         this.input.focus();
117     }
118
119     getLineRangeFromPosition(position: number): MarkdownEditorInputSelection {
120         const lines = this.getText().split('\n');
121         let lineStart = 0;
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};
127             }
128             lineStart = lineEnd + 1;
129         }
130
131         return {from: 0, to: 0};
132     }
133
134     getLineText(lineIndex: number): string {
135         const text = this.getText();
136         const lines = text.split("\n");
137         return lines[lineIndex] || '';
138     }
139
140     getSelection(): MarkdownEditorInputSelection {
141         return {from: this.input.selectionStart, to: this.input.selectionEnd};
142     }
143
144     getSelectionText(selection?: MarkdownEditorInputSelection): string {
145         const text = this.getText();
146         const range = selection || this.getSelection();
147         return text.slice(range.from, range.to);
148     }
149
150     getText(): string {
151         return this.input.value;
152     }
153
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', ''));
160
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');
165     }
166
167     searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
168         const textPosition = this.getText().indexOf(text);
169         if (textPosition > -1) {
170             return this.getLineRangeFromPosition(textPosition);
171         }
172
173         return null;
174     }
175
176     setSelection(selection: MarkdownEditorInputSelection, scrollIntoView: boolean): void {
177         this.input.selectionStart = selection.from;
178         this.input.selectionEnd = selection.to;
179     }
180
181     setText(text: string, selection?: MarkdownEditorInputSelection): void {
182         this.input.value = text;
183         this.input.dispatchEvent(new Event('input'));
184         if (selection) {
185             this.setSelection(selection, false);
186         }
187     }
188
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);
196         }
197     }
198
199     protected measureTextSize(): {x: number; y: number} {
200         if (this.textSizeCache) {
201             return this.textSizeCache;
202         }
203
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);
215
216         const bounds = el.getBoundingClientRect();
217         el.remove();
218         this.textSizeCache = {
219             x: bounds.width,
220             y: bounds.height / 2,
221         };
222         return this.textSizeCache;
223     }
224
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);
231
232         return Math.floor(textSpace / textWidth);
233     }
234
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', ''));
240
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);
243
244         return {x: xPos, y: yPos};
245     }
246 }