From 9a773093a840a182e645c390c4252a19769703d3 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 00:03:37 +0200 Subject: [PATCH] fix(editor): correct caret behavior in autocomplete accept + disable auto-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four smoke-discovered fixes in acceptCompletion / CodeJar options: - Backward selection walk via Selection.modify replaces the buggy range.setStart(endContainer, endOffset - fragment.length). The old code assumed the fragment lived in endContainer; at end-of-line the caret often sits in a post-Prism- text node, so the subtraction went negative → IndexSizeError → caught silently → popup dismissed with no insert. - Save/restore caret around updateCode because codejar.js:469-474 does editor.textContent = code; highlight(editor) with no caret preservation, which dropped the caret to the start of the editor. - Set the selection inside the inserted text node before save() so CodeJar's save() doesn't trip its anchorNode === editor special case at codejar.js:122-127, which collapses to end-of-all-text. - addClosing: false on both CodeJar constructors so closing quotes don't duplicate — CodeJar's addClosing: true default inserts a paired closing character without skipping past an existing one, producing e.g. "rcon_password"" when you finish a string literal. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/l4d2web/static/js/editor.js | 60 ++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/l4d2web/l4d2web/static/js/editor.js b/l4d2web/l4d2web/static/js/editor.js index 68b6a12..6535d6e 100644 --- a/l4d2web/l4d2web/static/js/editor.js +++ b/l4d2web/l4d2web/static/js/editor.js @@ -156,7 +156,7 @@ // CodeJar mounts on the contenteditable and re-runs the highlighter // on each input while preserving caret position. - const jar = window.CodeJar(code, highlightFor(language), { tab: " " }); + const jar = window.CodeJar(code, highlightFor(language), { tab: " ", addClosing: false }); function attachOnUpdate(jarInstance) { jarInstance.onUpdate(function (value) { @@ -205,28 +205,52 @@ hidePopup(); return; } - // Replace the trailing word fragment with the chosen identifier. - // Assumes the fragment lives in one text node — true for the current - // srccfg and bash grammars (identifiers tokenize as a single \b…\b - // chunk). If a future grammar splits identifiers across nodes, - // setStart(endContainer, endOffset - fragment.length) will land - // inside the wrong node and the deleteContents will corrupt text. - // The try/catch below converts IndexSizeError or any other DOM - // exception into a graceful popup-dismiss rather than a broken caret. + // Walk the selection focus backwards `fragment.length` characters + // and replace the result with the chosen identifier. + // + // We can't use `range.setStart(endContainer, endOffset - fragment.length)` + // because at end-of-line the caret often sits in a text node AFTER + // Prism's (the trailing newline / empty text + // node), so `endOffset` is small and the subtraction would go + // negative or land in the wrong node. `Selection.modify` walks + // across node boundaries the way the browser's own caret-movement + // logic does, so it works regardless of which DOM node the caret + // ended up in after the last Prism rehighlight. + // + // (Selection.modify is well-supported in all evergreen browsers + // even though it's not standardised — same family of API as + // contenteditable itself.) try { const sel = window.getSelection(); + for (let i = 0; i < ctx.fragment.length; i++) { + sel.modify("extend", "backward", "character"); + } const range = sel.getRangeAt(0); - range.setStart(range.endContainer, range.endOffset - ctx.fragment.length); range.deleteContents(); - range.insertNode(document.createTextNode(entry.name)); - range.collapse(false); + const insertedNode = document.createTextNode(entry.name); + range.insertNode(insertedNode); + // Move the caret to inside the inserted text node (at its end), + // NOT just "after" it via range.collapse(false). The latter + // leaves anchorNode/focusNode pointing at with an offset + // — and CodeJar's save() has a special case (codejar.js:122-127) + // that collapses any such selection to end-of-all-text, sending + // restore() to the wrong place. Pointing the selection inside + // the inserted text node keeps save() on its normal walk path. + const caretRange = document.createRange(); + caretRange.setStart(insertedNode, insertedNode.length); + caretRange.setEnd(insertedNode, insertedNode.length); sel.removeAllRanges(); - sel.addRange(range); - // Force CodeJar to re-highlight + emit onUpdate. - // Use instance.jar (not bare `jar` closure) for consistency with - // setLanguage's tear-down-and-remount — the jar reference can be - // swapped at runtime. + sel.addRange(caretRange); + // Force CodeJar to re-highlight + emit onUpdate. updateCode does + // `editor.textContent = code; highlight(editor)` — no caret + // preservation — so we save the linear-text offset of the caret + // (now positioned after the inserted identifier) before and + // restore after. Use instance.jar (not bare `jar` closure) for + // consistency with setLanguage's tear-down-and-remount: the jar + // reference is swapped at runtime. + const caretPos = instance.jar.save(); instance.jar.updateCode(instance.jar.toString()); + instance.jar.restore(caretPos); } catch (err) { console.warn("[editor] acceptCompletion failed; dismissing popup", err); } @@ -341,7 +365,7 @@ instance.language = next; code.className = "editor-code language-" + next; code.textContent = currentText; - instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " }); + instance.jar = window.CodeJar(code, highlightFor(next), { tab: " ", addClosing: false }); attachOnUpdate(instance.jar); }, destroy: function () {