fix(editor): correct caret behavior in autocomplete accept + disable auto-close

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-<span> 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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 00:03:37 +02:00
parent 5bec91ab17
commit 9a773093a8
No known key found for this signature in database

View file

@ -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 <span class="token …"> (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 <code> 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 () {