From 3732f7024c5ae8405f4152d14e2b43a34a5326aa Mon Sep 17 00:00:00 2001 From: NineKey Date: Sun, 13 Sep 2026 21:11:43 +0800 Subject: [PATCH] Add stable caret positions around inline tags --- web/dynamic_loader.js | 63 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/web/dynamic_loader.js b/web/dynamic_loader.js index f0ed992..26728db 100644 --- a/web/dynamic_loader.js +++ b/web/dynamic_loader.js @@ -97,6 +97,13 @@ app.registerExtension({ return result; }, []); + // Chromium needs a real inline caret stop beside a + // contenteditable=false element, especially when the element + // is the first item after a BR. This character is editor-only + // and is removed from every serialized representation. + const CARET_ANCHOR = "\u200B"; + const withoutCaretAnchors = text => String(text).replaceAll(CARET_ANCHOR, ""); + const makeChip = (segment) => { // Keep chips directly in the editor's text flow. An // editable inline wrapper confuses native vertical caret @@ -129,8 +136,10 @@ app.registerExtension({ }; chip.addEventListener("click", selectChip); - if (Number(segment.strength ?? 1) === 1) token.append(chip); - else token.append("(", chip, chipWeightSuffix(segment.strength)); + const before = document.createTextNode(CARET_ANCHOR); + const after = document.createTextNode(CARET_ANCHOR); + if (Number(segment.strength ?? 1) === 1) token.append(before, chip, after); + else token.append("(", before, chip, after, chipWeightSuffix(segment.strength)); return token; }; @@ -157,7 +166,7 @@ app.registerExtension({ const serializeComposer = () => { const segments = []; - const appendText = (text) => segments.push({ type: "text", text }); + const appendText = (text) => segments.push({ type: "text", text: withoutCaretAnchors(text) }); const walk = (element) => { for (const child of element.childNodes) { if (child.nodeType === Node.TEXT_NODE) appendText(child.nodeValue); @@ -173,7 +182,7 @@ app.registerExtension({ }; const appendComposerText = (parent, text) => { - const lines = String(text).replace(/\r\n?/g, "\n").split("\n"); + const lines = withoutCaretAnchors(text).replace(/\r\n?/g, "\n").split("\n"); lines.forEach((line, index) => { if (index) parent.append(document.createElement("br")); if (line) parent.append(document.createTextNode(line)); @@ -194,7 +203,7 @@ app.registerExtension({ const selection = window.getSelection(); if (!selection?.rangeCount || !composer.contains(selection.focusNode) || selection.focusNode?.nodeType !== Node.TEXT_NODE) return null; const before = selection.focusNode.nodeValue.slice(0, selection.focusOffset); - const match = before.match(/(?:^|[\s,])@([^\s,@]*)$/); + const match = before.match(/(?:^|[\s,\u200B])@([^\s,@]*)$/); return match ? { selection, textNode: selection.focusNode, start: selection.focusOffset - match[0].length + (match[0].startsWith("@") ? 0 : 1), query: match[1] } : null; }; const getCaretRect = () => { @@ -294,7 +303,9 @@ app.registerExtension({ let container = selection.focusNode; let sibling = null; if (container?.nodeType === Node.TEXT_NODE) { - if ((direction < 0 && selection.focusOffset !== 0) || (direction > 0 && selection.focusOffset !== container.nodeValue.length)) return null; + const before = withoutCaretAnchors(container.nodeValue.slice(0, selection.focusOffset)); + const after = withoutCaretAnchors(container.nodeValue.slice(selection.focusOffset)); + if ((direction < 0 && before.length) || (direction > 0 && after.length)) return null; sibling = direction < 0 ? container.previousSibling : container.nextSibling; } else if (container === composer) { sibling = composer.childNodes[selection.focusOffset + (direction < 0 ? -1 : 0)]; @@ -303,6 +314,22 @@ app.registerExtension({ return sibling.classList.contains("dynamic-tag-chip") ? sibling : null; }; + const moveAcrossAdjacentChip = (direction) => { + const selected = node.inlineComposerSelectedChip; + const chip = selected || adjacentChip(direction); + if (!chip) return false; + const range = document.createRange(); + if (direction < 0) range.setStartBefore(chip); + else range.setStartAfter(chip); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + selected?.classList.remove("selected"); + node.inlineComposerSelectedChip = null; + return true; + }; + const moveFromSelectedChip = (direction) => { const chip = node.inlineComposerSelectedChip; if (!chip) return false; @@ -332,7 +359,7 @@ app.registerExtension({ let text = ""; const walk = node => { for (const child of node.childNodes) { - if (child.nodeType === Node.TEXT_NODE) text += child.nodeValue; + if (child.nodeType === Node.TEXT_NODE) text += withoutCaretAnchors(child.nodeValue); else if (child.nodeName === "BR") text += "\n"; else walk(child); } @@ -366,7 +393,8 @@ app.registerExtension({ let current; while ((current = walker.nextNode())) { if (current.nodeType === Node.TEXT_NODE) { - const length = current.nodeValue.length; + const visible = withoutCaretAnchors(current.nodeValue); + const length = visible.length; // At an exact text-node boundary prefer the next // DOM position. A range ending inside the prior // node is visually equivalent, but Chromium can @@ -378,7 +406,16 @@ app.registerExtension({ const chip = current.parentElement?.closest(".dynamic-tag-chip"); if (chip && remaining === 0) range.setStartBefore(chip); else if (chip && remaining === length) range.setStartAfter(chip); - else range.setStart(current, remaining); + else { + let rawOffset = 0; + let logicalOffset = 0; + while (rawOffset < current.nodeValue.length + && (logicalOffset < remaining || current.nodeValue[rawOffset] === CARET_ANCHOR)) { + if (current.nodeValue[rawOffset] !== CARET_ANCHOR) logicalOffset++; + rawOffset++; + } + range.setStart(current, rawOffset); + } range.collapse(true); return range; } @@ -654,6 +691,12 @@ app.registerExtension({ event.preventDefault(); return; } + if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey + && (event.key === "ArrowLeft" || event.key === "ArrowRight") + && moveAcrossAdjacentChip(event.key === "ArrowLeft" ? -1 : 1)) { + event.preventDefault(); + return; + } if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { node.inlineComposerSelectedChip?.classList.remove("selected"); node.inlineComposerSelectedChip = null; @@ -689,7 +732,7 @@ app.registerExtension({ } fragment.querySelectorAll(".dynamic-tag-chip").forEach(chip => chip.replaceWith(document.createTextNode("@{" + tagKey(chip.dataset.folder, chip.dataset.file) + "}"))); - return fragment.textContent; + return withoutCaretAnchors(fragment.textContent); }; const pasteTextIntoComposer = (pasted, selectInserted = false) => { const selection = window.getSelection();