]> BookStack Code Mirror - bookstack/commitdiff
MD Editor: Worked to improve/fix positioning code
authorDan Brown <redacted>
Tue, 22 Jul 2025 15:42:47 +0000 (16:42 +0100)
committerDan Brown <redacted>
Tue, 22 Jul 2025 15:42:47 +0000 (16:42 +0100)
Still pending testing. Old logic did not work when lines would wrap, so
changing things to a character/line measuring technique.
Fixed some other isues too while testing shortcuts.

resources/js/markdown/actions.ts
resources/js/markdown/dom-handlers.ts
resources/js/markdown/index.mts
resources/js/markdown/inputs/codemirror.ts
resources/js/markdown/inputs/interface.ts
resources/js/markdown/inputs/textarea.ts

index ed4ee5904ba8d9dd30d0e83fe0dc177500635b21..36d21ab1dc6cb698045abaa347e14bee9688fe29 100644 (file)
@@ -236,7 +236,7 @@ export class Actions {
         if (lineStart === newStart) {
             const newLineContent = lineContent.replace(`${newStart} `, '');
             const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
         if (lineStart === newStart) {
             const newLineContent = lineContent.replace(`${newStart} `, '');
             const selectFrom = selectionRange.from + (newLineContent.length - lineContent.length);
-            this.editor.input.spliceText(selectionRange.from, selectionRange.to, newLineContent, {from: selectFrom});
+            this.editor.input.spliceText(lineRange.from, lineRange.to, newLineContent, {from: selectFrom});
             return;
         }
 
             return;
         }
 
@@ -353,8 +353,8 @@ export class Actions {
      * Fetch and insert the template of the given ID.
      * The page-relative position provided can be used to determine insert location if possible.
      */
      * Fetch and insert the template of the given ID.
      * The page-relative position provided can be used to determine insert location if possible.
      */
-    async insertTemplate(templateId: string, posX: number, posY: number): Promise<void> {
-        const cursorPos = this.editor.input.coordsToSelection(posX, posY).from;
+    async insertTemplate(templateId: string, event: MouseEvent): Promise<void> {
+        const cursorPos = this.editor.input.eventToPosition(event).from;
         const responseData = (await window.$http.get(`/templates/${templateId}`)).data as {markdown: string, html: string};
         const content = responseData.markdown || responseData.html;
         this.editor.input.spliceText(cursorPos, cursorPos, content, {from: cursorPos});
         const responseData = (await window.$http.get(`/templates/${templateId}`)).data as {markdown: string, html: string};
         const content = responseData.markdown || responseData.html;
         this.editor.input.spliceText(cursorPos, cursorPos, content, {from: cursorPos});
@@ -364,8 +364,8 @@ export class Actions {
      * Insert multiple images from the clipboard from an event at the provided
      * screen coordinates (Typically form a paste event).
      */
      * Insert multiple images from the clipboard from an event at the provided
      * screen coordinates (Typically form a paste event).
      */
-    insertClipboardImages(images: File[], posX: number, posY: number): void {
-        const cursorPos = this.editor.input.coordsToSelection(posX, posY).from;
+    insertClipboardImages(images: File[], event: MouseEvent): void {
+        const cursorPos = this.editor.input.eventToPosition(event).from;
         for (const image of images) {
             this.uploadImage(image, cursorPos);
         }
         for (const image of images) {
             this.uploadImage(image, cursorPos);
         }
index db3f2b57676a2aaaf65c3d977802f23cf545c7ac..37e1723de3505fb95a0f30b235df081da234175d 100644 (file)
@@ -25,7 +25,7 @@ export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEdi
             const templateId = event.dataTransfer.getData('bookstack/template');
             if (templateId) {
                 event.preventDefault();
             const templateId = event.dataTransfer.getData('bookstack/template');
             if (templateId) {
                 event.preventDefault();
-                editor.actions.insertTemplate(templateId, event.pageX, event.pageY);
+                editor.actions.insertTemplate(templateId, event);
             }
 
             const clipboard = new Clipboard(event.dataTransfer);
             }
 
             const clipboard = new Clipboard(event.dataTransfer);
@@ -33,7 +33,7 @@ export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEdi
             if (clipboardImages.length > 0) {
                 event.stopPropagation();
                 event.preventDefault();
             if (clipboardImages.length > 0) {
                 event.stopPropagation();
                 event.preventDefault();
-                editor.actions.insertClipboardImages(clipboardImages, event.pageX, event.pageY);
+                editor.actions.insertClipboardImages(clipboardImages, event);
             }
         },
         // Handle dragover event to allow as drop-target in chrome
             }
         },
         // Handle dragover event to allow as drop-target in chrome
index 4cd89c0777f090aa31f2d3cb34736c8c9952c5d1..7538c197255c0e1f98f6f9bdf8eb9e830b4062ee 100644 (file)
@@ -62,7 +62,7 @@ export async function init(config: MarkdownEditorConfig): Promise<MarkdownEditor
         editor.input.teardown();
         editor.input = newInput;
     });
         editor.input.teardown();
         editor.input = newInput;
     });
-    // window.devinput = editor.input;
+    window.devinput = editor.input;
 
     listenToCommonEvents(editor);
 
 
     listenToCommonEvents(editor);
 
index 3ab219a6330e9cd6ee07c32b559b9940d122243a..827068238e0e70c400525a55a484966a05a95cb5 100644 (file)
@@ -72,8 +72,8 @@ export class CodemirrorInput implements MarkdownEditorInput {
         return this.cm.state.doc.lineAt(index).text;
     }
 
         return this.cm.state.doc.lineAt(index).text;
     }
 
-    coordsToSelection(x: number, y: number): MarkdownEditorInputSelection {
-        const cursorPos = this.cm.posAtCoords({x, y}, false);
+    eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
+        const cursorPos = this.cm.posAtCoords({x: event.screenX, y: event.screenY}, false);
         return {from: cursorPos, to: cursorPos};
     }
 
         return {from: cursorPos, to: cursorPos};
     }
 
index 66a8c07e7980c844f2f64bab7f595277c466e8cc..1f7474a50880de86782f5f26f05cc77b375c6f09 100644 (file)
@@ -65,9 +65,9 @@ export interface MarkdownEditorInput {
     getLineRangeFromPosition(position: number): MarkdownEditorInputSelection;
 
     /**
     getLineRangeFromPosition(position: number): MarkdownEditorInputSelection;
 
     /**
-     * Convert the given screen coords to a selection position within the input.
+     * Convert the given event position to a selection position within the input.
      */
      */
-    coordsToSelection(x: number, y: number): MarkdownEditorInputSelection;
+    eventToPosition(event: MouseEvent): MarkdownEditorInputSelection;
 
     /**
      * Search and return a line range which includes the provided text.
 
     /**
      * Search and return a line range which includes the provided text.
index 25c8779fc7350f063dbdc159e44a64e88822080b..a80054ee24a74a2ccd0a69aa3c51b0a0d123a961 100644 (file)
@@ -11,6 +11,8 @@ export class TextareaInput implements MarkdownEditorInput {
     protected onChange: () => void;
     protected eventController = new AbortController();
 
     protected onChange: () => void;
     protected eventController = new AbortController();
 
+    protected textSizeCache: {x: number; y: number}|null = null;
+
     constructor(
         input: HTMLTextAreaElement,
         shortcuts: MarkdownEditorShortcutMap,
     constructor(
         input: HTMLTextAreaElement,
         shortcuts: MarkdownEditorShortcutMap,
@@ -25,6 +27,8 @@ export class TextareaInput implements MarkdownEditorInput {
         this.onKeyDown = this.onKeyDown.bind(this);
         this.configureListeners();
 
         this.onKeyDown = this.onKeyDown.bind(this);
         this.configureListeners();
 
+        // TODO - Undo/Redo
+
         this.input.style.removeProperty("display");
     }
 
         this.input.style.removeProperty("display");
     }
 
@@ -45,15 +49,24 @@ export class TextareaInput implements MarkdownEditorInput {
         this.input.addEventListener('input', () => {
             this.onChange();
         }, {signal: this.eventController.signal});
         this.input.addEventListener('input', () => {
             this.onChange();
         }, {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) {
         const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
     }
 
     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,
         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('-');
         ];
 
         const keyString = keyParts.filter(Boolean).join('-');
@@ -65,10 +78,37 @@ export class TextareaInput implements MarkdownEditorInput {
 
     appendText(text: string): void {
         this.input.value += `\n${text}`;
 
     appendText(text: string): void {
         this.input.value += `\n${text}`;
+        this.input.dispatchEvent(new Event('input'));
     }
 
     }
 
-    coordsToSelection(x: number, y: number): MarkdownEditorInputSelection {
-        // TODO
+    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.getSelection();
     }
 
@@ -81,11 +121,11 @@ export class TextareaInput implements MarkdownEditorInput {
         let lineStart = 0;
         for (let i = 0; i < lines.length; i++) {
             const line = lines[i];
         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};
         }
 
         return {from: 0, to: 0};
@@ -140,6 +180,7 @@ export class TextareaInput implements MarkdownEditorInput {
 
     setText(text: string, selection?: MarkdownEditorInputSelection): void {
         this.input.value = text;
 
     setText(text: string, selection?: MarkdownEditorInputSelection): void {
         this.input.value = text;
+        this.input.dispatchEvent(new Event('input'));
         if (selection) {
             this.setSelection(selection, false);
         }
         if (selection) {
             this.setSelection(selection, false);
         }
@@ -154,4 +195,52 @@ export class TextareaInput implements MarkdownEditorInput {
             this.setSelection(newSelection, false);
         }
     }
             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};
+    }
 }
\ No newline at end of file
 }
\ No newline at end of file