]> BookStack Code Mirror - bookstack/commitdiff
MD Editor: Added custom textarea undo/redo, updated positioning methods
authorDan Brown <redacted>
Wed, 23 Jul 2025 11:17:36 +0000 (12:17 +0100)
committerDan Brown <redacted>
Wed, 23 Jul 2025 11:17:36 +0000 (12:17 +0100)
resources/js/markdown/inputs/textarea.ts
resources/sass/_forms.scss

index a80054ee24a74a2ccd0a69aa3c51b0a0d123a961..e0c3ac37cf277a3adb7627e6482d0550afa9c778 100644 (file)
@@ -1,7 +1,70 @@
 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 {
 
@@ -10,6 +73,7 @@ export class TextareaInput implements MarkdownEditorInput {
     protected events: MarkdownEditorEventMap;
     protected onChange: () => void;
     protected eventController = new AbortController();
+    protected undoStack = new UndoStack();
 
     protected textSizeCache: {x: number; y: number}|null = null;
 
@@ -25,17 +89,34 @@ export class TextareaInput implements MarkdownEditorInput {
         this.onChange = onChange;
 
         this.onKeyDown = this.onKeyDown.bind(this);
+        this.configureLocalShortcuts();
         this.configureListeners();
 
-        // TODO - Undo/Redo
-
         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 {
         // Keyboard shortcuts
         this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
@@ -48,15 +129,8 @@ export class TextareaInput implements MarkdownEditorInput {
         // Input change handling
         this.input.addEventListener('input', () => {
             this.onChange();
+            this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
         }, {signal: this.eventController.signal});
-
-        this.input.addEventListener('click', (event: MouseEvent) => {
-            const x = event.clientX;
-            const y = event.clientY;
-            const range = this.eventToPosition(event);
-            const text = this.getText().split('');
-            console.log(range, text.slice(0, 20));
-        });
     }
 
     onKeyDown(e: KeyboardEvent) {
@@ -83,33 +157,7 @@ export class TextareaInput implements MarkdownEditorInput {
 
     eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
         const eventCoords = this.mouseEventToTextRelativeCoords(event);
-        const textSize = this.measureTextSize();
-        const lineWidth = this.measureLineCharCount(textSize.x);
-
-        const lines = this.getText().split('\n');
-
-        // TODO - Check this
-
-        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 > eventCoords.y) {
-                    const targetX = Math.floor(eventCoords.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();
+        return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
     }
 
     focus(): void {
@@ -153,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 {
@@ -243,4 +284,32 @@ export class TextareaInput implements MarkdownEditorInput {
 
         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
index e71edc1d75776dd7e9c9d0027aa91f4500e980ea..c16f0609499fbb3ba24c5f903df90ec4df62585b 100644 (file)
@@ -64,6 +64,7 @@
     flex: 1;
     border: 0;
     width: 100%;
+    margin: 0;
     &:focus {
       outline: 0;
     }