Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -86,20 +88,25 @@ 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)) {
ghost = InlineSuggestionView.renderMirrorPreview({
target: entry.elem as HTMLInputElement | HTMLTextAreaElement,
suffix,
cursorOffset: snapshot.cursorOffset,
trailingTokenText,
entryId: entry.id,
doc: this.doc,
});
} else if (isMidText) {
ghost = InlineSuggestionView.renderContentEditableMirrorPreview({
target: entry.elem,
suffix,
trailingTokenText,
entryId: entry.id,
doc: this.doc,
});
Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -396,13 +402,17 @@ 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).
// range.startOffset is the child index where the caret sits.
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);
Expand All @@ -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[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
75 changes: 75 additions & 0 deletions tests/InlineSuggestionPresenter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
139 changes: 139 additions & 0 deletions tests/InlineSuggestionView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>, with the rest of the word in a following
// <em> 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 <strong>
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 <em> 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 <pre> blocks", () => {
const container = document.createElement("div");
container.contentEditable = "true";
Expand Down
Loading
Loading