From 11e3a18111e4c48df02de296db70209f86fb4345 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sat, 4 Apr 2026 11:21:59 +0200 Subject: [PATCH 1/2] fix: add CKEditor-5 host editor bridge to fix text handling bugs (#356) CKEditor-5 manages its own model and doesn't correctly handle synthetic beforeinput events dispatched by FluentTyper. This caused duplicate character insertion, cursor jumping across lines, suggestions landing at wrong positions, and lost formatting when accepting suggestions. The fix adds CKEditor-5 as a recognized host editor (same pattern as CodeMirror) by detecting `element.ckeditorInstance` and routing grammar edits and suggestion acceptance through CKEditor-5's model API. Key details: - Detect CKEditor-5 via `ckeditorInstance` property on the editable element - Map between DOM text offsets and CKEditor-5 model offsets, accounting for softBreak elements that occupy 1 model unit but 0 text characters - Use "end" endpoint for range-end and cursor offsets so replacements at softBreak boundaries don't consume the break element - Strip zero-width filler characters when matching BR-separated line context against the host's full-block text - Preserve text attributes (bold, italic) when inserting replacement text - Route grammar edits through the host session, with fallback offset translation for BR-separated lines within a single model block Co-Authored-By: Claude Opus 4.6 (1M context) --- .../suggestions/HostEditorMainWorldBridge.ts | 253 +++++++++++ .../suggestions/SuggestionTextEditService.ts | 185 +++++++- tests/HostEditorCKEditor5Bridge.test.ts | 415 ++++++++++++++++++ tests/SuggestionTextEditService.test.ts | 325 ++++++++++++++ 4 files changed, 1160 insertions(+), 18 deletions(-) create mode 100644 tests/HostEditorCKEditor5Bridge.test.ts diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index 2bdd1442..067515c8 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -27,6 +27,244 @@ 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; +} + +function findCKEditor5Instance(elem: HTMLElement): CKEditorInstance | null { + let current: any = elem; + while (current) { + try { + if ( + current.ckeditorInstance && + typeof current.ckeditorInstance.model?.change === "function" + ) { + return current.ckeditorInstance as CKEditorInstance; + } + } catch { + // Property access may throw on exotic host objects. + } + current = current.parentElement; + } + 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; + } + } + 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, softBreak positions after the edit shift. + const replacedLength = request.replaceEnd - request.replaceStart; + const lengthDelta = request.replacementText.length - replacedLength; + const updatedSoftBreakOffsets = mapping.softBreakModelOffsets.map((sbOffset) => + sbOffset > modelReplaceStart ? sbOffset + lengthDelta : sbOffset, + ); + const modelCursorAfter = textOffsetToModelOffset( + request.cursorAfter, + updatedSoftBreakOffsets, + "end", + ); + + // 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 +442,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..3f8c6601 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,82 @@ 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. + const fillerOffset = brLineText.length - cleanBrLineText.length; + const cleanReplaceStart = Math.max(0, localReplaceStart - fillerOffset); + const cleanReplaceEnd = Math.max(0, localReplaceEnd - fillerOffset); + const cleanCursorAfter = Math.max(0, localCursorAfter - fillerOffset); + 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 +1242,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 "); + }); }); From e2f93d108f1cb47471763e787c0793976b5a93e0 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sat, 4 Apr 2026 11:29:21 +0200 Subject: [PATCH 2/2] fix: address code review findings for CKEditor-5 bridge - Fix #1: Use "end" cursor endpoint only when cursor is at the replacement boundary; use "start" when cursor is past it (avoids wrong mapping for degenerate softBreak-at-offset-0 edge case) - Fix #2: Count filler characters per-position instead of using a global total, so interspersed fillers produce correct clean offsets - Fix #3: Add else clause in extractModelBlockMapping for nodes without an `is()` method (exotic 3rd-party CKEditor plugins) - Fix #4: Filter out softBreaks inside the deleted range before shifting survivors in updatedSoftBreakOffsets - Fix #5: Cache findCKEditor5Instance results in a WeakMap to avoid re-walking the ancestor chain on every keystroke Co-Authored-By: Claude Opus 4.6 (1M context) --- .../suggestions/HostEditorMainWorldBridge.ts | 31 +++++++++++++++---- .../suggestions/SuggestionTextEditService.ts | 15 ++++++--- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index 067515c8..4375af7e 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -44,7 +44,13 @@ 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 { @@ -52,13 +58,16 @@ function findCKEditor5Instance(elem: HTMLElement): CKEditorInstance | null { current.ckeditorInstance && typeof current.ckeditorInstance.model?.change === "function" ) { - return current.ckeditorInstance as CKEditorInstance; + 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; } @@ -91,6 +100,9 @@ function extractModelBlockMapping(block: any): BlockTextMapping | null { } 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 }; @@ -207,16 +219,23 @@ function applyCKEditor5BlockReplacement( mapping.softBreakModelOffsets, "end", ); - // After the replacement, softBreak positions after the edit shift. + // 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.map((sbOffset) => - sbOffset > modelReplaceStart ? sbOffset + lengthDelta : sbOffset, - ); + 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, - "end", + cursorIsAtReplacementBoundary ? "end" : "start", ); // Capture text attributes (bold, italic, etc.) at the replacement start so diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index 3f8c6601..3e71c31f 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -1146,11 +1146,16 @@ export class SuggestionTextEditService { return null; } const lineOffset = hostBefore.length - cleanBrBefore.length; - // Recompute local offsets in terms of the clean (filler-stripped) text. - const fillerOffset = brLineText.length - cleanBrLineText.length; - const cleanReplaceStart = Math.max(0, localReplaceStart - fillerOffset); - const cleanReplaceEnd = Math.max(0, localReplaceEnd - fillerOffset); - const cleanCursorAfter = Math.max(0, localCursorAfter - fillerOffset); + // 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;