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 () => {