From 8a5f593586c43e88d652f455afc817931ed16a54 Mon Sep 17 00:00:00 2001 From: bartekplus Date: Mon, 13 Apr 2026 20:07:46 -0700 Subject: [PATCH] fix: hide trailing word chars under caret in mid-text inline preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-text inline preview mirrored the block DOM and inserted a ghost suffix at the caret, but left the remaining characters of the word under the caret in place. Acceptance consumes those characters via findTrailingToken, so the preview read "Threee dog walked the street" while acceptance produced "Three dog walked the street" — breaking the WYSIWYG invariant users rely on. Thread a resolveTrailingToken callback through the presenter that mirrors acceptance's trailing-token logic, and strip those characters from the cloned preview in both the input/textarea and contenteditable mirror paths (walking across inline element boundaries for formatted words). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../suggestions/InlineSuggestionPresenter.ts | 10 +- .../suggestions/InlineSuggestionView.ts | 46 +++++- .../suggestions/SuggestionManagerRuntime.ts | 12 ++ tests/InlineSuggestionPresenter.test.ts | 75 ++++++++++ tests/InlineSuggestionView.test.ts | 139 ++++++++++++++++++ tests/e2e/coverage-baseline-ids.json | 1 + tests/e2e/coverage-matrix.json | 26 ++++ tests/e2e/full.e2e.test.ts | 98 ++++++++++++ 8 files changed, 405 insertions(+), 2 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/InlineSuggestionPresenter.ts b/src/adapters/chrome/content-script/suggestions/InlineSuggestionPresenter.ts index 1fb59b22..6c4ab4c8 100644 --- a/src/adapters/chrome/content-script/suggestions/InlineSuggestionPresenter.ts +++ b/src/adapters/chrome/content-script/suggestions/InlineSuggestionPresenter.ts @@ -43,10 +43,12 @@ export class InlineSuggestionPresenter { enabled, entry, resolveMentionToken, + resolveTrailingToken, }: { enabled: boolean; entry: SuggestionEntry; resolveMentionToken: (beforeCursor: string) => { token: string; start: number }; + resolveTrailingToken?: (afterCursor: string) => string; }): void { if (!enabled) { this.clearForEntry(entry.id); @@ -86,6 +88,9 @@ export class InlineSuggestionPresenter { } const isMidText = snapshot.afterCursor.length > 0; + // Acceptance consumes the trailing word chars under the caret, so hide + // them in the preview to match the post-acceptance rendering. + const trailingTokenText = isMidText ? (resolveTrailingToken?.(snapshot.afterCursor) ?? "") : ""; let ghost: HTMLDivElement | null; if (isMidText && TextTargetAdapter.isTextValue(entry.elem as TextTarget)) { @@ -93,6 +98,7 @@ export class InlineSuggestionPresenter { target: entry.elem as HTMLInputElement | HTMLTextAreaElement, suffix, cursorOffset: snapshot.cursorOffset, + trailingTokenText, entryId: entry.id, doc: this.doc, }); @@ -100,6 +106,7 @@ export class InlineSuggestionPresenter { ghost = InlineSuggestionView.renderContentEditableMirrorPreview({ target: entry.elem, suffix, + trailingTokenText, entryId: entry.id, doc: this.doc, }); @@ -115,7 +122,8 @@ export class InlineSuggestionPresenter { this.activeGhost = ghost; this.activeEntryId = entry.id; - this.pendingRerender = () => this.renderForEntry({ enabled, entry, resolveMentionToken }); + this.pendingRerender = () => + this.renderForEntry({ enabled, entry, resolveMentionToken, resolveTrailingToken }); this.observeGhostRemoval(); } diff --git a/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts b/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts index 4861d9a5..785ddf45 100644 --- a/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts +++ b/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts @@ -237,12 +237,14 @@ export class InlineSuggestionView { target, suffix, cursorOffset, + trailingTokenText = "", entryId, doc = document, }: { target: HTMLInputElement | HTMLTextAreaElement; suffix: string; cursorOffset: number; + trailingTokenText?: string; entryId?: number; doc?: Document; }): HTMLDivElement | null { @@ -282,7 +284,9 @@ export class InlineSuggestionView { // would look after accepting, with only the suffix ghost-styled. const value = target.value ?? ""; const beforeText = value.slice(0, cursorOffset); - const afterText = value.slice(cursorOffset); + // Skip the trailing token chars — acceptance would replace them, so + // the preview must reflect the post-acceptance text. + const afterText = value.slice(cursorOffset + trailingTokenText.length); const textColor = computed.color; const beforeSpan = doc.createElement("span"); @@ -330,11 +334,13 @@ export class InlineSuggestionView { static renderContentEditableMirrorPreview({ target, suffix, + trailingTokenText = "", entryId, doc = document, }: { target: HTMLElement; suffix: string; + trailingTokenText?: string; entryId?: number; doc?: Document; }): HTMLDivElement | null { @@ -396,6 +402,7 @@ export class InlineSuggestionView { const textNode = cloneTarget as Text; const afterNode = textNode.splitText(range.startOffset); afterNode.parentNode!.insertBefore(suffixSpan, afterNode); + InlineSuggestionView.stripLeadingTextChars(afterNode, mirror, trailingTokenText.length); } else if (cloneTarget && cloneTarget.nodeType === Node.ELEMENT_NODE) { // Caret is on an element node (common in Lexical / ProseMirror / // TinyMCE when the selection sits between inline children). @@ -403,6 +410,9 @@ export class InlineSuggestionView { const parent = cloneTarget as HTMLElement; const refChild = parent.childNodes[range.startOffset] ?? null; parent.insertBefore(suffixSpan, refChild); + if (refChild) { + InlineSuggestionView.stripLeadingTextChars(refChild, mirror, trailingTokenText.length); + } } else { // Fallback: append suffix at end if we can't resolve the position. mirror.appendChild(suffixSpan); @@ -419,6 +429,40 @@ export class InlineSuggestionView { return mirror; } + /** + * Remove `count` leading text characters from the subtree starting at + * `startNode`, walking forward in document order within `root`. + * + * Used by the mid-text mirror preview to swallow the trailing chars of + * the word under the caret — these will be replaced on acceptance, so + * the preview must hide them to match the final rendered text. + */ + private static stripLeadingTextChars(startNode: Node, root: Node, count: number): void { + if (count <= 0) { + return; + } + const doc = startNode.ownerDocument ?? document; + // NodeFilter.SHOW_TEXT = 0x4; use the numeric constant directly so the + // code stays compatible with test environments that do not expose the + // NodeFilter global. + const walker = doc.createTreeWalker(root, 0x4); + walker.currentNode = startNode; + + let current: Node | null = + startNode.nodeType === Node.TEXT_NODE ? startNode : walker.nextNode(); + let remaining = count; + + while (current && remaining > 0) { + const text = current as Text; + const toRemove = Math.min(remaining, text.data.length); + if (toRemove > 0) { + text.deleteData(0, toRemove); + remaining -= toRemove; + } + current = walker.nextNode(); + } + } + /** Compute the path (child-node indices) from root to target. */ private static getNodePath(root: Node, target: Node): number[] { const path: number[] = []; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index 79edcbb4..e4edcf91 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -748,6 +748,18 @@ export class SuggestionManagerRuntime { resolveMentionToken: this.predictionCoordinator.findMentionToken.bind( this.predictionCoordinator, ), + // Mirror acceptance's findTrailingToken so the mid-text preview + // hides the characters that acceptance will replace. + resolveTrailingToken: (afterCursor: string) => { + let end = 0; + while ( + end < afterCursor.length && + !this.predictionCoordinator.isSeparator(afterCursor.charAt(end)) + ) { + end += 1; + } + return afterCursor.slice(0, end); + }, }), recordSuggestionShown: (context) => this.telemetry.recordSuggestionShown(context), recordSuggestionAccepted: (context) => this.telemetry.recordSuggestionAccepted(context), diff --git a/tests/InlineSuggestionPresenter.test.ts b/tests/InlineSuggestionPresenter.test.ts index 117f6811..eb5d571e 100644 --- a/tests/InlineSuggestionPresenter.test.ts +++ b/tests/InlineSuggestionPresenter.test.ts @@ -195,6 +195,81 @@ describe("InlineSuggestionPresenter", () => { container.remove(); }); + test("passes trailingTokenText from resolveTrailingToken into mid-text previews", () => { + const mirrorPreviewSpy = jest + .spyOn(InlineSuggestionView, "renderMirrorPreview") + .mockImplementation(() => undefined); + const ceMirrorSpy = jest + .spyOn(InlineSuggestionView, "renderContentEditableMirrorPreview") + .mockImplementation(() => undefined); + const positioning = { + getCaretRect: jest.fn(() => createRect()), + } as unknown as SuggestionPositioningService; + const presenter = new InlineSuggestionPresenter({ positioningService: positioning }); + + // Input mid-text: cursor at "Thr|e dog…" — trailing token is "e". + const input = document.createElement("input"); + input.value = "Thre dog walked the street"; + input.selectionStart = 3; + input.selectionEnd = 3; + const inputEntry = createSuggestionEntry({ + elem: input, + inlineSuggestion: "Three", + latestMentionText: "Thr", + }); + + presenter.renderForEntry({ + enabled: true, + entry: inputEntry, + resolveMentionToken: () => ({ token: "Thr", start: 0 }), + resolveTrailingToken: (afterCursor) => { + const match = afterCursor.match(/^\S+/); + return match?.[0] ?? ""; + }, + }); + + expect(mirrorPreviewSpy).toHaveBeenCalledTimes(1); + expect(mirrorPreviewSpy.mock.calls[0]?.[0].suffix).toBe("ee"); + expect(mirrorPreviewSpy.mock.calls[0]?.[0].trailingTokenText).toBe("e"); + + // Contenteditable mid-text: same expectation for the CE preview path. + const container = document.createElement("div"); + container.contentEditable = "true"; + Object.defineProperty(container, "isContentEditable", { value: true, configurable: true }); + container.textContent = "Thre dog walked the street"; + document.body.appendChild(container); + + const textNode = container.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 3); + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + + const ceEntry = createSuggestionEntry({ + elem: container, + inlineSuggestion: "Three", + latestMentionText: "Thr", + }); + + presenter.renderForEntry({ + enabled: true, + entry: ceEntry, + resolveMentionToken: () => ({ token: "Thr", start: 0 }), + resolveTrailingToken: (afterCursor) => { + const match = afterCursor.match(/^\S+/); + return match?.[0] ?? ""; + }, + }); + + expect(ceMirrorSpy).toHaveBeenCalledTimes(1); + expect(ceMirrorSpy.mock.calls[0]?.[0].suffix).toBe("ee"); + expect(ceMirrorSpy.mock.calls[0]?.[0].trailingTokenText).toBe("e"); + + container.remove(); + }); + test("uses standard render when caret is at end of text", () => { const renderSpy = jest .spyOn(InlineSuggestionView, "render") diff --git a/tests/InlineSuggestionView.test.ts b/tests/InlineSuggestionView.test.ts index 32dcf344..10dca63d 100644 --- a/tests/InlineSuggestionView.test.ts +++ b/tests/InlineSuggestionView.test.ts @@ -545,6 +545,145 @@ describe("InlineSuggestionView", () => { container.remove(); }); + test("renderContentEditableMirrorPreview removes trailing token chars from cloned text when cursor is mid-word", () => { + // Regression for CKEditor-5 inline preview bug: user types "r" inside + // "the" (cursor at "Th|e") and the suggestion "Three" should show the + // final text — not leave the stale "e" after the ghost suffix. + const container = document.createElement("div"); + container.contentEditable = "true"; + Object.defineProperty(container, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(container); + + const p = document.createElement("p"); + p.textContent = "Thre dog walked the street"; + container.appendChild(p); + + const textNode = p.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 3); // cursor after "Thr" + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + + const mirror = InlineSuggestionView.renderContentEditableMirrorPreview({ + target: container, + suffix: "ee", + trailingTokenText: "e", + doc: document, + }); + + expect(mirror).not.toBeNull(); + const suffixSpan = mirror!.querySelector("span"); + expect(suffixSpan).not.toBeNull(); + expect(suffixSpan!.textContent).toBe("ee"); + expect(suffixSpan!.style.opacity).toBe("0.5"); + // The stale trailing "e" must be gone so the preview reads "Three dog walked the street". + expect(mirror!.textContent).toBe("Three dog walked the street"); + + container.remove(); + }); + + test("renderContentEditableMirrorPreview leaves trailing text intact when trailingTokenText is empty", () => { + // When cursor sits at a word boundary (end of word, before space), + // no trailing chars should be consumed — this matches the acceptance + // behaviour where trailingTokenText is empty and "with…" stays as-is. + const container = document.createElement("div"); + container.contentEditable = "true"; + Object.defineProperty(container, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(container); + + const p = document.createElement("p"); + p.textContent = "highest stand with Spell Checker"; + container.appendChild(p); + + const textNode = p.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 14); + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + + const mirror = InlineSuggestionView.renderContentEditableMirrorPreview({ + target: container, + suffix: "ards", + trailingTokenText: "", + doc: document, + }); + + expect(mirror).not.toBeNull(); + expect(mirror!.textContent).toBe("highest stand ardswith Spell Checker"); + + container.remove(); + }); + + test("renderContentEditableMirrorPreview removes trailing token across inline element boundaries", () => { + // Caret inside , with the rest of the word in a following + // sibling — the trailing-token removal must walk forward across + // element boundaries so formatted words are handled correctly. + const container = document.createElement("div"); + container.contentEditable = "true"; + Object.defineProperty(container, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(container); + + const p = document.createElement("p"); + const strong = document.createElement("strong"); + strong.textContent = "Th"; + const em = document.createElement("em"); + em.textContent = "re"; + p.appendChild(strong); + p.appendChild(em); + p.appendChild(document.createTextNode(" more")); + container.appendChild(p); + + const strongText = strong.firstChild!; + const range = document.createRange(); + range.setStart(strongText, 2); // caret at end of "Th" inside + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + + const mirror = InlineSuggestionView.renderContentEditableMirrorPreview({ + target: container, + suffix: "ree", + trailingTokenText: "re", + doc: document, + }); + + expect(mirror).not.toBeNull(); + // Expect the "re" that lived in to be removed, leaving the preview + // as "Th" + "ree" (ghost) + " more". + expect(mirror!.textContent).toBe("Three more"); + + container.remove(); + }); + + test("renderMirrorPreview removes trailing token chars from after-cursor text for input mid-word", () => { + const input = document.createElement("input"); + input.value = "Thre dog walked the street"; + document.body.appendChild(input); + + const mirror = InlineSuggestionView.renderMirrorPreview({ + target: input, + suffix: "ee", + cursorOffset: 3, + trailingTokenText: "e", + doc: document, + }); + + expect(mirror).not.toBeNull(); + const spans = mirror!.querySelectorAll("span"); + expect(spans.length).toBe(3); + expect(spans[0]!.textContent).toBe("Thr"); + expect(spans[1]!.textContent).toBe("ee"); + // The trailing "e" is gone; the after-span starts at the space. + expect(spans[2]!.textContent).toBe("\u00A0dog\u00A0walked\u00A0the\u00A0street"); + + input.remove(); + }); + test("renderContentEditableMirrorPreview preserves pre whitespace for
 blocks", () => {
     const container = document.createElement("div");
     container.contentEditable = "true";
diff --git a/tests/e2e/coverage-baseline-ids.json b/tests/e2e/coverage-baseline-ids.json
index b3577713..0912e165 100644
--- a/tests/e2e/coverage-baseline-ids.json
+++ b/tests/e2e/coverage-baseline-ids.json
@@ -15,6 +15,7 @@
     "dev_ai_predictor_latency_fallback",
     "dev_predictor_debug_toggle",
     "prediction_accept_tab_and_click",
+    "inline_preview_midword_trailing_token",
     "ckeditor_paragraph_preservation",
     "contenteditable_enter_dismisses_popup",
     "contenteditable_enter_restores_previous_block_prediction",
diff --git a/tests/e2e/coverage-matrix.json b/tests/e2e/coverage-matrix.json
index 1e81e200..cd7fab9c 100644
--- a/tests/e2e/coverage-matrix.json
+++ b/tests/e2e/coverage-matrix.json
@@ -185,6 +185,32 @@
         }
       ]
     },
+    {
+      "id": "inline_preview_midword_trailing_token",
+      "description": "Inline suggestion preview hides the trailing word chars under the caret so the mid-text preview reflects the post-acceptance text (no stale characters leaking past the ghost suffix).",
+      "coverage": [
+        {
+          "layer": "e2e-full",
+          "file": "tests/e2e/full.e2e.test.ts",
+          "test": "CKEditor inline preview hides trailing word chars when caret is mid-word"
+        },
+        {
+          "layer": "unit",
+          "file": "tests/InlineSuggestionView.test.ts",
+          "test": "renderContentEditableMirrorPreview removes trailing token chars from cloned text when cursor is mid-word"
+        },
+        {
+          "layer": "unit",
+          "file": "tests/InlineSuggestionView.test.ts",
+          "test": "renderMirrorPreview removes trailing token chars from after-cursor text for input mid-word"
+        },
+        {
+          "layer": "unit",
+          "file": "tests/InlineSuggestionPresenter.test.ts",
+          "test": "passes trailingTokenText from resolveTrailingToken into mid-text previews"
+        }
+      ]
+    },
     {
       "id": "ckeditor_paragraph_preservation",
       "description": "CKEditor suggestion acceptance preserves paragraph structure.",
diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts
index da0f324e..c6e60405 100644
--- a/tests/e2e/full.e2e.test.ts
+++ b/tests/e2e/full.e2e.test.ts
@@ -3639,6 +3639,104 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => {
     browserTimeout(30000, 45000),
   );
 
+  test(
+    "CKEditor inline preview hides trailing word chars when caret is mid-word",
+    async () => {
+      try {
+        await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, true);
+        await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US");
+        await setSettingAndWait(worker!, KEY_ENABLED_LANGUAGES, SUPPORTED_PREDICTION_LANGUAGE_KEYS);
+        await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1);
+        await setSettingAndWait(worker!, KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, false);
+        await applyConfigChange(browser, worker!);
+
+        await gotoTestPage(page, { enableCkEditor: true });
+        await page.bringToFront();
+        await waitForInputReady(page, CKEDITOR_SELECTOR);
+
+        // Seed the editor with "The dog walked the street".
+        await page.evaluate(() => {
+          const ckEditor = (
+            window as typeof window & {
+              __testCkEditor?: { setData: (data: string) => void };
+            }
+          ).__testCkEditor;
+          if (!ckEditor) {
+            throw new Error("CKEditor test instance not found");
+          }
+          ckEditor.setData("

The dog walked the street

"); + }); + + await page.focus(CKEDITOR_SELECTOR); + // Place the caret inside the first word, after "Th". + await page.evaluate(() => { + const editable = document.querySelector(".ck-editor__editable"); + const firstParagraph = editable?.querySelector("p"); + const textNode = firstParagraph?.firstChild; + if (!(textNode instanceof Text)) { + throw new Error("CKEditor first paragraph text node missing"); + } + const selection = window.getSelection(); + if (!selection) { + throw new Error("Selection unavailable"); + } + const range = document.createRange(); + range.setStart(textNode, 2); // "Th|e dog walked the street" + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }); + + // Type "r" so the text becomes "Thr|e dog walked the street". + await page.keyboard.type("r"); + + // Wait for the inline preview to render. + const previewText = await waitUntil( + "CKEditor inline preview for mid-word typing", + async () => { + const text = await page.evaluate(() => { + const preview = document.querySelector(".ft-suggestion-inline"); + const raw = preview?.textContent ?? ""; + return raw.replace(/\u00a0/g, " "); + }); + return text.length > 0 ? text : false; + }, + { timeoutMs: browserTimeout(3000, 6000), intervalMs: 50 }, + ); + + // The preview must read as the post-acceptance text: a single word + // starting with "Thr" followed by " dog walked the street" with no + // stale "e" from the original word leaking through after the ghost + // suffix (the pre-fix bug produced "Threee dog walked the street"). + expect(previewText).toMatch(/^thr\S* dog walked the street$/i); + + // Accepting the suggestion yields the exact text the preview showed, + // validating the "WYSIWYG" invariant the preview is supposed to + // guarantee (with insert-space-after-accept disabled so we can + // compare directly). + await page.keyboard.press("Tab"); + const finalText = await waitUntil( + "CKEditor first-paragraph text after accepting inline suggestion", + async () => { + const text = await page.evaluate(() => { + const editable = document.querySelector(".ck-editor__editable"); + const firstParagraph = editable?.querySelector("p"); + return (firstParagraph?.textContent ?? "").replace(/\u00a0/g, " ").trim(); + }); + return text === previewText ? text : false; + }, + { timeoutMs: browserTimeout(3000, 6000), intervalMs: 50 }, + ); + expect(finalText).toBe(previewText); + } finally { + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await setSettingAndWait(worker!, KEY_INSERT_SPACE_AFTER_AUTOCOMPLETE, true); + await applyConfigChange(browser, worker!); + } + }, + browserTimeout(45000, 70000), + ); + test( "Enabled languages restrict popup language list", async () => {