diff --git a/CONTEXT.md b/CONTEXT.md
index 6d6d67f3..bfe2e1bc 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -12,6 +12,9 @@
- Authored Content: DesignDocument가 표현하고 ReactDesignRenderer가 실제 browser DOM으로 투영하는 사용자 디자인 내용. live DOM은 derived runtime output이지 document state가 아니다.
- Editor Runtime State: selection, camera, active tool/mode, focus, hover, DOM registration/measurement, overlay, guide, marquee, pointer draft와 live preview처럼 편집 세션 동안만 유효한 상태. DesignDocument, authored history payload, document sync에 들어가지 않는다.
- ReactDesignRenderer: DesignDocument node를 intrinsic element 또는 registered component/widget definition으로 해석해 direct React DOM을 만들고 stable node identity를 DomProjection에 등록하는 renderer. document mutation과 editing policy는 소유하지 않는다.
+- ReactDesignEditorRenderer: ReactDesignEditorRuntime의 document, editor, registry, projection을 ReactDesignRenderer에 연결하고 immutable DesignDocument/EditorEngine snapshot identity를 mounted subtree layout commit에서 acknowledge하는 runtime-owned renderer. 이 신호는 DOM mutation과 ref commit 완료를 뜻하며 paint, font, image, async layout settle을 뜻하지 않는다.
+- ReactDesign Editor External Change Host: EditorEngineDocumentHost의 preview/mutation gate에 정확히 하나인 canonical ReactDesignEditorRenderer의 mounted render lease와 snapshot commit acknowledgement를 결합하는 browser coordination Module. ready external change는 현재 document/editor state가 React DOM에 commit된 뒤에만 실행되고, renderer 중복 mount가 감지된 runtime은 projection ownership을 증명할 수 없어 남은 lifetime 동안 fail-closed하며, 다음 retry 알림은 generation과 snapshot identity를 다시 확인한다.
+- ReactDesign Text Selection: raw DOM Range 대신 stable design node id, UTF-16 anchor/focus offset과 direction을 저장하고 ownership/focus generation이 유지될 때만 현재 text control에 복원하는 ephemeral browser selection Module. 같은 node의 text patch에 대한 offset rebase는 하지 않고 현재 value 길이에 clamp한다.
- DomProjection: stable design node id와 HTMLElement 사이의 ephemeral runtime 관계, measurement, coordinate projection, observation, hit target을 소유하는 Module. DOM element와 측정값은 DesignDocument에 저장하지 않는다.
- EditorEngine: Canvas Foundation/Affordance grammar를 DesignDocument read/command Interface와 DomProjection에 적용해 Figma식 selection, transform, text/layout, keyboard effect를 계획하는 Module. committed edit는 하나의 atomic document command로만 만든다.
- React Component Definition: stable definition id에 React renderer, JSON prop validation/default, creation/edit capability, optional inspector contribution을 결합하는 registered definition. DesignDocument는 definition function 대신 id와 JSON data만 참조한다.
diff --git a/docs/adr/0006-canonical-design-document-causal-host.md b/docs/adr/0006-canonical-design-document-causal-host.md
index 84c6318a..abea8867 100644
--- a/docs/adr/0006-canonical-design-document-causal-host.md
+++ b/docs/adr/0006-canonical-design-document-causal-host.md
@@ -77,16 +77,16 @@ validation and widen the public mutation surface.
- Canvas now has the headless seam needed to test delayed stable-id editing
without exposing the internal JSONDocument or adding a runtime dependency on
- private labs. A FigJam browser test remains follow-up evidence.
+ private labs. ADR 0007 adds separate browser evidence for React commit,
+ composition blur, DOM remount, and stable text selection restoration.
- Structural and positional delayed edits need granular DesignDocument command
patches instead of a root replacement.
- A concrete remote publication discards pre-remote local undo and redo. A
future selective-undo design must rebase owned inverses explicitly before it
can preserve that history safely.
-- DOM caret handoff needs a separate selection adapter plus a render-settle
- signal; it must not be inferred from the authored graph.
-- FigJam composition/blur behavior and a real ReactDesignRenderer commit must
- be covered before claiming IME-safe browser coordination.
+- DOM caret handoff now has a text-control selection adapter and explicit React
+ commit acknowledgement. Contenteditable affinity and native cross-browser IME
+ evidence remain follow-up work.
- Large documents still pay whole-snapshot clone/freeze, validation, and index
rebuild costs. Incremental validation and indexing remain separate
performance work.
diff --git a/docs/adr/0007-dom-input-render-coordination.md b/docs/adr/0007-dom-input-render-coordination.md
new file mode 100644
index 00000000..8c41ff09
--- /dev/null
+++ b/docs/adr/0007-dom-input-render-coordination.md
@@ -0,0 +1,115 @@
+# ADR 0007: DOM Input and Render Coordination
+
+## Status
+
+Provisional — accepted for browser dogfood, not a native IME or convergence claim.
+
+## Context
+
+ADR 0005 makes `DesignDocument` the authored source of truth and direct React
+DOM its derived browser surface. ADR 0006 prevents a ready external document
+change from running while `EditorEngine` preview owns uncommitted input. The
+headless causal tracer intentionally retries in a microtask and does not prove
+that React has committed either the local or external snapshot.
+
+That gap matters for browser input. A canonical document publication can finish
+synchronously while React is still rendering the previous snapshot. A raw DOM
+selection can also point at an element that React is about to replace. Using
+`DomProjection.revision()`, `EditorEngineSnapshot.revision`, a timer, animation
+frame, or `ResizeObserver` as a render acknowledgement would mix unrelated
+invalidation with actual React commit.
+
+FigJam additionally committed `blur` during an active composition. That could
+end the preview and unmount its textarea before the final composition input was
+observed.
+
+## Decision
+
+1. `ReactDesignEditorRenderer` is the runtime-owned canonical render surface.
+ It keeps the existing `ReactDesignRenderer` interface intact and
+ acknowledges the immutable `DesignDocument.snapshot` and
+ `EditorEngineSnapshot` identities from a parent layout effect after the
+ renderer subtree DOM mutation and callback refs have committed. The editor
+ snapshot identity invalidates old evidence; its revision number is not
+ itself render evidence. The acknowledgement is a mounted render lease and
+ is revoked by layout-effect cleanup.
+2. `getReactDesignEditorExternalChangeHost(runtime)` exposes the browser-aware
+ external change host without changing the existing `ReactDesignEditorRuntime`
+ shape. It delegates publication ownership and the headless preview/mutation
+ gate to `EditorEngineDocumentHost`, and additionally returns `host_not_ready`
+ until the one canonical renderer lease acknowledges the current document
+ and editor snapshot identities. A runtime has exactly one canonical render
+ surface because its `DomProjection` registration is singular/latest-wins;
+ a runtime that observes overlapping renderer mounts remains fail closed for
+ the rest of its lifetime. Runtimes backed by a document without patch
+ coordination remain usable when this additive capability is not requested.
+3. A ready notification is delivered in a generation-checked microtask after
+ the explicit layout-commit acknowledgement. The microtask is delivery
+ scheduling, not the evidence of render completion. If the document snapshot
+ changes while one listener applies an external change, remaining listeners
+ do not run for the obsolete snapshot.
+4. The required ordering is:
+
+ ```text
+ input preview owns browser edits
+ -> local canonical commit
+ -> local React DOM commit acknowledgement
+ -> ready external change apply
+ -> external React DOM commit acknowledgement
+ -> selection correction
+ ```
+
+5. `ReactDesignTextSelection` stores text-control selection as stable design
+ node id, UTF-16 anchor/focus offsets, and direction. It does not
+ retain a DOM `Range` or element reference in the bookmark. Restore resolves
+ the current element and succeeds only for the active ownership generation,
+ unchanged focus generation, connected element, and current logical node.
+ A body-focus gap is accepted only when the captured control was disconnected
+ and the resolver now returns a different connected replacement. Focusing is
+ revalidated before the range is written.
+6. A React remount may focus the replacement control before its ref callback is
+ visible to the resolver. That focus transition is reconciled only when the
+ previously focused element becomes disconnected, no later focus event has
+ intervened, and the resolver identifies the new target as the same active
+ logical input.
+7. FigJam composition blur is deferred in explicit `composing` and `settling`
+ phases. `compositionend` starts a generation-checked 30 ms settling window;
+ a later final input updates the existing `EditorEnginePreviewSession` first
+ and restarts that window. New composition, preview-session replacement, or
+ unmount invalidates the timer. This is a conservative browser event window,
+ not render acknowledgement.
+8. The browser causal tracer lives under `e2e/fixtures`. It may compose the
+ SHA-pinned private causal inbox, but production `src/canvas/**` keeps no
+ runtime dependency on unpublished json-document labs.
+
+## Boundaries
+
+- A React layout commit acknowledgement does not mean browser paint, font load,
+ image decode, network completion, or asynchronous layout has settled.
+- `DesignDocument` remains independent from DOM, React, focus, composition, and
+ selection.
+- `DomProjection` remains measurement and node-to-element runtime state. Its
+ revision and subscription channel are not reused for render acknowledgement.
+- The external change host does not choose CRDT, OT, transport, retry backoff,
+ acknowledgement, persistence, or conflict policy.
+- Text selection bookmarks currently cover native text controls. They preserve
+ and clamp UTF-16 offsets across remount; they do not rebase offsets through a
+ same-node text patch. A contenteditable adapter or patch-aware correction
+ layer must define its own position mapping and boundary affinity semantics.
+- Synthetic Chromium composition evidence verifies browser event-handler
+ ordering, not real Korean/Japanese IME behavior across operating systems or
+ Safari. Native IME verification remains required before an IME-safe claim.
+
+## Consequences
+
+- Figma and FigJam canonical routes use the runtime-owned renderer, so future
+ external-change adapters can consume one render-aware host instead of wiring
+ document, engine, projection, and React timing independently.
+- A delayed change cannot run merely because a preview ended; the locally
+ committed or reverted DOM must first be observed in a React commit.
+- Selection correction cannot steal focus after the user moves to another
+ control, deliberately blurs a still-connected editor, or a newer edit
+ generation takes ownership.
+- The headless causal tracer remains useful for deterministic document
+ coordination, while the Chromium tracer provides separate evidence for DOM
+ focus, remount, and selection behavior.
diff --git a/e2e/figjam-react-cutover.e2e.ts b/e2e/figjam-react-cutover.e2e.ts
index 4342270c..02c7bf79 100644
--- a/e2e/figjam-react-cutover.e2e.ts
+++ b/e2e/figjam-react-cutover.e2e.ts
@@ -194,6 +194,45 @@ test('previews, cancels, commits, and restores direct text edits', async ({
await expect(sticky).toContainText('Ship the canonical DOM board')
})
+test('keeps a blurred composition preview until its final input commits', async ({
+ page,
+}) => {
+ await page.goto('/figjam')
+
+ const app = figJamApp(page)
+ const sticky = designNode(page, 'figjam-sticky')
+
+ await sticky.dblclick({ force: true })
+ const editor = page.getByRole('textbox', {
+ name: 'Edit sticky note text',
+ })
+
+ await editor.focus()
+ await editor.dispatchEvent('compositionstart', { data: '한' })
+ await editor.evaluate((element) => element.blur())
+
+ await expect(editor).toBeVisible()
+ await expect(app).toHaveAttribute('data-preview-node-id', 'figjam-sticky')
+
+ await editor.evaluate((element) => {
+ element.dispatchEvent(new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: '한',
+ }))
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ 'value',
+ )?.set
+
+ setter?.call(element, '한글 입력')
+ element.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+
+ await expect(editor).toHaveCount(0)
+ await expect(sticky).toContainText('한글 입력')
+ await expect.poll(() => app.getAttribute('data-preview-node-id')).toBeNull()
+})
+
test('honors widget move and resize capabilities in DOM controls', async ({
page,
}) => {
diff --git a/e2e/fixtures/react-design-dom-settle.html b/e2e/fixtures/react-design-dom-settle.html
new file mode 100644
index 00000000..6d2cc7b9
--- /dev/null
+++ b/e2e/fixtures/react-design-dom-settle.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ React design DOM settle tracer
+
+
+
+
+
+
diff --git a/e2e/fixtures/react-design-dom-settle.tsx b/e2e/fixtures/react-design-dom-settle.tsx
new file mode 100644
index 00000000..97830897
--- /dev/null
+++ b/e2e/fixtures/react-design-dom-settle.tsx
@@ -0,0 +1,211 @@
+import { useEffect, useRef, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+import { createCausalPatchInbox } from '@interactive-os/json-document-causal-patch-inbox'
+import type { EditorEnginePreviewSession } from '@interactive-os/canvas/editor'
+import {
+ ReactDesignEditorRenderer,
+ createDesignDocument,
+ createReactDesignDefinitionRegistry,
+ createReactDesignTextSelection,
+ getDesignDocumentPatchPort,
+ getReactDesignEditorExternalChangeHost,
+ useReactDesignEditorRuntime,
+ type DesignNodeId,
+ type ReactDesignTextSelectionBookmark,
+ type ReactDesignTextSelectionOwnership,
+} from '@interactive-os/canvas/react-design'
+
+const STABLE_ID_SCOPES = [{
+ scope: 'design-node',
+ query: '$.nodes[*]',
+ readId(value: unknown) {
+ if (!value || typeof value !== 'object' || !('id' in value)) {
+ return undefined
+ }
+
+ return typeof value.id === 'string' ? value.id : undefined
+ },
+}]
+
+type ActiveEdit = {
+ readonly draft: string
+ readonly session: EditorEnginePreviewSession | null
+}
+
+export function DomSettleTracer() {
+ const runtime = useReactDesignEditorRuntime({
+ createDocument: createTracerDocument,
+ createRegistry: () => createReactDesignDefinitionRegistry({
+ intrinsics: ['div'],
+ }),
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime)
+ const [inbox] = useState(() => createCausalPatchInbox(
+ getDesignDocumentPatchPort(runtime.document),
+ {
+ host: externalChanges,
+ stableIdScopes: STABLE_ID_SCOPES,
+ },
+ ))
+ const [selection] = useState(() =>
+ createReactDesignTextSelection({ document }))
+ const [edit, setEdit] = useState(null)
+ const [status, setStatus] = useState('idle')
+ const editorRef = useRef(null)
+ const ownershipRef = useRef(null)
+ const bookmarkRef = useRef(null)
+ const noteA = runtime.editor.read.node('note-a')?.text ?? ''
+ const noteB = runtime.editor.read.node('note-b')?.text ?? ''
+
+ useEffect(() => externalChanges.subscribeReady(() => {
+ const hasReadyRemote = inbox.current().queued.some((entry) =>
+ entry.missing.length === 0)
+
+ if (hasReadyRemote) {
+ const result = inbox.ingest([])
+
+ setStatus(result.ok ? 'remote-applied' : result.code)
+ return
+ }
+
+ const ownership = ownershipRef.current
+ const bookmark = bookmarkRef.current
+
+ if (!ownership || !bookmark) {
+ return
+ }
+
+ bookmarkRef.current = null
+ setStatus(ownership.restore(bookmark)
+ ? 'selection-restored'
+ : 'selection-stale')
+ }), [externalChanges, inbox])
+
+ useEffect(() => () => {
+ ownershipRef.current?.release()
+ selection.dispose()
+ inbox.dispose()
+ }, [inbox, selection])
+
+ const beginEdit = () => {
+ const session = runtime.editor.commands.beginPreview({
+ label: 'Edit note A',
+ nodeId: 'note-a',
+ })
+
+ if (!session) {
+ setStatus('preview-unavailable')
+ return
+ }
+
+ ownershipRef.current?.release()
+ ownershipRef.current = selection.claim({
+ nodeId: 'note-a',
+ readElement: () => editorRef.current,
+ })
+ setEdit({ draft: noteA, session })
+ setStatus('editing')
+ }
+
+ const updateEdit = (value: string) => {
+ if (!edit?.session) {
+ return
+ }
+
+ const result = edit.session.update([{ target: 'text', value }])
+
+ setStatus(result.ok ? 'previewing' : result.code)
+ if (result.ok) {
+ setEdit({ ...edit, draft: value })
+ }
+ }
+
+ const commitEdit = () => {
+ if (!edit?.session) {
+ return
+ }
+
+ bookmarkRef.current = ownershipRef.current?.capture() ?? null
+ const result = edit.session.commit()
+
+ setStatus(result.ok ? 'local-committed' : result.code)
+ if (result.ok) {
+ setEdit({ ...edit, session: null })
+ }
+ }
+
+ const queueRemote = () => {
+ const result = inbox.ingest({
+ id: 'remote-note-b',
+ dependsOn: [],
+ intent: {
+ kind: 'stable-id-replace',
+ target: { scope: 'design-node', id: 'note-b' },
+ relativePath: '/text',
+ expected: 'Draft B',
+ value: 'Remote B',
+ },
+ })
+
+ setStatus(result.ok ? 'remote-applied' : result.code)
+ }
+
+ return (
+
+
+ Start edit
+ Queue remote
+ {edit ? (
+
+ )
+}
+
+function createTracerDocument() {
+ return createDesignDocument({
+ schemaVersion: 1,
+ roots: ['root'],
+ nodes: [
+ createNode('root', ['note-a', 'note-b']),
+ { ...createNode('note-a'), text: 'Draft A' },
+ { ...createNode('note-b'), text: 'Draft B' },
+ ],
+ })
+}
+
+function createNode(id: DesignNodeId, children: readonly DesignNodeId[] = []) {
+ return {
+ id,
+ label: id,
+ definition: { kind: 'intrinsic' as const, id: 'div' },
+ children,
+ props: {},
+ text: null,
+ layout: {},
+ style: {},
+ frame: null,
+ component: null,
+ }
+}
+
+createRoot(document.getElementById('root')!).render( )
diff --git a/e2e/react-design-dom-settle.e2e.ts b/e2e/react-design-dom-settle.e2e.ts
new file mode 100644
index 00000000..061c848f
--- /dev/null
+++ b/e2e/react-design-dom-settle.e2e.ts
@@ -0,0 +1,34 @@
+import { expect, test } from '@playwright/test'
+
+test('settles local and remote DOM before restoring a stable selection', async ({
+ page,
+}) => {
+ await page.goto('/e2e/fixtures/react-design-dom-settle.html')
+
+ const tracer = page.locator('main')
+
+ await page.getByRole('button', { name: 'Start edit' }).click()
+ const editor = page.getByRole('textbox', { name: 'Note A editor' })
+
+ await editor.fill('Local A')
+ await page.getByRole('button', { name: 'Queue remote' }).click()
+
+ await expect(tracer).toHaveAttribute('data-status', 'host_not_ready')
+ await expect(tracer).toHaveAttribute('data-note-b', 'Draft B')
+
+ await editor.focus()
+ await editor.evaluate((element) => {
+ element.setSelectionRange(2, 5, 'backward')
+ })
+ await editor.press('Control+Enter')
+
+ await expect(tracer).toHaveAttribute('data-status', 'selection-restored')
+ await expect(tracer).toHaveAttribute('data-note-a', 'Local A')
+ await expect(tracer).toHaveAttribute('data-note-b', 'Remote B')
+ await expect(editor).toBeFocused()
+ await expect.poll(() => editor.evaluate((element) => ({
+ direction: element.selectionDirection,
+ end: element.selectionEnd,
+ start: element.selectionStart,
+ }))).toEqual({ direction: 'backward', end: 5, start: 2 })
+})
diff --git a/packages/figjam-clone/src/FigJamCloneApp.tsx b/packages/figjam-clone/src/FigJamCloneApp.tsx
index d5bec7ee..6f2494fe 100644
--- a/packages/figjam-clone/src/FigJamCloneApp.tsx
+++ b/packages/figjam-clone/src/FigJamCloneApp.tsx
@@ -32,7 +32,7 @@ import {
type WheelEvent as ReactWheelEvent,
} from 'react'
import {
- ReactDesignRenderer,
+ ReactDesignEditorRenderer,
createReactDesignDefinitionRegistry,
useReactDesignEditorRuntime,
type DesignJSONValue,
@@ -195,7 +195,6 @@ export function FigJamCloneApp() {
document: designDocument,
editor,
projection,
- registry,
snapshot,
viewport: viewportRuntime,
} = runtime
@@ -1371,11 +1370,7 @@ export function FigJamCloneApp() {
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
>
-
+
{selectedNode?.definition.id ===
FIGJAM_STICKY_NOTE_DEFINITION_ID ? (
{
+ let root: Root | null = null
+ let container: HTMLDivElement | null = null
+
+ afterEach(async () => {
+ if (root) {
+ await act(async () => root?.unmount())
+ }
+
+ container?.remove()
+ root = null
+ container = null
+ vi.useRealTimers()
+ })
+
+ it('keeps composition input owned through blur and commits once after it ends', async () => {
+ vi.useFakeTimers()
+ const onChange = vi.fn()
+ const onCommit = vi.fn()
+
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+
+ await act(async () => root?.render(
+ undefined}
+ onChange={onChange}
+ onCommit={onCommit}
+ />,
+ ))
+
+ const editor = container.querySelector('textarea')
+
+ expect(editor).toBeInstanceOf(HTMLTextAreaElement)
+ if (!(editor instanceof HTMLTextAreaElement)) {
+ return
+ }
+
+ await act(async () => {
+ editor.dispatchEvent(new CompositionEvent('compositionstart', {
+ bubbles: true,
+ data: '한',
+ }))
+ editor.blur()
+ })
+
+ expect(onCommit).not.toHaveBeenCalled()
+
+ await act(async () => {
+ editor.dispatchEvent(new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: '한',
+ }))
+ setTextAreaValue(editor, '한글')
+ editor.dispatchEvent(new Event('input', { bubbles: true }))
+ editor.dispatchEvent(new Event('change', { bubbles: true }))
+ await vi.advanceTimersByTimeAsync(30)
+ })
+
+ expect(onChange).toHaveBeenLastCalledWith('한글')
+ expect(onCommit).toHaveBeenCalledTimes(1)
+ })
+
+ it('waits for final composition input from a later task before committing blur', async () => {
+ vi.useFakeTimers()
+ const calls: string[] = []
+ const onChange = vi.fn((value: string) => calls.push(`change:${value}`))
+ const onCommit = vi.fn(() => calls.push('commit'))
+
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+
+ await act(async () => root?.render(
+ undefined}
+ onChange={onChange}
+ onCommit={onCommit}
+ />,
+ ))
+
+ const editor = container.querySelector('textarea')
+
+ expect(editor).toBeInstanceOf(HTMLTextAreaElement)
+ if (!(editor instanceof HTMLTextAreaElement)) {
+ return
+ }
+
+ await act(async () => {
+ editor.dispatchEvent(new CompositionEvent('compositionstart', {
+ bubbles: true,
+ data: '한',
+ }))
+ editor.dispatchEvent(new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: '한',
+ }))
+ editor.blur()
+ })
+
+ expect(onCommit).not.toHaveBeenCalled()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15)
+ setTextAreaValue(editor, '한글')
+ editor.dispatchEvent(new Event('input', { bubbles: true }))
+ editor.dispatchEvent(new Event('change', { bubbles: true }))
+ })
+
+ expect(calls).toEqual(['change:한글'])
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(29)
+ })
+ expect(onCommit).not.toHaveBeenCalled()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1)
+ })
+
+ expect(calls).toEqual(['change:한글', 'commit'])
+ expect(onCommit).toHaveBeenCalledTimes(1)
+ })
+
+ it('invalidates an older settle timer when the preview session changes', async () => {
+ vi.useFakeTimers()
+ const onCommit = vi.fn()
+
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+
+ const renderEditor = (edit: FigJamTextEdit) => root?.render(
+ undefined}
+ onChange={() => undefined}
+ onCommit={onCommit}
+ />,
+ )
+
+ await act(async () => renderEditor(createEdit()))
+
+ const editor = container.querySelector('textarea')
+
+ expect(editor).toBeInstanceOf(HTMLTextAreaElement)
+ if (!(editor instanceof HTMLTextAreaElement)) {
+ return
+ }
+
+ await act(async () => {
+ editor.dispatchEvent(new CompositionEvent('compositionstart', {
+ bubbles: true,
+ }))
+ editor.blur()
+ editor.dispatchEvent(new CompositionEvent('compositionend', {
+ bubbles: true,
+ }))
+ })
+
+ await act(async () => renderEditor({
+ ...createEdit(),
+ session: {} as EditorEnginePreviewSession,
+ }))
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(31)
+ })
+
+ expect(onCommit).not.toHaveBeenCalled()
+ })
+})
+
+function createEdit(): FigJamTextEdit {
+ return {
+ draft: '',
+ label: 'Edit sticky note text',
+ nodeId: 'note',
+ session: {} as EditorEnginePreviewSession,
+ }
+}
+
+function createProjection() {
+ return {
+ measure: () => ({
+ clientBounds: { h: 80, w: 120, x: 0, y: 0 },
+ nodeId: 'note',
+ worldBounds: { h: 80, w: 120, x: 0, y: 0 },
+ }),
+ } as unknown as DomProjection
+}
+
+function setTextAreaValue(element: HTMLTextAreaElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ 'value',
+ )?.set
+
+ setter?.call(element, value)
+}
diff --git a/packages/figjam-clone/src/FigJamTextEditor.tsx b/packages/figjam-clone/src/FigJamTextEditor.tsx
index 1bf3ca10..4d3881d5 100644
--- a/packages/figjam-clone/src/FigJamTextEditor.tsx
+++ b/packages/figjam-clone/src/FigJamTextEditor.tsx
@@ -5,11 +5,16 @@ import type {
} from '@interactive-os/canvas/react-design'
import {
useEffect,
+ useLayoutEffect,
useRef,
type CompositionEvent,
type KeyboardEvent,
} from 'react'
+const COMPOSITION_SETTLE_DELAY_MS = 30
+
+type CompositionPhase = 'composing' | 'idle' | 'settling'
+
export type FigJamTextEdit = {
readonly draft: string
readonly label: string
@@ -31,29 +36,137 @@ export function FigJamTextEditor({
readonly onCommit: () => void
}) {
const inputRef = useRef(null)
- const composingRef = useRef(false)
+ const compositionPhaseRef = useRef('idle')
+ const pendingBlurCommitRef = useRef(false)
+ const commitGenerationRef = useRef(0)
+ const settleTimerRef = useRef | null>(null)
+ const onCommitRef = useRef(onCommit)
const measurement = projection.measure(edit.nodeId)
+ useLayoutEffect(() => {
+ onCommitRef.current = onCommit
+ }, [onCommit])
+
+ useLayoutEffect(() => {
+ if (settleTimerRef.current !== null) {
+ clearTimeout(settleTimerRef.current)
+ settleTimerRef.current = null
+ }
+
+ const generation = commitGenerationRef.current + 1
+
+ commitGenerationRef.current = generation
+ compositionPhaseRef.current = 'idle'
+ pendingBlurCommitRef.current = false
+
+ return () => {
+ if (commitGenerationRef.current === generation) {
+ commitGenerationRef.current += 1
+ }
+
+ if (settleTimerRef.current !== null) {
+ clearTimeout(settleTimerRef.current)
+ settleTimerRef.current = null
+ }
+
+ compositionPhaseRef.current = 'idle'
+ pendingBlurCommitRef.current = false
+ }
+ }, [edit.nodeId, edit.session])
+
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
- }, [edit.nodeId])
+ }, [edit.nodeId, edit.session])
if (!measurement) {
return null
}
+ const clearSettleTimer = () => {
+ if (settleTimerRef.current === null) {
+ return
+ }
+
+ clearTimeout(settleTimerRef.current)
+ settleTimerRef.current = null
+ }
+ const commit = () => {
+ clearSettleTimer()
+ compositionPhaseRef.current = 'idle'
+ pendingBlurCommitRef.current = false
+ commitGenerationRef.current += 1
+ onCommitRef.current()
+ }
+ const scheduleCompositionSettle = () => {
+ clearSettleTimer()
+ const generation = commitGenerationRef.current + 1
+
+ commitGenerationRef.current = generation
+ settleTimerRef.current = setTimeout(() => {
+ if (commitGenerationRef.current !== generation) {
+ return
+ }
+
+ settleTimerRef.current = null
+ if (compositionPhaseRef.current !== 'settling') {
+ return
+ }
+
+ compositionPhaseRef.current = 'idle'
+ if (!pendingBlurCommitRef.current) {
+ return
+ }
+
+ pendingBlurCommitRef.current = false
+ commitGenerationRef.current += 1
+ onCommitRef.current()
+ }, COMPOSITION_SETTLE_DELAY_MS)
+ }
const handleComposition = (event: CompositionEvent) => {
- composingRef.current = event.type === 'compositionstart'
+ if (event.type === 'compositionstart') {
+ clearSettleTimer()
+ compositionPhaseRef.current = 'composing'
+ commitGenerationRef.current += 1
+ return
+ }
+
+ compositionPhaseRef.current = 'settling'
+ scheduleCompositionSettle()
+ }
+ const handleBlur = () => {
+ if (compositionPhaseRef.current !== 'idle') {
+ pendingBlurCommitRef.current = true
+
+ if (compositionPhaseRef.current === 'settling') {
+ scheduleCompositionSettle()
+ }
+ return
+ }
+
+ commit()
+ }
+ const handleChange = (value: string) => {
+ onChange(value)
+
+ if (compositionPhaseRef.current === 'settling') {
+ scheduleCompositionSettle()
+ }
}
const handleKeyDown = (event: KeyboardEvent) => {
- if (event.nativeEvent.isComposing || composingRef.current) {
+ if (
+ event.nativeEvent.isComposing ||
+ compositionPhaseRef.current !== 'idle'
+ ) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
+ clearSettleTimer()
+ commitGenerationRef.current += 1
+ pendingBlurCommitRef.current = false
onCancel()
return
}
@@ -61,7 +174,7 @@ export function FigJamTextEditor({
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
event.stopPropagation()
- onCommit()
+ commit()
}
}
@@ -80,8 +193,8 @@ export function FigJamTextEditor({
width: measurement.worldBounds.w,
}}
value={edit.draft}
- onBlur={onCommit}
- onChange={(event) => onChange(event.currentTarget.value)}
+ onBlur={handleBlur}
+ onChange={(event) => handleChange(event.currentTarget.value)}
onCompositionEnd={handleComposition}
onCompositionStart={handleComposition}
onKeyDown={handleKeyDown}
diff --git a/packages/figma-clone/src/FigmaCloneApp.tsx b/packages/figma-clone/src/FigmaCloneApp.tsx
index bd17b3e2..0545bea7 100644
--- a/packages/figma-clone/src/FigmaCloneApp.tsx
+++ b/packages/figma-clone/src/FigmaCloneApp.tsx
@@ -16,7 +16,7 @@ import {
} from 'react'
import type { EditorEngine } from '@interactive-os/canvas/editor'
import {
- ReactDesignRenderer,
+ ReactDesignEditorRenderer,
createReactDesignDefinitionRegistry,
useReactDesignEditorRuntime,
type DesignNodeId,
@@ -78,7 +78,6 @@ export function FigmaCloneApp() {
document,
editor,
projection,
- registry,
snapshot: editorSnapshot,
stage,
viewport: viewportRuntime,
@@ -382,11 +381,7 @@ export function FigmaCloneApp() {
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
>
-
+
{
expect(source).toContain('useReactDesignEditorRuntime')
expect(source).not.toContain('createEditorEngine')
expect(source).not.toContain('createDomProjection')
- expect(source).toContain(' {
'no mutation, history, persistence, or reverse sync',
)
})
+
+ it('records browser input and render coordination without widening document ownership', () => {
+ expect(coordinationAdr).toContain(
+ '# ADR 0007: DOM Input and Render Coordination',
+ )
+ expect(coordinationAdr).toContain('## Status\n\nProvisional')
+
+ for (const term of [
+ 'ReactDesignEditorRenderer',
+ 'getReactDesignEditorExternalChangeHost',
+ 'DesignDocument.snapshot',
+ 'EditorEngineSnapshot',
+ 'ReactDesignTextSelection',
+ 'ownership generation',
+ 'focus generation',
+ 'UTF-16',
+ ]) {
+ expect(coordinationAdr).toContain(term)
+ }
+
+ expect(coordinationAdr).toContain('production `src/canvas/**` keeps no')
+ expect(coordinationAdr).toContain('Native IME verification remains required')
+ })
})
diff --git a/src/canvas/architecture/__snapshots__/CanvasPackagePublicSurface.test.ts.snap b/src/canvas/architecture/__snapshots__/CanvasPackagePublicSurface.test.ts.snap
index 3c2579c1..451e5503 100644
--- a/src/canvas/architecture/__snapshots__/CanvasPackagePublicSurface.test.ts.snap
+++ b/src/canvas/architecture/__snapshots__/CanvasPackagePublicSurface.test.ts.snap
@@ -743,14 +743,17 @@ exports[`Canvas package public surface > snapshots facade runtime value exports
"CanvasRenderer",
],
"reactDesign": [
+ "ReactDesignEditorRenderer",
"ReactDesignRenderer",
"createDesignDocument",
"createDomProjection",
"createReactDesignDefinitionRegistry",
"createReactDesignNodeDomProps",
+ "createReactDesignTextSelection",
"createReactDesignWidgetPack",
"defineReactDesignWidget",
"getDesignDocumentPatchPort",
+ "getReactDesignEditorExternalChangeHost",
"restoreDesignDocument",
"useReactDesignEditorRuntime",
],
diff --git a/src/canvas/react-design/ReactDesignEditorExternalChanges.ts b/src/canvas/react-design/ReactDesignEditorExternalChanges.ts
new file mode 100644
index 00000000..61389bf5
--- /dev/null
+++ b/src/canvas/react-design/ReactDesignEditorExternalChanges.ts
@@ -0,0 +1,232 @@
+import type {
+ DesignDocument,
+ DesignDocumentSnapshot,
+} from '../design-document'
+import {
+ getEditorEngineDocumentHost,
+ type EditorEngine,
+ type EditorEngineDocumentHost,
+ type EditorEngineSnapshot,
+} from '../editor-engine'
+
+export type ReactDesignEditorExternalChangeHost =
+ EditorEngineDocumentHost & {
+ subscribeReady(listener: () => void): () => void
+ }
+
+type ReactDesignEditorRuntimeOwner = {
+ readonly document: DesignDocument
+ readonly editor: EditorEngine
+}
+
+type RenderCommitToken = {
+ readonly documentSnapshot: DesignDocumentSnapshot
+ readonly editorSnapshot: EditorEngineSnapshot
+}
+
+type ReadySubscription = {
+ readonly notify: () => void
+}
+
+type ExternalChangeHostState = {
+ acknowledge(token: RenderCommitToken): () => void
+ dispose(): void
+}
+
+const hosts = new WeakMap()
+const states = new WeakMap<
+ ReactDesignEditorExternalChangeHost,
+ ExternalChangeHostState
+>()
+
+export function getReactDesignEditorExternalChangeHost(
+ runtime: ReactDesignEditorRuntimeOwner,
+): ReactDesignEditorExternalChangeHost {
+ const existing = hosts.get(runtime.editor)
+
+ if (existing) {
+ return existing
+ }
+
+ return createReactDesignEditorExternalChangeHost(runtime)
+}
+
+function createReactDesignEditorExternalChangeHost({
+ document,
+ editor,
+}: ReactDesignEditorRuntimeOwner): ReactDesignEditorExternalChangeHost {
+ const editorHost = getEditorEngineDocumentHost(editor)
+ const listeners = new Set()
+ const renderLeases = new Map()
+ let disposed = false
+ let notificationGeneration = 0
+ let overlappingRenderers = false
+
+ const isCurrentRender = () => {
+ if (
+ disposed ||
+ overlappingRenderers ||
+ renderLeases.size !== 1
+ ) {
+ return false
+ }
+
+ const documentSnapshot = document.snapshot
+ const editorSnapshot = editor.snapshot()
+
+ if (editorSnapshot.preview !== null) {
+ return false
+ }
+
+ for (const token of renderLeases.values()) {
+ if (
+ token.documentSnapshot !== documentSnapshot ||
+ token.editorSnapshot !== editorSnapshot
+ ) {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ const scheduleReadyNotification = () => {
+ notificationGeneration += 1
+ const generation = notificationGeneration
+
+ if (!isCurrentRender()) {
+ return
+ }
+
+ queueMicrotask(() => {
+ if (
+ disposed ||
+ notificationGeneration !== generation ||
+ !isCurrentRender()
+ ) {
+ return
+ }
+
+ notifyListeners(listeners, () =>
+ !disposed &&
+ notificationGeneration === generation &&
+ isCurrentRender()
+ )
+ })
+ }
+
+ const host: ReactDesignEditorExternalChangeHost = {
+ ownsPublication: editorHost.ownsPublication,
+ runReady(request) {
+ if (disposed) {
+ return notReady('The React design editor runtime is disposed')
+ }
+
+ if (!isCurrentRender()) {
+ return notReady(
+ 'The current editor state has not committed to React DOM',
+ )
+ }
+
+ return editorHost.runReady(request)
+ },
+ subscribeReady(listener) {
+ if (disposed) {
+ return () => undefined
+ }
+
+ const subscription = { notify: listener }
+
+ listeners.add(subscription)
+ return () => listeners.delete(subscription)
+ },
+ }
+
+ hosts.set(editor, host)
+ states.set(host, {
+ acknowledge(token) {
+ if (disposed) {
+ return () => undefined
+ }
+
+ const lease = {}
+
+ if (renderLeases.size > 0) {
+ overlappingRenderers = true
+ }
+
+ renderLeases.set(lease, token)
+ scheduleReadyNotification()
+
+ return () => {
+ if (disposed || !renderLeases.delete(lease)) {
+ return
+ }
+
+ scheduleReadyNotification()
+ }
+ },
+ dispose() {
+ if (disposed) {
+ return
+ }
+
+ disposed = true
+ notificationGeneration += 1
+ overlappingRenderers = false
+ renderLeases.clear()
+ listeners.clear()
+ states.delete(host)
+ },
+ })
+
+ return host
+}
+
+export function acknowledgeReactDesignEditorRender(
+ host: ReactDesignEditorExternalChangeHost,
+ documentSnapshot: DesignDocumentSnapshot,
+ editorSnapshot: EditorEngineSnapshot,
+) {
+ return states.get(host)?.acknowledge({
+ documentSnapshot,
+ editorSnapshot,
+ }) ?? (() => undefined)
+}
+
+export function disposeReactDesignEditorExternalChangeHost(editor: EditorEngine) {
+ const host = hosts.get(editor)
+
+ if (host) {
+ states.get(host)?.dispose()
+ }
+}
+
+function notReady(reason: string) {
+ return {
+ code: 'host_not_ready',
+ ok: false,
+ reason,
+ } as const
+}
+
+function notifyListeners(
+ listeners: ReadonlySet,
+ isCurrent: () => boolean,
+) {
+ for (const subscription of [...listeners]) {
+ if (!isCurrent()) {
+ return
+ }
+
+ if (!listeners.has(subscription)) {
+ continue
+ }
+
+ try {
+ subscription.notify()
+ } catch {
+ // One retry policy cannot starve remaining external-change consumers.
+ }
+ }
+}
diff --git a/src/canvas/react-design/ReactDesignEditorRenderer.tsx b/src/canvas/react-design/ReactDesignEditorRenderer.tsx
new file mode 100644
index 00000000..ad915c86
--- /dev/null
+++ b/src/canvas/react-design/ReactDesignEditorRenderer.tsx
@@ -0,0 +1,32 @@
+import { useLayoutEffect } from 'react'
+
+import { ReactDesignRenderer } from '../react-design-renderer'
+import type { ReactDesignEditorRuntime } from './ReactDesignEditorRuntime'
+import {
+ acknowledgeReactDesignEditorRender,
+ getReactDesignEditorExternalChangeHost,
+} from './ReactDesignEditorExternalChanges'
+
+export function ReactDesignEditorRenderer({
+ runtime,
+}: {
+ readonly runtime: ReactDesignEditorRuntime
+}) {
+ const documentSnapshot = runtime.document.snapshot
+ const editorSnapshot = runtime.snapshot
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime)
+
+ useLayoutEffect(() => acknowledgeReactDesignEditorRender(
+ externalChanges,
+ documentSnapshot,
+ editorSnapshot,
+ ), [documentSnapshot, editorSnapshot, externalChanges])
+
+ return (
+
+ )
+}
diff --git a/src/canvas/react-design/ReactDesignEditorRuntime.test.tsx b/src/canvas/react-design/ReactDesignEditorRuntime.test.tsx
index 60e36e3e..c0e5e0b3 100644
--- a/src/canvas/react-design/ReactDesignEditorRuntime.test.tsx
+++ b/src/canvas/react-design/ReactDesignEditorRuntime.test.tsx
@@ -1,14 +1,23 @@
// @vitest-environment jsdom
-import { StrictMode, act } from 'react'
+import { StrictMode, act, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { createDesignDocument } from '../design-document'
+import {
+ createDesignDocument,
+ getDesignDocumentPatchPort,
+} from '../design-document'
import {
createReactDesignDefinitionRegistry,
ReactDesignRenderer,
} from '../react-design-renderer'
+import { ReactDesignEditorRenderer } from './ReactDesignEditorRenderer'
+import {
+ getReactDesignEditorExternalChangeHost,
+ type ReactDesignEditorExternalChangeHost,
+} from './ReactDesignEditorExternalChanges'
+import { createReactDesignTextSelection } from './ReactDesignTextSelection'
import {
useReactDesignEditorRuntime,
type ReactDesignEditorRuntime,
@@ -17,6 +26,10 @@ import {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true
+type ExternalChangeResult = ReturnType<
+ ReactDesignEditorExternalChangeHost['runReady']
+>
+
describe('ReactDesignEditorRuntime', () => {
afterEach(() => {
document.body.replaceChildren()
@@ -208,11 +221,7 @@ describe('ReactDesignEditorRuntime', () => {
return (
-
+
)
}
@@ -223,6 +232,10 @@ describe('ReactDesignEditorRuntime', () => {
,
))
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime!)
+ const onReady = vi.fn()
+ const stopReady = externalChanges.subscribeReady(onReady)
+
let selectionResult
await act(async () => {
@@ -234,10 +247,24 @@ describe('ReactDesignEditorRuntime', () => {
expect(selectionResult).toEqual({ changed: true, ok: true })
expect(runtime!.projection.element('page')).not.toBeNull()
+ expect(onReady).toHaveBeenCalledTimes(1)
- await act(async () => root.unmount())
+ onReady.mockClear()
+ act(() => {
+ runtime!.editor.commands.execute({
+ type: 'selection.replace',
+ nodeId: null,
+ })
+ })
+ act(() => root.unmount())
await Promise.resolve()
+ expect(onReady).not.toHaveBeenCalled()
+ expect(externalChanges.runReady({
+ id: 'after-unmount',
+ apply: vi.fn(),
+ })).toMatchObject({ code: 'host_not_ready', ok: false })
+ expect(getReactDesignEditorExternalChangeHost(runtime!)).toBe(externalChanges)
expect(runtime!.editor.commands.execute({
type: 'selection.replace',
nodeId: null,
@@ -246,6 +273,419 @@ describe('ReactDesignEditorRuntime', () => {
ok: false,
reason: 'EditorEngine is disposed',
})
+ stopReady()
+ })
+
+ it('keeps the existing runtime usable for a document without patch coordination', async () => {
+ stubResizeObserver()
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ let runtime: ReactDesignEditorRuntime | null = null
+
+ function RuntimeHarness() {
+ const current = useReactDesignEditorRuntime({
+ createDocument: () => ({ ...createRuntimeDocument() }),
+ createRegistry: createRuntimeRegistry,
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+
+ runtime = current
+ return (
+
+ )
+ }
+
+ await act(async () => root.render( ))
+
+ expect(runtime!.editor.read.node('page')?.text).toBe('Runtime')
+ expect(container.textContent).toBe('Runtime')
+
+ await act(async () => root.unmount())
+ })
+
+ it('revokes DOM readiness when its renderer unmounts before the runtime', async () => {
+ stubResizeObserver()
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ let runtime: ReactDesignEditorRuntime | null = null
+ let setRendererVisible: ((visible: boolean) => void) | null = null
+
+ function RuntimeHarness() {
+ const current = useReactDesignEditorRuntime({
+ createDocument: createRuntimeDocument,
+ createRegistry: createRuntimeRegistry,
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+ const [rendererVisible, setVisible] = useState(true)
+
+ runtime = current
+ setRendererVisible = setVisible
+ return rendererVisible
+ ?
+ : null
+ }
+
+ await act(async () => root.render( ))
+
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime!)
+
+ expect(externalChanges.runReady({
+ id: 'while-mounted',
+ apply: vi.fn(),
+ })).toEqual({ ok: true })
+
+ await act(async () => setRendererVisible?.(false))
+
+ const applyAfterUnmount = vi.fn()
+
+ expect(externalChanges.runReady({
+ id: 'after-renderer-unmount',
+ apply: applyAfterUnmount,
+ })).toMatchObject({ code: 'host_not_ready', ok: false })
+ expect(applyAfterUnmount).not.toHaveBeenCalled()
+
+ await act(async () => root.unmount())
+ })
+
+ it('fails closed for the runtime lifetime after canonical renderers overlap', async () => {
+ stubResizeObserver()
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ let runtime: ReactDesignEditorRuntime | null = null
+ let setRendererKeys: ((keys: string[]) => void) | null = null
+
+ function RuntimeHarness() {
+ const current = useReactDesignEditorRuntime({
+ createDocument: createRuntimeDocument,
+ createRegistry: createRuntimeRegistry,
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+ const [rendererKeys, setKeys] = useState(['first'])
+
+ runtime = current
+ setRendererKeys = setKeys
+ return rendererKeys.map((key) => (
+
+ ))
+ }
+
+ await act(async () => root.render( ))
+
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime!)
+
+ expect(externalChanges.runReady({
+ id: 'one-renderer',
+ apply: vi.fn(),
+ })).toEqual({ ok: true })
+
+ await act(async () => setRendererKeys?.(['first', 'second']))
+
+ expect(externalChanges.runReady({
+ id: 'overlapping-renderers',
+ apply: vi.fn(),
+ })).toMatchObject({ code: 'host_not_ready', ok: false })
+
+ await act(async () => setRendererKeys?.(['first']))
+
+ expect(externalChanges.runReady({
+ id: 'remaining-overlapped-renderer',
+ apply: vi.fn(),
+ })).toMatchObject({ code: 'host_not_ready', ok: false })
+
+ await act(async () => setRendererKeys?.([]))
+ await act(async () => setRendererKeys?.(['fresh']))
+
+ expect(externalChanges.runReady({
+ id: 'fresh-canonical-renderer',
+ apply: vi.fn(),
+ })).toMatchObject({ code: 'host_not_ready', ok: false })
+
+ await act(async () => root.unmount())
+ })
+
+ it('defers listeners subscribed during the current ready notification', async () => {
+ stubResizeObserver()
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ let runtime: ReactDesignEditorRuntime | null = null
+
+ function RuntimeHarness() {
+ const current = useReactDesignEditorRuntime({
+ createDocument: createRuntimeDocument,
+ createRegistry: createRuntimeRegistry,
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+
+ runtime = current
+ return
+ }
+
+ await act(async () => root.render( ))
+
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime!)
+ const lateListener = vi.fn()
+ let stopLateListener: () => void = () => undefined
+ let subscribedLateListener = false
+ const firstListener = vi.fn(() => {
+ if (!subscribedLateListener) {
+ subscribedLateListener = true
+ stopLateListener = externalChanges.subscribeReady(lateListener)
+ }
+ })
+ const stopFirstListener = externalChanges.subscribeReady(firstListener)
+
+ await act(async () => {
+ runtime!.editor.commands.execute({
+ type: 'selection.replace',
+ nodeId: 'page',
+ })
+ })
+
+ expect(firstListener).toHaveBeenCalledTimes(1)
+ expect(lateListener).not.toHaveBeenCalled()
+
+ await act(async () => {
+ runtime!.editor.commands.execute({
+ type: 'selection.replace',
+ nodeId: null,
+ })
+ })
+
+ expect(firstListener).toHaveBeenCalledTimes(2)
+ expect(lateListener).toHaveBeenCalledTimes(1)
+
+ stopFirstListener()
+ stopLateListener()
+ await act(async () => root.unmount())
+ })
+
+ it('runs a ready external change only after the local canonical DOM commits', async () => {
+ stubResizeObserver()
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ let runtime: ReactDesignEditorRuntime | null = null
+
+ function RuntimeHarness() {
+ const current = useReactDesignEditorRuntime({
+ createDocument: createRuntimeDocument,
+ createRegistry: createRuntimeRegistry,
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+
+ runtime = current
+ return
+ }
+
+ await act(async () => root.render( ))
+
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime!)
+
+ const apply = vi.fn(() => {
+ expect(container.textContent).toBe('Local')
+ expect(getDesignDocumentPatchPort(runtime!.document).commit([
+ { op: 'replace', path: '/nodes/0/text', value: 'Remote' },
+ ])).toEqual({ ok: true })
+ })
+ let session: ReturnType<
+ ReactDesignEditorRuntime['editor']['commands']['beginPreview']
+ > = null
+
+ await act(async () => {
+ session = runtime!.editor.commands.beginPreview({
+ label: 'Edit page text',
+ nodeId: 'page',
+ })
+ expect(session?.update([{ target: 'text', value: 'Local' }]))
+ .toEqual({ changed: true, ok: true })
+ expect(externalChanges.runReady({ id: 'remote', apply }))
+ .toMatchObject({ code: 'host_not_ready', ok: false })
+ })
+
+ let beforeRenderCommit: ExternalChangeResult | null = null
+
+ const retries: ExternalChangeResult[] = []
+ let stopRetry: () => void = () => undefined
+
+ stopRetry = externalChanges.subscribeReady(() => {
+ const result = externalChanges.runReady({ id: 'remote', apply })
+
+ retries.push(result)
+ if (result.ok) {
+ stopRetry()
+ }
+ })
+
+ await act(async () => {
+ expect(session?.commit()).toEqual({ changed: true, ok: true })
+ beforeRenderCommit = externalChanges.runReady({
+ id: 'remote',
+ apply,
+ })
+ expect(beforeRenderCommit).toMatchObject({
+ code: 'host_not_ready',
+ ok: false,
+ })
+ expect(apply).not.toHaveBeenCalled()
+ })
+
+ expect(apply).toHaveBeenCalledTimes(1)
+ expect(retries).toEqual([{ ok: true }])
+ expect(container.textContent).toBe('Remote')
+
+ await act(async () => root.unmount())
+ })
+
+ it('retries ready work after a canceled preview DOM commit', async () => {
+ stubResizeObserver()
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ let runtime: ReactDesignEditorRuntime | null = null
+
+ function RuntimeHarness() {
+ const current = useReactDesignEditorRuntime({
+ createDocument: createRuntimeDocument,
+ createRegistry: createRuntimeRegistry,
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+
+ runtime = current
+ return
+ }
+
+ await act(async () => root.render( ))
+
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime!)
+
+ let session: ReturnType<
+ ReactDesignEditorRuntime['editor']['commands']['beginPreview']
+ > = null
+
+ await act(async () => {
+ session = runtime!.editor.commands.beginPreview({
+ label: 'Edit page text',
+ nodeId: 'page',
+ })
+ expect(session?.update([{ target: 'text', value: 'Preview' }]))
+ .toEqual({ changed: true, ok: true })
+ })
+
+ const apply = vi.fn()
+ let beforeRenderCommit: ExternalChangeResult | null = null
+ const results: ExternalChangeResult[] = []
+ const stopRetry = externalChanges.subscribeReady(() => {
+ results.push(externalChanges.runReady({ id: 'remote', apply }))
+ })
+
+ await act(async () => {
+ expect(session?.cancel()).toEqual({ changed: true, ok: true })
+ beforeRenderCommit = externalChanges.runReady({
+ id: 'remote-before-cancel-render',
+ apply,
+ })
+ expect(beforeRenderCommit).toMatchObject({
+ code: 'host_not_ready',
+ ok: false,
+ })
+ expect(apply).not.toHaveBeenCalled()
+ })
+
+ expect(results).toEqual([{ ok: true }])
+ expect(apply).toHaveBeenCalledTimes(1)
+ expect(container.textContent).toBe('Runtime')
+
+ stopRetry()
+ await act(async () => root.unmount())
+ })
+
+ it('restores selection only after the external snapshot remount commits', async () => {
+ stubResizeObserver()
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ let runtime: ReactDesignEditorRuntime | null = null
+ let editorElement: HTMLTextAreaElement | null = null
+ const setEditorElement = (element: HTMLTextAreaElement | null) => {
+ editorElement = element
+ }
+
+ function RuntimeHarness() {
+ const current = useReactDesignEditorRuntime({
+ createDocument: createRuntimeDocument,
+ createRegistry: createRuntimeRegistry,
+ viewport: { initial: { scale: 1, x: 0, y: 0 } },
+ })
+
+ runtime = current
+ const text = current.document.snapshot.nodes[0]?.text ?? ''
+
+ return (
+ <>
+
+
+ >
+ )
+ }
+
+ await act(async () => root.render( ))
+
+ const externalChanges = getReactDesignEditorExternalChangeHost(runtime!)
+
+ const selection = createReactDesignTextSelection({ document })
+ const ownership = selection.claim({
+ nodeId: 'page',
+ readElement: () => editorElement,
+ })
+ const initialElement = requireTextArea(editorElement)
+
+ initialElement.focus()
+ initialElement.setSelectionRange(1, 5, 'forward')
+ const bookmark = ownership.capture()
+ const restores: boolean[] = []
+ const stopRestore = externalChanges.subscribeReady(() => {
+ if (bookmark) {
+ restores.push(ownership.restore(bookmark))
+ }
+ })
+
+ await act(async () => {
+ expect(externalChanges.runReady({
+ id: 'remote',
+ apply() {
+ expect(getDesignDocumentPatchPort(runtime!.document).commit([
+ { op: 'replace', path: '/nodes/0/text', value: 'Remote text' },
+ ])).toEqual({ ok: true })
+ },
+ })).toEqual({ ok: true })
+
+ expect(editorElement).toBe(initialElement)
+ })
+
+ expect(editorElement).not.toBe(initialElement)
+ expect(restores).toEqual([true])
+ expect(document.activeElement).toBe(editorElement)
+ const restoredElement = requireTextArea(editorElement)
+
+ expect(restoredElement.selectionStart).toBe(1)
+ expect(restoredElement.selectionEnd).toBe(5)
+
+ stopRestore()
+ ownership.release()
+ selection.dispose()
+ await act(async () => root.unmount())
})
})
@@ -268,6 +708,14 @@ function createRuntimeDocument() {
})
}
+function requireTextArea(element: HTMLTextAreaElement | null) {
+ if (!element) {
+ throw new Error('Expected textarea')
+ }
+
+ return element
+}
+
function createRuntimeRegistry() {
return createReactDesignDefinitionRegistry({
intrinsics: ['section'],
diff --git a/src/canvas/react-design/ReactDesignEditorRuntime.ts b/src/canvas/react-design/ReactDesignEditorRuntime.ts
index fe3e9c1c..56d9b33a 100644
--- a/src/canvas/react-design/ReactDesignEditorRuntime.ts
+++ b/src/canvas/react-design/ReactDesignEditorRuntime.ts
@@ -27,6 +27,9 @@ import {
type EditorEngineSnapshot,
} from '../editor-engine'
import type { ReactDesignDefinitionRegistry } from '../react-design-renderer'
+import {
+ disposeReactDesignEditorExternalChangeHost,
+} from './ReactDesignEditorExternalChanges'
export type ReactDesignEditorViewportOptions = {
readonly fitPadding?: number
@@ -206,6 +209,7 @@ export function useReactDesignEditorRuntime(
current.generation === generation ||
current.runtime !== runtime
) {
+ disposeReactDesignEditorExternalChangeHost(runtime.editor)
runtime.editor.dispose()
runtime.projection.dispose()
}
diff --git a/src/canvas/react-design/ReactDesignTextSelection.test.ts b/src/canvas/react-design/ReactDesignTextSelection.test.ts
new file mode 100644
index 00000000..2ff5c12c
--- /dev/null
+++ b/src/canvas/react-design/ReactDesignTextSelection.test.ts
@@ -0,0 +1,286 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { createReactDesignTextSelection } from './ReactDesignTextSelection'
+
+describe('ReactDesignTextSelection', () => {
+ afterEach(() => {
+ document.body.replaceChildren()
+ })
+
+ it('restores a stable text selection into a remounted node element', () => {
+ const selection = createReactDesignTextSelection({ document })
+ let element = document.createElement('textarea')
+
+ element.value = 'Canonical text'
+ document.body.append(element)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => element,
+ })
+
+ element.focus()
+ element.setSelectionRange(2, 9, 'backward')
+ const bookmark = ownership.capture()
+
+ expect(bookmark).toEqual({
+ anchorOffset: 9,
+ direction: 'backward',
+ focusOffset: 2,
+ kind: 'text-control',
+ nodeId: 'note',
+ })
+
+ const replacement = document.createElement('textarea')
+
+ replacement.value = 'Canonical text after render'
+ element.replaceWith(replacement)
+ element = replacement
+
+ expect(ownership.restore(bookmark!)).toBe(true)
+ expect(document.activeElement).toBe(replacement)
+ expect(replacement.selectionStart).toBe(2)
+ expect(replacement.selectionEnd).toBe(9)
+ expect(replacement.selectionDirection).toBe('backward')
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('keeps a capture made after overlapping remount focus as the baseline', () => {
+ const selection = createReactDesignTextSelection({ document })
+ const previous = document.createElement('textarea')
+ const replacement = document.createElement('textarea')
+ let element = previous
+
+ previous.value = 'Previous'
+ replacement.value = 'Replacement'
+ document.body.append(previous, replacement)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => element,
+ })
+
+ previous.focus()
+ element = replacement
+ replacement.focus()
+ replacement.setSelectionRange(2, 7)
+ const bookmark = ownership.capture()
+
+ previous.remove()
+
+ expect(bookmark).not.toBeNull()
+ expect(ownership.restore(bookmark!)).toBe(true)
+ expect(document.activeElement).toBe(replacement)
+ expect(replacement.selectionStart).toBe(2)
+ expect(replacement.selectionEnd).toBe(7)
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('rejects restore when focus redirects to another control', () => {
+ const selection = createReactDesignTextSelection({ document })
+ let editor = document.createElement('textarea')
+ const inspector = document.createElement('input')
+
+ editor.value = 'Draft'
+ document.body.append(editor, inspector)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => editor,
+ })
+
+ editor.focus()
+ editor.setSelectionRange(1, 4)
+ const bookmark = ownership.capture()
+ const replacement = document.createElement('textarea')
+
+ replacement.value = 'Draft'
+ replacement.addEventListener('focus', () => inspector.focus())
+ editor.replaceWith(replacement)
+ editor = replacement
+
+ expect(bookmark).not.toBeNull()
+ expect(ownership.restore(bookmark!)).toBe(false)
+ expect(document.activeElement).toBe(inspector)
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('clamps UTF-16 offsets when the remounted value becomes shorter', () => {
+ const selection = createReactDesignTextSelection({ document })
+ let editor = document.createElement('textarea')
+
+ editor.value = 'A😀BC'
+ document.body.append(editor)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => editor,
+ })
+
+ editor.focus()
+ editor.setSelectionRange(1, 4, 'forward')
+ const bookmark = ownership.capture()
+ const replacement = document.createElement('textarea')
+
+ replacement.value = '😀'
+ editor.replaceWith(replacement)
+ editor = replacement
+
+ expect(bookmark).toMatchObject({
+ anchorOffset: 1,
+ focusOffset: 4,
+ })
+ expect(ownership.restore(bookmark!)).toBe(true)
+ expect(replacement.selectionStart).toBe(1)
+ expect(replacement.selectionEnd).toBe(2)
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('rejects a remounted input type without a text selection API', () => {
+ const selection = createReactDesignTextSelection({ document })
+ let editor = document.createElement('input')
+
+ editor.value = 'Draft'
+ document.body.append(editor)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => editor,
+ })
+
+ editor.focus()
+ editor.setSelectionRange(1, 4)
+ const bookmark = ownership.capture()
+ const replacement = document.createElement('input')
+
+ replacement.type = 'number'
+ replacement.value = '12'
+ editor.replaceWith(replacement)
+ editor = replacement
+
+ expect(bookmark).not.toBeNull()
+ expect(ownership.restore(bookmark!)).toBe(false)
+ expect(document.activeElement).toBe(document.body)
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('does not steal focus after the user moves to another control', () => {
+ const selection = createReactDesignTextSelection({ document })
+ const editor = document.createElement('textarea')
+ const inspector = document.createElement('input')
+
+ editor.value = 'Draft'
+ document.body.append(editor, inspector)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => editor,
+ })
+
+ editor.focus()
+ editor.setSelectionRange(5, 5)
+ const bookmark = ownership.capture()
+
+ inspector.focus()
+
+ expect(bookmark).not.toBeNull()
+ expect(ownership.restore(bookmark!)).toBe(false)
+ expect(document.activeElement).toBe(inspector)
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('does not steal focus after the user blurs a connected editor', () => {
+ const selection = createReactDesignTextSelection({ document })
+ const editor = document.createElement('textarea')
+
+ editor.value = 'Draft'
+ document.body.append(editor)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => editor,
+ })
+
+ editor.focus()
+ editor.setSelectionRange(5, 5)
+ const bookmark = ownership.capture()
+
+ editor.blur()
+
+ expect(bookmark).not.toBeNull()
+ expect(document.activeElement).toBe(document.body)
+ expect(ownership.restore(bookmark!)).toBe(false)
+ expect(document.activeElement).toBe(document.body)
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('does not steal focus after the user blurs a remounted editor', () => {
+ const selection = createReactDesignTextSelection({ document })
+ let editor = document.createElement('textarea')
+
+ editor.value = 'Draft'
+ document.body.append(editor)
+ const ownership = selection.claim({
+ nodeId: 'note',
+ readElement: () => editor,
+ })
+
+ editor.focus()
+ editor.setSelectionRange(5, 5)
+ const bookmark = ownership.capture()
+ const replacement = document.createElement('textarea')
+
+ replacement.value = 'Draft'
+ editor.replaceWith(replacement)
+ editor = replacement
+ replacement.focus()
+ replacement.blur()
+
+ expect(bookmark).not.toBeNull()
+ expect(document.activeElement).toBe(document.body)
+ expect(ownership.restore(bookmark!)).toBe(false)
+ expect(document.activeElement).toBe(document.body)
+
+ ownership.release()
+ selection.dispose()
+ })
+
+ it('ignores a bookmark from an older input ownership generation', () => {
+ const selection = createReactDesignTextSelection({ document })
+ const first = document.createElement('textarea')
+ const second = document.createElement('textarea')
+
+ first.value = 'First'
+ second.value = 'Second'
+ document.body.append(first, second)
+ const staleOwnership = selection.claim({
+ nodeId: 'first-note',
+ readElement: () => first,
+ })
+
+ first.focus()
+ first.setSelectionRange(1, 3)
+ const staleBookmark = staleOwnership.capture()
+
+ const currentOwnership = selection.claim({
+ nodeId: 'second-note',
+ readElement: () => second,
+ })
+
+ expect(staleBookmark).not.toBeNull()
+ expect(staleOwnership.restore(staleBookmark!)).toBe(false)
+ expect(document.activeElement).toBe(first)
+
+ currentOwnership.release()
+ selection.dispose()
+ })
+})
diff --git a/src/canvas/react-design/ReactDesignTextSelection.ts b/src/canvas/react-design/ReactDesignTextSelection.ts
new file mode 100644
index 00000000..e57c3442
--- /dev/null
+++ b/src/canvas/react-design/ReactDesignTextSelection.ts
@@ -0,0 +1,247 @@
+import type { DesignNodeId } from '../design-document'
+
+export type ReactDesignTextSelectionBookmark = {
+ readonly anchorOffset: number
+ readonly direction: 'backward' | 'forward' | 'none'
+ readonly focusOffset: number
+ readonly kind: 'text-control'
+ readonly nodeId: DesignNodeId
+}
+
+export type ReactDesignTextSelectionOwnership = {
+ capture(): ReactDesignTextSelectionBookmark | null
+ release(): void
+ restore(bookmark: ReactDesignTextSelectionBookmark): boolean
+}
+
+export type ReactDesignTextSelection = {
+ claim(input: {
+ readonly nodeId: DesignNodeId
+ readonly readElement: () => HTMLInputElement | HTMLTextAreaElement | null
+ }): ReactDesignTextSelectionOwnership
+ dispose(): void
+}
+
+export function createReactDesignTextSelection({
+ document,
+}: {
+ readonly document: Document
+}): ReactDesignTextSelection {
+ let disposed = false
+ let focusGeneration = 0
+ let ownershipGeneration = 0
+ let activeReadElement: (() => HTMLInputElement | HTMLTextAreaElement | null) |
+ null = null
+ let lastFocusedElement: Element | null = null
+ let pendingFocusReconciliation: {
+ readonly generation: number
+ readonly previous: Element
+ readonly target: Element
+ } | null = null
+
+ const reconcileRemountedFocus = (
+ pending = pendingFocusReconciliation,
+ ) => {
+ if (
+ !pending ||
+ pendingFocusReconciliation !== pending ||
+ disposed ||
+ focusGeneration !== pending.generation ||
+ pending.previous.isConnected ||
+ activeReadElement?.() !== pending.target
+ ) {
+ return false
+ }
+
+ pendingFocusReconciliation = null
+ focusGeneration -= 1
+ return true
+ }
+
+ const advanceFocusGeneration = (event: FocusEvent) => {
+ const target = event.target
+
+ if (!(target instanceof Element) || target === document.body) {
+ return
+ }
+
+ const currentElement = activeReadElement?.() ?? null
+ const previousFocusedElement = lastFocusedElement
+
+ pendingFocusReconciliation = null
+
+ if (
+ target === currentElement &&
+ previousFocusedElement !== null &&
+ !previousFocusedElement.isConnected
+ ) {
+ lastFocusedElement = target
+ return
+ }
+
+ focusGeneration += 1
+ const generation = focusGeneration
+
+ lastFocusedElement = target
+
+ if (previousFocusedElement !== null) {
+ const pending = {
+ generation,
+ previous: previousFocusedElement,
+ target,
+ }
+
+ pendingFocusReconciliation = pending
+ queueMicrotask(() => reconcileRemountedFocus(pending))
+ }
+ }
+
+ document.addEventListener('focusin', advanceFocusGeneration)
+
+ return {
+ claim({ nodeId, readElement }) {
+ if (disposed) {
+ throw new Error('ReactDesignTextSelection is disposed')
+ }
+
+ ownershipGeneration += 1
+ const generation = ownershipGeneration
+ activeReadElement = readElement
+ pendingFocusReconciliation = null
+ let pending: {
+ readonly bookmark: ReactDesignTextSelectionBookmark
+ readonly element: HTMLInputElement | HTMLTextAreaElement
+ readonly focusGeneration: number
+ } | null = null
+
+ return {
+ capture() {
+ if (disposed || generation !== ownershipGeneration) {
+ return null
+ }
+
+ reconcileRemountedFocus()
+
+ const element = readElement()
+
+ if (
+ !element ||
+ document.activeElement !== element ||
+ element.selectionStart === null ||
+ element.selectionEnd === null
+ ) {
+ return null
+ }
+
+ const direction = normalizeDirection(element.selectionDirection)
+ const backward = direction === 'backward'
+ const bookmark = Object.freeze({
+ anchorOffset: backward
+ ? element.selectionEnd
+ : element.selectionStart,
+ direction,
+ focusOffset: backward
+ ? element.selectionStart
+ : element.selectionEnd,
+ kind: 'text-control',
+ nodeId,
+ } satisfies ReactDesignTextSelectionBookmark)
+
+ pendingFocusReconciliation = null
+ pending = { bookmark, element, focusGeneration }
+ lastFocusedElement = element
+ return bookmark
+ },
+ release() {
+ pending = null
+
+ if (generation === ownershipGeneration) {
+ ownershipGeneration += 1
+ activeReadElement = null
+ pendingFocusReconciliation = null
+ }
+ },
+ restore(bookmark) {
+ const captured = pending
+
+ reconcileRemountedFocus()
+
+ if (
+ disposed ||
+ generation !== ownershipGeneration ||
+ captured?.bookmark !== bookmark ||
+ captured.focusGeneration !== focusGeneration ||
+ bookmark.nodeId !== nodeId
+ ) {
+ return false
+ }
+
+ pending = null
+ const element = readElement()
+ const activeElement = document.activeElement
+ const remountedIntoBody =
+ (activeElement === document.body || activeElement === null) &&
+ captured.element !== element &&
+ !captured.element.isConnected &&
+ lastFocusedElement === captured.element
+
+ if (
+ !element ||
+ !element.isConnected ||
+ element.selectionStart === null ||
+ element.selectionEnd === null ||
+ (activeElement !== element && !remountedIntoBody)
+ ) {
+ return false
+ }
+
+ const length = element.value.length
+ const anchorOffset = clampOffset(bookmark.anchorOffset, length)
+ const focusOffset = clampOffset(bookmark.focusOffset, length)
+
+ element.focus({ preventScroll: true })
+
+ if (
+ disposed ||
+ generation !== ownershipGeneration ||
+ readElement() !== element ||
+ document.activeElement !== element
+ ) {
+ return false
+ }
+
+ element.setSelectionRange(
+ Math.min(anchorOffset, focusOffset),
+ Math.max(anchorOffset, focusOffset),
+ bookmark.direction,
+ )
+ return true
+ },
+ }
+ },
+ dispose() {
+ if (disposed) {
+ return
+ }
+
+ disposed = true
+ ownershipGeneration += 1
+ activeReadElement = null
+ lastFocusedElement = null
+ pendingFocusReconciliation = null
+ document.removeEventListener('focusin', advanceFocusGeneration)
+ },
+ }
+}
+
+function normalizeDirection(
+ direction: string | null,
+): ReactDesignTextSelectionBookmark['direction'] {
+ return direction === 'backward' || direction === 'forward'
+ ? direction
+ : 'none'
+}
+
+function clampOffset(offset: number, length: number) {
+ return Math.min(length, Math.max(0, Math.trunc(offset)))
+}
diff --git a/src/canvas/react-design/index.ts b/src/canvas/react-design/index.ts
index 88e5273b..d1dcb0bd 100644
--- a/src/canvas/react-design/index.ts
+++ b/src/canvas/react-design/index.ts
@@ -70,6 +70,9 @@ export type {
ReactDesignWidgetTextEditCapability,
} from './ReactDesignWidgetPack'
+export {
+ ReactDesignEditorRenderer,
+} from './ReactDesignEditorRenderer'
export {
useReactDesignEditorRuntime,
} from './ReactDesignEditorRuntime'
@@ -78,3 +81,17 @@ export type {
ReactDesignEditorViewportOptions,
UseReactDesignEditorRuntimeOptions,
} from './ReactDesignEditorRuntime'
+export type {
+ ReactDesignEditorExternalChangeHost,
+} from './ReactDesignEditorExternalChanges'
+export {
+ getReactDesignEditorExternalChangeHost,
+} from './ReactDesignEditorExternalChanges'
+export {
+ createReactDesignTextSelection,
+} from './ReactDesignTextSelection'
+export type {
+ ReactDesignTextSelection,
+ ReactDesignTextSelectionBookmark,
+ ReactDesignTextSelectionOwnership,
+} from './ReactDesignTextSelection'