From: Dan Brown Date: Tue, 22 Jul 2025 15:42:47 +0000 (+0100) Subject: MD Editor: Worked to improve/fix positioning code X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/6621d55f3d2248a273554c0f780dbeef465e6b15 MD Editor: Worked to improve/fix positioning code 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. --- diff --git a/resources/js/markdown/actions.ts b/resources/js/markdown/actions.ts index ed4ee5904..36d21ab1d 100644 --- a/resources/js/markdown/actions.ts +++ b/resources/js/markdown/actions.ts @@ -236,7 +236,7 @@ export class Actions { 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; } @@ -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. */ - async insertTemplate(templateId: string, posX: number, posY: number): Promise { - const cursorPos = this.editor.input.coordsToSelection(posX, posY).from; + async insertTemplate(templateId: string, event: MouseEvent): Promise { + 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}); @@ -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). */ - 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); } diff --git a/resources/js/markdown/dom-handlers.ts b/resources/js/markdown/dom-handlers.ts index db3f2b576..37e1723de 100644 --- a/resources/js/markdown/dom-handlers.ts +++ b/resources/js/markdown/dom-handlers.ts @@ -25,7 +25,7 @@ export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEdi 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); @@ -33,7 +33,7 @@ export function getMarkdownDomEventHandlers(editor: MarkdownEditor): MarkdownEdi 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 diff --git a/resources/js/markdown/index.mts b/resources/js/markdown/index.mts index 4cd89c077..7538c1972 100644 --- a/resources/js/markdown/index.mts +++ b/resources/js/markdown/index.mts @@ -62,7 +62,7 @@ export async function init(config: MarkdownEditorConfig): Promise void; protected eventController = new AbortController(); + protected textSizeCache: {x: number; y: number}|null = null; + constructor( input: HTMLTextAreaElement, shortcuts: MarkdownEditorShortcutMap, @@ -25,6 +27,8 @@ export class TextareaInput implements MarkdownEditorInput { this.onKeyDown = this.onKeyDown.bind(this); this.configureListeners(); + // TODO - Undo/Redo + 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('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"; + 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('-'); @@ -65,10 +78,37 @@ 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 + 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(); } @@ -81,11 +121,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}; @@ -140,6 +180,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); } @@ -154,4 +195,52 @@ 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}; + } } \ No newline at end of file