diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index 2bdd1442..4375af7e 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -27,6 +27,263 @@ type BridgeRequest = expectedBlockText: string; }; +// ── CKEditor-5 integration ────────────────────────────────────────── +// CKEditor-5 stores its editor instance on the root editable element as +// `ckeditorInstance`. We use the model API to apply replacements so the +// editor's internal model stays consistent with the DOM. This avoids +// the problems caused by dispatching synthetic beforeinput events which +// CKEditor-5 handles using its own (stale) model selection. + +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument */ +interface CKEditorModel { + document: { selection: { getFirstPosition(): any } }; + change(callback: (writer: any) => void): void; +} + +interface CKEditorInstance { + model: CKEditorModel; +} + +const ckEditorInstanceCache = new WeakMap(); + +function findCKEditor5Instance(elem: HTMLElement): CKEditorInstance | null { + const cached = ckEditorInstanceCache.get(elem); + if (cached !== undefined) { + return cached; + } + let current: any = elem; + while (current) { + try { + if ( + current.ckeditorInstance && + typeof current.ckeditorInstance.model?.change === "function" + ) { + const instance = current.ckeditorInstance as CKEditorInstance; + ckEditorInstanceCache.set(elem, instance); + return instance; + } + } catch { + // Property access may throw on exotic host objects. + } + current = current.parentElement; + } + ckEditorInstanceCache.set(elem, null); + return null; +} + +/** + * Mapping between plain-text offsets (used by FluentTyper / DOM textContent) + * and CKEditor-5 model offsets. softBreak elements count as 1 model offset + * but contribute 0 text characters, so every softBreak before a position adds + * +1 to the model offset relative to the text offset. + */ +interface BlockTextMapping { + text: string; + /** Model offsets at which softBreak elements occur (sorted ascending). */ + softBreakModelOffsets: number[]; +} + +function extractModelBlockMapping(block: any): BlockTextMapping | null { + if (!block || typeof block.getChildren !== "function") { + return null; + } + let text = ""; + const softBreakModelOffsets: number[] = []; + let modelOffset = 0; + for (const child of block.getChildren()) { + if (typeof child.data === "string") { + text += child.data; + modelOffset += child.data.length; + } else if (child.is && (child.is("softBreak") || child.is("element", "softBreak"))) { + softBreakModelOffsets.push(modelOffset); + modelOffset += 1; + } else if (child.is && !child.is("$text") && !child.is("$textProxy")) { + // Inline object (image, widget, etc.) – offsets diverge unpredictably. + return null; + } else if (!child.is) { + // Unknown node type without an `is` method (exotic 3rd-party plugin). + return null; + } + } + return { text, softBreakModelOffsets }; +} + +/** + * Convert a plain-text offset to a CKEditor-5 model offset. + * + * @param endpoint — `"start"` (default) maps to the position AFTER a + * softBreak when the text offset sits exactly at the boundary. Use for + * range starts and cursor positions. `"end"` maps to BEFORE the + * softBreak, which is correct for range ends so the softBreak element + * itself is not included in a removal range. + */ +function textOffsetToModelOffset( + textOffset: number, + softBreakModelOffsets: number[], + endpoint: "start" | "end" = "start", +): number { + let adjustment = 0; + for (const sbModelOffset of softBreakModelOffsets) { + const crosses = + endpoint === "end" + ? sbModelOffset < textOffset + adjustment + : sbModelOffset <= textOffset + adjustment; + if (crosses) { + adjustment += 1; + } else { + break; + } + } + return textOffset + adjustment; +} + +/** Convert a CKEditor-5 model offset to a plain-text offset. */ +function modelOffsetToTextOffset(modelOffset: number, softBreakModelOffsets: number[]): number { + let adjustment = 0; + for (const sbModelOffset of softBreakModelOffsets) { + if (sbModelOffset < modelOffset) { + adjustment += 1; + } else { + break; + } + } + return modelOffset - adjustment; +} + +function getCKEditor5BlockContext( + editor: CKEditorInstance, +): { beforeCursor: string; afterCursor: string; blockText: string } | null { + const position = editor.model.document.selection.getFirstPosition(); + if (!position) { + return null; + } + const block = position.parent; + if (!block) { + return null; + } + if (typeof block.is === "function" && block.is("rootElement")) { + return null; + } + const mapping = extractModelBlockMapping(block); + if (mapping === null) { + return null; + } + const textOffset = modelOffsetToTextOffset(position.offset, mapping.softBreakModelOffsets); + if (textOffset < 0 || textOffset > mapping.text.length) { + return null; + } + return { + beforeCursor: mapping.text.slice(0, textOffset), + afterCursor: mapping.text.slice(textOffset), + blockText: mapping.text, + }; +} + +function applyCKEditor5BlockReplacement( + editor: CKEditorInstance, + request: Extract, +): { applied: boolean; didDispatchInput: boolean } { + const position = editor.model.document.selection.getFirstPosition(); + if (!position) { + return { applied: false, didDispatchInput: false }; + } + const block = position.parent; + if (!block) { + return { applied: false, didDispatchInput: false }; + } + const mapping = extractModelBlockMapping(block); + if ( + mapping === null || + mapping.text !== request.expectedBlockText || + request.replaceStart < 0 || + request.replaceEnd < request.replaceStart || + request.replaceEnd > mapping.text.length + ) { + return { applied: false, didDispatchInput: false }; + } + const expectedLength = + mapping.text.length - + (request.replaceEnd - request.replaceStart) + + request.replacementText.length; + if (request.cursorAfter < 0 || request.cursorAfter > expectedLength) { + return { applied: false, didDispatchInput: false }; + } + + // Translate text offsets to model offsets (accounting for softBreaks). + const modelReplaceStart = textOffsetToModelOffset( + request.replaceStart, + mapping.softBreakModelOffsets, + ); + const modelReplaceEnd = textOffsetToModelOffset( + request.replaceEnd, + mapping.softBreakModelOffsets, + "end", + ); + // After the replacement, softBreaks inside the deleted range no longer + // exist. Filter them out, then shift the survivors that come after the + // edit by the length delta. + const replacedLength = request.replaceEnd - request.replaceStart; + const lengthDelta = request.replacementText.length - replacedLength; + const updatedSoftBreakOffsets = mapping.softBreakModelOffsets + .filter((sbOffset) => sbOffset <= modelReplaceStart || sbOffset >= modelReplaceEnd) + .map((sbOffset) => (sbOffset > modelReplaceStart ? sbOffset + lengthDelta : sbOffset)); + // Use "end" when the cursor sits at the replacement boundary so it stays + // on the same line as the replaced text (before a softBreak). Use "start" + // when the cursor is past the replacement (e.g. on the next line). + const cursorIsAtReplacementBoundary = + request.cursorAfter <= request.replaceStart + request.replacementText.length; + const modelCursorAfter = textOffsetToModelOffset( + request.cursorAfter, + updatedSoftBreakOffsets, + cursorIsAtReplacementBoundary ? "end" : "start", + ); + + // Capture text attributes (bold, italic, etc.) at the replacement start so + // the inserted text preserves the surrounding formatting. + let textAttrs: Record | null = null; + try { + const probePos = editor.model.document.selection.getFirstPosition(); + if (probePos) { + const node = probePos.textNode ?? probePos.nodeBefore ?? probePos.nodeAfter; + if (node && typeof node.getAttributes === "function") { + const attrs: Record = {}; + for (const [key, value] of node.getAttributes()) { + attrs[key] = value; + } + if (Object.keys(attrs).length > 0) { + textAttrs = attrs; + } + } + } + } catch { + // Best-effort: proceed without attributes. + } + + try { + editor.model.change((writer: any) => { + const startPos = writer.createPositionAt(block, modelReplaceStart); + const endPos = writer.createPositionAt(block, modelReplaceEnd); + const range = writer.createRange(startPos, endPos); + writer.remove(range); + if (request.replacementText.length > 0) { + const insertPos = writer.createPositionAt(block, modelReplaceStart); + if (textAttrs) { + writer.insertText(request.replacementText, textAttrs, insertPos); + } else { + writer.insertText(request.replacementText, insertPos); + } + } + const cursorPos = writer.createPositionAt(block, modelCursorAfter); + writer.setSelection(cursorPos); + }); + } catch { + return { applied: false, didDispatchInput: false }; + } + + return { applied: true, didDispatchInput: false }; +} +/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument */ + function findLineEditorController(elem: HTMLElement): LineEditorController | null { let current: HTMLElement | null = elem; while (current) { @@ -204,6 +461,21 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void result: applyBlockReplacement(controller, source, request), }; } + } else { + const ckEditor = findCKEditor5Instance(source); + if (ckEditor) { + if (request.action === "getBlockContext") { + const blockContext = getCKEditor5BlockContext(ckEditor); + if (blockContext) { + response = { ok: true, blockContext }; + } + } else { + response = { + ok: true, + result: applyCKEditor5BlockReplacement(ckEditor, request), + }; + } + } } } catch { response = { ok: false }; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index a3efe1b5..3e71c31f 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -17,6 +17,12 @@ const logger = createLogger("SuggestionTextEditService"); const TRACE_TEXT_LIMIT = 48; const TRACE_HTML_LIMIT = 220; +/** Strip zero-width filler characters that rich editors inject into the DOM. */ +const FILLER_CHARS_REGEX = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g; +function stripFillerChars(value: string): string { + return value.replace(FILLER_CHARS_REGEX, ""); +} + function buildElementSnapshot( element: HTMLElement | null, beforeCursor: string, @@ -587,33 +593,86 @@ export class SuggestionTextEditService { replaceEnd, replacement, ); - const applyResult = + let applyResult: + | ContentEditableEditResult + | { didMutateDom: boolean; didDispatchInput: boolean } + | null = null; + if ( !TextTargetAdapter.isTextValue(entry.elem) && activeBlock !== null && blockReplaceStart !== null && blockReplaceEnd !== null && - blockCursorAfter !== null - ? this.replaceTextByOffsets( - entry.elem, - blockSourceText ?? "", + blockCursorAfter !== null && + blockSourceText !== null + ) { + const blockContext = + context.contentEditableContext ?? + this.contentEditableAdapter.getBlockContext(entry.elem as HTMLElement); + if (blockContext) { + const hostEditorSession = this.resolveHostEditorSession(entry.elem as HTMLElement, { + beforeCursor: blockContext.beforeCursor, + afterCursor: blockContext.afterCursor, + blockText: blockSourceText, + }); + if (hostEditorSession) { + const hostResult = hostEditorSession.applyBlockReplacement({ + replaceStart: blockReplaceStart, + replaceEnd: blockReplaceEnd, + replacementText: replacement, + cursorAfter: blockCursorAfter, + }); + if (hostResult.applied) { + applyResult = { + didMutateDom: true, + didDispatchInput: hostResult.didDispatchInput, + }; + } + } + if (applyResult === null) { + // The primary match may fail when getBlockContext returns a + // BR-separated line but the host editor (e.g. CKEditor-5) uses + // the full paragraph block. Translate local offsets into the + // host's full-block coordinate space and retry. + applyResult = this.tryHostGrammarEditWithFullBlockOffsets( + entry.elem as HTMLElement, + blockSourceText, blockReplaceStart, blockReplaceEnd, replacement, blockCursorAfter, - { - preferDomMutation: this.shouldPreferDomMutationForGrammar(entry.elem), - scopeRoot: activeBlock, - }, - ) - : this.replaceTextByOffsets( - entry.elem, - fullText, - replaceStart, - replaceEnd, - replacement, - cursorAfter, - { preferDomMutation: this.shouldPreferDomMutationForGrammar(entry.elem) }, ); + } + } + } + if (applyResult === null) { + applyResult = + !TextTargetAdapter.isTextValue(entry.elem) && + activeBlock !== null && + blockReplaceStart !== null && + blockReplaceEnd !== null && + blockCursorAfter !== null + ? this.replaceTextByOffsets( + entry.elem, + blockSourceText ?? "", + blockReplaceStart, + blockReplaceEnd, + replacement, + blockCursorAfter, + { + preferDomMutation: this.shouldPreferDomMutationForGrammar(entry.elem), + scopeRoot: activeBlock, + }, + ) + : this.replaceTextByOffsets( + entry.elem, + fullText, + replaceStart, + replaceEnd, + replacement, + cursorAfter, + { preferDomMutation: this.shouldPreferDomMutationForGrammar(entry.elem) }, + ); + } // For edits with cursorOffset on contenteditable, schedule deferred cursor // repositioning BEFORE the didMutateDom check. React-based editors (Lexical, // Slate) handle beforeinput by calling preventDefault() and reconciling the @@ -1046,6 +1105,87 @@ export class SuggestionTextEditService { ); } + /** + * Fallback for grammar edits when the primary host session match fails. + * + * This handles editors like CKEditor-5 whose model block spans the entire + * paragraph while FluentTyper returns a BR-separated line context. We + * resolve the host session without a parity check, locate where the + * BR-separated line sits inside the host's full block text, translate the + * local offsets, and apply via the host API. + */ + private tryHostGrammarEditWithFullBlockOffsets( + elem: HTMLElement, + brLineText: string, + localReplaceStart: number, + localReplaceEnd: number, + replacementText: string, + localCursorAfter: number, + ): { didMutateDom: boolean; didDispatchInput: boolean } | null { + const session = this.hostEditorAdapterResolver.resolve(elem); + if (!session) { + return null; + } + const hostBlockContext = session.getBlockContextAtSelection(); + if (!hostBlockContext) { + return null; + } + const hostBlockText = hostBlockContext.blockText; + // Strip zero-width filler characters that CKEditor-5 inserts in the DOM + // but that don't exist in its model text. + const cleanBrLineText = stripFillerChars(brLineText); + if (hostBlockText === cleanBrLineText) { + return null; + } + // The BR-separated line should appear as a substring of the host's full + // block text. We locate it by anchoring on the cursor position: the host + // reports beforeCursor whose suffix must match the BR-line beforeCursor. + const cleanBrBefore = stripFillerChars(brLineText.slice(0, localReplaceEnd)); + const hostBefore = this.normalizeComparableBlockText(hostBlockContext.beforeCursor); + if (!hostBefore.endsWith(this.normalizeComparableBlockText(cleanBrBefore))) { + return null; + } + const lineOffset = hostBefore.length - cleanBrBefore.length; + // Recompute local offsets in terms of the clean (filler-stripped) text + // by counting only the filler characters that precede each position. + const fillersBeforeOffset = (offset: number): number => + brLineText.slice(0, offset).length - stripFillerChars(brLineText.slice(0, offset)).length; + const cleanReplaceStart = Math.max( + 0, + localReplaceStart - fillersBeforeOffset(localReplaceStart), + ); + const cleanReplaceEnd = Math.max(0, localReplaceEnd - fillersBeforeOffset(localReplaceEnd)); + const cleanCursorAfter = Math.max(0, localCursorAfter - fillersBeforeOffset(localCursorAfter)); + const fullReplaceStart = lineOffset + cleanReplaceStart; + const fullReplaceEnd = lineOffset + cleanReplaceEnd; + const fullCursorAfter = lineOffset + cleanCursorAfter; + if (fullReplaceStart < 0 || fullReplaceEnd > hostBlockText.length || fullCursorAfter < 0) { + return null; + } + // Verify the text at the computed range matches what we expect to replace. + const textAtRange = hostBlockText.slice(fullReplaceStart, fullReplaceEnd); + const expectedText = stripFillerChars(brLineText.slice(localReplaceStart, localReplaceEnd)); + if ( + this.normalizeComparableBlockText(textAtRange) !== + this.normalizeComparableBlockText(expectedText) + ) { + return null; + } + const hostResult = session.applyBlockReplacement({ + replaceStart: fullReplaceStart, + replaceEnd: fullReplaceEnd, + replacementText, + cursorAfter: fullCursorAfter, + }); + if (!hostResult.applied) { + return null; + } + return { + didMutateDom: true, + didDispatchInput: hostResult.didDispatchInput, + }; + } + private resolveHostEditorSession( elem: HTMLElement, expectedBlockContext: { @@ -1107,6 +1247,20 @@ export class SuggestionTextEditService { }; } + // When the primary host session match failed (e.g. BR-separated line + // context vs CKEditor-5 full-block), try with translated offsets. + const fallbackResult = this.tryHostGrammarEditWithFullBlockOffsets( + elem as HTMLElement, + blockSourceText, + replaceStart, + replaceEnd, + replacementText, + cursorAfter, + ); + if (fallbackResult) { + return fallbackResult; + } + const initialApplyResult = this.replaceTextByOffsets( elem, blockSourceText, diff --git a/tests/HostEditorCKEditor5Bridge.test.ts b/tests/HostEditorCKEditor5Bridge.test.ts new file mode 100644 index 00000000..70220522 --- /dev/null +++ b/tests/HostEditorCKEditor5Bridge.test.ts @@ -0,0 +1,415 @@ +import { describe, expect, test } from "bun:test"; +import { + HOST_EDITOR_REQUEST_ATTR, + HOST_EDITOR_REQUEST_EVENT, + HOST_EDITOR_RESPONSE_ATTR, +} from "../src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol"; +// Importing the module auto-installs the bridge via installHostEditorMainWorldBridge(). +import "../src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge"; + +// --------------------------------------------------------------------------- +// CKEditor-5 model mock +// +// Simulates the minimal CKEditor-5 model surface that the bridge relies on: +// - editor.model.document.selection.getFirstPosition() +// - editor.model.change(writer => ...) +// - Writer: createPositionAt, createRange, remove, insertText, setSelection +// --------------------------------------------------------------------------- + +function createCKEditorMock(initialText: string, initialCursorOffset: number) { + let text = initialText; + let cursorOffset = initialCursorOffset; + + const block = { + is(type: string) { + return type === "element" || type === "paragraph"; + }, + getChildren() { + return [{ data: text, is: (type: string) => type === "$text" }]; + }, + }; + + const writer = { + createPositionAt(_element: unknown, offset: number) { + return { offset }; + }, + createRange(start: { offset: number }, end: { offset: number }) { + return { start, end }; + }, + remove(range: { start: { offset: number }; end: { offset: number } }) { + text = text.slice(0, range.start.offset) + text.slice(range.end.offset); + }, + insertText(insertText: string, position: { offset: number }) { + text = text.slice(0, position.offset) + insertText + text.slice(position.offset); + }, + setSelection(position: { offset: number }) { + cursorOffset = position.offset; + }, + }; + + const editor = { + model: { + document: { + selection: { + getFirstPosition() { + return { parent: block, offset: cursorOffset }; + }, + }, + }, + change(callback: (w: typeof writer) => void) { + callback(writer); + }, + }, + }; + + return { + editor, + getText: () => text, + getCursorOffset: () => cursorOffset, + }; +} + +function dispatchBridgeRequest( + elem: HTMLElement, + request: Record, +): Record | null { + elem.removeAttribute(HOST_EDITOR_RESPONSE_ATTR); + elem.setAttribute(HOST_EDITOR_REQUEST_ATTR, JSON.stringify(request)); + elem.dispatchEvent(new CustomEvent(HOST_EDITOR_REQUEST_EVENT, { bubbles: true, composed: true })); + const raw = elem.getAttribute(HOST_EDITOR_RESPONSE_ATTR); + if (!raw) return null; + return JSON.parse(raw) as Record; +} + +describe("HostEditorMainWorldBridge – CKEditor-5", () => { + test("returns block context for a CKEditor-5 element", () => { + const mock = createCKEditorMock("Hello world", 5); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { action: "getBlockContext" }); + + expect(response).toEqual({ + ok: true, + blockContext: { + beforeCursor: "Hello", + afterCursor: " world", + blockText: "Hello world", + }, + }); + }); + + test("applies block replacement via CKEditor-5 model API", () => { + const mock = createCKEditorMock("hello world", 1); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { + action: "applyBlockReplacement", + replaceStart: 0, + replaceEnd: 1, + replacementText: "H", + cursorAfter: 1, + expectedBlockText: "hello world", + }); + + expect(response).toEqual({ ok: true, result: { applied: true, didDispatchInput: false } }); + expect(mock.getText()).toBe("Hello world"); + expect(mock.getCursorOffset()).toBe(1); + }); + + test("rejects replacement when expected block text does not match", () => { + const mock = createCKEditorMock("hello world", 1); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { + action: "applyBlockReplacement", + replaceStart: 0, + replaceEnd: 1, + replacementText: "H", + cursorAfter: 1, + expectedBlockText: "WRONG TEXT", + }); + + expect(response).toEqual({ ok: true, result: { applied: false, didDispatchInput: false } }); + expect(mock.getText()).toBe("hello world"); + }); + + test("does not activate for elements without ckeditorInstance", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { action: "getBlockContext" }); + + expect(response).toEqual({ ok: false }); + }); + + test("detects CKEditor-5 instance on a parent element", () => { + const mock = createCKEditorMock("test", 2); + const wrapper = document.createElement("div"); + (wrapper as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + wrapper.appendChild(editable); + document.body.appendChild(wrapper); + + const response = dispatchBridgeRequest(editable, { action: "getBlockContext" }); + + expect(response).toEqual({ + ok: true, + blockContext: { + beforeCursor: "te", + afterCursor: "st", + blockText: "test", + }, + }); + }); + + test("applies multi-character replacement correctly", () => { + const mock = createCKEditorMock("teh quick", 4); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { + action: "applyBlockReplacement", + replaceStart: 0, + replaceEnd: 4, + replacementText: "the ", + cursorAfter: 4, + expectedBlockText: "teh quick", + }); + + expect(response).toEqual({ ok: true, result: { applied: true, didDispatchInput: false } }); + expect(mock.getText()).toBe("the quick"); + expect(mock.getCursorOffset()).toBe(4); + }); + + test("prefers LineEditorController over CKEditor-5 when both are present", () => { + const ckMock = createCKEditorMock("from ckeditor", 5); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = ckMock.editor; + + // Also attach a LineEditorController + let lineText = "from controller"; + let lineCh = 5; + (editable as HTMLElement & { editorCtl?: unknown }).editorCtl = { + replaceRange(text: string, from: { ch: number }, to?: { ch: number }) { + lineText = `${lineText.slice(0, from.ch)}${text}${lineText.slice(to?.ch ?? from.ch)}`; + }, + setCursor(pos: { ch: number }) { + lineCh = pos.ch; + }, + getCursor: () => ({ line: 0, ch: lineCh }), + getLine: (l: number) => (l === 0 ? lineText : ""), + posFromIndex: (i: number) => ({ line: 0, ch: i }), + indexFromPos: (p: { ch: number }) => p.ch, + }; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { action: "getBlockContext" }); + + expect(response).toEqual({ + ok: true, + blockContext: { + beforeCursor: "from ", + afterCursor: "controller", + blockText: "from controller", + }, + }); + }); + + // ── softBreak tests ────────────────────────────────────────────────── + // CKEditor-5 represents Shift+Enter as a model element that + // occupies 1 model offset but adds 0 text characters. + + function createCKEditorMockWithSoftBreak( + textBefore: string, + textAfter: string, + cursorModelOffset: number, + ) { + // Internal state uses model offsets: textBefore + softBreak(1) + textAfter + let beforeText = textBefore; + let afterText = textAfter; + let cursor = cursorModelOffset; + + const block = { + is(type: string) { + return type === "element" || type === "paragraph"; + }, + getChildren() { + return [ + { data: beforeText, is: (type: string) => type === "$text" }, + { + is: (type: string) => + type === "softBreak" || type === "element" || (type === "element" && false), + }, + { data: afterText, is: (type: string) => type === "$text" }, + ]; + }, + }; + + // Model layout: [beforeText chars][softBreak(1)][afterText chars] + // Offsets: 0..beforeText.length = beforeText, beforeText.length = softBreak, + // beforeText.length+1..end = afterText + + const writer = { + createPositionAt(_element: unknown, offset: number) { + return { offset }; + }, + createRange(start: { offset: number }, end: { offset: number }) { + return { start, end }; + }, + remove(range: { start: { offset: number }; end: { offset: number } }) { + const sbOffset = beforeText.length; + const startOff = range.start.offset; + const endOff = range.end.offset; + // Replacement should be within one text segment (before or after softBreak) + if (startOff > sbOffset) { + // In afterText segment + const localStart = startOff - sbOffset - 1; + const localEnd = endOff - sbOffset - 1; + afterText = afterText.slice(0, localStart) + afterText.slice(localEnd); + } else { + const localStart = startOff; + const localEnd = endOff; + beforeText = beforeText.slice(0, localStart) + beforeText.slice(localEnd); + } + }, + insertText(insertTextStr: string, position: { offset: number }) { + const sbOffset = beforeText.length; + if (position.offset > sbOffset) { + const localOff = position.offset - sbOffset - 1; + afterText = afterText.slice(0, localOff) + insertTextStr + afterText.slice(localOff); + } else { + beforeText = + beforeText.slice(0, position.offset) + + insertTextStr + + beforeText.slice(position.offset); + } + }, + setSelection(position: { offset: number }) { + cursor = position.offset; + }, + }; + + const editor = { + model: { + document: { + selection: { + getFirstPosition() { + return { parent: block, offset: cursor }; + }, + }, + }, + change(callback: (w: typeof writer) => void) { + callback(writer); + }, + }, + }; + + return { + editor, + getBeforeText: () => beforeText, + getAfterText: () => afterText, + getFullText: () => beforeText + afterText, + getCursorModelOffset: () => cursor, + }; + } + + test("returns block context with softBreak, cursor after break", () => { + // Model: "First line.""second line a" + // softBreak is at model offset 11, cursor at model offset 24 (11+1+12) + const mock = createCKEditorMockWithSoftBreak("First line.", "second line a", 24); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { action: "getBlockContext" }); + + // Text offset for model offset 24: 24 - 1 softBreak = 23 + // Full text = "First line.second line a" (24 chars) + // beforeCursor = text[0:23] = "First line.second line " + // afterCursor = text[23:] = "a" + expect(response).toEqual({ + ok: true, + blockContext: { + beforeCursor: "First line.second line ", + afterCursor: "a", + blockText: "First line.second line a", + }, + }); + }); + + test("applies replacement after softBreak with correct model offsets", () => { + // Model: "First line.""second line. a" + // Text: "First line.second line. a" (25 chars) + // softBreak at model offset 11 + // Cursor at model offset 26 (11 + 1 + 14) + // Want to replace text offset 24-25 ("a") with "A" (capitalize) + const mock = createCKEditorMockWithSoftBreak("First line.", "second line. a", 26); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { + action: "applyBlockReplacement", + replaceStart: 24, // text offset of "a" + replaceEnd: 25, + replacementText: "A", + cursorAfter: 25, + expectedBlockText: "First line.second line. a", + }); + + expect(response).toEqual({ ok: true, result: { applied: true, didDispatchInput: false } }); + // The "a" in afterText should be capitalized + expect(mock.getAfterText()).toBe("second line. A"); + expect(mock.getFullText()).toBe("First line.second line. A"); + // Model cursor should be at 26 (25 text + 1 softBreak) + expect(mock.getCursorModelOffset()).toBe(26); + }); + + test("preserves softBreak when replacing text that ends at the break boundary", () => { + // Model: "The edit""second line" + // Text: "The editsecond line" (19 chars) + // softBreak at model offset 8 + // Cursor at model offset 8 (right before softBreak, end of "The edit") + // Replace text 4-8 ("edit") with "editor " — must NOT eat the softBreak + const mock = createCKEditorMockWithSoftBreak("The edit", "second line", 8); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + (editable as HTMLElement & { ckeditorInstance?: unknown }).ckeditorInstance = mock.editor; + document.body.appendChild(editable); + + const response = dispatchBridgeRequest(editable, { + action: "applyBlockReplacement", + replaceStart: 4, + replaceEnd: 8, + replacementText: "editor ", + cursorAfter: 11, + expectedBlockText: "The editsecond line", + }); + + expect(response).toEqual({ ok: true, result: { applied: true, didDispatchInput: false } }); + // "edit" should be replaced with "editor " BEFORE the softBreak + expect(mock.getBeforeText()).toBe("The editor "); + // Text after softBreak must be preserved + expect(mock.getAfterText()).toBe("second line"); + expect(mock.getFullText()).toBe("The editor second line"); + // Cursor must stay BEFORE the softBreak (on the same line), not jump after it + // "The editor " is 11 chars, softBreak is at model offset 11, cursor should be 11 (before break) + expect(mock.getCursorModelOffset()).toBe(11); + }); +}); diff --git a/tests/SuggestionTextEditService.test.ts b/tests/SuggestionTextEditService.test.ts index 788bfe7c..3906a61a 100644 --- a/tests/SuggestionTextEditService.test.ts +++ b/tests/SuggestionTextEditService.test.ts @@ -2038,4 +2038,329 @@ describe("SuggestionTextEditService", () => { expect(entry.pendingExtensionEdit).toBeNull(); expect(input.value).toBe("t"); }); + + test("routes block-scoped grammar edit through host editor session when available", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "hello world"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 1); + + let blockText = "hello world"; + let beforeCursor = "h"; + let afterCursor = "ello world"; + let applyCalls = 0; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { beforeCursor, afterCursor, blockText }; + }, + applyBlockReplacement(_elem, args) { + applyCalls += 1; + blockText = `${blockText.slice(0, args.replaceStart)}${args.replacementText}${blockText.slice(args.replaceEnd)}`; + beforeCursor = blockText.slice(0, args.cursorAfter); + afterCursor = blockText.slice(args.cursorAfter); + editable.textContent = blockText; + setContentEditableCursor(editable, args.cursorAfter); + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ elem: editable }); + + const result = service.applyGrammarEdit( + entry, + { + replacement: "H", + deleteBackwards: 1, + deleteForwards: 0, + sourceRuleId: "capitalizeSentenceStart", + }, + { + snapshot: { + beforeCursor: "h", + afterCursor: "ello world", + cursorOffset: 1, + }, + contentEditableContext: { + beforeCursor: "h", + afterCursor: "ello world", + useFullTextOffsets: false, + }, + }, + ); + + expect(result).toEqual({ applied: true, didDispatchInput: false }); + expect(applyCalls).toBe(1); + expect(editable.textContent).toBe("Hello world"); + }); + + test("grammar edit via host session sets correct pendingExtensionEdit for undo", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "hello world"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 1); + + let blockText = "hello world"; + let beforeCursor = "h"; + let afterCursor = "ello world"; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { beforeCursor, afterCursor, blockText }; + }, + applyBlockReplacement(_elem, args) { + blockText = `${blockText.slice(0, args.replaceStart)}${args.replacementText}${blockText.slice(args.replaceEnd)}`; + beforeCursor = blockText.slice(0, args.cursorAfter); + afterCursor = blockText.slice(args.cursorAfter); + editable.textContent = blockText; + setContentEditableCursor(editable, args.cursorAfter); + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ elem: editable }); + + service.applyGrammarEdit( + entry, + { + replacement: "H", + deleteBackwards: 1, + deleteForwards: 0, + sourceRuleId: "capitalizeSentenceStart", + }, + { + snapshot: { + beforeCursor: "h", + afterCursor: "ello world", + cursorOffset: 1, + }, + contentEditableContext: { + beforeCursor: "h", + afterCursor: "ello world", + useFullTextOffsets: false, + }, + }, + ); + + expect(entry.pendingExtensionEdit).not.toBeNull(); + expect(entry.pendingExtensionEdit?.source).toBe("grammar"); + expect(entry.pendingExtensionEdit?.originalText).toBe("h"); + expect(entry.pendingExtensionEdit?.replacementText).toBe("H"); + expect(entry.pendingExtensionEdit?.replaceStart).toBe(0); + }); + + test("falls back to replaceTextByOffsets for grammar edit when host session is not available", () => { + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + }); + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + editable.textContent = "teh "; + document.body.appendChild(editable); + setContentEditableCursor(editable, 4); + const entry = createSuggestionEntry({ elem: editable }); + + const result = service.applyGrammarEdit( + entry, + { + replacement: "the ", + deleteBackwards: 4, + deleteForwards: 0, + sourceRuleId: "typoFix", + }, + { + snapshot: { + beforeCursor: "teh ", + afterCursor: "", + cursorOffset: 4, + }, + contentEditableContext: { + beforeCursor: "teh ", + afterCursor: "", + useFullTextOffsets: false, + }, + }, + ); + + expect(result).toEqual({ applied: true, didDispatchInput: true }); + expect(editable.textContent).toBe("the "); + }); + + test("translates BR-separated line offsets to full-block offsets for host grammar edit", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + // Simulate a block with text before and after a
+ editable.textContent = "First line. second line. a"; + document.body.appendChild(editable); + setContentEditableCursor(editable, 26); + + // The host editor sees the FULL block text + let fullBlockText = "First line. second line. a"; + let hostBeforeCursor = "First line. second line. a"; + let hostAfterCursor = ""; + let applyCalls = 0; + let lastApplyArgs: { + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + } | null = null; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { + beforeCursor: hostBeforeCursor, + afterCursor: hostAfterCursor, + blockText: fullBlockText, + }; + }, + applyBlockReplacement(_elem, args) { + applyCalls += 1; + lastApplyArgs = { + replaceStart: args.replaceStart, + replaceEnd: args.replaceEnd, + replacementText: args.replacementText, + cursorAfter: args.cursorAfter, + }; + fullBlockText = `${fullBlockText.slice(0, args.replaceStart)}${args.replacementText}${fullBlockText.slice(args.replaceEnd)}`; + hostBeforeCursor = fullBlockText.slice(0, args.cursorAfter); + hostAfterCursor = fullBlockText.slice(args.cursorAfter); + editable.textContent = fullBlockText; + setContentEditableCursor(editable, args.cursorAfter); + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ elem: editable }); + + // FluentTyper sees the BR-separated line context: "second line. a" + // Grammar wants to capitalize 'a' -> 'A' (deleteBackwards: 1, replacement: "A") + const result = service.applyGrammarEdit( + entry, + { + replacement: "A", + deleteBackwards: 1, + deleteForwards: 0, + sourceRuleId: "capitalizeSentenceStart", + }, + { + snapshot: { + beforeCursor: "First line. second line. a", + afterCursor: "", + cursorOffset: 26, + }, + contentEditableContext: { + // BR-separated line context (text after the BR) + beforeCursor: "second line. a", + afterCursor: "", + useFullTextOffsets: false, + }, + }, + ); + + expect(result).toEqual({ applied: true, didDispatchInput: false }); + expect(applyCalls).toBe(1); + // The replacement should happen at offset 25 in the full block (not offset 13) + expect(lastApplyArgs?.replaceStart).toBe(25); + expect(lastApplyArgs?.replaceEnd).toBe(26); + expect(lastApplyArgs?.replacementText).toBe("A"); + expect(lastApplyArgs?.cursorAfter).toBe(26); + expect(editable.textContent).toBe("First line. second line. A"); + }); + + test("routes suggestion acceptance through host with full-block offset translation for BR-separated lines", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + // Build DOM with a
to trigger BR-separated line context + editable.appendChild(document.createTextNode("First line.")); + editable.appendChild(document.createElement("br")); + editable.appendChild(document.createTextNode("tes")); + document.body.appendChild(editable); + // Place cursor at end of "tes" (the text node after
) + const textAfterBr = editable.childNodes[2] as Text; + const range = document.createRange(); + range.setStart(textAfterBr, 3); + range.collapse(true); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + + // Host editor sees full block text (no BR separation) + let fullBlockText = "First line.tes"; + let hostBeforeCursor = "First line.tes"; + let hostAfterCursor = ""; + let applyCalls = 0; + let lastApplyArgs: { + replaceStart: number; + replaceEnd: number; + replacementText: string; + cursorAfter: number; + } | null = null; + const pageBridge: HostEditorPageBridge = { + getBlockContextAtSelection() { + return { + beforeCursor: hostBeforeCursor, + afterCursor: hostAfterCursor, + blockText: fullBlockText, + }; + }, + applyBlockReplacement(_elem, args) { + applyCalls += 1; + lastApplyArgs = { + replaceStart: args.replaceStart, + replaceEnd: args.replaceEnd, + replacementText: args.replacementText, + cursorAfter: args.cursorAfter, + }; + fullBlockText = `${fullBlockText.slice(0, args.replaceStart)}${args.replacementText}${fullBlockText.slice(args.replaceEnd)}`; + hostBeforeCursor = fullBlockText.slice(0, args.cursorAfter); + hostAfterCursor = fullBlockText.slice(args.cursorAfter); + editable.textContent = fullBlockText; + setContentEditableCursor(editable, args.cursorAfter); + return { applied: true, didDispatchInput: false }; + }, + }; + + const service = new SuggestionTextEditService({ + findMentionToken, + isSeparator: (value) => /\s/.test(value), + hostEditorAdapterResolver: new HostEditorAdapterResolver(pageBridge), + }); + const entry = createSuggestionEntry({ + elem: editable, + latestMentionText: "tes", + latestMentionStart: -1, + }); + + const accepted = service.acceptSuggestion(entry, "test "); + + expect(accepted).not.toBeNull(); + expect(applyCalls).toBe(1); + // The replacement should happen at offset 11 in the full block + expect(lastApplyArgs?.replaceStart).toBe(11); + expect((editable.textContent ?? "").replace(/\u00a0/g, " ")).toBe("First line.test "); + }); });