From ceae5ceb13d1517da1426099de750a311c793128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 7 Sep 2026 14:52:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=8E=B8=EC=A7=91=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9D=98=20host=20=EC=86=8C=EC=9C=A0=EA=B6=8C?= =?UTF-8?q?=EA=B3=BC=20composition=20=EC=88=98=EB=AA=85=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #711 --- docs/api-reference/web.md | 5 + package-lock.json | 6 +- .../contenteditable-collaboration/README.md | 4 + .../package.json | 2 + .../src/lease.ts | 46 +- .../tsconfig.json | 1 + .../json-document-contenteditable/README.md | 5 + .../package.json | 2 + .../src/lease.ts | 63 +-- .../tests/composition-tail.test.ts | 65 +++ .../tsconfig.json | 2 +- .../json-document-rich-text-react/README.md | 11 + .../benchmarks/render-store.mjs | 4 +- .../src/index.tsx | 35 +- .../src/render-store.ts | 51 +- .../tests/render-locality.test.tsx | 16 +- .../tests/surface-lifecycle.test.tsx | 447 ++++++++++++++++++ .../json-document-rich-text-web/README.md | 11 + .../src/contenteditable.ts | 76 ++- .../tests/input-ownership.test.ts | 124 +++++ packages/json-document-web/README.md | 22 + packages/json-document-web/src/index.ts | 2 +- packages/json-document-web/src/input.ts | 15 + .../tests/editing-host.test.ts | 50 ++ .../clipboard/ClipboardAdapterDemoRoute.tsx | 16 +- .../clipboard/ClipboardAdapterLab.tsx | 11 +- .../src/shared/demo-workbench/demo-sources.ts | 5 + site/tests/browser/rich-text-demo.spec.ts | 56 +++ site/tests/unit/demo-workbench.test.tsx | 6 + standards/dom-editing-lifecycle.md | 11 +- .../corpora/browser.json | 32 +- 31 files changed, 1018 insertions(+), 184 deletions(-) create mode 100644 packages/json-document-contenteditable/tests/composition-tail.test.ts create mode 100644 packages/json-document-rich-text-react/tests/surface-lifecycle.test.tsx create mode 100644 packages/json-document-rich-text-web/tests/input-ownership.test.ts create mode 100644 packages/json-document-web/tests/editing-host.test.ts diff --git a/docs/api-reference/web.md b/docs/api-reference/web.md index 413b9eee4..27e6f6e49 100644 --- a/docs/api-reference/web.md +++ b/docs/api-reference/web.md @@ -163,6 +163,11 @@ gridBoundary(topology: GridTopology, point: GridPoint, edge: "start" | "end"): G ```ts isWebEditableTarget(target: object | null): boolean ``` +## `isWebEditingHostTarget` + +```ts +isWebEditingHostTarget(root: object, target: object | null): boolean +``` ## `kanbanCardDropTargetFromWebElement` ```ts diff --git a/package-lock.json b/package-lock.json index 879bc0812..f6d8ebe44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6409,6 +6409,7 @@ "@interactive-os/json-document": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-contenteditable": "*", + "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "jsdom": "^29.1.1", "typescript": "^5.0.0", @@ -6417,7 +6418,8 @@ "peerDependencies": { "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-collaboration": "^0.2.0-rc.1", - "@interactive-os/json-document-contenteditable": "^0.1.0-rc.0" + "@interactive-os/json-document-contenteditable": "^0.1.0-rc.0", + "@interactive-os/json-document-web": "^0.1.0-rc.0" } }, "packages/json-document": { @@ -6585,6 +6587,7 @@ "license": "MIT", "devDependencies": { "@interactive-os/json-document": "*", + "@interactive-os/json-document-web": "*", "@testing-library/react": "^16.3.2", "@types/node": "^25.9.0", "@types/react": "^19.2.14", @@ -6598,6 +6601,7 @@ }, "peerDependencies": { "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-web": "^0.1.0-rc.0", "react": "^18.0.0 || ^19.0.0" } }, diff --git a/packages/contenteditable-collaboration/README.md b/packages/contenteditable-collaboration/README.md index e961ab910..a5a2df50c 100644 --- a/packages/contenteditable-collaboration/README.md +++ b/packages/contenteditable-collaboration/README.md @@ -1,5 +1,9 @@ # json-document-contenteditable-collaboration +Editing-host ownership is supplied by the Web peer's +`isWebEditingHostTarget` capability. This Draft revision must be consumed with +that Web revision; causal capture/plan/commit and wire formats are unchanged. + IME-safe native-input DOM lease for `@interactive-os/json-document-collaboration/text`. diff --git a/packages/contenteditable-collaboration/package.json b/packages/contenteditable-collaboration/package.json index 20e84ec67..4893177a9 100644 --- a/packages/contenteditable-collaboration/package.json +++ b/packages/contenteditable-collaboration/package.json @@ -47,11 +47,13 @@ }, "peerDependencies": { "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-web": "^0.1.0-rc.0", "@interactive-os/json-document-collaboration": "^0.2.0-rc.1", "@interactive-os/json-document-contenteditable": "^0.1.0-rc.0" }, "devDependencies": { "@interactive-os/json-document": "*", + "@interactive-os/json-document-web": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-contenteditable": "*", "@types/node": "^25.9.0", diff --git a/packages/contenteditable-collaboration/src/lease.ts b/packages/contenteditable-collaboration/src/lease.ts index 5a311f6d8..f27d887c5 100644 --- a/packages/contenteditable-collaboration/src/lease.ts +++ b/packages/contenteditable-collaboration/src/lease.ts @@ -3,6 +3,7 @@ import type { TextSelection, } from "@interactive-os/json-document-collaboration/text"; import { plainTextDOMAdapter } from "@interactive-os/json-document-contenteditable"; +import { isWebEditingHostTarget } from "@interactive-os/json-document-web"; import type { ContentEditableAdapter, ContentEditableOptions, @@ -309,7 +310,7 @@ export function createContentEditableAdapter({ }; const boundHandle = (event: Event): void => { - if (!eventBelongsToRoot(event, root)) return; + if (!isWebEditingHostTarget(root, event.target)) return; report(handleInternal(event)); }; @@ -363,49 +364,6 @@ export function createContentEditableAdapter({ }); } -function eventBelongsToRoot(event: Event, root: HTMLElement): boolean { - const target = event.target; - if (!(target instanceof root.ownerDocument.defaultView!.Node)) return false; - if (target === root) return true; - if (!root.contains(target)) return false; - - let element = target instanceof root.ownerDocument.defaultView!.Element - ? target - : target.parentElement; - while (element !== null && element !== root) { - const tag = element.tagName.toLowerCase(); - if ( - tag === "input" - || tag === "textarea" - || tag === "select" - || tag === "option" - ) { - return false; - } - const editable = element.getAttribute("contenteditable"); - const editableProperty = "contentEditable" in element - && typeof element.contentEditable === "string" - ? element.contentEditable.toLowerCase() - : ""; - if ( - (editable !== null || editableProperty !== "") - && ( - editable === "" - || editable?.toLowerCase() === "true" - || editable?.toLowerCase() === "plaintext-only" - || editable?.toLowerCase() === "false" - || editableProperty === "true" - || editableProperty === "plaintext-only" - || editableProperty === "false" - ) - ) { - return false; - } - element = element.parentElement; - } - return true; -} - function isCompositionInput(event: Event): boolean { const inputType = "inputType" in event && typeof event.inputType === "string" diff --git a/packages/contenteditable-collaboration/tsconfig.json b/packages/contenteditable-collaboration/tsconfig.json index d609537e6..8af3ab764 100644 --- a/packages/contenteditable-collaboration/tsconfig.json +++ b/packages/contenteditable-collaboration/tsconfig.json @@ -18,6 +18,7 @@ "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ + { "path": "../json-document-web" }, { "path": "../json-document" }, { "path": "../json-document-collaboration" }, { "path": "../json-document-contenteditable" } diff --git a/packages/json-document-contenteditable/README.md b/packages/json-document-contenteditable/README.md index a732eb26c..cd8f25ebe 100644 --- a/packages/json-document-contenteditable/README.md +++ b/packages/json-document-contenteditable/README.md @@ -21,3 +21,8 @@ immediately and cannot replace the leased root. Release renders the latest string at the bound pointer. Removing this package does not change canonical JSON or Editing semantics. + +Composition-tail input and timer completion both publish the latest model, +including changes received just after compositionend. They never author a +second composition commit. Native nested controls retain their own input. +This Draft revision requires the Web peer's `isWebEditingHostTarget` capability. diff --git a/packages/json-document-contenteditable/package.json b/packages/json-document-contenteditable/package.json index 73c3fbe3f..28f58bab1 100644 --- a/packages/json-document-contenteditable/package.json +++ b/packages/json-document-contenteditable/package.json @@ -34,10 +34,12 @@ }, "peerDependencies": { "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-web": "^0.1.0-rc.0", "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@interactive-os/json-document": "*", + "@interactive-os/json-document-web": "*", "@testing-library/react": "^16.3.2", "@types/node": "^25.9.0", "@types/react": "^19.2.14", diff --git a/packages/json-document-contenteditable/src/lease.ts b/packages/json-document-contenteditable/src/lease.ts index 2a7309af4..d0faae2be 100644 --- a/packages/json-document-contenteditable/src/lease.ts +++ b/packages/json-document-contenteditable/src/lease.ts @@ -1,4 +1,5 @@ import { plainTextDOMAdapter } from "./dom/plain-text.js"; +import { isWebEditingHostTarget } from "@interactive-os/json-document-web"; import type { ContentEditableBinding, ContentEditableBindingOptions, @@ -76,6 +77,11 @@ export function createContentEditableBinding({ trailingComposition = false; }; + const finishTrailing = (): void => { + clearTrailing(); + renderLatest(undefined, true); + }; + const clearActiveLease = (): void => { const fallback = activeLease?.nativeFallback; if (fallback !== null && fallback !== undefined) clearTimeout(fallback); @@ -169,14 +175,14 @@ export function createContentEditableBinding({ if (event.type === "beforeinput") { if (trailingComposition) { if (isCompositionInput(event)) return NO_CHANGE; - clearTrailing(); + finishTrailing(); } if (activeLease !== null) return NO_CHANGE; return begin("native", event); } if (event.type === "compositionstart") { - if (trailingComposition) clearTrailing(); + if (trailingComposition) finishTrailing(); return begin("composing", event); } @@ -185,16 +191,13 @@ export function createContentEditableBinding({ activeLease.phase = "composing"; const result = commitObservation(); trailingComposition = true; - trailingTimer = setTimeout(() => { - trailingTimer = null; - trailingComposition = false; - }, 0); + trailingTimer = setTimeout(finishTrailing, 0); return result; } if (event.type === "input") { if (trailingComposition) { - clearTrailing(); + finishTrailing(); return NO_CHANGE; } if (activeLease?.phase === "composing") return NO_CHANGE; @@ -210,7 +213,7 @@ export function createContentEditableBinding({ }; const boundHandle = (event: Event): void => { - if (!eventBelongsToRoot(event, root)) return; + if (!isWebEditingHostTarget(root, event.target)) return; handleInternal(event); }; @@ -261,50 +264,6 @@ export function createContentEditableBinding({ }); } -function eventBelongsToRoot(event: Event, root: HTMLElement): boolean { - const target = event.target; - const view = root.ownerDocument.defaultView; - if (view === null || !(target instanceof view.Node)) return false; - if (target === root) return true; - if (!root.contains(target)) return false; - - let element = target instanceof view.Element - ? target - : target.parentElement; - while (element !== null && element !== root) { - const tag = element.tagName.toLowerCase(); - if ( - tag === "input" - || tag === "textarea" - || tag === "select" - || tag === "option" - ) { - return false; - } - const editable = element.getAttribute("contenteditable"); - const editableProperty = "contentEditable" in element - && typeof element.contentEditable === "string" - ? element.contentEditable.toLowerCase() - : ""; - if ( - (editable !== null || editableProperty !== "") - && ( - editable === "" - || editable?.toLowerCase() === "true" - || editable?.toLowerCase() === "plaintext-only" - || editable?.toLowerCase() === "false" - || editableProperty === "true" - || editableProperty === "plaintext-only" - || editableProperty === "false" - ) - ) { - return false; - } - element = element.parentElement; - } - return true; -} - function isCompositionInput(event: Event): boolean { const inputType = "inputType" in event && typeof event.inputType === "string" diff --git a/packages/json-document-contenteditable/tests/composition-tail.test.ts b/packages/json-document-contenteditable/tests/composition-tail.test.ts new file mode 100644 index 000000000..3f0dc7dcf --- /dev/null +++ b/packages/json-document-contenteditable/tests/composition-tail.test.ts @@ -0,0 +1,65 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { afterEach, expect, it, vi } from "vitest"; +import { createContentEditableBinding } from "../src/index.js"; + +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +it.each(["timer", "trailing-input"])( + "publishes the latest model when composition tail ends via %s", + (ending) => { + vi.useFakeTimers(); + const json = createJSONDocument({ title: "ab" }); + const root = document.createElement("div"); + root.contentEditable = "true"; + document.body.append(root); + const binding = createContentEditableBinding({ + document: json, + pointer: "/title", + root, + }); + const unbind = binding.bind(); + try { + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + root.textContent = "ab한"; + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ); + expect(json.at("/title")).toMatchObject({ ok: true, value: "ab한" }); + json.commit([{ op: "replace", path: "/title", value: "latest" }]); + if (ending === "timer") vi.runAllTimers(); + else + root.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType: "insertFromComposition", + data: "한", + }), + ); + expect(root.textContent).toBe("latest"); + root.dispatchEvent( + new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: "!", + }), + ); + root.textContent += "!"; + root.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType: "insertText", + data: "!", + }), + ); + expect(json.at("/title")).toMatchObject({ ok: true, value: "latest!" }); + } finally { + unbind(); + } + }, +); diff --git a/packages/json-document-contenteditable/tsconfig.json b/packages/json-document-contenteditable/tsconfig.json index 8a00949f4..637eaccb2 100644 --- a/packages/json-document-contenteditable/tsconfig.json +++ b/packages/json-document-contenteditable/tsconfig.json @@ -5,6 +5,6 @@ "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, - "references": [{ "path": "../json-document" }], + "references": [{ "path": "../json-document" }, { "path": "../json-document-web" }], "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/packages/json-document-rich-text-react/README.md b/packages/json-document-rich-text-react/README.md index 73b0d9db0..45b863d5d 100644 --- a/packages/json-document-rich-text-react/README.md +++ b/packages/json-document-rich-text-react/README.md @@ -29,6 +29,17 @@ the final native DOM diff into the canonical document, the surface resumes from the committed snapshot. This prevents React renders from terminating Korean jamo composition or duplicating the final `insertText` event. +The render lease belongs to each mounted surface, not to the shared editor. +Other surfaces continue observing the model while one surface composes. Release +catches up every intervening model change and restores the affected block's DOM +and selection even after cancellation or rejected input. Ordinary typing keeps +the incremental render path. + +Changes to `onAction` or `createId` use the latest callbacks without restarting +the binding. An editor change, `as` root replacement, or unmount ends the old +binding and its active composition. Hosts need not memoize callbacks to protect +native input. + The official editable surface enforces `white-space: pre-wrap` so consecutive U+0020 spaces remain visible and caret geometry stays aligned with canonical UTF-16 offsets. Other host-provided inline styles are preserved. diff --git a/packages/json-document-rich-text-react/benchmarks/render-store.mjs b/packages/json-document-rich-text-react/benchmarks/render-store.mjs index bebe77725..5174ab29a 100644 --- a/packages/json-document-rich-text-react/benchmarks/render-store.mjs +++ b/packages/json-document-rich-text-react/benchmarks/render-store.mjs @@ -4,7 +4,7 @@ import { createRichTextEditor, } from "@interactive-os/json-document-rich-text"; import { benchmarkConfig, measure, reportScaling } from "../../../benchmarks/measure.mjs"; -import { richTextRenderStore } from "../dist/render-store.js"; +import { createRichTextRenderStore } from "../dist/render-store.js"; const config = benchmarkConfig("PERF_RICH_TEXT_REACT_BLOCKS", [1_000, 10_000]); const rows = []; @@ -19,7 +19,7 @@ for (const size of config.sizes) { document: createJSONDocument(createRichTextBlockFixture(size, { idPrefix: `p${size}` })), selection: collapsed(`p${size}-text-${middle}`, 1), }); - richTextRenderStore(editor).subscribePlaceholder(() => {}); + createRichTextRenderStore(editor).subscribePlaceholder(() => {}); return () => editor.dispatch({ type: "text.insert", text: "y" }).ok; }); rows.push({ size, ...result }); diff --git a/packages/json-document-rich-text-react/src/index.tsx b/packages/json-document-rich-text-react/src/index.tsx index 723c6ebe8..422d24ad2 100644 --- a/packages/json-document-rich-text-react/src/index.tsx +++ b/packages/json-document-rich-text-react/src/index.tsx @@ -5,12 +5,14 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useSyncExternalStore, type HTMLAttributes, type ReactNode, } from "react"; import { + createRichTextNodeId, hasRichTextContent, isRichTextText, richTextPlainText, @@ -30,7 +32,7 @@ import { recordRichTextBlockRender, recordRichTextSurfaceRender, } from "./render-instrument.js"; -import { richTextRenderStore } from "./render-store.js"; +import { createRichTextRenderStore, type RichTextRenderStore } from "./render-store.js"; export interface RichTextRendererProps { readonly document: RichTextDocument; @@ -70,7 +72,7 @@ export interface RichTextEditorSurfaceProps extends Omit createRichTextRenderStore(editor), [editor]); const blockIds = useSyncExternalStore(store.subscribeStructure, store.getBlockIds, store.getBlockIds); const documentId = useSyncExternalStore( store.subscribeStructure, @@ -84,6 +86,8 @@ export function RichTextEditorSurface({ editor, as = "article", createId, onActi ); const rootRef = useRef(null); const bindingRef = useRef(null); + const callbacks = useRef({ createId, onAction }); + useLayoutEffect(() => { callbacks.current = { createId, onAction }; }); const assignRootRef = useCallback((node: HTMLElement | null) => { rootRef.current = node; if (elementRef) elementRef.current = node; @@ -95,15 +99,16 @@ export function RichTextEditorSurface({ editor, as = "article", createId, onActi const binding = createRichTextContentEditableBinding({ root, editor, - ...(createId === undefined ? {} : { createId }), - ...(onAction === undefined ? {} : { onAction }), + createId: () => callbacks.current.createId?.() ?? createRichTextNodeId(), + onAction: (action, result) => callbacks.current.onAction?.(action, result), + onCompositionChange: (composing) => store.setComposing(composing), }); bindingRef.current = binding; return () => { binding.destroy(); bindingRef.current = null; }; - }, [createId, editor, onAction]); + }, [as, editor, store]); return createElement(as, { ...props, @@ -116,11 +121,11 @@ export function RichTextEditorSurface({ editor, as = "article", createId, onActi "data-rich-text-empty": placeholderBlockId === null ? "false" : "true", ...(placeholder === undefined ? {} : { "aria-placeholder": placeholder }), }, [ - , + , ...blockIds.map((nodeId) => ( editor.subscribe(() => notify()), () => editor.snapshot.selection, @@ -152,12 +160,12 @@ function SelectionRestorer({ if (!bindingRef.current?.isComposing() && root?.contains(root.ownerDocument.activeElement)) { bindingRef.current?.restoreSelection(); } - }, [bindingRef, editor, rootRef, selection]); + }, [bindingRef, editor, rootRef, selection, renderRevision]); return null; } function RichTextBlockSlot({ - editor, + store, nodeId, schema, editable, @@ -166,7 +174,7 @@ function RichTextBlockSlot({ renderExtensionMark, renderUnknown, }: { - readonly editor: RichTextEditor; + readonly store: RichTextRenderStore; readonly nodeId: string; readonly schema: RichTextSchema; readonly editable: boolean; @@ -175,15 +183,20 @@ function RichTextBlockSlot({ readonly renderExtensionMark: RichTextRendererProps["renderExtensionMark"]; readonly renderUnknown: RichTextRendererProps["renderUnknown"]; }) { - const store = richTextRenderStore(editor); const node = useSyncExternalStore( (notify) => store.subscribeNode(nodeId, notify), () => store.getNode(nodeId), () => store.getNode(nodeId), ); + const version = useSyncExternalStore( + (notify) => store.subscribeNode(nodeId, notify), + () => store.getNodeVersion(nodeId), + () => store.getNodeVersion(nodeId), + ); if (node === null) return null; return ( ; getDocumentId(): string; getNode(nodeId: string): RichTextNode | null; @@ -18,17 +21,7 @@ export interface RichTextRenderStore { subscribeStructure(notify: () => void): () => void; } -const stores = new WeakMap(); - -export function richTextRenderStore(editor: RichTextEditor): RichTextRenderStore { - const cached = stores.get(editor); - if (cached) return cached; - const created = createRichTextRenderStore(editor); - stores.set(editor, created); - return created; -} - -function createRichTextRenderStore(editor: RichTextEditor): RichTextRenderStore { +export function createRichTextRenderStore(editor: RichTextEditor): RichTextRenderStore { const pointer = editor.pointer ?? ""; let document = documentAtPointer(editor.snapshot.value, pointer); let blockIds: ReadonlyArray = document.content.map((node) => node.id); @@ -39,14 +32,19 @@ function createRichTextRenderStore(editor: RichTextEditor): RichTextRenderStore const placeholderListeners = new Set<() => void>(); const structureListeners = new Set<() => void>(); let unsubscribeEditor: (() => void) | null = null; + let composing = false; + let composingBlockId: string | undefined; + const nodeVersions = new Map(); + let renderRevision = 0; - function synchronize(snapshot = editor.snapshot): void { + function synchronize(snapshot = editor.snapshot, catchUp = false): void { + if (composing) return; const next = documentAtPointer(snapshot.value, pointer); if (next === document) return; const previous = document; document = next; // A disconnected store can have missed more than the latest change. - const recorded = unsubscribeEditor === null ? null : appliedOperationsFor(next); + const recorded = catchUp || unsubscribeEditor === null ? null : appliedOperationsFor(next); const applied = recorded === null ? null : relativeOperations(recorded, pointer); if (placeholderListeners.size > 0) { const nextPlaceholderBlockId = applied !== null && !contentStructureChanged(applied) @@ -97,6 +95,33 @@ function createRichTextRenderStore(editor: RichTextEditor): RichTextRenderStore } return { + setComposing(next) { + if (composing === next) return; + if (next) { + synchronize(); + const selection = editor.snapshot.selection; + const point = selection.primaryIndex === null ? undefined : selection.ranges[selection.primaryIndex]?.anchor; + const path = point === undefined ? undefined : richTextTopology(document).locate(point.nodeId)?.path; + const index = path?.[0] ?? (point?.kind === "child" && path?.length === 0 ? point.offset : undefined); + composingBlockId = index === undefined ? undefined : document.content[index]?.id; + composing = true; + return; + } + composing = false; + // More than one change may have arrived during this root's DOM lease. + synchronize(editor.snapshot, true); + renderRevision++; + if (composingBlockId !== undefined) { + // Native DOM can differ even after cancellation or a rejected commit. + // Replace only its block's React boundary, not the whole document. + nodeVersions.set(composingBlockId, renderRevision); + notifyNode(composingBlockId, nodeListeners); + composingBlockId = undefined; + } + for (const notify of structureListeners) notify(); + }, + getNodeVersion(nodeId) { return nodeVersions.get(nodeId) ?? 0; }, + getRenderRevision() { return renderRevision; }, getBlockIds() { synchronize(); return blockIds; }, getDocumentId() { synchronize(); return document.id; }, getNode(nodeId) { diff --git a/packages/json-document-rich-text-react/tests/render-locality.test.tsx b/packages/json-document-rich-text-react/tests/render-locality.test.tsx index 2453f7e0b..f147f77b5 100644 --- a/packages/json-document-rich-text-react/tests/render-locality.test.tsx +++ b/packages/json-document-rich-text-react/tests/render-locality.test.tsx @@ -17,7 +17,7 @@ import { observeRichTextBlockRenders, observeRichTextSurfaceRenders, } from "../src/render-instrument.js"; -import { richTextRenderStore } from "../src/render-store.js"; +import { createRichTextRenderStore } from "../src/render-store.js"; describe("Rich Text React locality", () => { it("catches up after disconnected structural and leaf edits", () => { @@ -25,7 +25,7 @@ describe("Rich Text React locality", () => { document: createJSONDocument(createRichTextBlockFixture(3, { idPrefix: "offline" })), selection: collapsed("offline-text-0", 1), }); - const store = richTextRenderStore(editor); + const store = createRichTextRenderStore(editor); const unsubscribe = store.subscribeStructure(() => {}); unsubscribe(); expect(editor.dispatch({ type: "node.move", nodeId: "offline-0", point: { @@ -58,7 +58,7 @@ describe("Rich Text React locality", () => { point: { kind: "child", nodeId: (editor.snapshot.value as { id: string }).id, offset: 3, affinity: "forward" }, }).ok).toBe(true); }); - expect(richTextRenderStore(editor).getBlockIds()).toEqual(["move-1", "move-2", "move-0"]); + expect(createRichTextRenderStore(editor).getBlockIds()).toEqual(["move-1", "move-2", "move-0"]); expect([...container.querySelectorAll("p[data-rich-text-node-id]")].map((node) => node.getAttribute("data-rich-text-node-id"))).toEqual(["move-1", "move-2", "move-0"]); await act(async () => root.unmount()); expect(active).toBe(0); @@ -136,7 +136,7 @@ describe("Rich Text React locality", () => { document: jsonDocument, selection: collapsed("scan-text-5000", 1), }); - const store = richTextRenderStore(editor); + const store = createRichTextRenderStore(editor); const notified: string[] = []; store.subscribeNode("scan-5000", () => notified.push("scan-5000")); store.subscribeNode("scan-0", () => notified.push("scan-0")); @@ -151,7 +151,7 @@ describe("Rich Text React locality", () => { document: jsonDocument, selection: collapsed("placeholder-text-5000", 1), }); - const store = richTextRenderStore(editor); + const store = createRichTextRenderStore(editor); let notifies = 0; store.subscribePlaceholder(() => { notifies += 1; }); @@ -166,7 +166,7 @@ describe("Rich Text React locality", () => { document: jsonDocument, selection: collapsed("plain-text-5000", 1), }); - const unsubscribe = richTextRenderStore(editor).subscribeStructure(() => {}); + const unsubscribe = createRichTextRenderStore(editor).subscribeStructure(() => {}); expect(editor.dispatch({ type: "text.insert", text: "y" }).ok).toBe(true); expect(lastPlaceholderScan()).toBe(0); @@ -179,7 +179,7 @@ describe("Rich Text React locality", () => { document: jsonDocument, selection: collapsed("split-text-1", 1), }); - const store = richTextRenderStore(editor); + const store = createRichTextRenderStore(editor); let structureNotifies = 0; store.subscribeStructure(() => { structureNotifies += 1; }); const beforeIds = store.getBlockIds(); @@ -196,7 +196,7 @@ describe("Rich Text React locality", () => { pointer: "/instruction", selection: collapsed("nested-text-0", 1), }); - const store = richTextRenderStore(editor); + const store = createRichTextRenderStore(editor); expect(store.getDocumentId()).toBe(value.id); expect(store.getBlockIds()).toEqual(["nested-0", "nested-1"]); diff --git a/packages/json-document-rich-text-react/tests/surface-lifecycle.test.tsx b/packages/json-document-rich-text-react/tests/surface-lifecycle.test.tsx new file mode 100644 index 000000000..fb4bdb7de --- /dev/null +++ b/packages/json-document-rich-text-react/tests/surface-lifecycle.test.tsx @@ -0,0 +1,447 @@ +/** @vitest-environment jsdom */ +import { createJSONDocument } from "@interactive-os/json-document"; +import { createRichTextEditor } from "@interactive-os/json-document-rich-text"; +import { act, StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { RichTextEditorSurface } from "../src/index.js"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; +const disposers: (() => Promise)[] = []; +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose(); + document.body.replaceChildren(); +}); + +async function fixture(rejectComposition = false) { + const json = createJSONDocument( + { + profile: "urn:interactive-os:json-document:rich-text:1", + id: "doc", + type: "doc", + content: [ + { + id: "p", + type: "paragraph", + content: [{ id: "t", type: "text", text: "abc", marks: [] }], + }, + ], + }, + { + validate: (candidate) => + rejectComposition && JSON.stringify(candidate).includes("한") + ? { ok: false, code: "schema_violation" } + : { ok: true }, + }, + ); + const point = { + kind: "text" as const, + nodeId: "t", + offset: 3, + affinity: "forward" as const, + }; + const editor = createRichTextEditor({ + document: json, + selection: { + kind: "range", + ranges: [{ anchor: point, focus: point }], + primaryIndex: 0, + }, + }); + const container = document.createElement("div"); + document.body.append(container); + const reactRoot = createRoot(container); + disposers.push(async () => { + await act(async () => reactRoot.unmount()); + }); + await act(async () => + reactRoot.render(), + ); + const root = container.querySelector("article")!; + const text = root.querySelector('[data-rich-text-text-id="t"]')!.firstChild!; + root.focus(); + document.getSelection()!.setBaseAndExtent(text, 3, text, 3); + return { json, editor, root, text, reactRoot, container }; +} + +it("retains browser DOM ownership while model changes during composition", async () => { + const { json, editor, root, text, reactRoot } = await fixture(); + const actions: string[] = []; + await act(async () => + reactRoot.render( + actions.push(action)} + />, + ), + ); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abcㅎ"; + await act(async () => { + json.commit([ + { op: "replace", path: "/content/0/content/0/text", value: "abcREMOTE" }, + ]); + }); + expect(root.querySelector('[data-rich-text-text-id="t"]')!.textContent).toBe( + "abcㅎ", + ); + text.textContent = "abc한"; + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ), + ); + expect(actions).toContain("rich-text.composition-stale"); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "abcREMOTE", + }); + expect(root.textContent).toBe("abcREMOTE"); +}); + +it("restores rejected native DOM when canonical text never changed", async () => { + const { json, editor, root, text, reactRoot } = await fixture(true); + const actions: string[] = []; + await act(async () => + reactRoot.render( + actions.push(action)} + />, + ), + ); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abc한"; + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ), + ); + expect(actions).toContain("schema_violation"); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "abc", + }); + expect(root.textContent).toBe("abc"); + const restored = root.querySelector( + '[data-rich-text-text-id="t"]', + )!.firstChild!; + expect(restored).not.toBe(text); + expect(document.getSelection()!.anchorNode).toBe(restored); + expect(document.getSelection()!.anchorOffset).toBe(3); +}); + +it("commits composition without losing an unrelated model update", async () => { + const { json, root, text } = await fixture(); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abc한"; + await act(async () => + json.commit([ + { + op: "add", + path: "/content/-", + value: { + id: "q", + type: "paragraph", + content: [{ id: "u", type: "text", text: "remote", marks: [] }], + }, + }, + ]), + ); + expect(root.textContent).toBe("abc한"); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ), + ); + expect(root.textContent).toBe("abc한remote"); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "abc한", + }); + expect(json.at("/content/1/content/0/text")).toMatchObject({ + ok: true, + value: "remote", + }); +}); + +it("preserves an active composition across a callback-only parent rerender", async () => { + const { json, editor, root, text, reactRoot } = await fixture(); + const first = vi.fn(); + const latest = vi.fn(); + await act(async () => + reactRoot.render( + "before"} + />, + ), + ); + document.getSelection()!.setBaseAndExtent(text, 3, text, 3); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abcㅎ"; + await act(async () => + reactRoot.render( + "after"} + />, + ), + ); + text.textContent = "abc한"; + await act(async () => { + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ); + root.dispatchEvent( + new InputEvent("input", { + bubbles: true, + inputType: "insertFromComposition", + data: "한", + }), + ); + }); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "abc한", + }); + expect(latest.mock.calls.map(([action]) => action)).toContain( + "composition.commit", + ); + expect(first.mock.calls.map(([action]) => action)).not.toContain( + "composition.commit", + ); + const inserted = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertLineBreak", + }); + await act(async () => root.dispatchEvent(inserted)); + expect(editor.topology.locate("after")?.node.type).toBe("hardBreak"); +}); + +it("binds the new editable root when the public as prop changes", async () => { + const { json, editor, reactRoot, container, root: oldRoot } = await fixture(); + await act(async () => + oldRoot.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + oldRoot.querySelector( + '[data-rich-text-text-id="t"]', + )!.firstChild!.textContent = "abc한"; + await act(async () => + reactRoot.render(), + ); + const root = container.querySelector("section")!; + expect(root.textContent).toBe("abc"); + const text = root.querySelector('[data-rich-text-text-id="t"]')!.firstChild!; + document.getSelection()!.setBaseAndExtent(text, 3, text, 3); + const event = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: "X", + }); + await act(async () => root.dispatchEvent(event)); + expect({ + prevented: event.defaultPrevented, + value: json.at("/content/0/content/0/text"), + }).toMatchObject({ prevented: true, value: { ok: true, value: "abcX" } }); + const stale = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: "OLD", + }); + oldRoot.dispatchEvent(stale); + expect(stale.defaultPrevented).toBe(false); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "abcX", + }); +}); + +it("keeps a second surface live while one surface leases its DOM", async () => { + const { json, editor, reactRoot, container } = await fixture(); + await act(async () => + reactRoot.render( + <> + + + , + ), + ); + const [first, second] = Array.from(container.querySelectorAll("article")); + const text = first!.querySelector( + '[data-rich-text-text-id="t"]', + )!.firstChild!; + document.getSelection()!.setBaseAndExtent(text, 3, text, 3); + await act(async () => + first!.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abcㅎ"; + await act(async () => + json.commit([ + { op: "replace", path: "/content/0/content/0/text", value: "remote" }, + ]), + ); + expect(first!.textContent).toBe("abcㅎ"); + expect(second!.textContent).toBe("remote"); +}); + +it("catches up multiple structural and text changes when the composing target disappears", async () => { + const { json, editor, root, text, reactRoot } = await fixture(); + const actions: string[] = []; + await act(async () => + reactRoot.render( + actions.push(action)} + />, + ), + ); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abc한"; + await act(async () => { + json.commit([ + { + op: "add", + path: "/content/-", + value: { + id: "q", + type: "paragraph", + content: [{ id: "u", type: "text", text: "other", marks: [] }], + }, + }, + ]); + json.commit([{ op: "remove", path: "/content/0" }]); + json.commit([ + { op: "replace", path: "/content/0/content/0/text", value: "latest" }, + ]); + }); + expect(root.textContent).toBe("abc한"); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ), + ); + expect(root.textContent).toBe("latest"); + expect(actions).toContain("rich-text.composition-stale"); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "latest", + }); +}); + +it("publishes composition and restores a later cancelled lease without another edit", async () => { + const { json, root, text } = await fixture(); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abc한"; + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ), + ); + expect(root.textContent).toBe("abc한"); + // A second cancelled lease must not create another history entry. + const currentText = root.querySelector( + '[data-rich-text-text-id="t"]', + )!.firstChild!; + document.getSelection()!.setBaseAndExtent(currentText, 4, currentText, 4); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + await act(async () => + root.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "" }), + ), + ); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "abc한", + }); + expect(root.textContent).toBe("abc한"); +}); + +it("releases active composition and all subscriptions on editor replacement and StrictMode unmount", async () => { + const { json, editor, reactRoot, container } = await fixture(); + await act(async () => + reactRoot.render( + + + , + ), + ); + const oldRoot = container.querySelector("article")!; + const text = oldRoot.querySelector( + '[data-rich-text-text-id="t"]', + )!.firstChild!; + document.getSelection()!.setBaseAndExtent(text, 3, text, 3); + await act(async () => + oldRoot.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + text.textContent = "abc한"; + const replacement = createRichTextEditor({ + document: createJSONDocument(json.value), + }); + await act(async () => + reactRoot.render( + + + , + ), + ); + await act(async () => + oldRoot.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true, data: "한" }), + ), + ); + expect(json.at("/content/0/content/0/text")).toMatchObject({ + ok: true, + value: "abc", + }); + await act(async () => reactRoot.render(null)); + const event = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: "x", + }); + oldRoot.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); +}); diff --git a/packages/json-document-rich-text-web/README.md b/packages/json-document-rich-text-web/README.md index c7a9a3c82..bd6b77cbe 100644 --- a/packages/json-document-rich-text-web/README.md +++ b/packages/json-document-rich-text-web/README.md @@ -21,3 +21,14 @@ official React surface does this automatically. The Web package does not store canonical state in DOM and does not define product keyboard or toolbar policy. + +Nested form controls and separate editing hosts never dispatch outer-editor +commands. This revision uses the Web peer's `isWebEditingHostTarget` capability. +Replacement, yank, and transpose input accept both `data` and a `text/plain` +`dataTransfer` representation, using the supplied target ranges. + +If a composition endpoint's canonical node changes or disappears during its +lease, the binding reports `rich-text.composition-stale` through `onAction`, +does not insert against the stale selection, and releases rendering through +`onCompositionChange(false)`. Changes outside those endpoints may still commit. +This is fail-closed recovery, not collaborative semantic selection mapping. diff --git a/packages/json-document-rich-text-web/src/contenteditable.ts b/packages/json-document-rich-text-web/src/contenteditable.ts index 0594b7ab0..348db05f8 100644 --- a/packages/json-document-rich-text-web/src/contenteditable.ts +++ b/packages/json-document-rich-text-web/src/contenteditable.ts @@ -4,7 +4,7 @@ import type { RichTextSelection, } from "@interactive-os/json-document-rich-text"; import { createRichTextNodeId } from "@interactive-os/json-document-rich-text"; -import { createWebClipboardBinding, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; +import { createWebClipboardBinding, isWebEditingHostTarget, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; import { createRichTextClipboardCodec, createRichTextClipboardRepresentations } from "./clipboard.js"; export interface RichTextContentEditableBinding { @@ -39,7 +39,7 @@ export function createRichTextContentEditableBinding(options: { }); const beforeInput = (event: InputEvent) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; if (composition !== null && ( event.inputType.includes("Composition") || event.isComposing @@ -56,7 +56,6 @@ export function createRichTextContentEditableBinding(options: { const platformSelection = readSelectionFromInput(event); const normallyUsesPlatformRange = ![ "insertText", - "insertTranspose", "deleteContentBackward", "deleteContentForward", ].includes(event.inputType); @@ -69,9 +68,11 @@ export function createRichTextContentEditableBinding(options: { ? publishSelection(platformSelection) : null; if (targetSelection === null) syncSelection(); - if (["insertText", "insertReplacementText", "insertFromYank", "insertTranspose"].includes(event.inputType) && event.data !== null) { + const text = event.data ?? (event.dataTransfer?.types.includes("text/plain") + ? event.dataTransfer.getData("text/plain") : null); + if (["insertText", "insertReplacementText", "insertFromYank", "insertTranspose"].includes(event.inputType) && text !== null) { event.preventDefault(); - report("text.insert", editor.dispatch({ type: "text.insert", text: event.data })); + report("text.insert", editor.dispatch({ type: "text.insert", text })); } else if (event.inputType === "insertParagraph") { event.preventDefault(); report("block.split", editor.dispatch({ type: "block.split" })); @@ -109,7 +110,7 @@ export function createRichTextContentEditableBinding(options: { } }; const compositionStart = (event: CompositionEvent) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; if (composition !== null) finishComposition(true); const selection = syncSelection() ?? editor.snapshot.selection; const scope = compositionScope(root, selection); @@ -119,6 +120,10 @@ export function createRichTextContentEditableBinding(options: { element: scope.element, beforeText: scope.beforeText, scoped: scope.scoped, + basis: selection.ranges.flatMap(({ anchor, focus }) => [anchor, focus]).map((point) => ({ + nodeId: point.nodeId, + node: JSON.stringify(editor.topology.locate(point.nodeId)?.node), + })), phase: "composing", endData: null, }; @@ -126,7 +131,7 @@ export function createRichTextContentEditableBinding(options: { options.onAction?.("composition.start"); }; const compositionEnd = (event: CompositionEvent) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; if (composition === null) return; composition.phase = "ending"; composition.endData = event.data; @@ -135,32 +140,32 @@ export function createRichTextContentEditableBinding(options: { compositionEndTimer = setTimeout(() => finishComposition(true), 30); }; const input = (event: Event) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; if (composition?.phase === "ending") queueMicrotask(() => finishComposition(false)); }; const copy = (event: ClipboardEvent) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; syncSelection(); reportClipboard("clipboard.copy", clipboard.copy(asWebEvent(event))); }; const cut = (event: ClipboardEvent) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; syncSelection(); reportClipboard("clipboard.cut", clipboard.cut(asWebEvent(event))); }; const paste = (event: ClipboardEvent) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; syncSelection(); reportClipboard("clipboard.paste", clipboard.paste(asWebEvent(event))); }; const selectionChanged = (event: Event) => { - if (eventBelongsToEditingRoot(root, event) && composition === null && !renderPending) syncSelection(); + if (isWebEditingHostTarget(root, event.target) && composition === null && !renderPending) syncSelection(); }; const documentSelectionChanged = () => { if (composition === null && !renderPending) syncSelection(); }; const keyDown = (event: KeyboardEvent) => { - if (!eventBelongsToEditingRoot(root, event)) return; + if (!isWebEditingHostTarget(root, event.target)) return; if ((event.key === "Backspace" || event.key === "Delete") && !event.metaKey && !event.ctrlKey && !event.altKey && !event.isComposing && composition === null) { const direction = event.key === "Backspace" ? "backward" : "forward"; event.preventDefault(); @@ -233,13 +238,22 @@ export function createRichTextContentEditableBinding(options: { return; } - report("selection.set", editor.dispatch({ type: "selection.set", selection: lease.selection })); - report("composition.commit", editor.dispatch({ - type: "text.insert", - text: diff.inserted, - historyGroup: lease.id, - })); - options.onCompositionChange?.(false); + try { + if (lease.basis.some(({ nodeId, node }) => JSON.stringify(editor.topology.locate(nodeId)?.node) !== node)) { + report("composition.commit", { ok: false, code: "rich-text.composition-stale" }); + return; + } + const selected = editor.dispatch({ type: "selection.set", selection: lease.selection }); + report("selection.set", selected); + if (!selected.ok) return; + report("composition.commit", editor.dispatch({ + type: "text.insert", + text: diff.inserted, + historyGroup: lease.id, + })); + } finally { + options.onCompositionChange?.(false); + } } function syncSelection(): RichTextSelection | null { @@ -288,6 +302,7 @@ interface CompositionLease { readonly element: HTMLElement; readonly beforeText: string; readonly scoped: boolean; + readonly basis: ReadonlyArray<{ readonly nodeId: string; readonly node: string | undefined }>; phase: "composing" | "ending"; endData: string | null; } @@ -369,7 +384,7 @@ export function restoreRichTextDOMSelection(root: HTMLElement, selection: RichTe function domPoint(root: HTMLElement, node: Node, offset: number): RichTextPoint | null { const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement; - if (!elementBelongsToEditingRoot(root, element)) return null; + if (!isWebEditingHostTarget(root, element)) return null; const textRoot = element?.closest("[data-rich-text-text-id]"); if (textRoot && root.contains(textRoot)) { const range = root.ownerDocument.createRange(); @@ -393,25 +408,6 @@ function domPoint(root: HTMLElement, node: Node, offset: number): RichTextPoint return { kind: "child", nodeId: containerId, offset: logicalOffset, affinity: "forward" }; } -function eventBelongsToEditingRoot(root: HTMLElement, event: Event): boolean { - const target = event.target; - const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; - return elementBelongsToEditingRoot(root, element); -} - -function elementBelongsToEditingRoot(root: HTMLElement, element: Element | null): boolean { - if (element === null || !root.contains(element)) return false; - let current: Element | null = element; - while (current !== null && current !== root) { - if (current.hasAttribute("contenteditable")) { - const value = current.getAttribute("contenteditable")?.toLowerCase(); - if (value !== "false" && value !== "inherit") return false; - } - current = current.parentElement; - } - return current === root; -} - function findDOMPoint(root: HTMLElement, point: RichTextPoint): { readonly node: Node; readonly offset: number } | null { const escaped = root.ownerDocument.defaultView?.CSS?.escape?.(point.nodeId) ?? point.nodeId.replaceAll('"', '\\"'); const element = root.querySelector(point.kind === "text" diff --git a/packages/json-document-rich-text-web/tests/input-ownership.test.ts b/packages/json-document-rich-text-web/tests/input-ownership.test.ts new file mode 100644 index 000000000..20624237e --- /dev/null +++ b/packages/json-document-rich-text-web/tests/input-ownership.test.ts @@ -0,0 +1,124 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { createRichTextEditor } from "@interactive-os/json-document-rich-text"; +import { afterEach, expect, it } from "vitest"; +import { createRichTextContentEditableBinding } from "../src/index.js"; + +const disposers: (() => void)[] = []; +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); + document.body.replaceChildren(); +}); + +function fixture() { + const json = createJSONDocument({ + profile: "urn:interactive-os:json-document:rich-text:1", + id: "doc", + type: "doc", + content: [ + { + id: "p", + type: "paragraph", + content: [{ id: "t", type: "text", text: "abc", marks: [] }], + }, + ], + }); + const point = { + kind: "text" as const, + nodeId: "t", + offset: 3, + affinity: "forward" as const, + }; + const editor = createRichTextEditor({ + document: json, + selection: { + kind: "range", + ranges: [{ anchor: point, focus: point }], + primaryIndex: 0, + }, + }); + const root = document.createElement("article"); + root.contentEditable = "true"; + root.dataset.richTextContainerId = "doc"; + root.innerHTML = + '

abc

'; + document.body.append(root); + const text = root.querySelector("span")!.firstChild!; + document.getSelection()!.setBaseAndExtent(text, 3, text, 3); + const binding = createRichTextContentEditableBinding({ root, editor }); + disposers.push(() => binding.destroy()); + return { json, editor, root, text }; +} + +it.each(["input", "textarea", "select"])( + "does not consume nested native %s input or clipboard", + (tag) => { + const { json, root } = fixture(); + const before = json.value; + const control = document.createElement(tag); + root.append(control); + control.focus(); + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Backspace", + }); + control.dispatchEvent(event); + expect({ prevented: event.defaultPrevented, value: json.value }).toEqual({ + prevented: false, + value: before, + }); + const beforeInput = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: "X", + }); + control.dispatchEvent(beforeInput); + expect(beforeInput.defaultPrevented).toBe(false); + for (const type of ["copy", "cut", "paste"]) { + const clipboard = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(clipboard, "clipboardData", { + value: { types: ["text/plain"], getData: () => "X", setData: () => {} }, + }); + control.dispatchEvent(clipboard); + expect(clipboard.defaultPrevented).toBe(false); + } + expect(json.value).toEqual(before); + }, +); + +it.each(["insertReplacementText", "insertFromYank", "insertTranspose"])( + "consumes %s carried in dataTransfer", + (inputType) => { + const { json, root, text } = fixture(); + const event = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType, + data: null, + }); + Object.defineProperties(event, { + dataTransfer: { + value: { + types: ["text/plain"], + getData: (type: string) => (type === "text/plain" ? "X" : ""), + }, + }, + getTargetRanges: { + value: () => [ + { + startContainer: text, + startOffset: 1, + endContainer: text, + endOffset: 2, + }, + ], + }, + }); + root.dispatchEvent(event); + expect({ + prevented: event.defaultPrevented, + value: json.at("/content/0/content/0/text"), + }).toMatchObject({ prevented: true, value: { ok: true, value: "aXc" } }); + }, +); diff --git a/packages/json-document-web/README.md b/packages/json-document-web/README.md index a57b25ede..74391a851 100644 --- a/packages/json-document-web/README.md +++ b/packages/json-document-web/README.md @@ -1,5 +1,27 @@ # @interactive-os/json-document-web +## Editing host ownership + +`isWebEditingHostTarget(root, target)` returns whether a DOM target belongs to +the supplied editing surface. It excludes nested input, textarea, select, +option, and explicit contenteditable boundaries (including `false`). Root and +inherited text targets are accepted. It uses the root's owner-document realm; +invalid or outside-root targets return false. This is distinct from +`isWebEditableTarget`, which identifies native editable targets without an owner. + +```ts +import { isWebEditingHostTarget } from "@interactive-os/json-document-web"; + +surface.addEventListener("copy", (event) => { + if (isWebEditingHostTarget(surface, event.target)) clipboard.copy(event); +}); +``` + +The plain local, collaborative text, and Rich Text bindings all consume this +same ownership predicate; each keeps its own model reconciliation contract. + +## Platform adapters + Official keyboard and clipboard adapters for the public editing contracts from `@interactive-os/json-document-editing` and `@interactive-os/json-document-selection`. diff --git a/packages/json-document-web/src/index.ts b/packages/json-document-web/src/index.ts index d505d2f4f..c928226ab 100644 --- a/packages/json-document-web/src/index.ts +++ b/packages/json-document-web/src/index.ts @@ -11,7 +11,7 @@ export { treeClipboardCodec, } from "./clipboard.js"; export { selectionOperationFromModifiers } from "./modifiers.js"; -export { isWebEditableTarget, textInputFromControl } from "./input.js"; +export { isWebEditableTarget, isWebEditingHostTarget, textInputFromControl } from "./input.js"; export { pressInteractionFromWeb } from "./press.js"; export { focusWebItem, webFocusItemProps } from "./focus-item.js"; export { findWebGridCell, webGridCellAddressProps } from "./grid-cell.js"; diff --git a/packages/json-document-web/src/input.ts b/packages/json-document-web/src/input.ts index 5acf0daf3..6749eec26 100644 --- a/packages/json-document-web/src/input.ts +++ b/packages/json-document-web/src/input.ts @@ -26,3 +26,18 @@ export function isWebEditableTarget(target: object | null): boolean { || (target instanceof HTMLElement && target.isContentEditable) || target.closest('[contenteditable]:not([contenteditable="false"])') !== null; } + +/** Whether a DOM target belongs to this host rather than a nested editing boundary. */ +export function isWebEditingHostTarget(root: object, target: object | null): boolean { + const view = (root as HTMLElement).ownerDocument?.defaultView; + if (!view || !(root instanceof view.HTMLElement) || !(target instanceof view.Node) || !root.contains(target)) return false; + let element = target instanceof view.Element ? target : target.parentElement; + while (element !== null && element !== root) { + if (["input", "textarea", "select", "option"].includes(element.localName)) return false; + const editable = (element.getAttribute("contenteditable") + ?? ("contentEditable" in element ? String(element.contentEditable) : "inherit")).toLowerCase(); + if (["", "true", "plaintext-only", "false"].includes(editable)) return false; + element = element.parentElement; + } + return element === root; +} diff --git a/packages/json-document-web/tests/editing-host.test.ts b/packages/json-document-web/tests/editing-host.test.ts new file mode 100644 index 000000000..a216b3678 --- /dev/null +++ b/packages/json-document-web/tests/editing-host.test.ts @@ -0,0 +1,50 @@ +/** @vitest-environment jsdom */ +import { afterEach, expect, it } from "vitest"; +import { isWebEditingHostTarget } from "../src/index.js"; + +afterEach(() => document.body.replaceChildren()); + +it("keeps root and inherited text targets, excluding every nested input boundary", () => { + const root = document.createElement("div"); + root.contentEditable = "true"; + root.innerHTML = + '

outer inherited

'; + document.body.append(root); + expect(isWebEditingHostTarget(root, root)).toBe(true); + expect( + isWebEditingHostTarget(root, root.querySelector("p")!.firstChild), + ).toBe(true); + expect( + isWebEditingHostTarget(root, root.querySelector("span")!.firstChild), + ).toBe(true); + for (const markup of [ + "", + "", + "", + "
x
", + '
x
', + '
x
', + '
x
', + ]) { + const container = document.createElement("div"); + container.innerHTML = markup; + root.append(container); + const control = container.firstElementChild!; + expect(isWebEditingHostTarget(root, control)).toBe(false); + if (control.firstChild) + expect(isWebEditingHostTarget(root, control.firstChild)).toBe(false); + } + expect(isWebEditingHostTarget(root, document.body)).toBe(false); + expect(isWebEditingHostTarget(root, null)).toBe(false); + expect(isWebEditingHostTarget({}, root)).toBe(false); +}); + +it("uses the root's owner-document realm", () => { + const frame = document.createElement("iframe"); + document.body.append(frame); + const root = frame.contentDocument!.createElement("div"); + root.innerHTML = "text"; + frame.contentDocument!.body.append(root); + expect(isWebEditingHostTarget(root, root.firstChild!.firstChild)).toBe(true); + expect(isWebEditingHostTarget(root, root.lastChild)).toBe(false); +}); diff --git a/site/src/routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx b/site/src/routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx index 37cd275c4..fcd0604bf 100644 --- a/site/src/routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx +++ b/site/src/routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx @@ -1,7 +1,9 @@ import { ClipboardAdapterLab } from "./ClipboardAdapterLab"; import { CatalogDemoPage } from "../../../shared/ui/catalog-demo-page"; -const connectionCode = `const clipboardSurface = createWebClipboardSurface({ +const connectionCode = `import { createWebClipboardSurface, documentClipboardCodec, isWebEditingHostTarget } from "@interactive-os/json-document-web"; + +const clipboardSurface = createWebClipboardSurface({ codec: documentClipboardCodec, read: () => editor.copy(), paste: (payload) => editor.dispatch({ @@ -11,7 +13,17 @@ const connectionCode = `const clipboardSurface = createWebClipboardSurface({ onResult: (result) => setAnnouncement(messageFor(result)), }); -return
;`; +return
{ + if (isWebEditingHostTarget(event.currentTarget, event.target)) clipboardSurface.onCopy(event); + }} + onCut={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) clipboardSurface.onCut(event); + }} + onPaste={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) clipboardSurface.onPaste(event); + }} +/>;`; export function ClipboardAdapterDemoRoute() { return ( diff --git a/site/src/routes/adapters/clipboard/ClipboardAdapterLab.tsx b/site/src/routes/adapters/clipboard/ClipboardAdapterLab.tsx index b4118dc07..623b377cc 100644 --- a/site/src/routes/adapters/clipboard/ClipboardAdapterLab.tsx +++ b/site/src/routes/adapters/clipboard/ClipboardAdapterLab.tsx @@ -9,6 +9,7 @@ import { DocumentTextControl, useEditing } from "@interactive-os/json-document-r import { createWebClipboardSurface, documentClipboardCodec, + isWebEditingHostTarget, } from "@interactive-os/json-document-web"; import { Inspector } from "../../../shared/ui/inspector"; import { SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; @@ -53,7 +54,15 @@ export function ClipboardAdapterLab() {
{ + if (isWebEditingHostTarget(event.currentTarget, event.target)) clipboardSurface.onCopy(event); + }} + onCut={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) clipboardSurface.onCut(event); + }} + onPaste={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) clipboardSurface.onPaste(event); + }} className={classes("p-4", ui.surface.raised, ui.state.focus)} >
diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index ec4815948..00585f0ae 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -280,6 +280,11 @@ const registeredUsageSources = new Map([ ["packages/json-document-zod/src/index.ts", zodSource], ]); const registeredPublicUsages = [ + { + packageName: "@interactive-os/json-document-web", + symbol: "isWebEditingHostTarget", + sourcePath: "packages/json-document-web/src/input.ts", + }, { packageName: "@interactive-os/json-document-editing", symbol: "createEditingSession", diff --git a/site/tests/browser/rich-text-demo.spec.ts b/site/tests/browser/rich-text-demo.spec.ts index d2bd56b50..47c31d5f7 100644 --- a/site/tests/browser/rich-text-demo.spec.ts +++ b/site/tests/browser/rich-text-demo.spec.ts @@ -1,5 +1,61 @@ import { expect, test, type Page } from "@playwright/test"; +test("Rich Text Lab leaves nested native input to its own host", async ({ page }) => { + await page.goto("/editing/rich-text"); + await setSelection(page, "text-editable", 3, 3); + const before = await json(page, "rich-text-document-json"); + const editor = page.getByTestId("rich-text-editor"); + await editor.evaluate(root => { + const control = document.createElement("textarea"); + control.dataset.testid = "nested-native-input"; + control.value = "inside"; + root.append(control); + }); + const control = page.getByTestId("nested-native-input"); + await control.focus(); + await control.evaluate(element => (element as HTMLTextAreaElement).setSelectionRange(6, 6)); + await control.press("Backspace"); + await expect(control).toHaveValue("insid"); + expect(await json(page, "rich-text-document-json")).toEqual(before); + await control.evaluate(element => element.remove()); +}); + +test("Rich Text Lab consumes replacement DataTransfer and target ranges", async ({ page }) => { + await page.goto("/editing/rich-text"); + const editor = page.getByTestId("rich-text-editor"); + const before = textNode(await json(page, "rich-text-document-json"), "text-editable").text; + const prevented = await editor.evaluate(root => { + const node = root.querySelector('[data-rich-text-text-id="text-editable"]')!.firstChild!; + const transfer = new DataTransfer(); + transfer.setData("text/plain", "X"); + const event = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertReplacementText", dataTransfer: transfer }); + Object.defineProperty(event, "getTargetRanges", { value: () => [new StaticRange({ startContainer: node, startOffset: 0, endContainer: node, endOffset: 1 })] }); + root.dispatchEvent(event); + return event.defaultPrevented; + }); + expect(prevented).toBe(true); + await expect.poll(async () => textNode(await json(page, "rich-text-document-json"), "text-editable").text).toBe(`X${before.slice(1)}`); +}); + +test("Rich Text Lab preserves leased DOM during a model change and recovers stale composition", async ({ page }) => { + await page.goto("/editing/rich-text"); + await setSelection(page, "text-editable", 3, 3); + const editor = page.getByTestId("rich-text-editor"); + const before = textNode(await json(page, "rich-text-document-json"), "text-editable").text; + await editor.evaluate(root => { + root.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + const node = root.querySelector('[data-rich-text-text-id="text-editable"]')!.firstChild!; + const text = node.textContent!; + node.textContent = `${text.slice(0, 3)}한${text.slice(3)}`; + }); + await page.getByRole("button", { name: "Apply sample intent" }).click(); + await expect.poll(async () => textNode(await json(page, "rich-text-document-json"), "text-editable").text).toBe(`${before} ✓`); + await expect(editor.locator('[data-rich-text-text-id="text-editable"]')).toHaveText(`${before.slice(0, 3)}한${before.slice(3)}`); + await editor.evaluate(root => root.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "한" }))); + await expect(editor.locator('[data-rich-text-text-id="text-editable"]')).toHaveText(`${before} ✓`); + await expect(page.getByText("last: rich-text.composition-stale", { exact: true })).toBeVisible(); +}); + test("Rich Text Lab commits contenteditable input through JSON Patch and restores history", async ({ page }) => { await page.goto("/editing/rich-text"); diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index ea55c7306..f9c8af68d 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -54,6 +54,12 @@ describe("DemoWorkbench", () => { }); describe("Demo definition and source discovery", () => { + test("exposes the canonical editing-host predicate in clipboard Usage", async () => { + const sources = await discoverDemoSources("routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx"); + const input = sources.find((file) => file.path === "packages/json-document-web/src/input.ts"); + expect(input).toBeDefined(); + expect(await input!.load()).toContain("export function isWebEditingHostTarget"); + }); test("registers Editing observation and move rendering owners through public Rich Text usage", async () => { const sources = await discoverDemoSources("routes/rich-text-demo/RichTextDemoRoute.tsx"); const session = sources.find((file) => file.path === "packages/json-document-editing/src/session.ts"); diff --git a/standards/dom-editing-lifecycle.md b/standards/dom-editing-lifecycle.md index c8d3e5d32..401f2ab63 100644 --- a/standards/dom-editing-lifecycle.md +++ b/standards/dom-editing-lifecycle.md @@ -83,6 +83,9 @@ bind - 초기 model을 DOM에 render한다. - Nested form control과 별도 contenteditable host의 event를 현재 host의 입력으로 취급하지 않는다. +- 세 binding의 root 소유권 판별은 Web `isWebEditingHostTarget`을 소비한다. + 명시적인 contenteditable=false descendant도 별도 경계이며, atom 전후의 + parent child-point mapping은 그대로 유지한다. - Unbind 또는 destroy는 listener, subscription, timer와 active lease를 해제한다. ### 2. Observe intent @@ -144,13 +147,19 @@ bind - Late composition event가 종료된 lease를 두 번째 commit으로 만들지 않는다. - Rich Text destroy는 pending composition timer를 해제하며 종료된 binding이 model operation을 만들지 않게 한다. +- React의 lease는 editor가 아니라 mounted surface별로 소유한다. Callback만 + 달라져도 lease를 재시작하지 않으며 실제 editor/root 교체와 unmount는 해제한다. +- Local composition tail의 timer/input 종료는 유예한 최신 model을 render한다. +- Rich Text composition endpoint가 외부 변경으로 바뀌거나 삭제되면 + `rich-text.composition-stale`로 거절하고 최신 model을 render한다. 이는 full + collaborative selection mapping을 추가하는 계약이 아니다. ## Event ownership | Event | Local string Binding | Collaborative text Adapter | Rich Text Binding | | --- | --- | --- | --- | | `beforeinput` | Lease 시작; browser mutation 허용 | Capture와 lease 시작; browser mutation 허용 | 지원 intent는 prevent하고 semantic dispatch; composition input은 browser에 맡김 | -| `input` | Non-composition DOM observation commit; composition tail 무시 | Non-composition plan/commit; composition tail selection reconcile | Ending composition DOM이 final인지 재검사 | +| `input` | Non-composition DOM observation commit; composition tail은 추가 commit 없이 latest render | Non-composition plan/commit; composition tail selection reconcile | Ending composition DOM이 final인지 재검사 | | `compositionstart` | Composing lease 시작 | Capture를 가진 composing lease 시작 | Selection과 scoped DOM text를 capture | | `compositionupdate` | 별도 listener 없음; browser DOM mutation으로 관찰 | 별도 listener 없음; browser DOM mutation으로 관찰 | 별도 listener 없음; scoped DOM mutation으로 관찰 | | `compositionend` | DOM string을 한 번 commit하고 trailing input guard 진입 | DOM observation을 plan/commit하고 tail reconciliation 진입 | Ending phase로 전환하고 microtask/timer에서 scoped diff commit | diff --git a/standards/json-document-rich-text-v1/corpora/browser.json b/standards/json-document-rich-text-v1/corpora/browser.json index 054b839b7..dbf6789e9 100644 --- a/standards/json-document-rich-text-v1/corpora/browser.json +++ b/standards/json-document-rich-text-v1/corpora/browser.json @@ -7,9 +7,9 @@ { "id": "composition", "status": "verified", - "observation": "composition 중 DOM mutation과 compositionend 뒤 trailing input의 순서는 엔진마다 다를 수 있다.", - "invariant": "한 composition session은 canonical document와 history를 정확히 한 번만 변경한다.", - "evidence": "korean-ime-composition", + "observation": "composition event 순서뿐 아니라 model 변경과 React callback/root 수명 변경이 native DOM을 덮어쓰거나 lease를 폐기할 수 있다.", + "invariant": "한 composition은 한 번만 commit하며 model ingestion은 계속되고 surface별 DOM lease는 종료 뒤 latest model로 복구된다.", + "evidence": "korean-ime-composition; composition-model-update-recovery (synthetic); packages/json-document-rich-text-react/tests/surface-lifecycle.test.tsx (synthetic)", "revisitWhen": "지원 엔진의 native IME 자동화 API 또는 Input Events 규약이 바뀔 때" }, { @@ -23,9 +23,9 @@ { "id": "deletion", "status": "verified", - "observation": "keydown과 beforeinput이 같은 Backspace/Delete 동작을 중복 보고할 수 있다.", - "invariant": "한 물리 입력은 character delete 또는 block join을 한 번만 dispatch한다.", - "evidence": "repeated-backspace-delete", + "observation": "keydown과 beforeinput은 같은 삭제를 중복 보고할 수 있고 nested native control의 event도 바깥 root까지 전달된다.", + "invariant": "올바른 editing host의 한 입력만 character delete 또는 block join을 한 번 dispatch한다.", + "evidence": "repeated-backspace-delete; nested-native-control-ownership", "revisitWhen": "keyboard/beforeinput ownership 또는 deletion intent가 바뀔 때" }, { @@ -57,7 +57,7 @@ "status": "synthetic", "observation": "spellchecker, extension, accessibility 도구가 React가 관리하는 descendant를 바꿀 수 있다.", "invariant": "composition lease 밖의 DOM 변경은 canonical model을 덮어쓰지 못하고 다음 publish에서 복구된다.", - "evidence": "unexpected-dom-mutation-recovery", + "evidence": "unexpected-dom-mutation-recovery; replacement-data-transfer (synthetic)", "revisitWhen": "renderer에 mutation observer 또는 외부 mutation recovery가 도입될 때" }, { @@ -78,6 +78,24 @@ } ], "cases": [ + { + "id": "nested-native-control-ownership", + "source": { "url": "https://w3c.github.io/input-events/", "meaning": "nested input·textarea·select는 바깥 Rich Text의 삭제·history를 변경하지 않는다" }, + "requiresNativeInput": false, + "browsers": ["chromium", "firefox", "webkit"] + }, + { + "id": "replacement-data-transfer", + "source": { "url": "https://w3c.github.io/input-events/", "meaning": "synthetic DataTransfer와 target range로 replacement payload를 model에 반영한다; native spellcheck 지원 증거는 아님" }, + "requiresNativeInput": false, + "browsers": ["chromium", "firefox", "webkit"] + }, + { + "id": "composition-model-update-recovery", + "source": { "url": "https://react.dev/reference/react/useEffect", "meaning": "synthetic composition 중 제품 command로 model을 변경해도 DOM lease를 보존하고 stale 종료에서 최신 model로 복구한다" }, + "requiresNativeInput": false, + "browsers": ["chromium", "firefox", "webkit"] + }, { "id": "beforeinput-insert-text", "source": { "url": "https://github.com/web-platform-tests/wpt/tree/master/input-events", "meaning": "insertText beforeinput는 Official Rich Text text.insert intent가 된다" }, From 67632511e86dd6b205b409b1c899515a68113721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 7 Sep 2026 16:00:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20WebKit=20replacement=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EC=9D=98=20=ED=95=A9=EC=84=B1=20payload?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EC=A1=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/tests/browser/rich-text-demo.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/tests/browser/rich-text-demo.spec.ts b/site/tests/browser/rich-text-demo.spec.ts index 47c31d5f7..ce5557926 100644 --- a/site/tests/browser/rich-text-demo.spec.ts +++ b/site/tests/browser/rich-text-demo.spec.ts @@ -29,6 +29,8 @@ test("Rich Text Lab consumes replacement DataTransfer and target ranges", async const transfer = new DataTransfer(); transfer.setData("text/plain", "X"); const event = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertReplacementText", dataTransfer: transfer }); + // WebKit omits dataTransfer from constructed InputEvents; supply the synthetic payload explicitly. + Object.defineProperty(event, "dataTransfer", { value: transfer }); Object.defineProperty(event, "getTargetRanges", { value: () => [new StaticRange({ startContainer: node, startOffset: 0, endContainer: node, endOffset: 1 })] }); root.dispatchEvent(event); return event.defaultPrevented;