From aad27032741f0dd01a92749a94274da0ebbbcca9 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Mon, 5 Oct 2026 12:18:46 +0200 Subject: [PATCH 01/37] docs: make the editor capability docs agree with the code - review-reference.md: Draft.js Review pushes "insert-fragment" (ReviewModelEditors.ts:279), not "spellcheck-change". Draft.js merges only insert/backspace/delete-character pushes (draft-js EditorState.js:533-536), so each push is its own undo step. - editor-capabilities.md: Quill typing is native input (ContentEditableAdapter.ts:780-791, :259-305); the bridge has no Quill typing branch (HostEditorMainWorldBridge.ts:520-544). - Lexical, Draft.js, CKEditor 5 and Trix each have a typing path: Draft.js/Trix through MODEL_TYPING_SELECTOR (HostEditorBridgeProtocol.ts:15, ReviewModelEditors.ts:509-550), CKEditor 5 through model.change in the MAIN world (HostEditorMainWorldBridge.ts:229-312), Lexical through a synthetic beforeinput (ContentEditableAdapter.ts:258-266). - Fingerprint-only typing: EditorCapabilities.ts:72-78 (since 6ad9fb95) permits only an attempt. The doc now splits "fingerprint without a typing path" (unverified-writer) from "typing-path fingerprint without its editor" (shown, each write refused). A new test in ContentEditableAdapter.test.ts proves that each of the ten typing-path fingerprints refuses the write without its editor. - Add CodeMirror 5 typing (HostEditorControllerUtils.ts:44-60, :150-187). Co-Authored-By: Claude Opus 5.5 --- docs/editor-capabilities.md | 48 ++++++++++++++++++-------- docs/review-reference.md | 9 +++-- tests/ContentEditableAdapter.test.ts | 51 ++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/docs/editor-capabilities.md b/docs/editor-capabilities.md index a761a1cfd..b845f0318 100644 --- a/docs/editor-capabilities.md +++ b/docs/editor-capabilities.md @@ -28,8 +28,16 @@ It does no model reads, stores no history, and creates no observers. The runtime checks this record before interaction. Review uses the same metadata gate before its text safety checks. `reviewApply` requires the target's separate `ReviewCapabilities` evidence. Typing permission never grants Review write permission. -The typing adapters support host transactions for ProseMirror and Slate, one model transaction for Draft.js and Trix (their Review writer), a native edit in one host undo step for TinyMCE, CKEditor 4, Froala and Summernote, and verified host input handling for CKEditor 5 and Lexical. -Quill, also a bundled Quill such as Slack's composer, takes typing through native input that Quill applies to its own model. Quill's history puts the changes of the last second into one undo step, as it does for its own typing. +The typing adapters write as follows: + +- ProseMirror and Slate: a host transaction through the MAIN-world bridge. +- CKEditor 5: one `model.change` batch through the MAIN-world bridge. The batch replaces the text of the model block at the DOM selection and keeps the attributes of the text at the caret. +- Draft.js and Trix (`MODEL_TYPING_SELECTOR`): one model transaction through their Review writer, with the caret after the accepted word. Draft.js gets one `insert-fragment` push. Trix gets one recorded undo entry. +- TinyMCE, CKEditor 4, Froala and Summernote: a native edit in one host undo step. +- Lexical: no bridge path. FluentTyper sends a synthetic `insertReplacementText` `beforeinput` event on the replaced range. Lexical applies the event to its own model. If no handler takes the event, the write is refused. +- Quill, also a bundled Quill such as Slack's composer: native input that Quill applies to its own model. Quill 2 takes the synthetic `insertReplacementText` `beforeinput` event. Quill 1 ignores that event, so FluentTyper uses the browser's `insertText` command, and Quill reads the DOM change into its model. Quill's history puts the changes of the last second into one undo step, as it does for its own typing. +- CodeMirror 5: the bridge finds the CodeMirror instance by its methods (`replaceRange`, `getLine`, `getCursor`) on the field or an ancestor. It replaces the caret line range with `replaceRange` and the `+input` origin in one `operation`. This path needs a contenteditable field, so it needs CodeMirror's `contenteditable` input style. + Their typing paths validate each write. Their fingerprints alone do not grant Review writes. Review writes need the MAIN-world bridge to find the editor itself: Lexical, Draft.js, CKEditor 5 and Trix get a model transaction, and TinyMCE, CKEditor 4, Froala and Summernote get a native edit inside one host undo step. A fingerprint without a working bridge gives Review only. It cannot select a generic Review DOM writer. @@ -42,19 +50,29 @@ Review-only finding cards offer Copy. Clipboard writes require a trusted click a ## Capability and reason matrix -| Context | Prose inspection | Typing suggestions / acceptance | Review / Apply | Reason or limit | -| ---------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------- | -------------------------------------------------- | -| Ordinary input or textarea, including stale ARIA hints | Yes | Yes, when a valid action is visible | Yes / native transaction required | `available` | -| Associated visible native popup | Yes | Temporarily paused with the preference enabled | Yes / target transaction required | `native-popup` | -| Unrelated visible popup | Yes | Unchanged | Unchanged | No field association | -| Structured purpose | According to the privacy rules | Manual activation | Conservative Review exclusions | `manual-activation` | -| Usable browser datalist | Yes for prose | Manual activation; acceptance keys yield while native preference is enabled | Yes / target transaction required | `manual-activation` (conflict `browser-unknown`) | -| Model-editor fingerprint without its editor | Yes | Disabled | Review and Copy / no Apply | `unverified-writer` | -| Verified Quill, ProseMirror or Slate Review bridge | Yes | Typing transaction path | Yes / verified model transaction | Each edit revalidates model and ranges | -| Verified Lexical, Draft.js, CKEditor 5 or Trix bridge | Yes | Typing path where one exists, else disabled | Yes / verified model transaction | Each edit revalidates model and ranges | -| Verified TinyMCE, CKEditor 4, Froala or Summernote bridge | Yes | Native edit in one host undo step | Yes / native edit in one host undo step | Each edit revalidates DOM, ranges and formatting | -| Mixed prose and code | Prose with protected ranges | Fresh code predictions keep capitalization suppression | Prose only / protected ranges cannot change | Current context is separate from host eligibility | -| Credential, disabled, read-only, hidden, or detached field | No | No | No | `sensitive`, `restricted`, `hidden`, or `detached` | +| Context | Prose inspection | Typing suggestions / acceptance | Review / Apply | Reason or limit | +| ---------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------- | +| Ordinary input or textarea, including stale ARIA hints | Yes | Yes, when a valid action is visible | Yes / native transaction required | `available` | +| Associated visible native popup | Yes | Temporarily paused with the preference enabled | Yes / target transaction required | `native-popup` | +| Unrelated visible popup | Yes | Unchanged | Unchanged | No field association | +| Structured purpose | According to the privacy rules | Manual activation | Conservative Review exclusions | `manual-activation` | +| Usable browser datalist | Yes for prose | Manual activation; acceptance keys yield while native preference is enabled | Yes / target transaction required | `manual-activation` (conflict `browser-unknown`) | +| Model-editor fingerprint without a typing path | Yes | Disabled | Review and Copy / no Apply | `unverified-writer` | +| Typing-path fingerprint without its editor | Yes | Shown; each write is refused | Review and Copy / no Apply | `available`; the record permits only an attempt | +| Verified ProseMirror or Slate Review bridge | Yes | Host transaction through the bridge | Yes / verified model transaction | Each edit revalidates model and ranges | +| Quill, also a bundled Quill | Yes | Native input that Quill applies to its own model | Yes / Delta transaction or Quill beforeinput | Each edit revalidates ranges and result text | +| Verified Draft.js or Trix bridge | Yes | One model transaction through the Review writer | Yes / verified model transaction | Each edit revalidates model and ranges | +| Verified CKEditor 5 bridge | Yes | One `model.change` batch through the bridge | Yes / verified model transaction | Each edit revalidates model and ranges | +| Verified Lexical bridge | Yes | `beforeinput` that Lexical applies to its own model | Yes / verified model transaction | Each edit revalidates ranges and result text | +| CodeMirror 5 with the `contenteditable` input style | No | Code predictions; `replaceRange` in one `operation` | No | `code` | +| Verified TinyMCE, CKEditor 4, Froala or Summernote bridge | Yes | Native edit in one host undo step | Yes / native edit in one host undo step | Each edit revalidates DOM, ranges and formatting | +| Mixed prose and code | Prose with protected ranges | Fresh code predictions keep capitalization suppression | Prose only / protected ranges cannot change | Current context is separate from host eligibility | +| Credential, disabled, read-only, hidden, or detached field | No | No | No | `sensitive`, `restricted`, `hidden`, or `detached` | + +A typing-path fingerprint is on the field itself. It is one of `.ProseMirror`, `[data-slate-editor]`, `[data-lexical-editor]`, `.ck-editor__editable`, `trix-editor`, `.public-DraftEditor-content`, `.mce-content-body`, `.cke_editable`, `.fr-element` and `.note-editable`, or a Gutenberg field. +The other fingerprints of `MODEL_EDITOR_SELECTOR` have no typing path, for example `.DraftEditor-root` or `[data-contents]` on the field. A field that is only inside a fingerprint, and has no typing-path fingerprint itself, also has no typing path. +A typing-path fingerprint without its editor never gets a generic DOM write. `ContentEditableAdapter.ts` refuses ProseMirror, Slate and Gutenberg before it sends an event. +It refuses the other model fingerprints when no handler takes the synthetic `beforeinput` event. It refuses the DOM-model editors when the bridge finds no host undo integration. No field in the record proves that a write will succeed. `displaySuggestions` and `reviewApply` permit only an attempt. Each write goes through a transaction. The transaction must validate its target, range, selection, result, and Undo behavior. diff --git a/docs/review-reference.md b/docs/review-reference.md index 5c44ccc50..2b42deac3 100644 --- a/docs/review-reference.md +++ b/docs/review-reference.md @@ -911,8 +911,13 @@ transaction, last edit first, that keeps the marks of the replaced text: - Lexical: the editor and node keys that Lexical stores on its DOM; `spliceText` in one discrete update tagged `history-push`, so the batch never merges into typing. - Draft.js: the editor component, found through React's fiber; the page's own - `EditorState.push` with the `spellcheck-change` type, which is always its own - undo step. Review waits for React to render the new text. + `EditorState.push` with the `insert-fragment` type. Draft.js merges a push into + the previous undo step only for `insert-characters`, `backspace-character` and + `delete-character`, so each `insert-fragment` push is its own undo step, and + Draft.js undoes it in its model. The `spellcheck-change` type is not used: + Draft.js gives its undo to the browser's native undo, which has no entry for a + model write. An editor with `allowUndo` off records no undo step. Review waits + for React to render the new text. - CKEditor 5: the public DOM converter and mapper give the model ranges; one `model.change` batch. - Trix: the public `editor` API in one recorded undo entry, while the document diff --git a/tests/ContentEditableAdapter.test.ts b/tests/ContentEditableAdapter.test.ts index 6e3cc2bb2..431f1125b 100644 --- a/tests/ContentEditableAdapter.test.ts +++ b/tests/ContentEditableAdapter.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { ContentEditableAdapter } from "../src/adapters/chrome/content-script/suggestions/ContentEditableAdapter"; import { createEditor, setCaret } from "./codeContextTestUtils"; +import { editorCapabilities } from "../src/adapters/chrome/content-script/suggestions/EditorCapabilities"; function ensureNodeFilterApi(): void { if (typeof (globalThis as { NodeFilter?: unknown }).NodeFilter !== "undefined") { @@ -644,3 +645,53 @@ test("refuses a rich-text edit when native editing is unavailable", () => { document.execCommand = original; } }); + +// A typing-path fingerprint permits only an attempt. Without its editor, the write is refused. +for (const fingerprint of [ + ".ProseMirror", + "[data-slate-editor]", + "[data-lexical-editor]", + ".ck-editor__editable", + "trix-editor", + ".DraftEditor-root .public-DraftEditor-content", + ".mce-content-body", + ".cke_editable", + ".fr-element", + ".note-editable", +]) { + test(`a ${fingerprint} fingerprint without its editor never gets a generic DOM write`, () => { + const host = createEditor(""); + let editable: HTMLElement = host; + for (const part of fingerprint.split(" ")) { + const next = part === "trix-editor" ? document.createElement("trix-editor") : editable; + if (part.startsWith(".")) next.classList.add(part.slice(1)); + else if (part.startsWith("[")) next.setAttribute(part.slice(1, -1), "true"); + if (next !== editable) editable.append(next); + editable = next; + if (part === ".DraftEditor-root") { + editable.removeAttribute("contenteditable"); + editable = editable.appendChild(document.createElement("div")); + } + } + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { configurable: true, value: true }); + editable.textContent = "teh cat"; + editable.focus(); + setCaret(editable.firstChild!, 3); + expect(editorCapabilities(editable).displaySuggestions).toBe(true); + const original = document.execCommand; + let writes = 0; + document.execCommand = () => { + writes++; + return true; + }; + try { + const result = new ContentEditableAdapter().replaceTextByOffsets(editable, 0, 3, "the", 3); + expect(result).toMatchObject({ appliedBy: "refused", didMutateDom: false }); + expect(writes).toBe(0); + expect(editable.textContent).toBe("teh cat"); + } finally { + document.execCommand = original; + } + }); +} From 6a25d2ab13221c9f7ec88e313d147873d8fecd65 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Mon, 5 Oct 2026 12:33:59 +0200 Subject: [PATCH 02/37] fix(trix): refuse Review and typing writes during IME composition The Trix Review adapter returned "composing: () => false". Thus the writer did not see an IME composition. During a composition, Review read the text that the IME had not committed. Review Apply and typing (replaceModelBlock through MODEL_TYPING_SELECTOR) could write into the composition. Trix has no public composition state, and its internal Level2InputController sets "composing" only at the first insertCompositionText. The MAIN-world bridge now records the target of each compositionstart and compositionend (recordComposition in HostEditorControllerUtils.ts). It clears the record when the bridge is turned on or off. isComposingIn(root) is true while a composition runs in the root, in a descendant or in an ancestor. The Trix adapter returns isComposingIn(root) (ReviewModelEditors.ts:405). Its snapshot then refuses Review reads, Review writes and typing writes. Test: HostEditorCKEditor5Bridge.test.ts, "Trix refuses Review reads, Review writes and typing writes while composing". Before the fix, readReviewModel gave the text during the composition. Co-Authored-By: Claude Opus 5.5 --- .../suggestions/HostEditorControllerUtils.ts | 27 +++++++ .../suggestions/HostEditorMainWorldBridge.ts | 5 ++ .../suggestions/ReviewModelEditors.ts | 9 ++- tests/HostEditorCKEditor5Bridge.test.ts | 80 +++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts b/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts index 68f81e28c..72c2cb697 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts @@ -21,6 +21,33 @@ export interface LineEditorController { focus?(): void; } +/** + * The target of the IME composition that runs now. The MAIN-world bridge and + * the content script each keep their own value in their own module instance. + */ +let compositionTarget: Node | null = null; + +export function setCompositionTarget(target: Node | null): void { + compositionTarget = target; +} + +/** Records the target of a compositionstart or compositionend event. */ +export function recordComposition(event: Event): void { + if (event.type === "compositionstart" || event.type === "compositionend") + compositionTarget = + event.type === "compositionstart" ? (event.composedPath()[0] as Node) : null; +} + +/** + * True while an IME composition runs in `root`, in a descendant or in an + * ancestor of it. During a composition the DOM holds text that the editor + * model does not have yet. Thus no writer may change the text then. + */ +export function isComposingIn(root: Node): boolean { + const target = compositionTarget; + return !!target?.isConnected && (root.contains(target) || target.contains(root)); +} + export interface LineEditorBlockContext { beforeCursor: string; afterCursor: string; diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index 036fe4bcf..05a61468f 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -42,6 +42,8 @@ import { findLineEditorController, isValidBlockReplacement, readLineEditorBlockContext, + recordComposition, + setCompositionTarget, type LineEditorBlockContext, type LineEditorController, } from "./HostEditorControllerUtils"; @@ -398,6 +400,7 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void let enabled = false; const observe = (event: Event) => { + recordComposition(event); const source = gutenbergSelectedField(event.composedPath()[0] as Element | null); if (source instanceof HTMLElement && isGutenbergField(source)) { if (event.type === "compositionstart" || event.type === "compositionend") @@ -441,6 +444,8 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void const next = doc.documentElement.getAttribute(HOST_EDITOR_ENABLED_ATTR) === "true"; if (next === enabled) return; enabled = next; + // The listeners stop or start now: a composition that they saw can end unseen. + setCompositionTarget(null); setProseMirrorObservationEnabled(enabled); for (const name of names) { if (enabled) doc.addEventListener(name, observe, true); diff --git a/src/adapters/chrome/content-script/suggestions/ReviewModelEditors.ts b/src/adapters/chrome/content-script/suggestions/ReviewModelEditors.ts index 5a8b91835..f23a86517 100644 --- a/src/adapters/chrome/content-script/suggestions/ReviewModelEditors.ts +++ b/src/adapters/chrome/content-script/suggestions/ReviewModelEditors.ts @@ -15,7 +15,11 @@ import { type HostEditorBlockReplacement, type HostEditorReviewApplyRequest, } from "./HostEditorBridgeProtocol"; -import { isValidBlockReplacement, type LineEditorBlockContext } from "./HostEditorControllerUtils"; +import { + isComposingIn, + isValidBlockReplacement, + type LineEditorBlockContext, +} from "./HostEditorControllerUtils"; import type { HostEditorApplyResult } from "./HostEditorAdapterResolver"; /** @@ -397,7 +401,8 @@ const trix: AdapterFactory = (root, map) => { if (text.replace(/ /g, " ") !== map.text.replace(/ /g, " ")) return null; const adapter: ModelAdapter = { identity: [editor, document], - composing: () => false, + // Trix has no public composition state; the bridge records the IME events. + composing: () => isComposingIn(root), resolve: (_range, offsets) => ({ start: offsets.start, end: offsets.end }), text: (ref) => map.text.slice(ref.start, ref.end), write(edits, caret) { diff --git a/tests/HostEditorCKEditor5Bridge.test.ts b/tests/HostEditorCKEditor5Bridge.test.ts index 24ca80991..3dbcee3a2 100644 --- a/tests/HostEditorCKEditor5Bridge.test.ts +++ b/tests/HostEditorCKEditor5Bridge.test.ts @@ -694,3 +694,83 @@ beforeEach(() => { document.dispatchEvent(new Event(HOST_EDITOR_ENABLED_EVENT)); document.documentElement.removeAttribute(HOST_EDITOR_ENABLED_ATTR); }); + +// ── IME composition ───────────────────────────────────────────────── +// No bridge writer may change the text while an input method composes in the field. + +const compose = (elem: HTMLElement, type: "compositionstart" | "compositionend") => + elem.dispatchEvent(new Event(type, { bubbles: true, composed: true })); + +const typing = (expectedBlockText: string, replaceStart: number, replacementText: string) => ({ + action: "applyBlockReplacement", + replaceStart, + replaceEnd: expectedBlockText.length, + replacementText, + cursorAfter: replaceStart + replacementText.length, + expectedBlockText, +}); + +/** The public API of the `trix-editor` element that the Review writer uses. */ +function mountTrix(initial: string) { + const element = document.createElement("trix-editor"); + element.setAttribute("contenteditable", "true"); + Object.defineProperty(element, "isContentEditable", { configurable: true, value: true }); + const block = element.appendChild(document.createElement("div")); + block.textContent = initial; + const text = () => block.textContent ?? ""; + // Trix keeps one immutable document object until the text changes. + let trixDocument = { toString: () => `${text()}\n` }; + let range: [number, number] = [initial.length, initial.length]; + const replace = (value: string) => { + block.textContent = text().slice(0, range[0]) + value + text().slice(range[1]); + range = [range[0] + value.length, range[0] + value.length]; + trixDocument = { toString: () => `${text()}\n` }; + }; + (element as HTMLElement & { editor: unknown }).editor = { + getDocument: () => trixDocument, + getSelectedRange: () => range, + setSelectedRange: (next: [number, number]) => (range = next), + insertString: replace, + deleteInDirection: () => replace(""), + recordUndoEntry: () => undefined, + }; + document.body.appendChild(element); + document.getSelection()!.collapse(block.firstChild, initial.length); + return { element, text }; +} + +describe("HostEditorMainWorldBridge – IME composition", () => { + test("Trix refuses Review reads, Review writes and typing writes while composing", () => { + const { element, text } = mountTrix("We saw teh"); + const read = dispatchBridgeRequest(element, { action: "readReviewModel" }) as { + snapshot: { text: string; signature: string }; + }; + expect(read.snapshot.text).toBe("We saw teh"); + const review = { + action: "applyReviewModel", + edits: [{ start: 7, end: 10, original: "teh", replacement: "the" }], + before: "We saw teh", + after: "We saw the", + signature: read.snapshot.signature, + }; + + compose(element, "compositionstart"); + expect(dispatchBridgeRequest(element, { action: "readReviewModel" })).toEqual({ ok: false }); + expect(dispatchBridgeRequest(element, review)).toEqual({ + ok: true, + reviewResult: { status: "rejected", reason: "unsupported" }, + }); + expect(dispatchBridgeRequest(element, typing("We saw teh", 7, "the"))).toEqual({ + ok: true, + result: { applied: false, didDispatchInput: false }, + }); + expect(text()).toBe("We saw teh"); + + compose(element, "compositionend"); + expect(dispatchBridgeRequest(element, review)).toEqual({ + ok: true, + reviewResult: { status: "applied" }, + }); + expect(text()).toBe("We saw the"); + }); +}); From 4bab69139187b7c1570508fdd9733f749674b901 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Mon, 5 Oct 2026 12:33:59 +0200 Subject: [PATCH 03/37] fix(slate): refuse Slate reads and writes during IME composition The Slate bridge (readSlate, applySlate, slateBlockContext and replaceSlateBlock) had no composition guard. During a composition the DOM holds text that the Slate model does not have yet. Thus Review read the uncommitted text, and a Review fix or a typing completion could write into the composition. slate-react keeps its composition state in a WeakMap of the page's own bundle (ReactEditor.isComposing), which the bridge cannot reach. owningSlate() now refuses the editor while isComposingIn(root) is true (SlateEditor.ts:99). All Slate reads and writes go through it. Test: SlateEditor.test.ts, "an IME composition blocks Review reads, Review writes and typing writes" (real slate-react). Before the fix, readSlate gave the text during the composition. The test file also defines the HTMLInputElement and HTMLTextAreaElement globals, because the slate-react composition handlers read them. Co-Authored-By: Claude Opus 5.5 --- .../content-script/suggestions/SlateEditor.ts | 10 ++++- tests/SlateEditor.test.ts | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/SlateEditor.ts b/src/adapters/chrome/content-script/suggestions/SlateEditor.ts index 4d2c3451f..5d8a32d18 100644 --- a/src/adapters/chrome/content-script/suggestions/SlateEditor.ts +++ b/src/adapters/chrome/content-script/suggestions/SlateEditor.ts @@ -6,7 +6,11 @@ import { } from "../review/ContentEditableTextMap"; import { formattingPreservingEdits } from "../review/RichTextFormatting"; import { isHiddenField, isLockedField, isSensitiveField } from "./FieldEligibility"; -import { isValidBlockReplacement, type LineEditorBlockContext } from "./HostEditorControllerUtils"; +import { + isComposingIn, + isValidBlockReplacement, + type LineEditorBlockContext, +} from "./HostEditorControllerUtils"; import type { HostEditorApplyResult } from "./HostEditorAdapterResolver"; import { NOT_APPLIED, @@ -90,7 +94,9 @@ function owningSlate(root: HTMLElement): SlateEditor | null { !root.isContentEditable || isLockedField(root) || isSensitiveField(root) || - isHiddenField(root) + isHiddenField(root) || + // ReactEditor.isComposing() is not reachable; the bridge records the IME events. + isComposingIn(root) ) return null; type Fiber = { return?: Fiber | null; memoizedProps?: { editor?: unknown; value?: unknown } }; diff --git a/tests/SlateEditor.test.ts b/tests/SlateEditor.test.ts index 22bd7a618..fd8baee15 100644 --- a/tests/SlateEditor.test.ts +++ b/tests/SlateEditor.test.ts @@ -18,6 +18,7 @@ import { import { ContentEditableReviewTarget } from "../src/adapters/chrome/content-script/review/ReviewTargets"; import { editorCapabilities } from "../src/adapters/chrome/content-script/suggestions/EditorCapabilities"; import { + applySlate, readSlate, replaceSlateBlock, slateBlockContext, @@ -38,6 +39,8 @@ for (const name of [ "Text", "Selection", "DataTransfer", + "HTMLInputElement", + "HTMLTextAreaElement", "requestAnimationFrame", "cancelAnimationFrame", ] as const) { @@ -238,6 +241,42 @@ describe("real Slate corrections", () => { ).toEqual({ applied: false, didDispatchInput: false }); }); + test("an IME composition blocks Review reads, Review writes and typing writes", async () => { + const { editor, dom } = mount([{ type: "paragraph", children: [{ text: "We saw teh" }] }]); + act(() => editor.select({ path: [0, 0], offset: 10 })); + await settle(); + const before = readSlate(dom)!; + const edits = [fix(before.text, "teh", "the")]; + const review = { edits, before: before.text, after: "We saw the", signature: before.signature }; + const typing = { + replaceStart: 7, + replaceEnd: 10, + replacementText: "the", + cursorAfter: 10, + expectedBlockText: "We saw teh", + }; + const compose = (type: string) => + act(() => dom.dispatchEvent(new Event(type, { bubbles: true, composed: true }))); + + compose("compositionstart"); + // While the IME composes, the DOM holds text that the model does not have yet. + expect(readSlate(dom)).toBeNull(); + expect(slateBlockContext(dom)).toBeNull(); + expect(applySlate(dom, review)).toEqual({ status: "rejected", reason: "unsupported" }); + expect(replaceSlateBlock(dom, typing)).toEqual({ applied: false, didDispatchInput: false }); + expect(editor.children).toEqual([ + { type: "paragraph", children: [{ text: "We saw teh" }] }, + ]); + + compose("compositionend"); + await act(async () => { + expect(replaceSlateBlock(dom, typing)).toEqual({ applied: true, didDispatchInput: false }); + }); + expect(editor.children).toEqual([ + { type: "paragraph", children: [{ text: "We saw the" }] }, + ]); + }); + test("an unflushed or unrendered model is not read", async () => { const { editor, dom } = mount([{ type: "paragraph", children: [{ text: "teh" }] }]); expect(readSlate(dom)?.text).toBe("teh"); From c22fb266fd2ea4233ef2ab0379e4d3dce965ced0 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Mon, 5 Oct 2026 12:33:59 +0200 Subject: [PATCH 04/37] fix(review): refuse content-script Review writes when Review opens during a composition The content-script Review targets keep a "composing" flag. The ReviewController sets it on compositionstart and compositionend, but it adds these listeners only when Review opens. The flag started as false. Thus a Review that opened during a composition read the uncommitted text, and Apply wrote into the composition. This applies to text controls, plain contenteditable fields, Quill beforeinput writes and the DOM-model editors. The content runtime now records the composition target from its start (ContentRuntimeController.ts, recordComposition on document capture). It uses the same helper as the MAIN-world bridge; each world has its own module instance. When Review opens, the target flag starts as isComposingIn(element) (ReviewController.ts:315). Test: ReviewAdapters.test.ts, "a review opened during an IME composition writes nothing until the composition ends". Before the fix, Review showed "Issues: 1" during the composition and Apply wrote "We saw the cat.". Now Review shows "Paused while you compose text." and writes only after compositionend. Co-Authored-By: Claude Opus 5.5 --- .../ContentRuntimeController.ts | 4 +++ .../content-script/review/ReviewController.ts | 3 ++ tests/ReviewAdapters.test.ts | 29 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/adapters/chrome/content-script/ContentRuntimeController.ts b/src/adapters/chrome/content-script/ContentRuntimeController.ts index 13b2bd022..9f2fc4531 100644 --- a/src/adapters/chrome/content-script/ContentRuntimeController.ts +++ b/src/adapters/chrome/content-script/ContentRuntimeController.ts @@ -3,6 +3,7 @@ import { HOST_EDITOR_ENABLED_ATTR, HOST_EDITOR_ENABLED_EVENT, } from "./suggestions/HostEditorBridgeProtocol"; +import { recordComposition } from "./suggestions/HostEditorControllerUtils"; import type { FieldPreferenceResponse } from "@core/domain/fieldPreferences"; import { languageMatchesScript, resolveReviewLanguage, resolveUiLanguage } from "@core/domain/lang"; import { createLogger, setGlobalObservabilityRuntime } from "@core/application/logging/Logger"; @@ -122,6 +123,9 @@ export class ContentRuntimeController { onRuntimeActivity: (runtimeGeneration: number) => void; }, ) { + // Review reads the composition state when it opens. Thus record it from the start. + for (const type of ["compositionstart", "compositionend"]) + document.addEventListener(type, recordComposition, true); this.domObserver = new DomObserver( document.body || document.documentElement, this.onMutationCallbackBound, diff --git a/src/adapters/chrome/content-script/review/ReviewController.ts b/src/adapters/chrome/content-script/review/ReviewController.ts index 77e49e8a2..991d9910d 100644 --- a/src/adapters/chrome/content-script/review/ReviewController.ts +++ b/src/adapters/chrome/content-script/review/ReviewController.ts @@ -21,6 +21,7 @@ import { GoogleDocsReviewTarget, type GoogleDocsReviewSurface } from "./GoogleDo import { WordReviewTarget } from "./WordReviewTarget"; import { GutenbergReviewTarget } from "./GutenbergReviewTarget"; import { isWordInputProxy } from "../suggestions/CodeContextResolver"; +import { isComposingIn } from "../suggestions/HostEditorControllerUtils"; import { ContentEditableReviewTarget, isTextControl, @@ -311,6 +312,8 @@ export class ReviewController { on(doc, "visibilitychange", () => session.notifySourceChanged()); if (!(target instanceof GoogleDocsReviewTarget)) { + // A composition can run already when the review opens. + target.composing = isComposingIn(element); on(element, "input", () => session.notifySourceChanged()); on(element, "compositionstart", () => { target.composing = true; diff --git a/tests/ReviewAdapters.test.ts b/tests/ReviewAdapters.test.ts index ecdf08844..929bdbb26 100644 --- a/tests/ReviewAdapters.test.ts +++ b/tests/ReviewAdapters.test.ts @@ -13,6 +13,7 @@ import { resolveReviewTarget, } from "../src/adapters/chrome/content-script/review/ReviewTargets"; import { InjectedHostEditorPageBridge } from "../src/adapters/chrome/content-script/suggestions/HostEditorPageBridge"; +import { recordComposition } from "../src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils"; import { LocalReviewEngine } from "../src/core/application/review/LocalReviewEngine"; import { explanationTable, @@ -1276,6 +1277,34 @@ describe("review controller lifecycle", () => { review.close(); }); + test("a review opened during an IME composition writes nothing until the composition ends", async () => { + // As the content runtime does from its start. + for (const type of ["compositionstart", "compositionend"]) + document.addEventListener(type, recordComposition, true); + try { + setExecCommand(textControlInsert); + const field = textarea("We saw teh cat."); + field.dispatchEvent(new Event("compositionstart", { bubbles: true })); + const { review } = controller(); + review.invoke(); + await until( + () => root()?.querySelector(".status")?.textContent === "Paused while you compose text.", + ); + expect(root()!.querySelector(".card [data-action=apply]")).toBeNull(); + expect(field.value).toBe("We saw teh cat."); + + field.dispatchEvent(new Event("compositionend", { bubbles: true })); + await until(() => root()?.querySelector(".status")?.textContent === "Issues: 1"); + root()!.querySelector(".item")!.click(); + root()!.querySelector(".card [data-action=apply]")!.click(); + await until(() => field.value === "We saw the cat."); + review.close(); + } finally { + for (const type of ["compositionstart", "compositionend"]) + document.removeEventListener(type, recordComposition, true); + } + }); + test("closing a review in the middle of a write resumes suggestions", async () => { const field = textarea("We saw teh cat."); const { review, suspend, resume } = controller(); From c1c3dc20a21890b7508bc6792f150bdcac8ab046 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Mon, 5 Oct 2026 12:33:59 +0200 Subject: [PATCH 05/37] feat(bridge): guard CKEditor 5 typing, CodeMirror 5 and DOM-editor writes during composition Three bridge writers had no composition guard: the CKEditor 5 typing path (applyCKEditor5BlockReplacement), the CodeMirror 5 typing path (applyBlockReplacement) and the native edit of the DOM-model editors (applyDomEditor). The upstream typing guard (entry.isComposing) blocks them in practice. These guards are extra safety: no bridge writer can change the text during an IME composition. - CKEditor 5 typing: readCKEditor5Block refuses while view.document.isComposing is true. It also refuses the block context. - CodeMirror 5: applyBlockReplacement refuses while isComposingIn(elem). - TinyMCE, CKEditor 4, Froala, Summernote: applyDomEditor refuses while isComposingIn(elem). The check runs before "begin" and again immediately before insertText. Tests (HostEditorCKEditor5Bridge.test.ts, "IME composition"): "CKEditor 5 typing refuses to write while its view composes", "a CodeMirror 5 controller refuses to write while the field composes", "a DOM-model editor (TinyMCE) refuses its native edit while the field composes". Before the fix, each write gave "applied": true during the composition. Composition guard of each writer (Phase 2 record): - ProseMirror Review and typing: ProseMirrorEditor.ts:131 (view.composing in readProseMirror; applyProseMirror and replaceProseMirrorBlock call it) - Quill Delta Review path: QuillEditor.ts:77, :122, :175 (selection.composing) - Quill and Lexical beforeinput, plain contenteditable, text controls and DOM-model Review writes (content script): ReviewTargets.ts:338, :376, :526, :551, :621; flag set at ReviewController.ts:315-324 - Slate Review and typing: SlateEditor.ts:99 (isComposingIn in owningSlate) - Gutenberg Review and typing: GutenbergEditor.ts:379 (fieldFor) - Lexical Review: ReviewModelEditors.ts:101 (editor.isComposing()) - Draft.js Review and typing: ReviewModelEditors.ts:205 (isInCompositionMode) - CKEditor 5 Review: ReviewModelEditors.ts:357 (view.document.isComposing) - Trix Review and typing: ReviewModelEditors.ts:405 (isComposingIn); model adapters checked at ReviewModelEditors.ts:458 - CKEditor 5 typing: HostEditorMainWorldBridge.ts:231 - CodeMirror 5 typing: HostEditorMainWorldBridge.ts:345 - TinyMCE, CKEditor 4, Froala, Summernote typing: HostEditorMainWorldBridge.ts:366 - All typing writes, upstream: SuggestionTextEditService.ts:119, :505, :812 (canEdit -> refreshInteraction, SuggestionEntrySession.ts:150) - Composition record: HostEditorControllerUtils.ts (recordComposition, isComposingIn); MAIN world at HostEditorMainWorldBridge.ts:408, :453; content script at ContentRuntimeController.ts:128 Co-Authored-By: Claude Opus 5.5 --- .../suggestions/HostEditorMainWorldBridge.ts | 5 + tests/HostEditorCKEditor5Bridge.test.ts | 107 ++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index 05a61468f..b04eec0ab 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -40,6 +40,7 @@ import { import { applyLineEditorReplacement, findLineEditorController, + isComposingIn, isValidBlockReplacement, readLineEditorBlockContext, recordComposition, @@ -77,6 +78,7 @@ interface CKEditorInstance { editing?: { mapper?: { toModelPosition(viewPosition: any): any }; view?: { + document?: { isComposing?: boolean }; domConverter?: { domPositionToView(domParent: Node, domOffset?: number): any }; _observers?: Map void; _mutationObserver?: unknown }>; }; @@ -226,6 +228,7 @@ function getCKEditor5SelectionPosition(editor: CKEditorInstance): any { function readCKEditor5Block( editor: CKEditorInstance, ): { position: any; block: any; mapping: BlockTextMapping } | null { + if (editor.editing?.view?.document?.isComposing) return null; flushCKEditor5PendingMutations(editor); const position = getCKEditor5SelectionPosition(editor); const block = position?.parent; @@ -339,6 +342,7 @@ function applyBlockReplacement( elem: HTMLElement, request: HostEditorBlockReplacement, ) { + if (isComposingIn(elem)) return NOT_APPLIED; return applyLineEditorReplacement( controller, TextTargetAdapter.findBackingTextValueTarget(elem), @@ -359,6 +363,7 @@ function applyDomEditor(elem: HTMLElement, request: DomEditorReplacement) { const selection = win.getSelection(); if ( !elem.isConnected || + isComposingIn(elem) || elem.ownerDocument.activeElement !== elem || elem.textContent !== request.before || !selection?.rangeCount diff --git a/tests/HostEditorCKEditor5Bridge.test.ts b/tests/HostEditorCKEditor5Bridge.test.ts index 3dbcee3a2..27886aa3b 100644 --- a/tests/HostEditorCKEditor5Bridge.test.ts +++ b/tests/HostEditorCKEditor5Bridge.test.ts @@ -773,4 +773,111 @@ describe("HostEditorMainWorldBridge – IME composition", () => { }); expect(text()).toBe("We saw the"); }); + + test("CKEditor 5 typing refuses to write while its view composes", () => { + const mock = createCKEditorMock("hello world", 1); + const editor = mock.editor as typeof mock.editor & { editing?: unknown }; + const view = { document: { isComposing: true } }; + editor.editing = { view }; + const editable = mountCkEditor(editor); + + expect(dispatchBridgeRequest(editable, typing("hello world", 6, "there"))).toEqual({ + ok: true, + result: { applied: false, didDispatchInput: false }, + }); + expect(mock.getText()).toBe("hello world"); + + view.document.isComposing = false; + expect(dispatchBridgeRequest(editable, typing("hello world", 6, "there"))).toEqual({ + ok: true, + result: { applied: true, didDispatchInput: false }, + }); + expect(mock.getText()).toBe("hello there"); + }); + + test("a CodeMirror 5 controller refuses to write while the field composes", () => { + const editable = document.body.appendChild(document.createElement("div")); + editable.setAttribute("contenteditable", "true"); + let line = "teh"; + (editable as HTMLElement & { editorCtl?: unknown }).editorCtl = { + replaceRange(value: string, from: { ch: number }, to?: { ch: number }) { + line = `${line.slice(0, from.ch)}${value}${line.slice(to?.ch ?? from.ch)}`; + }, + setCursor: () => undefined, + getCursor: () => ({ line: 0, ch: line.length }), + getLine: () => line, + posFromIndex: (index: number) => ({ line: 0, ch: index }), + indexFromPos: (position: { ch: number }) => position.ch, + }; + + compose(editable, "compositionstart"); + expect(dispatchBridgeRequest(editable, typing("teh", 0, "the"))).toEqual({ + ok: true, + result: { applied: false, didDispatchInput: false }, + }); + expect(line).toBe("teh"); + + compose(editable, "compositionend"); + expect(dispatchBridgeRequest(editable, typing("teh", 0, "the"))).toEqual({ + ok: true, + result: { applied: true, didDispatchInput: false }, + }); + expect(line).toBe("the"); + }); + + test("a DOM-model editor (TinyMCE) refuses its native edit while the field composes", () => { + const body = document.body.appendChild(document.createElement("div")); + body.className = "mce-content-body"; + body.setAttribute("contenteditable", "true"); + body.tabIndex = 0; + body.textContent = "teh"; + const editor = { + getBody: () => body, + undoManager: { transact: (callback: () => void) => callback(), add: () => undefined }, + nodeChanged: () => undefined, + }; + const win = window as unknown as { tinymce?: unknown }; + win.tinymce = { get: () => [editor] }; + const commands: string[] = []; + const execCommand = Object.getOwnPropertyDescriptor(document, "execCommand"); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: (_command: string, _ui: boolean, value: string) => { + commands.push(value); + body.textContent = value; + return true; + }, + }); + try { + body.focus(); + const request = () => { + document.getSelection()!.selectAllChildren(body); + return { + action: "applyDomEditor", + before: "teh", + prefix: "", + selected: "teh", + replacement: "the", + }; + }; + + compose(body, "compositionstart"); + expect(dispatchBridgeRequest(body, request())).toEqual({ + ok: true, + result: { applied: false, didDispatchInput: false }, + }); + expect(commands).toEqual([]); + + compose(body, "compositionend"); + expect(dispatchBridgeRequest(body, request())).toEqual({ + ok: true, + result: { applied: true, didDispatchInput: false }, + }); + expect(commands).toEqual(["the"]); + } finally { + delete win.tinymce; + if (execCommand) Object.defineProperty(document, "execCommand", execCommand); + else delete (document as unknown as { execCommand?: unknown }).execCommand; + } + }); }); From 4c0a60dc61aea4a0e1851b1bf2680ed932d9cc53 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Mon, 5 Oct 2026 12:39:06 +0200 Subject: [PATCH 06/37] fix(bridge): see a composition that starts while the bridge is off The MAIN-world bridge recorded IME composition events only while it was turned on. A composition that started before the bridge turned on was not seen. The Trix, Slate, CodeMirror 5 and DOM-editor writers could then write during that composition. The bridge now listens for compositionstart and compositionend at all times, through the same listener list that survives document.open() (HostEditorMainWorldBridge.ts). Remove the unused setCompositionTarget. Test: "a composition that starts while the bridge is off still blocks writes after it turns on" (tests/HostEditorCKEditor5Bridge.test.ts). It failed before the fix with applied: true. Co-Authored-By: Claude Opus 5.5 --- .../suggestions/HostEditorControllerUtils.ts | 4 ---- .../suggestions/HostEditorMainWorldBridge.ts | 7 +++---- tests/HostEditorCKEditor5Bridge.test.ts | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts b/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts index 72c2cb697..b1aa5f7fc 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils.ts @@ -27,10 +27,6 @@ export interface LineEditorController { */ let compositionTarget: Node | null = null; -export function setCompositionTarget(target: Node | null): void { - compositionTarget = target; -} - /** Records the target of a compositionstart or compositionend event. */ export function recordComposition(event: Event): void { if (event.type === "compositionstart" || event.type === "compositionend") diff --git a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts index b04eec0ab..3040d62aa 100644 --- a/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge.ts @@ -44,7 +44,6 @@ import { isValidBlockReplacement, readLineEditorBlockContext, recordComposition, - setCompositionTarget, type LineEditorBlockContext, type LineEditorController, } from "./HostEditorControllerUtils"; @@ -405,7 +404,6 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void let enabled = false; const observe = (event: Event) => { - recordComposition(event); const source = gutenbergSelectedField(event.composedPath()[0] as Element | null); if (source instanceof HTMLElement && isGutenbergField(source)) { if (event.type === "compositionstart" || event.type === "compositionend") @@ -445,12 +443,13 @@ export function installHostEditorMainWorldBridge(doc: Document = document): void doc.addEventListener(type, listener, capture); return result; } as typeof doc.open; + // Always on: a composition that starts while the bridge is off must still block writes. + listen("compositionstart", recordComposition, true); + listen("compositionend", recordComposition, true); listen(HOST_EDITOR_ENABLED_EVENT, () => { const next = doc.documentElement.getAttribute(HOST_EDITOR_ENABLED_ATTR) === "true"; if (next === enabled) return; enabled = next; - // The listeners stop or start now: a composition that they saw can end unseen. - setCompositionTarget(null); setProseMirrorObservationEnabled(enabled); for (const name of names) { if (enabled) doc.addEventListener(name, observe, true); diff --git a/tests/HostEditorCKEditor5Bridge.test.ts b/tests/HostEditorCKEditor5Bridge.test.ts index 27886aa3b..552338341 100644 --- a/tests/HostEditorCKEditor5Bridge.test.ts +++ b/tests/HostEditorCKEditor5Bridge.test.ts @@ -774,6 +774,24 @@ describe("HostEditorMainWorldBridge – IME composition", () => { expect(text()).toBe("We saw the"); }); + test("a composition that starts while the bridge is off still blocks writes after it turns on", () => { + const setEnabled = (on: boolean) => { + document.documentElement.setAttribute(HOST_EDITOR_ENABLED_ATTR, String(on)); + document.dispatchEvent(new Event(HOST_EDITOR_ENABLED_EVENT)); + document.documentElement.removeAttribute(HOST_EDITOR_ENABLED_ATTR); + }; + const { element, text } = mountTrix("We saw teh"); + setEnabled(false); + compose(element, "compositionstart"); + setEnabled(true); + expect(dispatchBridgeRequest(element, typing("We saw teh", 7, "the"))).toEqual({ + ok: true, + result: { applied: false, didDispatchInput: false }, + }); + expect(text()).toBe("We saw teh"); + compose(element, "compositionend"); + }); + test("CKEditor 5 typing refuses to write while its view composes", () => { const mock = createCKEditorMock("hello world", 1); const editor = mock.editor as typeof mock.editor & { editing?: unknown }; From d26e5a5db66512fc6807ba6dd0dca7105ee63e90 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Mon, 5 Oct 2026 12:53:40 +0200 Subject: [PATCH 07/37] test: add direct unit tests for the model and DOM Review writers Each writer now has a direct unit test with the real vendored library in JSDOM. No stub of an editor library is used. - tests/ReviewModelEditors.test.ts: Lexical, Draft.js and Trix. - tests/ReviewDomEditors.test.ts: TinyMCE, CKEditor 4, Froala and Summernote, through the real InjectedHostEditorPageBridge. For each editor the tests check: detection (and refusal of a fingerprint without its editor), one replacement with formatting and caret kept, one host Undo step that keeps earlier typing, refusal when the content changed after the read, and refusal during an IME composition with a write after it ends. Draft.js and Trix also test the typing path (replaceModelBlock). The test files polyfill only missing JSDOM features (customElements, ElementInternals.setValidity/setFormValue, InputEvent.getTargetRanges, Range rects, matchMedia, canvas). tests/modules.d.ts gets the Draft.js types the tests use and a "trix" module declaration. Known limits of the tests: the Lexical composition case is also caught by the model/DOM text check; Draft.js runs on React 19 in the unit test (the e2e fixture uses React 18). Co-Authored-By: Claude Opus 5.5 --- tests/ReviewDomEditors.test.ts | 442 +++++++++++++++++++++++++ tests/ReviewModelEditors.test.ts | 542 +++++++++++++++++++++++++++++++ tests/modules.d.ts | 17 + 3 files changed, 1001 insertions(+) create mode 100644 tests/ReviewDomEditors.test.ts create mode 100644 tests/ReviewModelEditors.test.ts diff --git a/tests/ReviewDomEditors.test.ts b/tests/ReviewDomEditors.test.ts new file mode 100644 index 000000000..1bc88f83a --- /dev/null +++ b/tests/ReviewDomEditors.test.ts @@ -0,0 +1,442 @@ +/** + * Review and typing writes into the real TinyMCE, CKEditor 4, Froala and + * Summernote libraries that the e2e fixtures use (tests/e2e/fixtures/review-editors). + * Each editor runs in its iframe-less mode on the shared JSDOM document. The + * polyfills below add only browser features that JSDOM does not have; the + * editor libraries are not changed. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { TinyMCE } from "tinymce"; +import type FroalaEditorClass from "froala-editor"; +import { + HOST_EDITOR_ENABLED_ATTR, + HOST_EDITOR_ENABLED_EVENT, +} from "../src/adapters/chrome/content-script/suggestions/HostEditorBridgeProtocol"; +import { InjectedHostEditorPageBridge } from "../src/adapters/chrome/content-script/suggestions/HostEditorPageBridge"; +import { isComposingIn } from "../src/adapters/chrome/content-script/suggestions/HostEditorControllerUtils"; +import { reviewTransaction } from "../src/adapters/chrome/content-script/suggestions/ReviewDomEditors"; +import { ContentEditableReviewTarget } from "../src/adapters/chrome/content-script/review/ReviewTargets"; +// Installs the MAIN-world bridge on this document; Review and typing reach it through events. +import "../src/adapters/chrome/content-script/suggestions/HostEditorMainWorldBridge"; + +// ── Browser features that JSDOM does not implement ────────────────── +const win = window as unknown as Record & typeof window; +// TinyMCE detects the device type with media queries. +win.matchMedia = (media: string) => + ({ + matches: false, + media, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + }) as unknown as MediaQueryList; +// Froala reads the cookie domain of the page. +Object.defineProperty(document, "domain", { configurable: true, value: "localhost" }); +// Editing hosts: the editors set and read contentEditable and isContentEditable. +Object.defineProperties(window.HTMLElement.prototype, { + contentEditable: { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute("contenteditable") ?? "inherit"; + }, + set(this: HTMLElement, value: string) { + if (value === "inherit") this.removeAttribute("contenteditable"); + else this.setAttribute("contenteditable", value); + }, + }, + isContentEditable: { + configurable: true, + get(this: HTMLElement) { + const host = this.closest("[contenteditable]"); + return !!host && host.getAttribute("contenteditable") !== "false"; + }, + }, +}); +// TinyMCE measures caret positions. +window.Range.prototype.getClientRects = () => [] as unknown as DOMRectList; +window.Range.prototype.getBoundingClientRect = () => new DOMRect(); +// Summernote measures installed fonts on a 2D canvas when it loads. +window.HTMLCanvasElement.prototype.getContext = (() => ({ + clearRect: () => undefined, + fillText: () => undefined, + getImageData: () => ({ data: [] }), +})) as unknown as typeof HTMLCanvasElement.prototype.getContext; +// The libraries use browser globals by their bare names (DOMParser, Range, getSelection...). +const globals = globalThis as Record; +for (const key of Object.getOwnPropertyNames(window)) { + if (key in globals) continue; + const value = win[key]; + globals[key] = typeof value === "function" && /^[a-z]/.test(key) ? value.bind(window) : value; +} + +// ── The real libraries ────────────────────────────────────────────── +await import("tinymce/tinymce"); +const tinymce = win.tinymce as TinyMCE; +// The TinyMCE plugin files register on the bare global `tinymce`. +globals.tinymce = tinymce; +// These parts of TinyMCE have no type declarations; they only register themselves. +for (const part of ["tinymce/icons/default", "tinymce/themes/silver", "tinymce/models/dom"]) + await import(part); +const { default: FroalaEditor } = await import("froala-editor"); +const { default: jQuery } = await import("jquery"); +await import("summernote/dist/summernote-lite.js"); + +interface CKEditor4 { + editable(): { $: HTMLElement }; + on(event: string, listener: () => void): void; + resetUndo(): void; + execCommand(command: string): boolean; + destroy(): void; +} +interface CKEditor4Global { + disableAutoInline: boolean; + config: Record; + inline(element: HTMLElement, config: Record): CKEditor4; +} +// CKEditor 4 ships as a script for a page, not as a module: run it with the +// window as its global scope. Its language and style files are run first, so +// the editor does not load them with