]> BookStack Code Mirror - bookstack/blobdiff - resources/js/markdown/inputs/textarea.ts
Deps: Updated PHP composer dependancy versions, fixed test namespaces
[bookstack] / resources / js / markdown / inputs / textarea.ts
index d1eabd27027c54f5f3e5b7b863062aa4addb3f68..e0c3ac37cf277a3adb7627e6482d0550afa9c778 100644 (file)
 import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
 import {MarkdownEditorShortcutMap} from "../shortcuts";
 import {MarkdownEditorEventMap} from "../dom-handlers";
+import {debounce} from "../../services/util";
 
+type UndoStackEntry = {
+    content: string;
+    selection: MarkdownEditorInputSelection;
+}
+
+class UndoStack {
+    protected onChangeDebounced: (callback: () => UndoStackEntry) => void;
+
+    protected stack: UndoStackEntry[] = [];
+    protected pointer: number = -1;
+    protected lastActionTime: number = 0;
+
+    constructor() {
+        this.onChangeDebounced = debounce(this.onChange, 1000, false);
+    }
+
+    undo(): UndoStackEntry|null {
+        if (this.pointer < 1) {
+            return null;
+        }
+
+        this.lastActionTime = Date.now();
+        this.pointer -= 1;
+        return this.stack[this.pointer];
+    }
+
+    redo(): UndoStackEntry|null {
+        const atEnd = this.pointer === this.stack.length - 1;
+        if (atEnd) {
+            return null;
+        }
+
+        this.lastActionTime = Date.now();
+        this.pointer++;
+        return this.stack[this.pointer];
+    }
+
+    push(getValueCallback: () => UndoStackEntry): void {
+        // Ignore changes made via undo/redo actions
+        if (Date.now() - this.lastActionTime < 100) {
+            return;
+        }
+
+        this.onChangeDebounced(getValueCallback);
+    }
+
+    protected onChange(getValueCallback: () => UndoStackEntry) {
+        // Trim the end of the stack from the pointer since we're branching away
+        if (this.pointer !== this.stack.length - 1) {
+            this.stack = this.stack.slice(0, this.pointer)
+        }
+
+        this.stack.push(getValueCallback());
+
+        // Limit stack size
+        if (this.stack.length > 50) {
+            this.stack = this.stack.slice(this.stack.length - 50);
+        }
+
+        this.pointer = this.stack.length - 1;
+    }
+}
 
 export class TextareaInput implements MarkdownEditorInput {
 
     protected input: HTMLTextAreaElement;
     protected shortcuts: MarkdownEditorShortcutMap;
     protected events: MarkdownEditorEventMap;
+    protected onChange: () => void;
+    protected eventController = new AbortController();
+    protected undoStack = new UndoStack();
 
-    constructor(input: HTMLTextAreaElement, shortcuts: MarkdownEditorShortcutMap, events: MarkdownEditorEventMap) {
+    protected textSizeCache: {x: number; y: number}|null = null;
+
+    constructor(
+        input: HTMLTextAreaElement,
+        shortcuts: MarkdownEditorShortcutMap,
+        events: MarkdownEditorEventMap,
+        onChange: () => void
+    ) {
         this.input = input;
         this.shortcuts = shortcuts;
         this.events = events;
+        this.onChange = onChange;
 
         this.onKeyDown = this.onKeyDown.bind(this);
+        this.configureLocalShortcuts();
         this.configureListeners();
+
+        this.input.style.removeProperty("display");
+        this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
+    }
+
+    teardown() {
+        this.eventController.abort('teardown');
+    }
+
+    configureLocalShortcuts(): void {
+        this.shortcuts['Mod-z'] = () => {
+            const undoEntry = this.undoStack.undo();
+            if (undoEntry) {
+                this.setText(undoEntry.content);
+                this.setSelection(undoEntry.selection, false);
+            }
+        };
+        this.shortcuts['Mod-y'] = () => {
+            const redoContent = this.undoStack.redo();
+            if (redoContent) {
+                this.setText(redoContent.content);
+                this.setSelection(redoContent.selection, false);
+            }
+        }
     }
 
     configureListeners(): void {
-        // TODO - Teardown handling
-        this.input.addEventListener('keydown', this.onKeyDown);
+        // Keyboard shortcuts
+        this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
 
+        // Shared event listeners
         for (const [name, listener] of Object.entries(this.events)) {
-            this.input.addEventListener(name, listener);
+            this.input.addEventListener(name, listener, {signal: this.eventController.signal});
         }
+
+        // Input change handling
+        this.input.addEventListener('input', () => {
+            this.onChange();
+            this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
+        }, {signal: this.eventController.signal});
     }
 
     onKeyDown(e: KeyboardEvent) {
         const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
+        const key = e.key.length > 1 ? e.key : e.key.toLowerCase();
         const keyParts = [
             e.shiftKey ? 'Shift' : null,
             isApple && e.metaKey ? 'Mod' : null,
             !isApple && e.ctrlKey ? 'Mod' : null,
-            e.key,
+            key,
         ];
 
         const keyString = keyParts.filter(Boolean).join('-');
@@ -45,11 +152,12 @@ export class TextareaInput implements MarkdownEditorInput {
 
     appendText(text: string): void {
         this.input.value += `\n${text}`;
+        this.input.dispatchEvent(new Event('input'));
     }
 
-    coordsToSelection(x: number, y: number): MarkdownEditorInputSelection {
-        // TODO
-        return this.getSelection();
+    eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
+        const eventCoords = this.mouseEventToTextRelativeCoords(event);
+        return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
     }
 
     focus(): void {
@@ -61,11 +169,11 @@ export class TextareaInput implements MarkdownEditorInput {
         let lineStart = 0;
         for (let i = 0; i < lines.length; i++) {
             const line = lines[i];
-            const newEnd = lineStart + line.length + 1;
-            if (position < newEnd) {
-                return {from: lineStart, to: newEnd};
+            const lineEnd = lineStart + line.length;
+            if (position <= lineEnd) {
+                return {from: lineStart, to: lineEnd};
             }
-            lineStart = newEnd;
+            lineStart = lineEnd + 1;
         }
 
         return {from: 0, to: 0};
@@ -93,15 +201,8 @@ export class TextareaInput implements MarkdownEditorInput {
 
     getTextAboveView(): string {
         const scrollTop = this.input.scrollTop;
-        const computedStyles = window.getComputedStyle(this.input);
-        const lines = this.getText().split('\n');
-        const paddingTop = Number(computedStyles.paddingTop.replace('px', ''));
-        const paddingBottom = Number(computedStyles.paddingBottom.replace('px', ''));
-
-        const avgLineHeight = (this.input.scrollHeight - paddingBottom - paddingTop) / lines.length;
-        const roughLinePos = Math.max(Math.floor((scrollTop - paddingTop) / avgLineHeight), 0);
-        const linesAbove = this.getText().split('\n').slice(0, roughLinePos);
-        return linesAbove.join('\n');
+        const selection = this.inputPositionToSelection(0, scrollTop);
+        return this.getSelectionText({from: 0, to: selection.to});
     }
 
     searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
@@ -120,6 +221,7 @@ export class TextareaInput implements MarkdownEditorInput {
 
     setText(text: string, selection?: MarkdownEditorInputSelection): void {
         this.input.value = text;
+        this.input.dispatchEvent(new Event('input'));
         if (selection) {
             this.setSelection(selection, false);
         }
@@ -134,4 +236,80 @@ export class TextareaInput implements MarkdownEditorInput {
             this.setSelection(newSelection, false);
         }
     }
+
+    protected measureTextSize(): {x: number; y: number} {
+        if (this.textSizeCache) {
+            return this.textSizeCache;
+        }
+
+        const el = document.createElement("div");
+        el.textContent = `a\nb`;
+        const inputStyles = window.getComputedStyle(this.input)
+        el.style.font = inputStyles.font;
+        el.style.lineHeight = inputStyles.lineHeight;
+        el.style.padding = '0px';
+        el.style.display = 'inline-block';
+        el.style.visibility = 'hidden';
+        el.style.position = 'absolute';
+        el.style.whiteSpace = 'pre';
+        this.input.after(el);
+
+        const bounds = el.getBoundingClientRect();
+        el.remove();
+        this.textSizeCache = {
+            x: bounds.width,
+            y: bounds.height / 2,
+        };
+        return this.textSizeCache;
+    }
+
+    protected measureLineCharCount(textWidth: number): number {
+        const inputStyles = window.getComputedStyle(this.input);
+        const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
+        const paddingRight = Number(inputStyles.paddingRight.replace('px', ''));
+        const width = Number(inputStyles.width.replace('px', ''));
+        const textSpace = width - (paddingLeft + paddingRight);
+
+        return Math.floor(textSpace / textWidth);
+    }
+
+    protected mouseEventToTextRelativeCoords(event: MouseEvent): {x: number; y: number} {
+        const inputBounds = this.input.getBoundingClientRect();
+        const inputStyles = window.getComputedStyle(this.input);
+        const paddingTop = Number(inputStyles.paddingTop.replace('px', ''));
+        const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
+
+        const xPos = Math.max(event.clientX - (inputBounds.left + paddingLeft), 0);
+        const yPos = Math.max((event.clientY - (inputBounds.top + paddingTop)) + this.input.scrollTop, 0);
+
+        return {x: xPos, y: yPos};
+    }
+
+    protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection {
+        const textSize = this.measureTextSize();
+        const lineWidth = this.measureLineCharCount(textSize.x);
+
+        const lines = this.getText().split('\n');
+
+        let currY = 0;
+        let currPos = 0;
+        for (const line of lines) {
+            let linePos = 0;
+            const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
+            for (let i = 0; i < wrapCount; i++) {
+                currY += textSize.y;
+                if (currY > y) {
+                    const targetX = Math.floor(x / textSize.x);
+                    const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
+                    return {from: maxPos, to: maxPos};
+                }
+
+                linePos += lineWidth;
+            }
+
+            currPos += line.length + 1;
+        }
+
+        return this.getSelection();
+    }
 }
\ No newline at end of file