diff --git a/README.md b/README.md index 3473d49..166a9ad 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,59 @@ # Interactive OS Editable -`@interactive-os/editable` is a JSON-backed contenteditable experiment focused -on one hard invariant: native IME input and application rendering must never -replace the same live DOM node at the same time. - -The current implementation keeps `@interactive-os/json-document` as canonical -state and mounts one coordinator that owns the entire editable subtree. Pure -command planning, keyed DOM projection, and native-mutation grammar sit behind -small internal seams; browser event ordering and the live IME lease remain -together in the coordinator. -Ordinary input is converted to model commands before the browser mutates DOM. -During composition the module pins the browser-owned text node and lends that -block to the IME; proven-disjoint `remote` updates are queued until settling, -while local or same-block commands return `composition_conflict` and must be -retried afterward. - -`getJsonEditableDocumentHost(editor)` exposes a document-host capability for -delayed or causal changes without widening the established `JsonEditable` -shape. It flushes pending native input before a ready change, defers while the -browser still owns a composition island, and identifies coordinator-owned -native, application, history, and ready publications with a pre-commit -sequence. The demo wires the SHA-pinned `json-document` causal inbox through -that capability; “지연 편집 추적” exercises both positional rebase after a -local insertion, selection correction, and settle-time retry during IME -composition. Ready changes always update canonical selection, but automatic -settle and causal publication restore DOM selection only while the editable -root already owns focus. A background change therefore cannot reclaim focus -from another control. - -Enter is handled as a structural intent, independently from DOM ownership. A -semantic paragraph event is retained and replayed once composition settles; -candidate-confirming `keydown Enter` by itself is never treated as a paragraph. -Non-cancelable native splits are accepted only when they exactly match one -bounded, expected split and are then rebuilt from canonical JSON. - -React renders the toolbar and diagnostics only. It provides an empty root to the -editor and never renders descendants inside that root. - -The package enforces a direct-child layer rule: +This repository explores a durable editing boundary: -```txt -public facade -> browser -> core +- `@interactive-os/json-document` is the canonical, headless JSON state + protocol. +- `@interactive-os/editable` owns DOM and Input Events normalization. +- document semantics live in adapters, with Markdown supplied as a reference + candidate rather than root policy. + +The stable package root deliberately exposes only `mountEditor`. Its adapter +contract is open to Docs-, Sheet-, Slides-, canvas-, block-, or source-oriented +models without putting Markdown text semantics into the host. + +The engine owns only the host's `contenteditable` state and +`data-editable-owner` marker. Each product supplies its own ARIA role, +spellcheck, and focus behavior; editor-owned subtrees cannot overlap or nest. + +```ts +import { mountEditor } from "@interactive-os/editable"; +import { + createMarkdownAdapter, + createMarkdownDocument, +} from "@interactive-os/editable/markdown"; + +const document = createMarkdownDocument({ + id: "note", + source: "# 제목 없는 노트", +}); +const adapter = createMarkdownAdapter(); +const editor = mountEditor({ root, document, adapter }); ``` -Cross-layer imports must use the child layer's `index.ts`; public-to-core -grandchild imports, browser-to-core implementation imports, and upward imports -are rejected by `pnpm run check:editable-layers`. +The editor snapshot atomically exposes the published JSON value, logical +selection, and `isComposing`. Document edits carry a namespaced transaction ID +and logical `selectionAfter`; external changes are mapped by the adapter. +Selection-only changes do not write the document. History is a separate +extension concern. -The previous implementation is preserved unchanged under -[`archive/pre-composition-island`](./archive/pre-composition-island). +Markdown keeps one `/source` string canonical. Rich and Live Preview modes are +DOM projections of that source. Composition uses a browser-owned island, +rebases disjoint external changes, and rejects overlap. + +```txt +packages/editable/ + index.ts stable DOM facade + browser/contract.ts stable protocol types + browser/genericEditor.ts generic DOM state machine + markdown/index.ts reference Markdown adapter + +src/note-editor/ app-owned demo +vendor/json-document-v2/ pinned pre-release dependency artifact +``` + +The vendored JSON document tarball is pinned because v2 is not yet published; +its source commit and SHA-256 are recorded beside the artifact. ## Run @@ -57,17 +62,17 @@ pnpm install pnpm dev ``` -Open `http://localhost:3000/`. - ## Verify ```bash -pnpm run test:core +pnpm exec tsc --noEmit +pnpm run test:package +pnpm run build:package pnpm run check:editable-layers +pnpm run build pnpm run verify:browser -pnpm run verify:internal ``` -Synthetic composition tests verify the coordinator protocol only. Actual IME -acceptance still requires the -[`manual IME acceptance`](./docs/manual-ime-acceptance.md) matrix. +Package verification also installs the packed artifact in a clean temporary +project. The root-only fixture proves that the Markdown parser is not installed; +the Markdown fixture adds its declared optional peer and imports the subpath. diff --git a/archive/pre-composition-island/ARCHIVE.md b/archive/pre-composition-island/ARCHIVE.md index 38d6ab6..dc7bdd4 100644 --- a/archive/pre-composition-island/ARCHIVE.md +++ b/archive/pre-composition-island/ARCHIVE.md @@ -15,6 +15,10 @@ island architecture. The archived scope is: At the baseline, `pnpm exec tsc --noEmit` passed and the core Vitest suite passed all **96 tests**. +On **2026-07-16**, superseded editor policy audits that describe this retired +architecture were moved under `docs`. Evidence manifests retain links to those +historical records without presenting them as current runtime documentation. + This is reference material only. It is excluded from the active build, typecheck, test suites, and package surface. It is not maintained, fixed, or kept compatible with the replacement implementation. Copy ideas deliberately; do not import or diff --git a/docs/editor-atom-contenteditable-policy-audit.md b/archive/pre-composition-island/docs/editor-atom-contenteditable-policy-audit.md similarity index 100% rename from docs/editor-atom-contenteditable-policy-audit.md rename to archive/pre-composition-island/docs/editor-atom-contenteditable-policy-audit.md diff --git a/docs/editor-bidi-rtl-geometry-policy-audit.md b/archive/pre-composition-island/docs/editor-bidi-rtl-geometry-policy-audit.md similarity index 100% rename from docs/editor-bidi-rtl-geometry-policy-audit.md rename to archive/pre-composition-island/docs/editor-bidi-rtl-geometry-policy-audit.md diff --git a/docs/editor-composition-history-policy-audit.md b/archive/pre-composition-island/docs/editor-composition-history-policy-audit.md similarity index 58% rename from docs/editor-composition-history-policy-audit.md rename to archive/pre-composition-island/docs/editor-composition-history-policy-audit.md index 8e47355..bdc0ac9 100644 --- a/docs/editor-composition-history-policy-audit.md +++ b/archive/pre-composition-island/docs/editor-composition-history-policy-audit.md @@ -29,9 +29,15 @@ The current duplicate final composition input guard is sufficient: the late fina ## Issue #84 Live Markdown Shortcut History Contract -Live markdown shortcuts are not part of the current editor product surface. The editor should not add a markdown transform registry until a producer needs it. +Live Markdown shortcuts remain opt-in at the package mount seam. The product +installs `plugins: [bearMarkdown()]`, starts it enabled, and exposes its runtime +toggle in the toolbar. The first producer supports paragraph-start `# ` and +`> ` rules. Package-provided extensions receive accepted canonical input, not +raw DOM events or mutation records, and cannot write the content DOM directly. +The opaque install surface is not a public plugin-authoring SDK. -If live markdown shortcuts are added later, transform success must be exposed as an explicit history origin, for example `markdownShortcutTransform`, rather than being hidden inside the native text input commit. +Successful product transforms use the explicit `plugin:bear-markdown` history +origin instead of hiding inside the native text input commit. History grouping policy: @@ -43,10 +49,25 @@ History grouping policy: This keeps accidental markdown transforms recoverable with one undo while preserving the typed input as a separate user action. Failed transforms do not create a transform history unit. -Required fixtures when implemented: +Implemented fixtures: - Typing `# ` at paragraph start transforms to heading; undo once restores literal `# ` in a paragraph; undo twice removes `# `. -- Typing `- ` at paragraph start transforms to a bullet/list item with the same two-step undo behavior. -- Typing a bold/italic inline shortcut transforms marks with delimiter restoration on first undo. +- Typing `> ` at paragraph start transforms to a quote. - Transform inside active composition is deferred until composition commit; no transform history unit is created during preedit. -- Pasting markdown-like text does not run live shortcut transforms unless the producer explicitly opts into paste transforms with a separate origin. +- A marker split across ordinary input and a settled composition remains one + literal undo unit in either order. +- Plugin triggers are queued in accepted-input order and retain stable block + ids plus offsets, not positional JSON paths. A later selection move or a + block insertion cannot retarget a deferred shortcut to another literal + marker; multiple deferred/current markers in one native turn all run. +- Pasting or programmatically dispatching markdown-like text does not run live shortcut transforms. +- A composition-time Enter still creates its separate paragraph history unit. + +When a synchronous out-of-band publication makes input ownership ambiguous, +the shortcut does not run and document history is cleared. This is a deliberate +failure mode: keeping an unprovable nested history entry can make Undo resurrect +an old marker. An unambiguous disjoint external write remains separate from the +literal input and transform units. + +List and inline delimiter rules remain future producers; they should reuse this +accepted-input and separate-transform history contract. diff --git a/docs/editor-decoration-boundary-composition-audit.md b/archive/pre-composition-island/docs/editor-decoration-boundary-composition-audit.md similarity index 100% rename from docs/editor-decoration-boundary-composition-audit.md rename to archive/pre-composition-island/docs/editor-decoration-boundary-composition-audit.md diff --git a/docs/editor-drag-drop-selection-policy-audit.md b/archive/pre-composition-island/docs/editor-drag-drop-selection-policy-audit.md similarity index 100% rename from docs/editor-drag-drop-selection-policy-audit.md rename to archive/pre-composition-island/docs/editor-drag-drop-selection-policy-audit.md diff --git a/docs/editor-figure-caption-container-policy-audit.md b/archive/pre-composition-island/docs/editor-figure-caption-container-policy-audit.md similarity index 100% rename from docs/editor-figure-caption-container-policy-audit.md rename to archive/pre-composition-island/docs/editor-figure-caption-container-policy-audit.md diff --git a/docs/editor-focus-selectionchange-race-audit.md b/archive/pre-composition-island/docs/editor-focus-selectionchange-race-audit.md similarity index 100% rename from docs/editor-focus-selectionchange-race-audit.md rename to archive/pre-composition-island/docs/editor-focus-selectionchange-race-audit.md diff --git a/docs/editor-native-mutation-policy-audit.md b/archive/pre-composition-island/docs/editor-native-mutation-policy-audit.md similarity index 100% rename from docs/editor-native-mutation-policy-audit.md rename to archive/pre-composition-island/docs/editor-native-mutation-policy-audit.md diff --git a/docs/editor-nested-editable-focus-policy-audit.md b/archive/pre-composition-island/docs/editor-nested-editable-focus-policy-audit.md similarity index 100% rename from docs/editor-nested-editable-focus-policy-audit.md rename to archive/pre-composition-island/docs/editor-nested-editable-focus-policy-audit.md diff --git a/docs/editor-root-context-policy-audit.md b/archive/pre-composition-island/docs/editor-root-context-policy-audit.md similarity index 100% rename from docs/editor-root-context-policy-audit.md rename to archive/pre-composition-island/docs/editor-root-context-policy-audit.md diff --git a/docs/editor-safari-composition-delete-policy-audit.md b/archive/pre-composition-island/docs/editor-safari-composition-delete-policy-audit.md similarity index 100% rename from docs/editor-safari-composition-delete-policy-audit.md rename to archive/pre-composition-island/docs/editor-safari-composition-delete-policy-audit.md diff --git a/docs/editor-selection-geometry-policy-audit.md b/archive/pre-composition-island/docs/editor-selection-geometry-policy-audit.md similarity index 100% rename from docs/editor-selection-geometry-policy-audit.md rename to archive/pre-composition-island/docs/editor-selection-geometry-policy-audit.md diff --git a/docs/editor-stray-break-policy-audit.md b/archive/pre-composition-island/docs/editor-stray-break-policy-audit.md similarity index 100% rename from docs/editor-stray-break-policy-audit.md rename to archive/pre-composition-island/docs/editor-stray-break-policy-audit.md diff --git a/docs/editor-text-replacement-formatting-policy-audit.md b/archive/pre-composition-island/docs/editor-text-replacement-formatting-policy-audit.md similarity index 100% rename from docs/editor-text-replacement-formatting-policy-audit.md rename to archive/pre-composition-island/docs/editor-text-replacement-formatting-policy-audit.md diff --git a/biome.json b/biome.json index 3f70ad9..667d7f1 100644 --- a/biome.json +++ b/biome.json @@ -9,12 +9,16 @@ "ignoreUnknown": false, "includes": [ "**/src/**/*", + "**/packages/editable/**/*.ts", + "**/packages/editable/**/*.mjs", + "**/packages/editable/package.json", "**/scripts/**/*.mjs", "**/tools/**/*.mjs", "**/.vscode/**/*", "**/index.html", "**/vite.config.ts", "!archive", + "!**/dist/**/*", "!**/src/routeTree.gen.ts", "!**/src/vendor/**/*" ] diff --git a/docs/composition-island-architecture.md b/docs/composition-island-architecture.md index 3f6a9b3..7782898 100644 --- a/docs/composition-island-architecture.md +++ b/docs/composition-island-architecture.md @@ -1,327 +1,82 @@ -# Composition Island Architecture +# Composition island architecture -## Decision +This is the current generic editor contract. `JSONDocument` remains canonical; +the browser owns only the live DOM mutation inside an active IME composition. -`JSONDocument` is the canonical value, selection, and history owner. One -coordinator owns every descendant of the editable root; React renders only the -empty mount element and the surrounding controls. +## Boundary -The implementation does not pretend that two writers can safely mutate the -same live `Text` node. Instead it changes ownership by input phase: +`mountEditor({ root, document, adapter })` owns `root` and all descendants until +`destroy()`. The stable host knows no Markdown schema. Its adapter maps between +logical JSON selections and DOM boundary points, projects canonical JSON, plans +Input Events, and reconciles native DOM mutations. -```txt -normal input active IME composition ------------- ---------------------- -beforeinput -> JSON command browser owns one block's DOM -JSONDocument -> keyed DOM bounded DOM diff -> JSONDocument - structural intent waits or is validated - document transactions wait -``` - -This is a short, block-granular lease rather than a CRDT. A causal or remote-op -adapter can sit above the coordinator through -`getJsonEditableDocumentHost(editor)`. The host flushes pending native input -and defers ready work until the lease is released; the app retries a deferred -inbox from a microtask after observing idle state. - -## Ownership Invariants - -1. The coordinator is the only bridge between DOM and `JSONDocument`. -2. React never reconciles descendants of the editable root. -3. Cancelable ordinary insertion, deletion, Enter, paste, cut, and history - intent are converted to model commands in `beforeinput`; the browser does - not perform those DOM writes. During composition, an explicit structural - intent is retained until the text-node lease can be released. -4. During composition, the exact `Text` node and its ancestor chain are pinned. - The composing block is opaque to projection until settling completes. -5. A `remote` change proven to touch only another block is queued and committed - immediately after settling. Local commands and any change that may touch the - composing block fail with `composition_conflict`; they do not claim to cancel - the operating-system IME. -6. Renderer writes run with the observer disconnected and drained. They can - never be re-ingested as native input. -7. DOM changes without a bounded native-input intent are rejected and the - canonical projection is restored. A rejected turn is atomic: admitted text - records from the same turn are not partially committed. - -## Input Protocol - -### Ordinary editable commands - -For cancelable `beforeinput`, the coordinator prevents the browser default and -commits the equivalent JSON patch. This avoids engine-specific empty-block -rewrites such as replacing the owned text surface with a bare `
`. - -Selection endpoints may be `Text` offsets or element child boundaries. Root and -block boundaries are resolved to the first or last owned text surface, covering -Firefox's native Select All representation and empty-block caret positions. -When `beforeinput.getTargetRanges()` is available, its range takes precedence -over a stale DOM selection, which is required by several mobile IME flows. - -Some engines expose a non-cancelable, composition-shaped event for a whole-root -replacement. The coordinator records the selected JSON range and data before -the browser writes, discards that known transient DOM rewrite on `input`, and -replays the captured model command. Firefox may deliver the mutation observer -turn before `input`; while this captured intent is live those records are also -discarded rather than passed to generic foreign-DOM admission. The transient -DOM is never adopted, and timeout recovery restores canonical projection if -`input` does not arrive. - -### IME composition - -`compositionstart` records the selected JSON range and pins the exact active -`Text` node and ancestor chain. It also snapshots the keyed block identities, -so an input-only fallback cannot redefine a post-mutation node or element as -the original block. `input` plus -`MutationObserver` evidence is accepted only from that block. The smallest text -change is computed near the known composition range so -repeated strings such as `aaa -> aaaa` do not move the edit to the wrong equal -substring. - -Each accepted native update becomes a JSON transaction. Consecutive updates -from the same composition session are explicitly merged into one undo entry. -Disjoint queued updates are committed only after that entry is complete, so -linear `JSONDocument` history never exposes an intermediate preedit on undo. - -`compositionend` is not treated as the final write by itself. The coordinator -enters a short settling phase, drains late input and mutation records, then -releases the DOM lease and restores normal projection. A new composition start -settles and cancels the previous timer first, so an old timer cannot terminate -the new session. - -### Structural intent during composition - -Composition state and edit intent are independent axes: - -- `isComposing` says whether the browser still owns the leased DOM; -- `inputType` says what edit the user requested. - -Therefore composing state is not a global event firewall. A physical -`keydown Enter` remains ignored because it may only confirm an IME candidate. -A paragraph intent is instead evidenced by `beforeinput` or `input` with -`insertParagraph`/`insertLineBreak`, or by a composition result ending in a -line-break character. `beforeinput` starts one logical intent. A non-cancelable -`beforeinput` is paired with its later `input`; a canceled `beforeinput` expects -no `input`, so a later input-only event remains a new intent. Newline-bearing -`compositionend` and its adjacent input phase are deduplicated. Two distinct -`beforeinput` events remain two paragraph intents even within one composition. -Candidate confirmation without one of those signals does not split a block. - -For a cancelable structural event, the coordinator prevents the transient DOM -write, drains the final composition text, releases the lease, and executes the -existing JSON paragraph command exactly once. The paragraph is a separate undo -entry from the composition. - -For a non-cancelable or input-only mobile path, the event grants a one-shot -capability for one expected native block split. The coordinator verifies the -original block and pinned text identities and order, a single adjacent native -block, a strict text/empty-`br` grammar (including native `

`), -and exact left/right text against the pre-event -canonical value. The native element, markup, ID, and type are never adopted. -The transient DOM is discarded and the same JSON paragraph command is replayed. -Any extra block, attribute, nested element, text mismatch, or mutation in -another block rejects the entire turn and restores the canonical projection. -Validation is mandatory when the lease settles, including an early settle -caused by teardown or a new composition. A final IME text replacement such as -preedit-to-committed text is accepted only when its diff remains inside the -captured composition range; it is merged into the composition history before -the paragraph command runs. - -Some mobile engines encode Enter as a trailing newline in the composition -text. That newline is removed from the composition history entry before the -paragraph command runs, preventing it from leaking into either resulting -block. Its location is recalculated after late final input or a validated native -split rather than inferred from `compositionend.data` length. The normalized -model selection is restored before queued remote commits, so their Undo records -cannot capture a stale pre-normalization DOM caret. - -### Concurrent application changes - -- Proven-disjoint `remote` change: validate and queue it, then commit it after - composition settles but before structural intent replay. This preserves the - queued patch's original index paths; the paragraph remains the first Undo. -- Local application command: return `composition_conflict` and retry after - settling, even if it currently targets another block. -- Same composing block: return `composition_conflict`; the caller retries or - buffers it until the phase becomes `idle`. -- Direct writes to the supplied `JSONDocument`: unsupported while mounted. The - editor reports `out_of_band_document_write` and recovers conservatively. - -### Causal document host - -`getJsonEditableDocumentHost(editor)` is the only supported path for an adapter -whose own `doc.commit()` must be reconciled as an editor-owned publication. It -is a small capability rather than a second transaction API, and the separate -getter keeps the established `JsonEditable` structural contract unchanged: +Ownership is exclusive: an editor cannot mount inside another owned subtree or +around an owned descendant. The host runtime owns only `contenteditable` and +its `data-editable-owner` marker; application-level ARIA, spellcheck, and focus +semantics remain caller-owned. ```ts -const inbox = createCausalPatchInbox(document, { - positionalSchema: EditableDocumentSchema, - host: getJsonEditableDocumentHost(editor), -}); -``` - -Before every coordinator-owned native, application, or history change, -`runDocumentChange` assigns a monotonic sequence. `ownsPublication` returns -that sequence only during the synchronous publication; origin strings are not -used as authority. A ready apply reserves from the same sequence before its -scope-bound closure runs. Raw `document.commit()` calls therefore remain out of -band. -Editor snapshot subscribers are exception-isolated and report -`subscriber_failed`; fault observers are isolated as well. A user callback -therefore cannot abort the document's subscriber loop before a causal inbox -journals an owned publication. - -For a ready causal envelope, `runReady` first drains mutation records. It calls -the supplied `apply()` exactly once only when the editor is idle and has no -pending composition, native intent, structural intent, or queued remote patch. -It refuses immediately, without attempting that drain, while a browser event -handler or another coordinator-owned document publication is still on the -stack. This prevents both pre-native DOM interleaving and nested journal order -from depending on subscriber registration order. +const editor = mountEditor({ root, document, adapter }); -That condition must hold both before and after the drain: recovery that cancels -a damaged composition still defers the current turn, so a causal render cannot -overlap an OS composition that has not delivered its final event. Otherwise -`runReady` returns `host_not_ready` without calling `apply()`. The first -publication whose `mergeKey` matches the ready envelope id is reconciled as a -remote change. A nested or second publication remains out of band, while the -causal inbox independently detects projection divergence. - -Browser composition activity is tracked separately from the pinned -`CompositionSession`. Losing an ancestor or Text identity may cancel the pin, -but it does not release the OS lease. Ready work remains deferred until -`compositionend`, blur, or a non-composing `insertFromComposition` input clears -that latch. Release is queued after the native event handler returns, so a -synchronous editor subscriber cannot re-enter the inbox before final native -flush and phase handling complete. A generation token prevents an old release -from closing a newly started composition, and a new composing signal cancels -any orphaned settle timer from a damaged session. Clearing the latch emits a -snapshot change so a coalesced retry waiting behind a damaged composition wakes -up. +editor.getSnapshot(); +// { value: JSONValue, selection: EditorSelection | null, isComposing: boolean } +``` -If `compositionend` arrives after the pinned session was already lost, the -editor still enters the same 30 ms settling window before exposing idle state; -this leaves room for the browser's late final input. +Document plans are RFC 6902 operations using RFC 6901 paths. Native vocabulary +is preserved as `InputEvent.inputType`; publications receive a namespaced +transaction ID, origin, input type, and logical selection. -Retry is scheduled in a microtask after an idle snapshot, not synchronously -inside an editor subscriber. This lets the current document publication finish -and reach the causal inbox's ownership subscriber before another ingestion -begins. The app-owned `causalDocumentInbox` tracer implements this coalesced -retry and avoids a same-revision microtask loop when another pending input state -still prevents readiness. +## Normal input -Ready publications and composition settling update canonical JSON selection -regardless of focus. They restore the matching DOM selection only when the -editable root still matches `:focus` in its document or shadow-root context; -automatic reconciliation must not reclaim focus from an external control. -Explicit local commands retain their existing focus-and-selection restoration -behavior. +Cancelable `beforeinput` is fail-closed: -The demo's “remote” origin means a queued asynchronous application update, not -a full collaborative undo model. True collaboration needs causal operations and -origin-selective undo in the CRDT/OT layer instead of this linear queue. +1. The host prevents the browser default. +2. It maps the complete `getTargetRanges()` sequence atomically; one + unmappable boundary declines the whole intent. +3. The adapter returns a plan or declines it. +4. Successful operations commit once through `JSONDocument`. +5. Canonical `change.applied` is projected and the logical selection is + restored without stealing focus. -## Rendering and Observation +For non-cancelable input, the host records the pre-mutation value, selection, +and actual `inputType`, then reconciles that baseline on `input`. -Blocks are keyed by persisted IDs. Text updates use `CharacterData` operations -and preserve a canonical single text node plus an empty-block placeholder. A -type change may replace a block element only outside composition. Foreign root -children, duplicate keyed nodes, nested markup, and attribute changes are -removed during recovery. Expected native paragraph DOM is accepted only as a -temporary effect of the one-shot structural capability described above. +## Composition -The mount API intentionally stays small: +At `compositionstart`, the host freezes a baseline `{value, selection}` and +sets `isComposing`. It does not project over the active browser-owned DOM +island. Selection-change noise is ignored while composing. -```ts -const editor = mountJsonEditable({ root, document, onFault }); -const host = getJsonEditableDocumentHost(editor); -editor.dispatch(action); -editor.getSnapshot(); -editor.subscribe(listener); -editor.destroy(); -``` - -The package encodes that public seam as a direct-child layer hierarchy: - -```txt -packages/editable/ -├─ index.ts, editor.ts, model.ts public facade layer -├─ browser/ -│ ├─ index.ts public -> browser seam -│ ├─ editor.ts editor contract and mount facade -│ ├─ editorCoordinator.ts event order and mounted-session state -│ ├─ documentProjection.ts keyed canonical DOM projection -│ ├─ editableDOM.ts surface and placeholder primitives -│ ├─ domSelection.ts DOM <-> model selection mapping -│ ├─ nativeTextMutation.ts bounded text-mutation admission -│ └─ nativeParagraph.ts one-shot native Enter grammar -└─ core/ - ├─ index.ts browser -> core seam - ├─ model.ts document model and selection meaning - ├─ editorCommands.ts action -> patch and selection plan - └─ textChange.ts DOM-free text change calculations -``` +The island settles on any of: -The allowed direction is `public -> browser -> core`. A layer may access peers -and its immediate child's explicit `index.ts`, but never a grandchild, parent, -or child implementation file. Code outside the package may import only the -root `index.ts`. `pnpm run check:editable-layers` checks static, type-only, -dynamic, and export-from dependencies and runs as part of the normal check. -Non-literal dynamic imports are rejected because their target cannot be proven -to stay on an allowed seam. Aliased CommonJS loaders, Node `createRequire` -sources, Vite glob loaders, and source-tree symlinks are rejected for the same -reason. -The DOM platform, `@interactive-os/json-document`, and Zod are declared -external dependencies rather than package child layers; core remains DOM-free. +- `compositionend`; +- a final non-composing composition `input`; +- `blur`; +- `destroy()`; +- a clipboard mutation, before cut or paste is planned. -The root `editor.ts` and `model.ts` are compatibility facades and enter browser -only through `browser/index.ts`. Browser policy modules neither commit -`JSONDocument` changes nor report faults. The coordinator remains the single -transaction and timing owner, so layering does not split the composition lease -across competing state holders. +A late final composition input cancels the pending `compositionend` timer so +the complete native result becomes one canonical publication. -`destroy()` uses the same mandatory native-effect validation, trailing-newline -normalization, queued-remote ordering, and structural replay as timed settling -before it removes listeners and restores the host attributes it borrowed. +## External JSON changes -## Evidence Boundary +External document publications remain canonical during composition. The +adapter maps logical selection without replacing the island. At settlement: -The automated suite verifies: +- disjoint native and external source changes rebase; +- overlapping changes return `composition_conflict`; +- mapping or reconciliation failure clears unsafe selection and reprojects the + canonical value after the island closes. -- coordinator transitions and history in jsdom; -- causal host publication ownership, delayed positional rebase, selection - restoration, pending-native flush, composition defer, and microtask retry; -- owner-document/cross-realm DOM behavior; -- ordinary typing, empty-block deletion, and root-boundary Select All in real - Chromium, Firefox, and WebKit; -- ordinary trusted Enter plus cancelable, input-only, trailing-newline, and - non-cancelable structural protocol paths in all three engines; -- synthetic composition range accumulation, node identity, conflict rejection, - and settling in all three engines. +The reference Markdown adapter restricts native reconciliation to the baseline +source range and uses UTF-16 offsets. Other schemas define their own island and +rebase rules behind the same host contract. -Script-created `CompositionEvent` and `InputEvent` objects do **not** establish -an OS text-input session. They test the protocol only. Release acceptance still -requires real Korean and Japanese desktop IME plus iOS and Android keyboard -traces described in [manual IME acceptance](./manual-ime-acceptance.md). +## Recovery -## Primary References +Adapter, subscriber, and error-reporter exceptions never interrupt later +subscribers or cleanup. A failed mount restores the original root nodes and +attributes. `destroy()` is idempotent and restores the two runtime-owned host +attributes even when a disposer throws. -- ProseMirror protects local composition DOM and incrementally reconciles - around it: [view descriptions](https://github.com/ProseMirror/prosemirror-view/blob/master/src/viewdesc.ts) - and [DOM observation](https://github.com/ProseMirror/prosemirror-view/blob/master/src/domobserver.ts). -- CodeMirror tests composition preservation and conflicting updates in browser - fixtures: [composition tests](https://github.com/codemirror/view/blob/main/test/webtest-composition.ts) - and [DOM observation](https://github.com/codemirror/view/blob/main/src/domobserver.ts). -- CKEditor suppresses view rendering while native composition owns DOM, then - renders after composition: [view renderer](https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-engine/src/view/renderer.ts) - and routes Enter from `beforeinput`: [Enter observer](https://github.com/ckeditor/ckeditor5/blob/master/packages/ckeditor5-enter/src/enterobserver.ts). -- Slate ignores composing keydown while retaining `insertParagraph`, with a - separate Android mutation/action queue: [editable input routing](https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/editable.tsx) - and [Android input manager](https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts). -- Lexical handles paragraph input and trailing composition newlines separately - from candidate-confirming keydown: [event routing](https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalEvents.ts). -- Quill's Safari IME guard shows why composing Enter keydown cannot itself be a - paragraph command: [keyboard module](https://github.com/slab/quill/blob/main/packages/quill/src/modules/keyboard.ts). -- EditContext is the platform-level alternative in which the user agent sends - text updates without directly editing the DOM: [W3C EditContext](https://www.w3.org/TR/edit-context/). +Browser conformance is exercised by `pnpm run verify:browser`. diff --git a/docs/editor-android-virtual-keyboard-policy-audit.md b/docs/editor-android-virtual-keyboard-policy-audit.md index d4cf03c..31ff18b 100644 --- a/docs/editor-android-virtual-keyboard-policy-audit.md +++ b/docs/editor-android-virtual-keyboard-policy-audit.md @@ -51,20 +51,20 @@ explicit manual trace requirements. Current code already follows most of this policy: -- `resolveEditTurn` treats `beforeinput insertParagraph`/`insertLineBreak` as - model commands. -- Native text input starts a text-surface lease and commits on `input`. -- Composition input is buffered until final composition input or command flush. -- Model commands during composition flush the active leaf and suppress duplicate - final composition input. -- Paste is app-owned through the paste handler, not trusted rich native DOM. -- Atom and block boundary deletion policies are documented as model-owned. +- The generic host preserves `beforeinput.inputType` and atomically maps the + complete `getTargetRanges()` sequence into adapter-owned logical ranges. +- Cancelable input is planned before DOM mutation; non-cancelable input is + reconciled against a canonical JSON baseline. +- Composition owns a temporary DOM island and commits once when it settles. +- Paste and cut use `ClipboardEvent.dataTransfer` and commit through the same + adapter/JSON transaction boundary. +- Schema-specific atom and block-boundary behavior remains adapter-owned. Remaining gaps before Android native expansion: - No real Android keyboard trace recorder has filled the matrix above. -- `getTargetRanges()` is not yet a first-class deletion source in the current - DOM adapter. +- Real-device traces are still needed before claiming keyboard-specific + conformance beyond the generic range and reconciliation contract. - Hardware Android keyboard behavior is not separated from virtual keyboard behavior in policy tests. diff --git a/docs/editor-manual-trace-collection.md b/docs/editor-manual-trace-collection.md index 2132492..0382641 100644 --- a/docs/editor-manual-trace-collection.md +++ b/docs/editor-manual-trace-collection.md @@ -2,83 +2,89 @@ ## Scope -This document tracks the manual evidence still required after the current -repository-only audits and fixtures. The trace corpus lives at: +The raw trace corpus lives at: ```txt tests/fixtures/manual-editor-traces/ ``` -`manifest.json` is intentionally test-covered. It keeps every real-device or -real-browser scenario visible in CI, and validates future samples once they are -added. +Its test-covered `manifest.json` is the evidence contract. A manifest row is +not evidence by itself: a scenario is complete only when a real-browser sample +and its required media are committed and the validator accepts them. -## Issue Order - -The remaining evidence issues are handled in this order: +## Issue order | Order | Issue | Evidence needed | | --- | --- | --- | -| 1 | #74 | Clipboard HTML source corpus from Google Docs, Notion, Slack, GitHub rendered pages, and generic webpages. | -| 2 | #85 | OS autocorrect, system text replacement, and `insertReplacementText` history traces. | -| 3 | #70 | iOS Safari and Android Chrome touch selection traces. | -| 4 | #72 | iOS and Android Enter/Backspace behavior at block and atom boundaries. | -| 5 | #78 | Mobile virtual-keyboard viewport, scroll, and caret visibility traces. | -| 6 | #81 | iOS BIU/native formatting context-menu traces. | +| 1 | #143 | Actual-app desktop and mobile Korean IME traces for source-preserving Markdown Live Preview. | +| 2 | #74 | Clipboard HTML corpus from external applications and webpages. | +| 3 | #85 | OS autocorrect, system replacement, and `insertReplacementText` history. | +| 4 | #70 | iOS Safari and Android Chrome touch selection. | +| 5 | #72 | Mobile Enter/Backspace at block and atom boundaries. | +| 6 | #78 | Mobile virtual-keyboard viewport, scroll, and caret visibility. | +| 7 | #81 | iOS BIU/native formatting menu. | -#74 uses the dedicated clipboard corpus at: +#74 uses `tests/fixtures/clipboard-html-corpus/`. The other rows use the manual +trace corpus above. -```txt -tests/fixtures/clipboard-html-corpus/ -``` +## Collection contracts -#85, #70, #72, #78, and #81 use the manual trace corpus at: +Legacy scenarios use the recorder's isolated generic `contenteditable` fixture. +#143 is different: the recorder must be served from the Vite app origin and +observe the actual app iframe plus `window.__jsonEditableLab`. See +[`manual-ime-acceptance.md`](manual-ime-acceptance.md) for the exact same-origin +desktop and LAN URLs. A #143 capture from port `8787` cannot inspect port `3000` +and is invalid. + +All manual traces retain the base shape: ```txt -tests/fixtures/manual-editor-traces/ +interactive-os.manual-editor-trace@1 + source + events[] + snapshots + operations[] + assertions + media[] + notes[] ``` -## Collection Contract +For #143, `source` identifies the device, form factor, OS/browser versions, +keyboard and layout, locale, tested commit, capture kind, and timestamp. +`events[]` preserves order and trust plus input/composition fields. Every +required operation has an explicit `{ id, passed }` record. Snapshots carry +actual-app source/DOM, native/canonical selection, revealed/pinned identity, +snapshot selection/composition, target-range, and Space/Enter results. Assertions are +human acceptance statements, not values inferred optimistically by the +recorder. At least one screenshot or recording is required. -Each issue declares: +The recorder additionally stores capture-, bubble-, and settled-phase event +snapshots, DOM-and-`/source` target ranges, node IDs and ancestor signatures, +mutations, and low-level operation groups. These are diagnostic evidence even +when a high-level operation is marked failed. -- Required scenarios. -- Device, browser, and input-method matrix. -- Operations to perform. -- Trace fields that must be captured. -- Related policy documents that explain how the evidence will change editor - behavior. +## Import and status -The issue is not complete just because the manifest row exists. A scenario is -complete only when a raw sample file is committed and listed under `samples`, or -when a sample records a concrete device-access reason that makes the capture -unavailable. - -After capture, import the downloaded JSON instead of editing `samples` by hand: +Import downloaded JSON instead of editing `samples` by hand: ```bash -pnpm run evidence:import -- --file --issue 85 --scenario macos-text-replacement-acceptance +pnpm run evidence:import -- --file --issue 143 --scenario desktop-korean-markdown-live-preview +pnpm run evidence:import -- --file --issue 143 --scenario mobile-korean-markdown-live-preview ``` -`pnpm run evidence:status` prints the exact import command for each missing -issue/scenario. - -## Minimum Trace Shape +Use the scenario-specific issue number for older traces. Then run: -Every manual trace sample must include: +```bash +pnpm run evidence:status -- --issue 143 +``` -- Device, OS, browser, keyboard/input method, and locale. -- Event order for relevant `keydown`, `beforeinput`, `input`, - `composition*`, `selectionchange`, `paste`, or native menu events. -- Raw browser fields: `key`, `code`, `inputType`, `data`, `isComposing`, - `dataTransfer.types`, and `getTargetRanges()` where available. -- DOM selection and mapped model selection before and after the operation. -- DOM text, model text, marks, atoms, and history/undo outcome. -- A clear assertion that separates native DOM effect from model command result. +The validator rejects missing metadata, missing required event types, untrusted +input-only captures, absent/failed operations, assertion mismatches, missing +media, and media paths that do not exist inside the corpus. -## Completion Rule +## Completion rule -Synthetic fixtures may reproduce parts of these issues after evidence is -collected, but they do not replace the required raw traces. These issues depend -on native OS/browser/app behavior and remain open until the real samples fill -the manifest. +Synthetic fixtures may promote stable behavior into browser regression tests, +but they never replace required raw traces. Issue #143 remains open until both +desktop and mobile real-OS samples pass the manifest contract. A failed real +trace should be kept as diagnosis input, not relabeled as accepted evidence. diff --git a/docs/editor-mobile-cjk-ime-targetrange-policy-audit.md b/docs/editor-mobile-cjk-ime-targetrange-policy-audit.md index 745ea1c..90e8c3d 100644 --- a/docs/editor-mobile-cjk-ime-targetrange-policy-audit.md +++ b/docs/editor-mobile-cjk-ime-targetrange-policy-audit.md @@ -38,7 +38,7 @@ delete range only when all conditions hold: - The event is trusted `beforeinput`. - `inputType` is `deleteContentBackward` or `deleteContentForward`. -- The target range maps entirely inside one active editor text surface. +- Every target range maps entirely into one adapter-owned model surface. - The mapped range does not cross an atom, block boundary, widget chrome, nested editor, iframe, or shadow-root ownership boundary. - The browser path is known to be IME-like, replacement-like, or otherwise lacks @@ -54,9 +54,10 @@ Do not use target ranges as authoritative when: - The current active lease belongs to another owner document/root. Decision: for mobile CJK IME-like deletion, targetRange is a deletion intent, -not a DOM mutation to trust blindly. It may become the active text leaf deletion -range after mapping and ownership checks; otherwise the editor must fall back to -a controlled command or ignore/re-render from model. +not a DOM mutation to trust blindly. The generic host maps the complete range +sequence atomically and the schema adapter decides whether that logical range +can be deleted; otherwise the intent is declined and canonical JSON is +reprojected. ## Event Handling Policy diff --git a/docs/manual-ime-acceptance.md b/docs/manual-ime-acceptance.md index 59f14a9..d13820f 100644 --- a/docs/manual-ime-acceptance.md +++ b/docs/manual-ime-acceptance.md @@ -1,102 +1,71 @@ -# Manual IME Acceptance +# Manual IME acceptance -Automation cannot create a trusted operating-system IME session. Use this -checklist before describing the editor as IME-verified. +Use this checklist for platform behavior that synthetic events cannot prove. -## Desktop run +## Setup ```bash +pnpm install pnpm dev ``` -Open `http://localhost:3000/`, focus the Korean or Japanese paragraph, and keep -the state panel visible. - -For each tested browser and IME: - -1. Start an uncommitted Korean syllable or Japanese conversion. -2. While the candidate/composition UI is still active, press “다른 블록 - 업데이트”. The toolbar prevents pointer focus transfer. -3. Confirm the candidate UI remains active, the composing text does not jump, - the other block has not changed yet, and `last fault` remains `null`. -4. While composing again, press “현재 조합과 충돌”. Confirm the command is not - applied, composition remains active, and `last fault.code` is - `composition_conflict`. -5. Complete conversion. Confirm the phase briefly becomes `settling`, then - `idle`, the queued other-block update appears, and visible text equals - `value.blocks[*].text`. -6. Undo once to revert the queued update, then undo once more. The entire - uninterrupted composition turn must revert on that second step, with no - intermediate preedit text left behind. -7. Repeat with selection replacement, an empty block, Backspace, and Select - All. - -Record browser/OS versions, IME name, event order, final DOM text, JSON value, -selection, undo result, and whether the candidate window survived step 2. - -## Enter while composing - -Run these separately from the general checklist. One physical Enter can mean -either “confirm this IME candidate” or “insert a paragraph”; a `keydown` alone -cannot distinguish them. - -### Korean composition - -1. Begin an uncommitted Korean syllable in the middle of a non-empty paragraph. -2. Press Enter once while the syllable is still under composition. -3. Confirm the completed syllable is preserved and exactly one new paragraph - appears at the caret. Neither resulting paragraph may contain `\n` or `\r`. -4. Confirm the phase reaches `idle`, `last fault` is `null`, and the visible DOM - text and block order equal `value.blocks`. -5. Undo once. The two paragraphs must join while the completed Korean text - remains. -6. Undo once more. The entire uninterrupted composition turn must revert. - -Repeat at the beginning and end of a paragraph and in an empty paragraph. -Also press Enter twice quickly during one composition: exactly two paragraph -boundaries must appear, and each Undo must remove one boundary. - -### Japanese candidate confirmation - -1. Start a Japanese conversion and open the candidate list. -2. Press Enter once to confirm the highlighted candidate. -3. Confirm the candidate is committed but no paragraph was inserted. -4. After the candidate UI closes, press Enter again. -5. Confirm exactly one paragraph is inserted and the confirmed text is - preserved. - -If the first Enter both confirms the candidate and inserts a paragraph, record -it as a failure. If the platform emits a semantic paragraph event for that -first Enter, preserve the complete event trace; this is evidence for a -platform-specific compatibility rule, not permission to infer intent from -`keydown` globally. - -### Mobile keyboards - -Repeat both applicable flows on Android Gboard and iOS. Also test the keyboard -Return key when the final composing update arrives as text ending in a newline. -The newline must become one paragraph split and must not remain in JSON text. - -For every Enter run, record in order: - -- `keydown`: `key`, `keyCode`, `isComposing`; -- `beforeinput` and `input`: `inputType`, `data`, `isComposing`, `cancelable`; -- `compositionend.data`; -- child-list and character-data mutations; -- final DOM, JSON, selection, phase, fault, and the result of two Undo actions. - -## Required matrix - -- macOS or Windows Korean 2-set in Chromium and a native-platform browser; -- desktop Japanese conversion with candidate selection; -- Android Chrome with Gboard Korean and Japanese; -- iOS Safari Korean and Japanese. - -The repository's generic trace recorder can be served on desktop and LAN: +Open the app and focus the note editor. In development, the console handle is: -```bash -pnpm run evidence:serve +```ts +window.__jsonEditableLab +// { document: JSONDocument, editor: EditorView } +``` + +Reset the canonical Markdown source with a JSON Patch commit: + +```ts +window.__jsonEditableLab?.document.commit([ + { + op: "replace", + path: "/source", + value: "**한글**\n\n# 제목\n\n> 인용", + }, +]); ``` -Those captured traces remain external evidence. Synthetic Playwright tests are -never a substitute for this matrix. +Read the stable state with: + +```ts +window.__jsonEditableLab?.editor.getSnapshot(); +// { value, selection, isComposing } +``` + +## Matrix + +Exercise at least: + +- macOS Korean 2-set, Japanese, and Simplified Chinese; +- Windows Korean and Microsoft Pinyin; +- iOS Korean/Japanese; +- Android Gboard Korean/Japanese. + +For each IME, compose at plain text, inside paired Markdown delimiters, at a +concealed marker boundary, and at the end of a trailing newline. + +## Required observations + +- The browser-owned Text node remains the same node while `isComposing` is + true. +- Canonical JSON does not publish intermediate pre-edit text. +- Settlement creates one publication containing the final text. +- Final logical and DOM selections resolve to the same UTF-16 `/source` + offsets. +- Blur and a final non-composing `input` both clear `isComposing` when + `compositionend` is missing. +- A disjoint external JSON commit rebases; an overlapping commit restores the + external canonical value and reports `composition_conflict`. +- Cut and paste settle composition before applying their own Input Events + intent. +- Enter at the end of `"ab"` produces `"ab\n"` with both carets at offset 3; + the next typed character produces `"ab\nx"`. + +Run the automated browser baseline before and after the manual matrix: + +```bash +pnpm run verify:browser +``` diff --git a/package.json b/package.json index a7160fa..02b3e84 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,16 @@ { - "name": "editable", + "name": "@interactive-os/editable-app", "private": true, "type": "module", + "packageManager": "pnpm@10.26.2", "scripts": { "dev": "vite dev --port 3000", - "build": "vite build", + "build": "pnpm run build:package && vite build", + "build:package": "pnpm --filter @interactive-os/editable verify", "preview": "vite preview", "test": "vitest run", - "test:core": "vitest run packages/editable", + "test:core": "vitest run packages/editable/core", + "test:package": "vitest run packages/editable", "evidence:status": "node tools/evidence/status.mjs", "evidence:check": "node tools/evidence/status.mjs --require-complete", "evidence:plan": "node tools/evidence/plan-sample.mjs", @@ -20,21 +23,20 @@ "check:editable-layers": "node tools/check-editable-layers.mjs" }, "dependencies": { - "@interactive-os/json-document": "1.1.0-rc.0", - "@interactive-os/json-document-causal-patch-inbox": "file:vendor/json-document-218aa475/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz", - "@interactive-os/json-document-id-resolver": "file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz", + "@interactive-os/editable": "workspace:*", + "@interactive-os/json-document": "file:vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "lucide-react": "^0.545.0", "react": "^19.2.0", - "react-dom": "^19.2.0", - "zod": "^4.4.3" + "react-dom": "^19.2.0" }, "devDependencies": { "@biomejs/biome": "2.4.5", "@playwright/test": "^1.61.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", + "@types/jsdom": "^28.0.3", "@types/node": "^22.10.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -45,10 +47,6 @@ "vitest": "^4.1.5" }, "pnpm": { - "overrides": { - "@interactive-os/json-document-patch-rebase": "file:vendor/json-document-218aa475/interactive-os-json-document-patch-rebase-0.1.0.tgz", - "@interactive-os/json-document-stable-id-rebase": "file:vendor/json-document-218aa475/interactive-os-json-document-stable-id-rebase-0.1.0.tgz" - }, "onlyBuiltDependencies": [ "esbuild", "lightningcss" diff --git a/packages/editable/README.md b/packages/editable/README.md new file mode 100644 index 0000000..630030f --- /dev/null +++ b/packages/editable/README.md @@ -0,0 +1,121 @@ +# @interactive-os/editable + +A small DOM ingress layer for canonical JSON documents. The stable root package +owns browser editing mechanics—`contenteditable`, Input Events, DOM selection, +composition, projection, and transaction attribution—without prescribing a +document schema or rich-text model. + +Markdown is the reference schema adapter on the +`@interactive-os/editable/markdown` subpath. + +## Install + +```sh +pnpm add @interactive-os/editable @interactive-os/json-document@^2.0.0-rc.0 +``` + +The stable root has no Markdown parser dependency. Add the optional peer only +when using the reference adapter: + +```sh +pnpm add @lezer/markdown@^1.7.2 +``` + +## Use + +```ts +import { mountEditor } from "@interactive-os/editable"; +import { + createMarkdownAdapter, + createMarkdownDocument, +} from "@interactive-os/editable/markdown"; + +const document = createMarkdownDocument({ + id: "article-1", + source: "# Start writing\n\n**Markdown remains canonical**", +}); +const adapter = createMarkdownAdapter({ + presentation: "live-preview", + preset: "bear", +}); +const editor = mountEditor({ root, document, adapter }); + +editor.dispatch({ + inputType: "formatBold", + targetRanges: [{ + anchor: { path: "/source", offset: 18 }, + focus: { path: "/source", offset: 26 }, + }], +}); + +adapter.setPresentation("rich"); +editor.destroy(); +``` + +The editor owns `root` and its descendants until `destroy()`. Frameworks may +render surrounding UI, but must not render into the owned subtree. The engine +owns only `contenteditable` and its `data-editable-owner` marker. The caller +owns product semantics such as `role`, `aria-*`, `spellcheck`, and `tabindex`. +Owned editor subtrees cannot overlap or nest. + +## Stable root protocol + +The root has one runtime export: `mountEditor`. + +An `EditorAdapter` translates open `EditIntent` values into `EditPlan` values, +projects JSON into the owned DOM, maps logical selections across external JSON +changes, and reconciles non-cancelable native input. The intent discriminator +is the platform term `inputType`; target ranges preserve the complete +`InputEvent.getTargetRanges()` sequence. Mapping is atomic: if any DOM boundary +is outside the adapter's model, the entire native intent is declined. + +The normative browser vocabulary comes from +[Input Events Level 2](https://www.w3.org/TR/input-events-2/), +the [Selection API](https://www.w3.org/TR/selection-api/), and the +[Clipboard API and events](https://www.w3.org/TR/clipboard-apis/). Adapters +preserve platform `inputType` strings instead of translating them into a +closed command enum. + +RFC 6901/6902 provide the fixed JSON mutation vocabulary. Browser editing +specifications can continue to evolve without changing this API because +`inputType` remains open and every DOM/selection interpretation stays behind +the adapter boundary. + +Document-changing plans use [RFC 6901 JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901) +and [RFC 6902 JSON Patch](https://www.rfc-editor.org/rfc/rfc6902), then commit +through `JSONDocument.commit()`. Each publication carries an +`@interactive-os/editable` metadata envelope with a unique `transactionId`, +`inputType`, origin, and `selectionAfter`. The editor consumes canonical +`change.applied`, not the requested patch. + +`getSnapshot()` returns a referentially cached immutable snapshot: + +```ts +type EditorSnapshot = { + value: JSONValue; + selection: EditorSelection | null; + isComposing: boolean; +}; +``` + +`subscribe(listener: () => void)` follows the external-store contract. A +selection-only plan publishes a new snapshot without creating a JSON document +publication. History is intentionally outside the root protocol. + +## Markdown reference adapter + +The Markdown subpath exports two runtime factories: + +- `createMarkdownDocument(initial)` admits JSON objects with a string + `/source`. +- `createMarkdownAdapter(options?)` supplies parsing, source-offset selection, + Rich/Live Preview projection, clipboard MIME handling, formatting intents, + and composition reconciliation. + +Markdown source offsets are UTF-16 offsets. External string replacement maps +selection explicitly in the adapter. During composition, only the baseline +selection range is admitted as the native island; disjoint external source +changes rebase and overlapping changes fail closed. + +The package exports no `browser`, `core`, session, history, migration, schema, +or compatibility subpath. diff --git a/packages/editable/browser/contract.ts b/packages/editable/browser/contract.ts new file mode 100644 index 0000000..767aa39 --- /dev/null +++ b/packages/editable/browser/contract.ts @@ -0,0 +1,131 @@ +import type { + JSONAppliedChange, + JSONDocument, + JSONPatchOperation, + JSONValue, + Pointer, +} from "@interactive-os/json-document"; + +export type EditorAffinity = "forward" | "backward"; + +export type EditorPoint = Readonly<{ + path: Pointer; + offset: number; + affinity?: EditorAffinity; +}>; + +export type EditorRange = Readonly<{ + anchor: EditorPoint; + focus: EditorPoint; +}>; + +export type EditorSelection = Readonly<{ + ranges: ReadonlyArray; + primaryIndex: number; +}>; + +export type EditIntent = Readonly<{ + inputType: string; + data?: string | null; + dataTransfer?: DataTransfer | null; + targetRanges?: ReadonlyArray; +}>; + +export type EditPlan = + | Readonly<{ + ok: true; + operations: ReadonlyArray; + selectionAfter: EditorSelection | null; + }> + | Readonly<{ + ok: false; + code: string; + reason?: string; + }>; + +export type EditorReconcileInput = Readonly<{ + root: HTMLElement; + inputType: string; + value: JSONValue; + baseline: Readonly<{ + value: JSONValue; + selection: EditorSelection | null; + }>; + selection: EditorSelection | null; +}>; + +export interface EditorAdapter { + edit( + input: Readonly<{ + value: JSONValue; + selection: EditorSelection | null; + intent: EditIntent; + }>, + ): EditPlan; + reconcile(input: EditorReconcileInput): EditPlan; + mapSelection( + input: Readonly<{ + before: JSONValue; + after: JSONValue; + change: JSONAppliedChange; + selection: EditorSelection | null; + }>, + ): EditorSelection | null; + project( + input: Readonly<{ + root: HTMLElement; + value: JSONValue; + selection: EditorSelection | null; + }>, + ): void; + fromDOMPoint( + input: Readonly<{ + root: HTMLElement; + node: Node; + offset: number; + }>, + ): EditorPoint | null; + toDOMPoint( + input: Readonly<{ + root: HTMLElement; + point: EditorPoint; + }>, + ): Readonly<{ node: Node; offset: number }> | null; + subscribe?(listener: () => void): () => void; + writeClipboard?( + input: Readonly<{ + dataTransfer: DataTransfer; + value: JSONValue; + selection: EditorSelection; + }>, + ): boolean; +} + +export type EditResult = + | Readonly<{ ok: true; change: JSONAppliedChange | null }> + | Readonly<{ ok: false; code: string; reason?: string }>; + +export type EditorSnapshot = Readonly<{ + value: JSONValue; + isComposing: boolean; + selection: EditorSelection | null; +}>; + +export type EditorError = Readonly<{ + code: string; + reason?: string; +}>; + +export interface EditorView { + dispatch(intent: EditIntent): EditResult; + getSnapshot(): EditorSnapshot; + subscribe(listener: () => void): () => void; + destroy(): void; +} + +export type MountEditorOptions = Readonly<{ + root: HTMLElement; + document: JSONDocument; + adapter: EditorAdapter; + onError?: (error: EditorError) => void; +}>; diff --git a/packages/editable/browser/documentProjection.test.ts b/packages/editable/browser/documentProjection.test.ts index 408c2f4..7a63f0c 100644 --- a/packages/editable/browser/documentProjection.test.ts +++ b/packages/editable/browser/documentProjection.test.ts @@ -1,133 +1,240 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from "vitest"; -import type { EditableDocumentValue } from "../core"; +import { describe, expect, it } from "vitest"; +import { + analyzeMarkdownBlock, + analyzeMarkdownDocument, + defineMarkdownProjectionPolicy, + type MarkdownBlockAnalysis, + type MarkdownProjectionState, + markdownProjection, +} from "../core/index"; import { - findBlockElement, - isCanonicalBlockElement, - isCanonicalSurfaceElement, projectDocumentDOM, + projectMarkdownDOM, + updateMarkdownDOMVisibility, } from "./documentProjection"; -function value( - blocks: EditableDocumentValue["blocks"], -): EditableDocumentValue { - return { - schema: "interactive-os.editable-document@2", - id: "projection-test", - blocks, - }; +function visibilityFor( + analysis: Pick, + state: MarkdownProjectionState, +) { + return analysis.syntaxes.flatMap((syntax) => + syntax.markerRanges.map((markerRange) => ({ + ownerId: syntax.id, + markerRange, + state, + })), + ); } -function surface(block: HTMLElement): HTMLElement { - const result = block.querySelector("[data-editable-text]"); - if (result === null) { - throw new Error("Missing projected text surface."); - } - return result; -} +describe("source DOM projection", () => { + it("keeps the exact Markdown source while exposing semantic segment metadata", () => { + const root = document.createElement("div"); + const analysis = analyzeMarkdownBlock("앞 **한글** 뒤"); -describe("document projection", () => { - it("reuses keyed block and Text identities while updating canonical content", () => { - const root = window.document.createElement("div"); - const initial = value([ - { id: "alpha", type: "paragraph", text: "first" }, - { id: "beta", type: "heading", text: "second" }, - ]); - projectDocumentDOM({ root, value: initial }); + projectMarkdownDOM({ + root, + analysis, + states: visibilityFor(analysis, "concealed"), + }); - const alpha = findBlockElement(root, "alpha"); - const alphaText = alpha === null ? null : surface(alpha).firstChild; - const updated = value([ - { id: "alpha", type: "paragraph", text: "first updated" }, - { id: "beta", type: "heading", text: "second" }, + expect(root.textContent).toBe(analysis.source); + expect(root.children).toHaveLength(analysis.segments.length); + expect( + Array.from(root.children, (segment) => [ + segment.getAttribute("data-source-from"), + segment.getAttribute("data-source-to"), + ]), + ).toEqual([ + ["0", "2"], + ["2", "4"], + ["4", "6"], + ["6", "8"], + ["8", "10"], ]); - projectDocumentDOM({ root, value: updated }); - expect(findBlockElement(root, "alpha")).toBe(alpha); - expect(surface(alpha as HTMLElement).firstChild).toBe(alphaText); - expect(surface(alpha as HTMLElement).textContent).toBe("first updated"); + const content = root.children[2]; + expect(content?.getAttribute("data-markdown-segment")).toBe("text"); + expect(content?.getAttribute("data-syntax-owner")).toBe("strong:0"); + expect(content?.getAttribute("data-marks")).toBe("bold"); + expect(content?.textContent).toBe("한글"); + expect( + Array.from(root.children).every( + (segment) => + segment.childNodes.length === 1 && segment.firstChild instanceof Text, + ), + ).toBe(true); }); - it("keeps the pinned composition Text opaque while projecting other blocks", () => { - const root = window.document.createElement("div"); - const initial = value([ - { id: "alpha", type: "paragraph", text: "first" }, - { id: "beta", type: "paragraph", text: "second" }, + it("patches revealed, concealed, and attachment visibility without touching nodes", () => { + const root = document.createElement("div"); + const analysis = analyzeMarkdownBlock("**한글**"); + projectMarkdownDOM({ + root, + analysis, + states: visibilityFor(analysis, "concealed"), + }); + const nodes = Array.from(root.childNodes); + const textNodes = nodes.map((node) => node.firstChild); + const markers = Array.from( + root.querySelectorAll("[data-markdown-marker]"), + ); + + expect(markers).toHaveLength(2); + expect( + markers.map((marker) => ({ + owner: marker.dataset.syntaxOwner, + state: marker.dataset.state, + hidden: marker.getAttribute("aria-hidden"), + })), + ).toEqual([ + { owner: "strong:0", state: "concealed", hidden: "true" }, + { owner: "strong:0", state: "concealed", hidden: "true" }, ]); - projectDocumentDOM({ root, value: initial }); - const alpha = findBlockElement(root, "alpha") as HTMLElement; - const alphaSurface = surface(alpha); - const composingText = alphaSurface.firstChild as Text; - composingText.insertData(5, "한"); - const invalidate = vi.fn(); - projectDocumentDOM({ + updateMarkdownDOMVisibility(root, visibilityFor(analysis, "revealed")); + expect(markers.every((marker) => marker.dataset.state === "revealed")).toBe( + true, + ); + expect(markers.every((marker) => !marker.hasAttribute("aria-hidden"))).toBe( + true, + ); + + updateMarkdownDOMVisibility(root, visibilityFor(analysis, "attachment")); + expect( + markers.every( + (marker) => + marker.dataset.state === "attachment" && + marker.getAttribute("aria-hidden") === "true", + ), + ).toBe(true); + expect(Array.from(root.childNodes)).toEqual(nodes); + expect(nodes.map((node) => node.firstChild)).toEqual(textNodes); + expect(root.textContent).toBe(analysis.source); + }); + + it("reuses segment Elements and Text nodes when source ranges move", () => { + const root = document.createElement("div"); + projectMarkdownDOM({ root, - value: value([ - { id: "alpha", type: "paragraph", text: "first" }, - { id: "beta", type: "paragraph", text: "remote" }, - ]), - composition: { - blockId: "alpha", - node: composingText, - isPinIntact: (candidate) => candidate === alphaSurface, - invalidate, - }, + analysis: analyzeMarkdownBlock("**한글**"), + states: visibilityFor(analyzeMarkdownBlock("**한글**"), "concealed"), + }); + const [opening, content, closing] = Array.from(root.children); + const textNodes = [ + opening?.firstChild, + content?.firstChild, + closing?.firstChild, + ]; + + projectMarkdownDOM({ + root, + analysis: analyzeMarkdownBlock("**한글입력**"), + states: visibilityFor(analyzeMarkdownBlock("**한글입력**"), "concealed"), }); - expect(surface(alpha).firstChild).toBe(composingText); - expect(composingText.data).toBe("first한"); - expect(findBlockElement(root, "beta")?.textContent).toBe("remote"); - expect(invalidate).not.toHaveBeenCalled(); + expect(Array.from(root.children)).toEqual([opening, content, closing]); + expect(Array.from(root.children, (segment) => segment.firstChild)).toEqual( + textNodes, + ); + expect(content?.textContent).toBe("한글입력"); + expect(content?.getAttribute("data-source-to")).toBe("6"); + expect(closing?.getAttribute("data-source-from")).toBe("6"); + expect(root.textContent).toBe("**한글입력**"); }); - it("can explicitly canonicalize the composing block when its lease settles", () => { - const root = window.document.createElement("div"); - const documentValue = value([ - { id: "alpha", type: "paragraph", text: "first" }, - ]); - projectDocumentDOM({ root, value: documentValue }); - const alpha = findBlockElement(root, "alpha") as HTMLElement; - const alphaSurface = surface(alpha); - const composingText = alphaSurface.firstChild as Text; - composingText.insertData(5, "한"); + it("projects incomplete notation as one literal text segment", () => { + const root = document.createElement("div"); + const analysis = analyzeMarkdownBlock("**미완성"); - projectDocumentDOM({ + projectMarkdownDOM({ root, analysis, states: [] }); + + expect(root.textContent).toBe("**미완성"); + expect(root.children).toHaveLength(1); + expect(root.firstElementChild).toMatchObject({ + textContent: "**미완성", + }); + expect(root.firstElementChild?.getAttribute("data-markdown-segment")).toBe( + "text", + ); + expect(root.firstElementChild?.getAttribute("data-marks")).toBe(""); + expect(root.querySelector("[data-markdown-marker]")).toBeNull(); + }); + + it("keeps one stable empty Text node for an empty editable source", () => { + const root = document.createElement("div"); + + projectMarkdownDOM({ root, - value: documentValue, - forceCanonicalBlockId: "alpha", - composition: { - blockId: "alpha", - node: composingText, - isPinIntact: () => true, - invalidate: vi.fn(), - }, + analysis: analyzeMarkdownBlock(""), + states: [], }); - expect(alphaSurface.firstChild).toBe(composingText); - expect(composingText.data).toBe("first"); + expect(root.textContent).toBe(""); + expect(root.children).toHaveLength(1); + expect(root.firstElementChild?.firstChild).toBeInstanceOf(Text); + expect((root.firstElementChild?.firstChild as Text).data).toBe(""); }); - it("uses the same canonical grammar for projection and native inspection", () => { - const root = window.document.createElement("div"); - const documentValue = value([ - { id: "alpha", type: "paragraph", text: "first" }, - ]); - projectDocumentDOM({ root, value: documentValue }); - const alpha = findBlockElement(root, "alpha") as HTMLElement; - const alphaSurface = surface(alpha); + it("projects multiple source blocks and their literal separators in one coordinate", () => { + const root = document.createElement("div"); + const source = "# 제목\n\n> 인용\n\n**한글**"; + const analysis = analyzeMarkdownDocument(source); - expect(isCanonicalBlockElement(alpha, documentValue, "alpha")).toBe(true); + projectDocumentDOM({ + root, + analysis, + states: analysis.blocks.flatMap((block) => + visibilityFor(block, "concealed"), + ), + }); + + expect(root.textContent).toBe(source); expect( - isCanonicalSurfaceElement(alphaSurface, documentValue, "alpha"), - ).toBe(true); + Array.from( + root.querySelectorAll("[data-markdown-block]"), + (element) => [ + element.dataset.markdownBlock, + element.dataset.blockType, + element.dataset.headingLevel ?? null, + ], + ), + ).toContainEqual(["block:0", "heading", "1"]); + expect( + root + .querySelector('[data-markdown-block="block:1"]') + ?.getAttribute("data-block-type"), + ).toBe("quote"); + expect( + Array.from(root.children) + .filter((element) => !element.hasAttribute("data-markdown-block")) + .map((element) => element.textContent) + .join(""), + ).toBe("\n\n\n\n"); + }); + + it("applies active-line visibility to each multiline quote marker", () => { + const root = document.createElement("div"); + const source = "> 첫째\n> 둘째"; + const analysis = analyzeMarkdownDocument(source); + const states = markdownProjection({ + source, + policy: defineMarkdownProjectionPolicy({ + fallback: "concealed", + bySyntaxKind: { "quote-marker": "active-line" }, + }), + selection: { anchor: 8, focus: 8 }, + syntaxes: analysis.blocks.flatMap((block) => block.syntaxes), + }); + + projectDocumentDOM({ root, analysis, states }); - alpha.setAttribute("data-foreign", "true"); - alphaSurface.setAttribute("data-foreign", "true"); - expect(isCanonicalBlockElement(alpha, documentValue, "alpha")).toBe(false); expect( - isCanonicalSurfaceElement(alphaSurface, documentValue, "alpha"), - ).toBe(false); + Array.from( + root.querySelectorAll("[data-markdown-marker]"), + (marker) => marker.dataset.state, + ), + ).toEqual(["concealed", "revealed"]); }); }); diff --git a/packages/editable/browser/documentProjection.ts b/packages/editable/browser/documentProjection.ts index 678caa3..0063f4d 100644 --- a/packages/editable/browser/documentProjection.ts +++ b/packages/editable/browser/documentProjection.ts @@ -1,260 +1,387 @@ -import { - editableTextPath, - findEditableBlockIndex, - type EditableBlock, - type EditableBlockType, - type EditableDocumentValue, -} from "../core"; -import { - EDITABLE_BLOCK_ATTRIBUTE, - EDITABLE_TEXT_ATTRIBUTE, - setCanonicalSurfaceText, -} from "./editableDOM"; +import type { + MarkdownBlockAnalysis, + MarkdownDocumentAnalysis, + MarkdownSegment, +} from "../core/markdown.js"; +import type { + MarkdownMarkerProjection, + MarkdownProjectionState, +} from "../core/markdownProjection.js"; -const OWNED_BLOCK_ATTRIBUTES = new Set([ - "class", - EDITABLE_BLOCK_ATTRIBUTE, - "data-block-type", - "data-block-index", -]); +export type MarkdownDOMVisibility = Pick< + MarkdownMarkerProjection, + "ownerId" | "markerRange" | "state" +>; -const OWNED_SURFACE_ATTRIBUTES = new Set([EDITABLE_TEXT_ATTRIBUTE]); - -export type DocumentProjectionComposition = { - blockId: string; - node: Text; - isPinIntact(surface: HTMLElement): boolean; - invalidate(reason: string): void; +export type ProjectMarkdownDOMOptions = { + root: HTMLElement; + analysis: Pick; + states: readonly MarkdownDOMVisibility[]; }; export type ProjectDocumentDOMOptions = { root: HTMLElement; - value: EditableDocumentValue; - composition?: DocumentProjectionComposition | null; - forceCanonicalBlockId?: string; + analysis: MarkdownDocumentAnalysis; + states: readonly MarkdownDOMVisibility[]; }; +const TRAILING_BREAK_ATTRIBUTE = "data-editable-trailing-break"; + export function projectDocumentDOM({ root, - value, - composition = null, - forceCanonicalBlockId, + analysis, + states, }: ProjectDocumentDOMOptions): void { - let activeComposition = composition; - const invalidateComposition = (reason: string): void => { - if (activeComposition === null) { - return; - } - const invalidated = activeComposition; - activeComposition = null; - invalidated.invalidate(reason); - }; + const segments = documentSegments(analysis); + const stateByMarker = visibilityMap(states); + projectMarkdownDOM({ + root, + analysis: { + source: analysis.source, + segments, + }, + states, + }); - const current = new Map(); - for (const node of Array.from(root.childNodes)) { - if (node.nodeType !== 1) { - node.remove(); + for (const element of directSegmentElements(root)) { + element.removeAttribute("data-block-attachment"); + const from = Number(element.dataset.sourceFrom); + const to = Number(element.dataset.sourceTo); + const block = analysis.blocks.find( + (candidate) => + candidate.sourceRange.from <= from && + candidate.sourceRange.to >= to && + (candidate.sourceRange.from !== candidate.sourceRange.to || + from === to), + ); + if (block === undefined) { + element.removeAttribute("data-markdown-block"); + element.removeAttribute("data-block-type"); + element.removeAttribute("data-heading-level"); continue; } - const element = node as HTMLElement; - const id = element.getAttribute(EDITABLE_BLOCK_ATTRIBUTE); - if (id === null || current.has(id)) { - if (activeComposition !== null && element.contains(activeComposition.node)) { - invalidateComposition( - "The composing block lost its keyed DOM identity.", - ); - } - element.remove(); - continue; + element.dataset.markdownBlock = block.id; + element.dataset.blockType = block.blockType; + if (block.headingLevel === null) { + element.removeAttribute("data-heading-level"); + } else { + element.dataset.headingLevel = String(block.headingLevel); } - current.set(id, element); } - - const desiredIds = new Set(value.blocks.map((block) => block.id)); - for (const [id, element] of current) { - if (!desiredIds.has(id)) { - element.remove(); - current.delete(id); + for (const block of analysis.blocks) { + const blockSyntax = block.syntaxes.find( + (syntax) => + syntax.kind === "heading-marker" || + syntax.kind === "quote-marker" || + syntax.kind === "code-fence", + ); + if ( + blockSyntax === undefined || + !ownerHasState(stateByMarker, blockSyntax.id, "attachment") + ) { + continue; + } + const elements = Array.from( + root.querySelectorAll(`[data-markdown-block="${block.id}"]`), + ); + const attachment = + elements.find( + (element) => !element.hasAttribute("data-markdown-marker"), + ) ?? elements[0]; + if (attachment !== undefined) { + attachment.dataset.blockAttachment = block.blockType; } } + configureTrailingBreak(root, analysis.source.endsWith("\n")); +} - value.blocks.forEach((block, blockIndex) => { - const tagName = blockTagName(block.type); - let element = current.get(block.id) ?? null; - if (element === null || element.tagName.toLowerCase() !== tagName) { - if ( - activeComposition !== null && - element?.contains(activeComposition.node) - ) { - invalidateComposition( - "The composing block changed its structural element.", - ); - } - const replacement = createBlockElement( - root, - block, - blockIndex, - activeComposition, - ); - if (element === null) { - element = replacement; - } else { - element.replaceWith(replacement); - element = replacement; - } - current.set(block.id, element); - } +export function projectMarkdownDOM({ + root, + analysis, + states, +}: ProjectMarkdownDOMOptions): void { + assertSourcePartition(analysis); + const stateByMarker = visibilityMap(states); + const current = directSegmentElements(root); + const used = new Set(); + const segments: readonly MarkdownSegment[] = + analysis.segments.length === 0 && analysis.source.length === 0 + ? [{ kind: "text", from: 0, to: 0, marks: [] }] + : analysis.segments; + const desired = segments.map((segment, index) => { + const element = + findCompatibleElement(current, used, segment, index) ?? + root.ownerDocument.createElement("span"); + used.add(element); + configureSegment(element, segment, analysis.source, stateByMarker); + return element; + }); - configureBlockElement(element, block, blockIndex, activeComposition); - let surface = Array.from(element.children).find((child) => - child.hasAttribute(EDITABLE_TEXT_ATTRIBUTE), - ) as HTMLElement | undefined; - if (surface === undefined) { - surface = root.ownerDocument.createElement("span"); - surface.setAttribute(EDITABLE_TEXT_ATTRIBUTE, editableTextPath(blockIndex)); - element.append(surface); + desired.forEach((element, index) => { + const reference = root.children[index] ?? null; + if (reference !== element) { + root.insertBefore(element, reference); } + }); + for (const child of Array.from(root.childNodes)) { if ( - activeComposition === null || - !element.contains(activeComposition.node) + child.nodeType === child.ELEMENT_NODE && + (child as HTMLElement).hasAttribute(TRAILING_BREAK_ATTRIBUTE) ) { - removeUnexpectedAttributes(surface, OWNED_SURFACE_ATTRIBUTES); + continue; } - for (const child of Array.from(element.childNodes)) { - if (child === surface) { - continue; - } - if ( - activeComposition !== null && - child.contains(activeComposition.node) - ) { - invalidateComposition( - "The composing text was moved outside its owned surface.", - ); - } + if ( + child.nodeType !== child.ELEMENT_NODE || + !used.has(child as HTMLElement) + ) { child.remove(); } - surface.setAttribute(EDITABLE_TEXT_ATTRIBUTE, editableTextPath(blockIndex)); + } +} - const protectedSurface = - activeComposition?.blockId === block.id && - activeComposition.isPinIntact(surface) && - forceCanonicalBlockId !== block.id; - if (!protectedSurface) { - setCanonicalSurfaceText(surface, block.text); - } +export function updateMarkdownDOMVisibility( + root: HTMLElement, + states: readonly MarkdownDOMVisibility[], +): void { + const stateByMarker = visibilityMap(states); + for (const marker of root.querySelectorAll( + "[data-markdown-marker][data-syntax-owner]", + )) { + setMarkerVisibility( + marker, + stateByMarker.get( + visibilityKey( + marker.dataset.syntaxOwner ?? "", + Number(marker.dataset.sourceFrom), + Number(marker.dataset.sourceTo), + ), + ) ?? "concealed", + ); + } +} - const reference = root.children[blockIndex] ?? null; - if (reference !== element) { - if ( - activeComposition !== null && - element.contains(activeComposition.node) - ) { - invalidateComposition( - "The composing block moved and could not keep its ancestor identity.", - ); - } - root.insertBefore(element, reference); +function directSegmentElements(root: HTMLElement): HTMLElement[] { + const elements: HTMLElement[] = []; + for (const child of Array.from(root.childNodes)) { + if ( + child.nodeType === child.ELEMENT_NODE && + !(child as HTMLElement).hasAttribute(TRAILING_BREAK_ATTRIBUTE) + ) { + elements.push(child as HTMLElement); + } else if ( + child.nodeType !== child.ELEMENT_NODE || + !(child as HTMLElement).hasAttribute(TRAILING_BREAK_ATTRIBUTE) + ) { + child.remove(); } - }); + } + return elements; } -export function findBlockElement( - root: HTMLElement, - blockId: string, -): HTMLElement | null { - return ( - Array.from( - root.querySelectorAll(`[${EDITABLE_BLOCK_ATTRIBUTE}]`), - ).find( - (element) => element.getAttribute(EDITABLE_BLOCK_ATTRIBUTE) === blockId, - ) ?? null +function configureTrailingBreak(root: HTMLElement, required: boolean): void { + const existing = root.querySelector( + `:scope > [${TRAILING_BREAK_ATTRIBUTE}]`, ); + if (!required) { + existing?.remove(); + return; + } + const trailing = existing ?? root.ownerDocument.createElement("br"); + trailing.setAttribute(TRAILING_BREAK_ATTRIBUTE, ""); + trailing.setAttribute("aria-hidden", "true"); + root.append(trailing); } -export function isCanonicalBlockElement( - element: HTMLElement, - value: EditableDocumentValue, - blockId: string, -): boolean { - const index = findEditableBlockIndex(value, blockId); - const block = value.blocks[index]; - return ( - block !== undefined && - element.attributes.length === OWNED_BLOCK_ATTRIBUTES.size && - element.className === - `contenteditable-block contenteditable-block-${block.type}` && - element.getAttribute(EDITABLE_BLOCK_ATTRIBUTE) === block.id && - element.getAttribute("data-block-type") === block.type && - element.getAttribute("data-block-index") === String(index) - ); +function findCompatibleElement( + current: readonly HTMLElement[], + used: ReadonlySet, + segment: MarkdownSegment, + desiredIndex: number, +): HTMLElement | null { + let best: { element: HTMLElement; score: number } | null = null; + for (const [currentIndex, element] of current.entries()) { + if (used.has(element) || !isCompatibleSegment(element, segment)) { + continue; + } + const score = + sourceRangeDistance(element, segment) * (current.length + 1) + + Math.abs(currentIndex - desiredIndex); + if (best === null || score < best.score) { + best = { element, score }; + } + } + return best?.element ?? null; } -export function isCanonicalSurfaceElement( - surface: HTMLElement, - value: EditableDocumentValue, - blockId: string, +function isCompatibleSegment( + element: HTMLElement, + segment: MarkdownSegment, ): boolean { - const index = findEditableBlockIndex(value, blockId); + if (element.dataset.markdownSegment !== segment.kind) { + return false; + } + const owner = element.getAttribute("data-syntax-owner"); + if (owner !== (segment.ownerId ?? null)) { + return false; + } return ( - index >= 0 && - surface.attributes.length === OWNED_SURFACE_ATTRIBUTES.size && - surface.getAttribute(EDITABLE_TEXT_ATTRIBUTE) === editableTextPath(index) + segment.kind === "text" || + element.getAttribute("data-marker-role") === segment.role ); } -function createBlockElement( - root: HTMLElement, - block: EditableBlock, - blockIndex: number, - composition: DocumentProjectionComposition | null, -): HTMLElement { - const element = root.ownerDocument.createElement(blockTagName(block.type)); - configureBlockElement(element, block, blockIndex, composition); - const surface = root.ownerDocument.createElement("span"); - surface.setAttribute(EDITABLE_TEXT_ATTRIBUTE, editableTextPath(blockIndex)); - element.append(surface); - setCanonicalSurfaceText(surface, block.text); - return element; +function sourceRangeDistance( + element: HTMLElement, + segment: MarkdownSegment, +): number { + const from = Number(element.getAttribute("data-source-from")); + const to = Number(element.getAttribute("data-source-to")); + return Number.isFinite(from) && Number.isFinite(to) + ? Math.abs(from - segment.from) + Math.abs(to - segment.to) + : Number.MAX_SAFE_INTEGER; } -function configureBlockElement( +function configureSegment( element: HTMLElement, - block: EditableBlock, - blockIndex: number, - composition: DocumentProjectionComposition | null, + segment: MarkdownSegment, + source: string, + stateByMarker: ReadonlyMap, ): void { - if (composition === null || !element.contains(composition.node)) { - removeUnexpectedAttributes(element, OWNED_BLOCK_ATTRIBUTES); + element.dataset.markdownSegment = segment.kind; + element.dataset.sourceFrom = String(segment.from); + element.dataset.sourceTo = String(segment.to); + element.dataset.marks = segment.marks.join(" "); + setOptionalAttribute(element, "data-syntax-owner", segment.ownerId); + + if (segment.kind === "marker") { + element.setAttribute("data-markdown-marker", ""); + element.dataset.markerRole = segment.role; + setMarkerVisibility( + element, + stateByMarker.get( + visibilityKey(segment.ownerId, segment.from, segment.to), + ) ?? "concealed", + ); + } else { + element.removeAttribute("data-markdown-marker"); + element.removeAttribute("data-marker-role"); + element.removeAttribute("data-state"); + element.removeAttribute("aria-hidden"); + } + + const text = onlyTextChild(element); + const data = source.slice(segment.from, segment.to); + if (text.data !== data) { + text.data = data; + } +} + +function onlyTextChild(element: HTMLElement): Text { + const child = element.firstChild; + if (child?.nodeType === 3 && element.childNodes.length === 1) { + return child as Text; } - element.className = `contenteditable-block contenteditable-block-${block.type}`; - element.setAttribute(EDITABLE_BLOCK_ATTRIBUTE, block.id); - element.setAttribute("data-block-type", block.type); - element.setAttribute("data-block-index", String(blockIndex)); + const text = element.ownerDocument.createTextNode(""); + element.replaceChildren(text); + return text; } -function blockTagName( - type: EditableBlockType, -): "p" | "h1" | "blockquote" | "pre" { - switch (type) { - case "heading": - return "h1"; - case "quote": - return "blockquote"; - case "code": - return "pre"; - case "paragraph": - return "p"; +function setMarkerVisibility( + marker: HTMLElement, + state: MarkdownProjectionState, +): void { + marker.dataset.state = state; + if (state === "revealed") { + marker.removeAttribute("aria-hidden"); + } else { + marker.setAttribute("aria-hidden", "true"); } } -function removeUnexpectedAttributes( +function setOptionalAttribute( element: HTMLElement, - allowed: ReadonlySet, + name: string, + value: string | undefined, ): void { - for (const attribute of Array.from(element.attributes)) { - if (!allowed.has(attribute.name)) { - element.removeAttribute(attribute.name); + if (value === undefined) { + element.removeAttribute(name); + } else { + element.setAttribute(name, value); + } +} + +function visibilityMap( + states: readonly MarkdownDOMVisibility[], +): ReadonlyMap { + return new Map( + states.map(({ ownerId, markerRange, state }) => [ + visibilityKey(ownerId, markerRange.from, markerRange.to), + state, + ]), + ); +} + +function visibilityKey(ownerId: string, from: number, to: number): string { + return `${ownerId}\u0000${from}:${to}`; +} + +function ownerHasState( + states: ReadonlyMap, + ownerId: string, + state: MarkdownProjectionState, +): boolean { + const prefix = `${ownerId}\u0000`; + for (const [key, candidate] of states) { + if (key.startsWith(prefix) && candidate === state) { + return true; + } + } + return false; +} + +function assertSourcePartition( + analysis: Pick, +): void { + let offset = 0; + for (const segment of analysis.segments) { + if ( + segment.from !== offset || + segment.to <= segment.from || + segment.to > analysis.source.length + ) { + throw new RangeError("Markdown segments must partition their source."); + } + offset = segment.to; + } + if (offset !== analysis.source.length) { + throw new RangeError("Markdown segments must cover their source."); + } +} + +function documentSegments( + analysis: MarkdownDocumentAnalysis, +): readonly MarkdownSegment[] { + const segments: MarkdownSegment[] = []; + let cursor = 0; + for (const block of analysis.blocks) { + if (cursor < block.sourceRange.from) { + segments.push({ + kind: "text", + from: cursor, + to: block.sourceRange.from, + marks: [], + }); } + segments.push(...block.segments); + cursor = block.sourceRange.to; + } + if (cursor < analysis.source.length) { + segments.push({ + kind: "text", + from: cursor, + to: analysis.source.length, + marks: [], + }); } + return segments; } diff --git a/packages/editable/browser/domSelection.test.ts b/packages/editable/browser/domSelection.test.ts new file mode 100644 index 0000000..5f986b0 --- /dev/null +++ b/packages/editable/browser/domSelection.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { analyzeMarkdownBlock } from "../core/markdown"; +import { projectMarkdownDOM } from "./documentProjection"; +import { domPointToSourceOffset, sourceOffsetToDOMPoint } from "./domSelection"; + +describe("source DOM selection", () => { + it("round-trips every UTF-16 offset through marker and content Text nodes", () => { + const { root } = createMarkdownRoot(); + + for (let offset = 0; offset <= root.textContent.length; offset += 1) { + for (const affinity of ["backward", "forward"] as const) { + const domPoint = sourceOffsetToDOMPoint(root, offset, affinity); + expect(domPoint).not.toBeNull(); + expect( + domPoint === null + ? null + : domPointToSourceOffset(root, domPoint.node, domPoint.offset) + ?.offset, + ).toBe(offset); + } + } + + expectPoint(root, 2, "backward", "SPAN", 2); + expectPoint(root, 2, "forward", "STRONG", 0); + expectPoint(root, 4, "backward", "STRONG", 2); + expectPoint(root, 4, "forward", "SPAN", 0); + }); + + it("counts newline Text.data and round-trips every multiline offset", () => { + const root = document.createElement("div"); + const middle = document.createElement("span"); + middle.append(document.createTextNode("한글")); + root.append(document.createTextNode("alpha\n"), middle, "\nomega"); + document.body.append(root); + + expect(root.textContent).toBe("alpha\n한글\nomega"); + for (let offset = 0; offset <= root.textContent.length; offset += 1) { + for (const affinity of ["backward", "forward"] as const) { + const domPoint = sourceOffsetToDOMPoint(root, offset, affinity); + expect(domPoint).not.toBeNull(); + if (domPoint !== null) { + expect( + domPointToSourceOffset(root, domPoint.node, domPoint.offset) + ?.offset, + ).toBe(offset); + } + } + } + }); + + it("round-trips every offset through concealed nested Markdown marks", () => { + const root = document.createElement("div"); + const source = "**굵고 *기울임***"; + const analysis = analyzeMarkdownBlock(source); + projectMarkdownDOM({ + root, + analysis, + states: analysis.syntaxes.flatMap((syntax) => + syntax.markerRanges.map((markerRange) => ({ + ownerId: syntax.id, + markerRange, + state: "concealed" as const, + })), + ), + }); + document.body.append(root); + + expect(analysis.syntaxes.map(({ kind }) => kind)).toEqual([ + "strong", + "emphasis", + ]); + expect(root.textContent).toBe(source); + for (let offset = 0; offset <= source.length; offset += 1) { + for (const affinity of ["backward", "forward"] as const) { + const point = sourceOffsetToDOMPoint(root, offset, affinity); + expect(point).not.toBeNull(); + if (point !== null) { + expect( + domPointToSourceOffset(root, point.node, point.offset)?.offset, + ).toBe(offset); + } + } + } + }); + + it("maps Element child boundaries without serializing a Range", () => { + const { root, content } = createMarkdownRoot(); + + expect(domPointToSourceOffset(root, root, 1)).toEqual({ + offset: 2, + affinity: "forward", + }); + expect(domPointToSourceOffset(root, root, 2)).toEqual({ + offset: 4, + affinity: "forward", + }); + expect(domPointToSourceOffset(root, root, 3)).toEqual({ + offset: 6, + affinity: "backward", + }); + expect(domPointToSourceOffset(root, content, 0)).toEqual({ + offset: 2, + affinity: "forward", + }); + expect(domPointToSourceOffset(root, content, 1)).toEqual({ + offset: 4, + affinity: "backward", + }); + }); + + it("ignores concealment attributes and does not replace Text nodes", () => { + const { root, opening, closing } = createMarkdownRoot(); + const openingText = opening.firstChild; + const closingText = closing.firstChild; + + opening.dataset.state = "revealed"; + closing.dataset.state = "revealed"; + opening.removeAttribute("aria-hidden"); + closing.removeAttribute("aria-hidden"); + + expect(sourceOffsetToDOMPoint(root, 1, "forward")?.node).toBe(openingText); + expect(sourceOffsetToDOMPoint(root, 5, "backward")?.node).toBe(closingText); + expect(opening.firstChild).toBe(openingText); + expect(closing.firstChild).toBe(closingText); + expect(root.textContent).toBe("**한글**"); + }); + + it("rejects outside or invalid DOM points and clamps source offsets", () => { + const { root } = createMarkdownRoot(); + const outside = document.createTextNode("outside"); + const first = root.querySelector("span")?.firstChild; + if (!(first instanceof Text)) { + throw new Error("Missing source Text fixture."); + } + + expect(domPointToSourceOffset(root, outside, 0)).toBeNull(); + expect(domPointToSourceOffset(root, first, -1)).toBeNull(); + expect(domPointToSourceOffset(root, first, 3)).toBeNull(); + expect(domPointToSourceOffset(root, root, 4)).toBeNull(); + expect(sourceOffsetToDOMPoint(root, Number.NaN)).toBeNull(); + + expect(sourceOffsetToDOMPoint(root, -20)?.offset).toBe(0); + expect(sourceOffsetToDOMPoint(root, 200)?.offset).toBe(2); + expect( + domPointToSourceOffset( + root, + sourceOffsetToDOMPoint(root, 200)?.node as Node, + sourceOffsetToDOMPoint(root, 200)?.offset as number, + )?.offset, + ).toBe(6); + }); +}); + +function createMarkdownRoot(): { + root: HTMLElement; + opening: HTMLElement; + content: HTMLElement; + closing: HTMLElement; +} { + const root = document.createElement("div"); + const opening = document.createElement("span"); + const content = document.createElement("strong"); + const closing = document.createElement("span"); + opening.dataset.marker = ""; + opening.dataset.state = "concealed"; + opening.setAttribute("aria-hidden", "true"); + closing.dataset.marker = ""; + closing.dataset.state = "concealed"; + closing.setAttribute("aria-hidden", "true"); + opening.append("**"); + content.append("한글"); + closing.append("**"); + root.append(opening, content, closing); + document.body.append(root); + return { root, opening, content, closing }; +} + +function expectPoint( + root: HTMLElement, + offset: number, + affinity: "backward" | "forward", + parentTag: string, + domOffset: number, +): void { + const point = sourceOffsetToDOMPoint(root, offset, affinity); + expect(point?.node.parentElement?.tagName).toBe(parentTag); + expect(point?.offset).toBe(domOffset); + if (point !== null) { + expect(domPointToSourceOffset(root, point.node, point.offset)).toEqual({ + offset, + affinity, + }); + } +} diff --git a/packages/editable/browser/domSelection.ts b/packages/editable/browser/domSelection.ts index 1259ae5..503f67c 100644 --- a/packages/editable/browser/domSelection.ts +++ b/packages/editable/browser/domSelection.ts @@ -1,274 +1,197 @@ -import type { - SelectionPoint, - SelectionSnap, -} from "@interactive-os/json-document"; -import { - editableBlockIndexFromTextPath, - editableTextPath, - type EditableDocumentValue, -} from "../core"; -import { - EDITABLE_BLOCK_ATTRIBUTE, - EDITABLE_TEXT_ATTRIBUTE, - editableBlockFromNode, - editableSurfaceFromNode, - textFromSurface, - textNodes, -} from "./editableDOM"; +import type { MarkdownAffinity } from "../core/markdownModel.js"; -export type DOMSelectionPoint = { - blockId: string; - blockIndex: number; +export type DOMPoint = { + node: Node; offset: number; }; -export function readDOMSelection( - root: HTMLElement, - value: EditableDocumentValue, -): SelectionSnap | null { - const selection = root.ownerDocument.getSelection(); - if ( - selection === null || - selection.anchorNode === null || - selection.focusNode === null || - !root.contains(selection.anchorNode) || - !root.contains(selection.focusNode) - ) { - return null; - } - - const anchor = readDOMPoint( - root, - value, - selection.anchorNode, - selection.anchorOffset, - ); - const focus = readDOMPoint( - root, - value, - selection.focusNode, - selection.focusOffset, - ); - if (anchor === null || focus === null) { - return null; - } - - const anchorPoint = { - path: editableTextPath(anchor.blockIndex), - offset: anchor.offset, - }; - const focusPoint = { - path: editableTextPath(focus.blockIndex), - offset: focus.offset, - }; - - return { - selectedPointers: [], - selectionRanges: [{ anchor: anchorPoint, focus: focusPoint }], - primaryIndex: 0, - anchor: anchorPoint, - focus: focusPoint, - }; -} +export type SourceOffsetPoint = { + offset: number; + affinity?: MarkdownAffinity; +}; -export function readDOMPoint( +/** Maps an owned DOM boundary point to its global UTF-16 source offset. */ +export function domPointToSourceOffset( root: HTMLElement, - value: EditableDocumentValue, node: Node, offset: number, -): DOMSelectionPoint | null { - const resolved = resolveOwnedSurfacePoint(root, node, offset); - const block = editableBlockFromNode(root, resolved?.surface ?? node); - if (resolved === null || block === null) { +): SourceOffsetPoint | null { + if (!isOwnedPoint(root, node, offset)) { return null; } - const blockId = block.getAttribute(EDITABLE_BLOCK_ATTRIBUTE); - if (blockId === null) { - return null; - } - const blockIndex = value.blocks.findIndex((candidate) => candidate.id === blockId); - if (blockIndex < 0) { + const sourceOffset = offsetWithinRoot(root, node, offset); + if (sourceOffset === null) { return null; } - const textLength = textFromSurface(resolved.surface).length; + const affinity = affinityAtDOMPoint(root, node, offset); return { - blockId, - blockIndex, - offset: Math.min(Math.max(resolved.offset, 0), textLength), + offset: sourceOffset, + ...(affinity === undefined ? {} : { affinity }), }; } -export function restoreDOMSelection( +/** Maps a clamped global UTF-16 source offset back to an owned Text point. */ +export function sourceOffsetToDOMPoint( root: HTMLElement, - value: EditableDocumentValue, - snapshot: SelectionSnap | null, -): boolean { - const range = - snapshot === null - ? undefined - : snapshot.selectionRanges[snapshot.primaryIndex]; - if (range === undefined) { - return false; + requestedOffset: number, + affinity?: MarkdownAffinity, +): DOMPoint | null { + if (!Number.isFinite(requestedOffset)) { + return null; } - const anchor = selectionPointToDOM(root, value, range.anchor); - const focus = selectionPointToDOM(root, value, range.focus); - if (anchor === null || focus === null) { - return false; + const nodes = textNodes(root); + if (nodes.length === 0) { + return { node: root, offset: 0 }; } - root.focus({ preventScroll: true }); - const selection = root.ownerDocument.getSelection(); - if (selection === null) { - return false; + const sourceLength = nodes.reduce( + (length, node) => length + node.data.length, + 0, + ); + const offset = Math.min( + sourceLength, + Math.max(0, Math.trunc(requestedOffset)), + ); + const trailingBreak = root.querySelector( + ":scope > [data-editable-trailing-break]", + ); + if (offset === sourceLength && trailingBreak !== null) { + return { + node: root, + offset: Array.prototype.indexOf.call(root.childNodes, trailingBreak), + }; } + let consumed = 0; - if (typeof selection.setBaseAndExtent === "function") { - selection.setBaseAndExtent( - anchor.node, - anchor.offset, - focus.node, - focus.offset, - ); - return true; + for (const [index, node] of nodes.entries()) { + const end = consumed + node.data.length; + if (offset < end) { + return { node, offset: offset - consumed }; + } + if (offset === end) { + const next = nodes[index + 1]; + if (affinity === "forward" && next !== undefined) { + return { node: next, offset: 0 }; + } + return { node, offset: node.data.length }; + } + consumed = end; } - const domRange = root.ownerDocument.createRange(); - domRange.setStart(anchor.node, anchor.offset); - domRange.setEnd(focus.node, focus.offset); - selection.removeAllRanges(); - selection.addRange(domRange); - return true; + const last = nodes[nodes.length - 1] as Text; + return { node: last, offset: last.data.length }; } -function selectionPointToDOM( - root: HTMLElement, - value: EditableDocumentValue, - point: SelectionPoint, -): { node: Node; offset: number } | null { - if (typeof point === "string") { - return null; +function isOwnedPoint(root: HTMLElement, node: Node, offset: number): boolean { + if ( + !Number.isInteger(offset) || + offset < 0 || + (node !== root && !root.contains(node)) + ) { + return false; } - const blockIndex = editableBlockIndexFromTextPath(point.path); - const block = blockIndex === null ? undefined : value.blocks[blockIndex]; - if (block === undefined) { - return null; + if (node.nodeType === 3) { + return offset <= (node as Text).data.length; } - const element = Array.from( - root.querySelectorAll(`[${EDITABLE_BLOCK_ATTRIBUTE}]`), - ).find( - (candidate) => candidate.getAttribute(EDITABLE_BLOCK_ATTRIBUTE) === block.id, - ); - const surface = element?.querySelector( - `[${EDITABLE_TEXT_ATTRIBUTE}]`, - ); - if (surface === null || surface === undefined) { - return null; + if (node.nodeType === 1) { + return offset <= node.childNodes.length; } - return domPointAtOffset(surface, point.offset ?? 0); + return false; } -function domOffsetWithin( - surface: HTMLElement, - node: Node, - offset: number, -): number { - try { - const range = surface.ownerDocument.createRange(); - range.setStart(surface, 0); - range.setEnd(node, offset); - return range.toString().length; - } catch { - return 0; - } +function offsetWithinRoot( + root: HTMLElement, + target: Node, + targetOffset: number, +): number | null { + let offset = 0; + let found = false; + + const visit = (node: Node): void => { + if (found) { + return; + } + if (node === target) { + if (node.nodeType === 3) { + offset += targetOffset; + } else { + for (let index = 0; index < targetOffset; index += 1) { + const child = node.childNodes[index]; + if (child !== undefined) { + offset += textLength(child); + } + } + } + found = true; + return; + } + if (node.nodeType === 3) { + offset += (node as Text).data.length; + return; + } + for (const child of node.childNodes) { + visit(child); + if (found) { + return; + } + } + }; + + visit(root); + return found ? offset : null; } -function resolveOwnedSurfacePoint( +function affinityAtDOMPoint( root: HTMLElement, node: Node, offset: number, -): { surface: HTMLElement; offset: number } | null { - const directSurface = editableSurfaceFromNode(root, node); - if (directSurface !== null) { - return { - surface: directSurface, - offset: domOffsetWithin(directSurface, node, offset), - }; +): MarkdownAffinity | undefined { + if (node.nodeType === 1) { + if (offset < node.childNodes.length) { + return "forward"; + } + return offset > 0 ? "backward" : undefined; } - - const containingBlock = editableBlockFromNode(root, node); - const blockSurface = containingBlock?.querySelector( - `[${EDITABLE_TEXT_ATTRIBUTE}]`, - ); - if (blockSurface !== null && blockSurface !== undefined) { - const blockRange = root.ownerDocument.createRange(); - blockRange.selectNodeContents(blockSurface); - try { - const relation = blockRange.comparePoint(node, offset); - if (relation < 0) { - return { surface: blockSurface, offset: 0 }; - } - if (relation > 0) { - return { - surface: blockSurface, - offset: textFromSurface(blockSurface).length, - }; - } - return { - surface: blockSurface, - offset: domOffsetWithin(blockSurface, node, offset), - }; - } catch { - return null; - } + const nodes = textNodes(root); + const index = nodes.indexOf(node as Text); + if (index < 0) { + return undefined; + } + if (offset <= 0 && index > 0) { + return "forward"; + } + if (offset >= (node as Text).data.length && index < nodes.length - 1) { + return "backward"; } + return undefined; +} - let previous: HTMLElement | null = null; - for (const surface of root.querySelectorAll( - `[${EDITABLE_TEXT_ATTRIBUTE}]`, - )) { - const range = root.ownerDocument.createRange(); - range.selectNodeContents(surface); - try { - const relation = range.comparePoint(node, offset); - if (relation < 0) { - return { surface, offset: 0 }; - } - if (relation === 0) { - return { - surface, - offset: domOffsetWithin(surface, node, offset), - }; - } - } catch { - return null; +function textNodes(root: HTMLElement): Text[] { + const nodes: Text[] = []; + const visit = (node: Node): void => { + if (node.nodeType === 3) { + nodes.push(node as Text); + return; } - previous = surface; - } - return previous === null - ? null - : { surface: previous, offset: textFromSurface(previous).length }; + for (const child of node.childNodes) { + visit(child); + } + }; + visit(root); + return nodes; } -function domPointAtOffset( - surface: HTMLElement, - requestedOffset: number, -): { node: Node; offset: number } { - const nodes = textNodes(surface); - if (nodes.length === 0) { - return { node: surface, offset: 0 }; +function textLength(node: Node): number { + if (node.nodeType === 3) { + return (node as Text).data.length; } - - let remaining = Math.max(requestedOffset, 0); - for (const node of nodes) { - if (remaining <= node.data.length) { - return { node, offset: remaining }; - } - remaining -= node.data.length; + let length = 0; + for (const child of node.childNodes) { + length += textLength(child); } - const last = nodes[nodes.length - 1] as Text; - return { node: last, offset: last.data.length }; + return length; } diff --git a/packages/editable/browser/editableDOM.ts b/packages/editable/browser/editableDOM.ts deleted file mode 100644 index 775f71b..0000000 --- a/packages/editable/browser/editableDOM.ts +++ /dev/null @@ -1,133 +0,0 @@ -export const EDITABLE_BLOCK_ATTRIBUTE = "data-editable-block"; -export const EDITABLE_TEXT_ATTRIBUTE = "data-editable-text"; -export const EDITABLE_PLACEHOLDER_ATTRIBUTE = "data-editable-placeholder"; - -export function editableSurfaceFromNode( - root: HTMLElement, - node: Node | null, -): HTMLElement | null { - const element = isElementNode(node) ? node : node?.parentElement ?? null; - const surface = element?.closest(`[${EDITABLE_TEXT_ATTRIBUTE}]`); - return surface != null && root.contains(surface) ? surface : null; -} - -export function editableBlockFromNode( - root: HTMLElement, - node: Node | null, -): HTMLElement | null { - const element = isElementNode(node) ? node : node?.parentElement ?? null; - const block = element?.closest(`[${EDITABLE_BLOCK_ATTRIBUTE}]`); - return block != null && root.contains(block) ? block : null; -} - -export function textFromSurface(surface: HTMLElement): string { - return surface.textContent ?? ""; -} - -export function ensureCompositionTextNode( - surface: HTMLElement, - focusNode: Node | null, -): Text { - if (isTextNode(focusNode) && surface.contains(focusNode)) { - return focusNode; - } - - const first = textNodes(surface)[0]; - if (first !== undefined) { - return first; - } - - const node = surface.ownerDocument.createTextNode(""); - surface.insertBefore(node, surface.firstChild); - return node; -} - -export function setCanonicalSurfaceText( - surface: HTMLElement, - value: string, -): Text { - const children = Array.from(surface.childNodes); - const onlyText = children.length === 1 && isTextNode(children[0]); - const emptyCanonical = - children.length === 2 && - isTextNode(children[0]) && - isElementNode(children[1]) && - children[1].hasAttribute(EDITABLE_PLACEHOLDER_ATTRIBUTE); - - if (onlyText || emptyCanonical) { - const text = children[0] as Text; - const current = text.data; - let prefix = 0; - while ( - prefix < current.length && - prefix < value.length && - current.charCodeAt(prefix) === value.charCodeAt(prefix) - ) { - prefix += 1; - } - let currentEnd = current.length; - let valueEnd = value.length; - while ( - currentEnd > prefix && - valueEnd > prefix && - current.charCodeAt(currentEnd - 1) === value.charCodeAt(valueEnd - 1) - ) { - currentEnd -= 1; - valueEnd -= 1; - } - if (currentEnd > prefix) { - text.deleteData(prefix, currentEnd - prefix); - } - if (valueEnd > prefix) { - text.insertData(prefix, value.slice(prefix, valueEnd)); - } - syncPlaceholder(surface); - return text; - } - - while (surface.firstChild !== null) { - surface.removeChild(surface.firstChild); - } - const text = surface.ownerDocument.createTextNode(value); - surface.append(text); - syncPlaceholder(surface); - return text; -} - -export function textNodes(root: Node): Text[] { - const nodes: Text[] = []; - const showText = root.ownerDocument?.defaultView?.NodeFilter.SHOW_TEXT ?? 0x4; - const walker = root.ownerDocument?.createTreeWalker(root, showText); - if (walker === undefined) { - return nodes; - } - for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { - nodes.push(node as Text); - } - return nodes; -} - -function syncPlaceholder(surface: HTMLElement): void { - const placeholder = surface.querySelector( - `[${EDITABLE_PLACEHOLDER_ATTRIBUTE}]`, - ); - const hasText = textNodes(surface).some((node) => node.data.length > 0); - if (hasText) { - placeholder?.remove(); - return; - } - if (placeholder !== null) { - return; - } - const br = surface.ownerDocument.createElement("br"); - br.setAttribute(EDITABLE_PLACEHOLDER_ATTRIBUTE, "true"); - surface.append(br); -} - -function isElementNode(node: Node | null | undefined): node is Element { - return node?.nodeType === 1; -} - -function isTextNode(node: Node | null | undefined): node is Text { - return node?.nodeType === 3; -} diff --git a/packages/editable/browser/editor.ts b/packages/editable/browser/editor.ts deleted file mode 100644 index 7e41706..0000000 --- a/packages/editable/browser/editor.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { - JSONChangeMetadata, - JSONDocument, - JSONPatchOperation, - SelectionSnap, -} from "@interactive-os/json-document"; -import type { - EditableDocumentValue, - EditorDocumentCommand, -} from "../core"; - -export { - getJsonEditableDocumentHost, - mountJsonEditable, -} from "./editorCoordinator"; - -export type EditorPhase = - | "idle" - | "native-input" - | "composing" - | "settling"; - -export type EditorSnapshot = { - phase: EditorPhase; - revision: number; - queuedChanges: number; - selection: SelectionSnap | null; - composition: { - blockId: string; - from: number; - to: number; - } | null; -}; - -export type EditorFault = { - code: - | "out_of_band_document_write" - | "foreign_dom_mutation" - | "native_change_commit_failed" - | "input_state_lost" - | "composition_overlap" - | "composition_conflict" - | "queued_change_commit_failed" - | "subscriber_failed"; - recoverable: boolean; - reason: string; -}; - -export type EditorAction = - | { - type: "patch"; - patch: ReadonlyArray; - label?: string; - origin?: string; - selectionAfter?: SelectionSnap | null; - } - | EditorDocumentCommand - | { type: "undo" | "redo" | "reset" }; - -export type EditorResult = - | { - ok: true; - change: "none" | "selection" | "document" | "queued"; - patch: ReadonlyArray; - } - | { - ok: false; - code: - | "destroyed" - | "reentrant_transaction" - | "block_not_found" - | "selection_unavailable" - | "composition_conflict" - | "commit_failed"; - reason: string; - }; - -export type JsonEditableDocumentHost = { - ownsPublication(publication: { - operations: ReadonlyArray; - metadata?: JSONChangeMetadata; - }): false | { sequence: number }; - runReady(request: { - id: string; - apply(): void; - }): - | { ok: true } - | { - ok: false; - code: "host_not_ready"; - reason: string; - }; -}; - -export type JsonEditable = { - dispatch(action: EditorAction): EditorResult; - getSnapshot(): EditorSnapshot; - subscribe(listener: (snapshot: EditorSnapshot) => void): () => void; - destroy(): void; -}; - -export type MountJsonEditableOptions = { - root: HTMLElement; - document: JSONDocument; - onFault?: (fault: EditorFault) => void; -}; diff --git a/packages/editable/browser/editorCoordinator.ts b/packages/editable/browser/editorCoordinator.ts deleted file mode 100644 index 71a8248..0000000 --- a/packages/editable/browser/editorCoordinator.ts +++ /dev/null @@ -1,2491 +0,0 @@ -import { - applyPatch, - type JSONChangeMetadata, - type JSONDocument, - type JSONPatchOperation, - type SelectionSnap, -} from "@interactive-os/json-document"; -import { - EditableDocumentSchema, - accumulateNativeCompositionRange, - diffText, - diffTextNearRange, - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - planEditorCommand, - primaryEditablePoint, - type EditableBlock, - type EditableDocumentValue, - type EditorDocumentCommand, - type TextChange, - type TextRange, -} from "../core"; -import type { - EditorAction, - EditorFault, - EditorPhase, - EditorResult, - EditorSnapshot, - JsonEditable, - JsonEditableDocumentHost, - MountJsonEditableOptions, -} from "./editor"; -import { - readDOMPoint, - readDOMSelection, - restoreDOMSelection, -} from "./domSelection"; -import { - EDITABLE_BLOCK_ATTRIBUTE, - EDITABLE_TEXT_ATTRIBUTE, - editableBlockFromNode, - editableSurfaceFromNode, - ensureCompositionTextNode, -} from "./editableDOM"; -import { - findBlockElement, - isCanonicalBlockElement, - isCanonicalSurfaceElement, - projectDocumentDOM, -} from "./documentProjection"; -import { - captureCompositionPlaceholder, - inspectNativeParagraphEffect, - readPinnedCompositionText, - type NativeParagraphEffect, -} from "./nativeParagraph"; -import { inspectNativeTextMutations } from "./nativeTextMutation"; - -type ChangeSource = - | "native" - | "app" - | "remote" - | "history" - | "authoritative" - | "external"; - -type CompositionSession = { - id: number; - blockId: string; - node: Text; - ancestors: Node[]; - sourceElement: HTMLElement; - sourceSurface: HTMLElement; - sourcePlaceholder: HTMLBRElement | null; - blockElements: ReadonlyArray; - range: TextRange; - ending: boolean; -}; - -type PendingNativeIntent = { - selection: SelectionSnap; - text: string; - inputType: string; -}; - -type PendingStructuralIntent = { - compositionId: number; - mode: "deferred-command" | "native-fallback"; - paragraphCount: number; - unmatchedBeforeInputCount: number; - compositionEndEvidencePending: boolean; - blockId: string; - sourceElement: HTMLElement; - sourceSurface: HTMLElement; - sourceText: Text; - sourcePlaceholder: HTMLBRElement | null; - blockElements: ReadonlyArray; - splitOffset: number; - canonicalText: string; - selection: SelectionSnap; - selectionIsAuthoritative: boolean; - normalizeTrailingLineBreak: boolean; - nativeRecords: MutationRecord[]; -}; - -type StructuralEvidence = "beforeinput" | "input" | "compositionend"; - -type QueuedRemotePatch = { - patch: ReadonlyArray; - label: string; -}; - -type BlockChange = { - blockId: string; - after: EditableBlock | null; - text: TextChange | null; - typeChanged: boolean; -}; - -type ReadyDocumentChange = { - id: string; - publicationCount: number; -}; - -const OWNED_HOST_ATTRIBUTES = [ - "contenteditable", - "spellcheck", - "tabindex", - "role", - "aria-multiline", -] as const; - -let editorSequence = 0; -const documentHosts = new WeakMap(); - -export function getJsonEditableDocumentHost( - editor: JsonEditable, -): JsonEditableDocumentHost { - const host = documentHosts.get(editor); - if (host === undefined) { - throw new TypeError("The editor was not created by mountJsonEditable()."); - } - return host; -} - -export function mountJsonEditable( - options: MountJsonEditableOptions, -): JsonEditable { - return new JsonEditableCoordinator(options); -} - -class JsonEditableCoordinator implements JsonEditable { - private readonly documentHost: JsonEditableDocumentHost = { - ownsPublication: () => { - const sequence = this.activeDocumentPublicationSequence; - return sequence === null ? false : { sequence }; - }, - runReady: (request) => this.runReadyDocumentChange(request), - }; - - private readonly root: HTMLElement; - private readonly document: JSONDocument; - private readonly onFault: ((fault: EditorFault) => void) | undefined; - private readonly ownerId: string; - private readonly originalHostAttributes: ReadonlyMap; - private readonly observer: MutationObserver; - private readonly listeners = new Set<(snapshot: EditorSnapshot) => void>(); - private readonly stopDocumentSubscription: () => void; - private readonly stopSelectionSubscription: (() => void) | null; - private lastValue: EditableDocumentValue; - private phase: EditorPhase = "idle"; - private revision = 0; - private composition: CompositionSession | null = null; - private browserCompositionActive = false; - private browserCompositionGeneration = 0; - private compositionSequence = 0; - private blockSequence = 0; - private settleTimer: number | null = null; - private nativeTurnTimer: number | null = null; - private commitSource: ChangeSource | null = null; - private commitTextChanges: ReadonlyMap | null = null; - private documentPublicationSequence = 0; - private activeDocumentPublicationSequence: number | null = null; - private readyDocumentChange: ReadyDocumentChange | null = null; - private lastNativeCompositionHistoryId: number | null = null; - private dispatching = false; - private destroyed = false; - private browserEventDepth = 0; - private domWriteDepth = 0; - private pendingRecords: MutationRecord[] = []; - private mutationFlushQueued = false; - private lastBeforeInputBlockId: string | null = null; - private nativeEvidenceUntil = 0; - private pendingNativeIntent: PendingNativeIntent | null = null; - private pendingStructuralIntent: PendingStructuralIntent | null = null; - private inputTargetSelection: SelectionSnap | null = null; - private queuedRemotePatches: QueuedRemotePatch[] = []; - private remoteFlushQueued = false; - - constructor({ root, document, onFault }: MountJsonEditableOptions) { - if (root.dataset.jsonEditableOwner !== undefined) { - throw new Error("The editable root is already owned by an editor."); - } - - this.root = root; - this.document = document; - this.onFault = onFault; - this.ownerId = `json-editable-${++editorSequence}`; - this.originalHostAttributes = new Map( - OWNED_HOST_ATTRIBUTES.map((name) => [name, root.getAttribute(name)]), - ); - this.lastValue = document.value; - - const MutationObserverConstructor = - root.ownerDocument.defaultView?.MutationObserver; - if (MutationObserverConstructor === undefined) { - throw new Error("MutationObserver is required to mount the editor."); - } - this.observer = new MutationObserverConstructor((records) => { - this.captureMutationRecords(records); - }); - - root.dataset.jsonEditableOwner = this.ownerId; - root.contentEditable = "true"; - root.spellcheck = false; - root.tabIndex = 0; - root.setAttribute("role", "textbox"); - root.setAttribute("aria-multiline", "true"); - - this.withDOMWrite(() => { - while (root.firstChild !== null) { - root.removeChild(root.firstChild); - } - this.renderDocument(document.value); - }); - this.observe(); - this.attachEvents(); - - this.stopDocumentSubscription = document.subscribe((_operations, metadata) => { - this.onDocumentChange(metadata); - }); - this.stopSelectionSubscription = - document.selection?.subscribe(() => { - this.bump(); - }) ?? null; - documentHosts.set(this, this.documentHost); - } - - dispatch(action: EditorAction): EditorResult { - if (this.destroyed) { - return failure("destroyed", "The editor has been destroyed."); - } - if (this.dispatching) { - return failure( - "reentrant_transaction", - "Editor transactions cannot be nested.", - ); - } - - this.dispatching = true; - try { - this.flushNativeMutations([], false); - if ( - this.composition !== null && - this.actionConflictsWithComposition(action) - ) { - const reason = - "The composing block is browser-owned until composition settles; retry this action afterward."; - this.reportFault({ - code: "composition_conflict", - recoverable: true, - reason, - }); - return failure("composition_conflict", reason); - } - const result = this.applyAction(action); - if ( - result.ok && - this.composition === null && - actionOrigin(action) !== "remote" - ) { - restoreDOMSelection( - this.root, - this.document.value, - this.document.selection?.snapshot() ?? null, - ); - } - return result; - } finally { - this.dispatching = false; - } - } - - getSnapshot(): EditorSnapshot { - return { - phase: this.phase, - revision: this.revision, - queuedChanges: this.queuedRemotePatches.length, - selection: this.document.selection?.snapshot() ?? null, - composition: - this.composition === null - ? null - : { - blockId: this.composition.blockId, - from: this.composition.range.from, - to: this.composition.range.to, - }, - }; - } - - subscribe(listener: (snapshot: EditorSnapshot) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - destroy(): void { - if (this.destroyed) { - return; - } - if (this.pendingNativeIntent !== null) { - this.commitPendingNativeIntent(); - } - if (this.composition === null) { - this.flushNativeMutations([], this.phase !== "idle"); - } else { - this.settleComposition(); - } - this.flushQueuedRemotePatches(); - this.destroyed = true; - this.clearSettleTimer(); - this.clearNativeTurnTimer(); - this.observer.disconnect(); - this.detachEvents(); - this.stopDocumentSubscription(); - this.stopSelectionSubscription?.(); - this.listeners.clear(); - this.pendingNativeIntent = null; - this.pendingStructuralIntent = null; - this.inputTargetSelection = null; - this.pendingRecords = []; - this.observer.takeRecords(); - this.lastNativeCompositionHistoryId = null; - if (this.root.dataset.jsonEditableOwner === this.ownerId) { - delete this.root.dataset.jsonEditableOwner; - } - for (const [name, value] of this.originalHostAttributes) { - if (value === null) { - this.root.removeAttribute(name); - } else { - this.root.setAttribute(name, value); - } - } - } - - private applyAction(action: EditorAction): EditorResult { - switch (action.type) { - case "patch": - return this.commitPatch( - action.patch, - action.label ?? "document patch", - sourceFromOrigin(action.origin), - action.selectionAfter, - ); - case "replaceText": - return this.applyDocumentCommand(action); - case "replaceSelection": - return this.applyDocumentCommand(action); - case "setBlockType": - return this.applyDocumentCommand(action); - case "insertParagraph": - return this.applyDocumentCommand(action); - case "deleteBackward": - return this.applyDocumentCommand(action); - case "deleteForward": - return this.applyDocumentCommand(action); - case "joinBackward": - return this.applyDocumentCommand(action); - case "joinForward": - return this.applyDocumentCommand(action); - case "undo": - return this.applyHistory("undo"); - case "redo": - return this.applyHistory("redo"); - case "reset": - return this.reset(); - } - } - - private actionConflictsWithComposition(action: EditorAction): boolean { - const blockId = this.composition?.blockId; - if (blockId === undefined) { - return false; - } - if (actionOrigin(action) !== "remote") { - return true; - } - if (action.type === "replaceText") { - return action.blockId === blockId; - } - if (action.type === "patch") { - const composingIndex = findEditableBlockIndex(this.document.value, blockId); - return action.patch.some((operation) => { - if (operation.op !== "replace" && operation.op !== "test") { - return true; - } - const match = /^\/blocks\/(0|[1-9]\d*)\/(text|type)$/u.exec( - operation.path, - ); - return match === null || Number(match[1]) === composingIndex; - }); - } - return true; - } - - private applyDocumentCommand( - action: EditorDocumentCommand, - sourceOverride?: ChangeSource, - ): EditorResult { - const plan = planEditorCommand( - this.document.value, - this.document.selection?.snapshot() ?? null, - action, - () => this.createBlockId(), - ); - switch (plan.kind) { - case "none": - return success("none", []); - case "failure": - return failure(plan.code, plan.reason); - case "commit": - return this.commitPatch( - plan.patch, - plan.label, - sourceOverride ?? plan.source, - plan.selectionAfter, - ); - } - } - - private replaceSelection( - text: string, - label: string, - source: ChangeSource, - ): EditorResult { - return this.applyDocumentCommand( - { type: "replaceSelection", text, label }, - source, - ); - } - - private insertParagraph(): EditorResult { - return this.applyDocumentCommand({ type: "insertParagraph" }); - } - - private applyHistory(command: "undo" | "redo"): EditorResult { - this.cancelComposition(false); - const result = this.runDocumentChange("history", () => - command === "undo" ? this.document.undo() : this.document.redo(), - ); - if (!result.ok) { - return failure("commit_failed", result.reason ?? result.code); - } - return success("document", this.document.lastPatch); - } - - private reset(): EditorResult { - this.cancelComposition(false); - const result = this.runDocumentChange("authoritative", () => - this.document.reset(), - ); - if (!result.ok) { - return failure("commit_failed", result.reason ?? result.code); - } - return success("document", this.document.lastPatch); - } - - private commitPatch( - patch: ReadonlyArray, - label: string, - source: ChangeSource, - selectionAfter?: SelectionSnap | null, - ): EditorResult { - if (patch.length === 0) { - return success("none", []); - } - if (source === "remote" && this.composition !== null) { - const preview = applyPatch( - EditableDocumentSchema, - this.document.value, - patch, - ); - if (!preview.result.ok) { - return failure( - "commit_failed", - preview.result.reason ?? preview.result.code, - ); - } - this.queuedRemotePatches.push({ - patch: patch.map((operation) => ({ ...operation })), - label, - }); - this.bump(); - return success("queued", patch); - } - const result = this.runDocumentChange(source, () => - this.document.commit(patch, { - label, - origin: source, - ...(selectionAfter === undefined || selectionAfter === null - ? {} - : { selectionAfter }), - }), - ); - if (!result.ok) { - return failure("commit_failed", result.reason ?? result.code); - } - return success("document", patch); - } - - private runDocumentChange( - source: ChangeSource, - change: () => T, - textChanges: ReadonlyMap | null = null, - ): T { - const previousSource = this.commitSource; - const previousTextChanges = this.commitTextChanges; - const previousPublicationSequence = - this.activeDocumentPublicationSequence; - const publicationSequence = this.reserveDocumentPublicationSequence(); - this.activeDocumentPublicationSequence = publicationSequence; - this.commitSource = source; - this.commitTextChanges = textChanges; - try { - return change(); - } finally { - this.commitSource = previousSource; - this.commitTextChanges = previousTextChanges; - this.activeDocumentPublicationSequence = previousPublicationSequence; - } - } - - private runReadyDocumentChange( - request: Parameters[0], - ): ReturnType { - if (this.destroyed) { - throw new Error("The editor has been destroyed."); - } - if (this.dispatching) { - return { - ok: false, - code: "host_not_ready", - reason: "The editor is already dispatching a document transaction.", - }; - } - if ( - this.browserEventDepth > 0 || - this.activeDocumentPublicationSequence !== null - ) { - return { - ok: false, - code: "host_not_ready", - reason: - "The editor is still handling a browser event or publishing a document change.", - }; - } - - this.dispatching = true; - try { - const enteredReady = this.canApplyReadyDocumentChange(); - this.flushNativeMutations([], false); - if (this.destroyed) { - throw new Error( - "The editor was destroyed while preparing a ready document change.", - ); - } - if (!enteredReady || !this.canApplyReadyDocumentChange()) { - return { - ok: false, - code: "host_not_ready", - reason: - "The editor must settle native input and composition before applying this document change.", - }; - } - - const readyChange: ReadyDocumentChange = { - id: request.id, - publicationCount: 0, - }; - const previousPublicationSequence = - this.activeDocumentPublicationSequence; - this.activeDocumentPublicationSequence = - this.reserveDocumentPublicationSequence(); - this.readyDocumentChange = readyChange; - const selectionBefore = selectionSnapshotSignature( - this.document.selection?.snapshot() ?? null, - ); - let didThrow = false; - let thrown: unknown; - try { - request.apply(); - } catch (error) { - didThrow = true; - thrown = error; - } finally { - this.readyDocumentChange = null; - this.activeDocumentPublicationSequence = previousPublicationSequence; - } - const selectionChanged = - selectionSnapshotSignature( - this.document.selection?.snapshot() ?? null, - ) !== selectionBefore; - - if ( - !this.destroyed && - this.composition === null && - (readyChange.publicationCount > 0 || selectionChanged) - ) { - try { - this.restoreDOMSelectionIfFocused(); - } catch (error) { - if (!didThrow) { - throw error; - } - } - } - if (didThrow) { - throw thrown; - } - return { ok: true }; - } finally { - this.dispatching = false; - } - } - - private canApplyReadyDocumentChange(): boolean { - return ( - this.phase === "idle" && - !this.destroyed && - this.browserEventDepth === 0 && - this.activeDocumentPublicationSequence === null && - !this.browserCompositionActive && - this.composition === null && - this.pendingNativeIntent === null && - this.pendingStructuralIntent === null && - this.queuedRemotePatches.length === 0 - ); - } - - private restoreDOMSelectionIfFocused(): void { - if (!this.root.matches(":focus")) { - return; - } - restoreDOMSelection( - this.root, - this.document.value, - this.document.selection?.snapshot() ?? null, - ); - } - - private onDocumentChange(metadata?: JSONChangeMetadata): void { - const before = this.lastValue; - const after = this.document.value; - const source = this.documentChangeSource(metadata); - if (source !== "native") { - this.lastNativeCompositionHistoryId = null; - } - if (source === "external") { - this.reportFault({ - code: "out_of_band_document_write", - recoverable: true, - reason: - "The JSON document changed outside editor.dispatch(); the editor recovered conservatively.", - }); - } - const changes = describeBlockChanges( - before, - after, - this.commitTextChanges, - ); - this.reconcileComposition(after, changes, source); - this.withDOMWrite(() => { - this.renderDocument(after); - }); - this.lastValue = after; - this.bump(); - } - - private documentChangeSource(metadata?: JSONChangeMetadata): ChangeSource { - if (this.commitSource !== null) { - return this.commitSource; - } - const readyChange = this.readyDocumentChange; - if (readyChange === null) { - return "external"; - } - readyChange.publicationCount += 1; - return readyChange.publicationCount === 1 && - metadata?.mergeKey === readyChange.id - ? "remote" - : "external"; - } - - private reserveDocumentPublicationSequence(): number { - const sequence = this.documentPublicationSequence + 1; - if (!Number.isSafeInteger(sequence)) { - throw new Error("The editor document publication sequence is exhausted."); - } - this.documentPublicationSequence = sequence; - return sequence; - } - - private reconcileComposition( - after: EditableDocumentValue, - changes: ReadonlyArray, - source: ChangeSource, - ): void { - const session = this.composition; - if (session === null) { - return; - } - const change = changes.find((candidate) => candidate.blockId === session.blockId); - if (change === undefined) { - return; - } - if (change.after === null || change.typeChanged) { - this.cancelComposition(true, "The composing block was structurally changed."); - return; - } - if (change.text === null) { - return; - } - - if (source === "external") { - this.cancelComposition( - true, - "An out-of-band write touched the browser-owned composing block.", - ); - return; - } - - if (source === "native") { - session.range = accumulateNativeCompositionRange( - session.range, - change.text, - change.after.text.length, - ); - this.refreshCompositionPin(after); - return; - } - - this.cancelComposition( - true, - `An unexpected ${source} write touched the browser-owned composing block.`, - ); - } - - private renderDocument( - value: EditableDocumentValue, - forceCanonicalBlockId?: string, - ): void { - const composition = this.composition; - projectDocumentDOM({ - root: this.root, - value, - composition: - composition === null - ? null - : { - blockId: composition.blockId, - node: composition.node, - isPinIntact: (surface) => this.isCompositionPinIntact(surface), - invalidate: (reason) => this.cancelComposition(true, reason), - }, - ...(forceCanonicalBlockId === undefined ? {} : { forceCanonicalBlockId }), - }); - } - - private captureMutationRecords(records: ReadonlyArray): void { - if (this.destroyed || records.length === 0) { - return; - } - this.pendingRecords.push(...records); - this.queueMutationFlush(); - } - - private queueMutationFlush(): void { - if (this.mutationFlushQueued) { - return; - } - this.mutationFlushQueued = true; - queueMicrotask(() => { - this.mutationFlushQueued = false; - if (!this.destroyed) { - this.flushNativeMutations([], false); - } - }); - } - - private handlePendingNativeParagraphMutations( - records: ReadonlyArray, - _nativeEvidence: boolean, - ): boolean { - const intent = this.pendingStructuralIntent; - if (intent === null || intent.mode !== "native-fallback") { - return false; - } - if (records.length > 0) { - intent.nativeRecords.push(...records); - } - return true; - } - - private expectedNativeParagraphEffect( - intent: PendingStructuralIntent, - session: CompositionSession, - ): NativeParagraphEffect | null { - if (this.composition !== session) { - return null; - } - return inspectNativeParagraphEffect({ - root: this.root, - value: this.document.value, - intent, - session, - isCompositionPinIntact: (surface) => - this.isCompositionPinIntact(surface), - }); - } - - private finalizePendingNativeParagraphEffect( - session: CompositionSession, - ): boolean { - const intent = this.pendingStructuralIntent; - if ( - intent === null || - intent.compositionId !== session.id || - intent.mode !== "native-fallback" - ) { - return true; - } - const effect = this.expectedNativeParagraphEffect(intent, session); - if (effect !== null) { - return this.commitNativeParagraphEffect(session, intent, effect); - } - if ( - intent.nativeRecords.length === 0 && - this.isCanonicalParagraphDOM(intent) - ) { - return true; - } - this.rejectPendingNativeParagraphMutation( - "The native paragraph effect exceeded its one-shot structural intent.", - ); - return false; - } - - private commitNativeParagraphEffect( - session: CompositionSession, - intent: PendingStructuralIntent, - effect: NativeParagraphEffect, - ): boolean { - const index = findEditableBlockIndex(this.document.value, intent.blockId); - const block = this.document.value.blocks[index]; - if (block === undefined || block.text !== intent.canonicalText) { - this.rejectPendingNativeParagraphMutation( - "The canonical paragraph changed before its native effect was settled.", - ); - return false; - } - const selection = selectionAt( - editableTextPath(index), - effect.splitOffset, - ); - if (effect.change !== null) { - const mergeWithPrevious = - this.lastNativeCompositionHistoryId === session.id; - const result = this.runDocumentChange( - "native", - () => - this.document.commit( - [ - { - op: "replace", - path: editableTextPath(index), - value: effect.text, - }, - ], - { - label: "IME composition", - origin: "native", - mergeKey: `composition:${session.id}`, - selectionAfter: selection, - }, - ), - new Map([[intent.blockId, effect.change]]), - ); - if (!result.ok) { - this.reportFault({ - code: "native_change_commit_failed", - recoverable: false, - reason: result.reason ?? result.code, - }); - this.cancelComposition(false); - return false; - } - if (mergeWithPrevious) { - this.document.history.mergeLast({ - mergeKey: `composition:${session.id}`, - }); - } - this.lastNativeCompositionHistoryId = session.id; - } - intent.canonicalText = effect.text; - intent.splitOffset = effect.splitOffset; - intent.selection = selection; - intent.selectionIsAuthoritative = true; - return true; - } - - private isCanonicalParagraphDOM(intent: PendingStructuralIntent): boolean { - const children = Array.from(this.root.children); - return ( - children.length === intent.blockElements.length && - children.every((element, index) => element === intent.blockElements[index]) && - intent.sourceElement.childNodes.length === 1 && - intent.sourceElement.firstChild === intent.sourceSurface && - readPinnedCompositionText(intent) === intent.canonicalText && - isCanonicalBlockElement( - intent.sourceElement, - this.document.value, - intent.blockId, - ) && - isCanonicalSurfaceElement( - intent.sourceSurface, - this.document.value, - intent.blockId, - ) - ); - } - - private rejectPendingNativeParagraphMutation(reason: string): void { - const intent = this.pendingStructuralIntent; - this.pendingStructuralIntent = null; - if (this.composition !== null) { - this.reportFault({ - code: "input_state_lost", - recoverable: true, - reason, - }); - this.cancelComposition(false); - } - this.reportFault({ - code: "foreign_dom_mutation", - recoverable: true, - reason, - }); - this.withDOMWrite(() => { - if (intent !== null) { - this.restoreStructuralIntentBaseline(intent); - } - this.renderDocument(this.document.value); - }); - restoreDOMSelection( - this.root, - this.document.value, - this.document.selection?.snapshot() ?? null, - ); - } - - private restoreStructuralIntentBaseline( - intent: PendingStructuralIntent, - ): void { - this.restoreBlockIdentityBaseline(intent.blockElements); - this.restoreCompositionSourceBaseline( - intent.sourceElement, - intent.sourceSurface, - intent.sourceText, - ); - } - - private restoreCompositionSessionBaseline( - session: CompositionSession, - ): void { - this.restoreBlockIdentityBaseline(session.blockElements); - this.restoreCompositionSourceBaseline( - session.sourceElement, - session.sourceSurface, - session.node, - ); - } - - private restoreCompositionSourceBaseline( - sourceElement: HTMLElement, - sourceSurface: HTMLElement, - sourceText: Text, - ): void { - if ( - sourceElement.childNodes.length !== 1 || - sourceElement.firstChild !== sourceSurface - ) { - sourceElement.replaceChildren(sourceSurface); - } - if ( - sourceSurface.childNodes.length !== 1 || - sourceSurface.firstChild !== sourceText - ) { - sourceSurface.replaceChildren(sourceText); - } - } - - private restoreBlockIdentityBaseline( - blockElements: ReadonlyArray, - ): void { - this.document.value.blocks.forEach((block, index) => { - const expected = blockElements[index]; - if (expected === undefined) { - return; - } - const current = findBlockElement(this.root, block.id); - if (current !== expected) { - current?.remove(); - this.root.insertBefore(expected, this.root.children[index] ?? null); - } - }); - } - - private flushNativeMutations( - additional: ReadonlyArray, - nativeEvidence: boolean, - ): void { - if (this.destroyed || this.domWriteDepth > 0) { - return; - } - const records = [ - ...this.pendingRecords, - ...additional, - ...this.observer.takeRecords(), - ]; - this.pendingRecords = []; - - if (this.pendingNativeIntent !== null) { - return; - } - - if (this.handlePendingNativeParagraphMutations(records, nativeEvidence)) { - return; - } - - const inspection = inspectNativeTextMutations({ - root: this.root, - value: this.document.value, - records, - nativeEvidence, - phase: this.phase, - nativeEvidenceUntil: this.nativeEvidenceUntil, - now: performance.now(), - lastBeforeInputBlockId: this.lastBeforeInputBlockId, - composition: - this.composition === null - ? null - : { - blockId: this.composition.blockId, - range: this.composition.range, - }, - }); - const { - patch, - textChanges, - dirtyBlockIds: dirtyIds, - rejectedBlockIds: rejectedIds, - } = inspection; - let rejectedMutation = inspection.rejected; - - const selection = - this.inputTargetSelection ?? - readDOMSelection(this.root, this.document.value); - if (!rejectedMutation && patch.length > 0) { - const compositionId = this.composition?.id ?? null; - const mergeWithPrevious = - compositionId !== null && - this.lastNativeCompositionHistoryId === compositionId; - const result = this.runDocumentChange( - "native", - () => - this.document.commit(patch, { - label: - this.composition === null ? "native input" : "IME composition", - origin: "native", - mergeKey: - compositionId === null - ? `native:${dirtyIds.values().next().value ?? "text"}` - : `composition:${compositionId}`, - ...(selection === null ? {} : { selectionAfter: selection }), - }), - textChanges, - ); - if (!result.ok) { - this.lastNativeCompositionHistoryId = null; - rejectedMutation = true; - this.cancelComposition(false); - this.reportFault({ - code: "native_change_commit_failed", - recoverable: false, - reason: result.reason ?? result.code, - }); - } else if ( - compositionId !== null && - this.composition?.id === compositionId - ) { - if (mergeWithPrevious) { - this.document.history.mergeLast({ - mergeKey: `composition:${compositionId}`, - }); - } - this.lastNativeCompositionHistoryId = compositionId; - } else { - this.lastNativeCompositionHistoryId = null; - } - } else if (!rejectedMutation && selection !== null) { - this.restoreModelSelection(selection); - } - - if (rejectedMutation) { - const compositionBaseline = this.composition; - if ( - this.composition !== null && - (rejectedIds.has(this.composition.blockId) || - dirtyIds.has(this.composition.blockId)) - ) { - this.reportFault({ - code: "input_state_lost", - recoverable: true, - reason: "The composing surface received an unowned structural change.", - }); - this.cancelComposition(false); - } else { - this.refreshCompositionPin(this.document.value); - } - this.reportFault({ - code: "foreign_dom_mutation", - recoverable: true, - reason: - "A DOM change outside the evidenced text surface was rejected and re-rendered.", - }); - this.withDOMWrite(() => { - if (compositionBaseline !== null) { - this.restoreCompositionSessionBaseline(compositionBaseline); - } - this.renderDocument(this.document.value); - }); - if (this.composition === null) { - restoreDOMSelection( - this.root, - this.document.value, - this.document.selection?.snapshot() ?? null, - ); - } - } - this.refreshCompositionPin(this.document.value); - } - - private restoreModelSelection(selection: SelectionSnap): void { - const current = this.document.selection?.snapshot(); - if ( - current !== undefined && - JSON.stringify(current) !== JSON.stringify(selection) - ) { - this.document.selection?.restore(selection); - } - } - - private beginComposition(): void { - this.clearNativeTurnTimer(); - if (this.composition !== null) { - if (this.composition.ending) { - this.settleComposition(); - } else if (this.inputTargetSelection !== null) { - const targeted = orderedEditableSelection( - this.document.value, - this.document.selection, - ); - if ( - targeted !== null && - targeted.start.blockId === targeted.end.blockId && - targeted.start.blockId === this.composition.blockId - ) { - this.composition.range = { - from: targeted.start.offset, - to: targeted.end.offset, - }; - restoreDOMSelection( - this.root, - this.document.value, - this.inputTargetSelection, - ); - this.bump(); - return; - } - if ( - targeted !== null && - targeted.start.blockId === targeted.end.blockId - ) { - this.composition = null; - this.phase = "idle"; - } else { - this.setPhase("composing"); - return; - } - } else { - this.setPhase("composing"); - return; - } - } - this.flushNativeMutations([], true); - const domSelection = this.root.ownerDocument.getSelection(); - const selection = - this.inputTargetSelection ?? - readDOMSelection(this.root, this.document.value); - if (selection !== null) { - this.restoreModelSelection(selection); - } - const ordered = orderedEditableSelection( - this.document.value, - this.document.selection, - ); - if (ordered !== null && ordered.start.blockId !== ordered.end.blockId) { - this.openNativeTurn(); - return; - } - const focusPoint = primaryEditablePoint( - this.document.value, - this.document.selection, - ); - const directSurface = editableSurfaceFromNode( - this.root, - domSelection?.focusNode ?? null, - ); - const directBlockId = editableBlockFromNode( - this.root, - directSurface, - )?.getAttribute(EDITABLE_BLOCK_ATTRIBUTE); - const surface = - directSurface !== null && directBlockId === focusPoint?.blockId - ? directSurface - : focusPoint === null - ? null - : findBlockElement( - this.root, - focusPoint.blockId, - )?.querySelector(`[${EDITABLE_TEXT_ATTRIBUTE}]`) ?? null; - if (ordered === null || surface === null) { - this.reportFault({ - code: "input_state_lost", - recoverable: true, - reason: "Composition started without a mappable text selection.", - }); - return; - } - - let textNode: Text | null = null; - this.withDOMWrite(() => { - textNode = ensureCompositionTextNode(surface, domSelection?.focusNode ?? null); - }); - if (textNode === null) { - return; - } - const node = textNode as Text; - const sourceElement = editableBlockFromNode(this.root, node); - const blockElements = this.document.value.blocks.map((block) => - findBlockElement(this.root, block.id), - ); - if ( - sourceElement === null || - blockElements.some((element) => element === null) - ) { - this.reportFault({ - code: "input_state_lost", - recoverable: true, - reason: "Composition started without a stable block identity baseline.", - }); - return; - } - this.composition = { - id: ++this.compositionSequence, - blockId: ordered.start.blockId, - node, - ancestors: ancestorsToRoot(node, this.root), - sourceElement, - sourceSurface: surface, - sourcePlaceholder: captureCompositionPlaceholder(surface, node), - blockElements: blockElements as HTMLElement[], - range: { from: ordered.start.offset, to: ordered.end.offset }, - ending: false, - }; - this.lastBeforeInputBlockId = ordered.start.blockId; - if (domSelection?.focusNode !== node) { - restoreDOMSelection( - this.root, - this.document.value, - this.document.selection?.snapshot() ?? null, - ); - } - this.setPhase("composing"); - } - - private refreshCompositionPin(value: EditableDocumentValue): void { - const session = this.composition; - if (session === null) { - return; - } - const index = findEditableBlockIndex(value, session.blockId); - const block = value.blocks[index]; - const element = findBlockElement(this.root, session.blockId); - const surface = element?.querySelector( - `[${EDITABLE_TEXT_ATTRIBUTE}]`, - ); - if ( - block !== undefined && - surface !== null && - surface !== undefined && - this.isCompositionPinIntact(surface) - ) { - return; - } - this.reportFault({ - code: "input_state_lost", - recoverable: true, - reason: "The pinned composition island lost its DOM identity.", - }); - this.cancelComposition(false); - } - - private isCompositionPinIntact(surface: HTMLElement): boolean { - const session = this.composition; - if ( - session === null || - !session.node.isConnected || - !this.root.contains(session.node) || - !surface.contains(session.node) - ) { - return false; - } - const currentAncestors = ancestorsToRoot(session.node, this.root); - return ( - currentAncestors.length === session.ancestors.length && - currentAncestors.every( - (ancestor, index) => ancestor === session.ancestors[index], - ) && - currentAncestors[currentAncestors.length - 1] === this.root - ); - } - - private endComposition(): void { - if (this.composition === null) { - if (this.browserCompositionActive) { - this.setPhase("settling"); - this.scheduleSettle(); - } else { - this.setPhase("idle"); - } - return; - } - this.flushNativeMutations([], true); - if (this.composition === null) { - this.reportFault({ - code: "input_state_lost", - recoverable: true, - reason: "Composition ended after its pinned DOM identity was lost.", - }); - this.setPhase("idle"); - return; - } - this.composition.ending = true; - this.setPhase("settling"); - this.scheduleSettle(); - } - - private scheduleSettle(): void { - this.clearSettleTimer(); - const view = this.root.ownerDocument.defaultView; - if (view === null) { - this.settleComposition(); - return; - } - this.settleTimer = view.setTimeout(() => { - this.settleTimer = null; - this.settleComposition(); - }, 30); - } - - private settleComposition(): void { - this.clearSettleTimer(); - const session = this.composition; - if (session === null) { - this.setPhase("idle"); - return; - } - this.flushNativeMutations([], true); - if (this.composition !== session) { - this.flushQueuedRemotePatches(); - return; - } - if (!this.finalizePendingNativeParagraphEffect(session)) { - this.flushQueuedRemotePatches(); - return; - } - this.normalizePendingCompositionLineBreak(session); - if (this.composition !== session) { - this.flushQueuedRemotePatches(); - return; - } - const pendingIntent = this.pendingStructuralIntent; - const selection = - pendingIntent?.compositionId === session.id && - pendingIntent.selectionIsAuthoritative - ? pendingIntent.selection - : readDOMSelection(this.root, this.document.value); - if (selection !== null) { - this.restoreModelSelection(selection); - } - const blockId = session.blockId; - this.composition = null; - this.lastBeforeInputBlockId = null; - this.nativeEvidenceUntil = 0; - this.setPhase("idle"); - this.withDOMWrite(() => { - this.renderDocument(this.document.value, blockId); - }); - this.flushQueuedRemotePatches(); - this.flushPendingStructuralIntent(session.id, selection); - this.restoreDOMSelectionIfFocused(); - } - - private cancelComposition(report: boolean, reason?: string): void { - if (this.composition === null) { - return; - } - this.clearSettleTimer(); - this.composition = null; - this.pendingStructuralIntent = null; - this.lastBeforeInputBlockId = null; - this.nativeEvidenceUntil = 0; - this.phase = "idle"; - this.bump(); - if (report) { - this.reportFault({ - code: "composition_overlap", - recoverable: true, - reason: reason ?? "The active composition was canceled.", - }); - } - this.scheduleRemoteFlush(); - } - - private rememberParagraphIntent( - selection: SelectionSnap, - splitOffset: number, - mode: PendingStructuralIntent["mode"], - evidence: StructuralEvidence, - selectionIsAuthoritative = false, - ): boolean { - const session = this.composition; - if (session === null) { - return false; - } - const compositionId = session.id; - const pending = this.pendingStructuralIntent; - if (pending?.compositionId === compositionId) { - if (evidence === "beforeinput") { - pending.paragraphCount += 1; - if (mode === "native-fallback") { - pending.unmatchedBeforeInputCount += 1; - } - } else if (evidence === "input") { - if (pending.unmatchedBeforeInputCount > 0) { - pending.unmatchedBeforeInputCount -= 1; - pending.compositionEndEvidencePending = false; - } else if (pending.compositionEndEvidencePending) { - pending.compositionEndEvidencePending = false; - } else { - pending.paragraphCount += 1; - } - } else { - pending.compositionEndEvidencePending = true; - pending.normalizeTrailingLineBreak = true; - } - if (selectionIsAuthoritative || !pending.selectionIsAuthoritative) { - pending.selection = selection; - pending.splitOffset = splitOffset; - pending.selectionIsAuthoritative = selectionIsAuthoritative; - } - if (mode === "native-fallback") { - pending.mode = mode; - } - return true; - } - const index = findEditableBlockIndex(this.document.value, session.blockId); - const block = this.document.value.blocks[index]; - const sourceSurface = editableSurfaceFromNode(this.root, session.node); - const sourceElement = editableBlockFromNode(this.root, session.node); - if ( - block === undefined || - sourceSurface === null || - sourceElement === null || - sourceSurface !== session.sourceSurface || - sourceElement !== session.sourceElement || - !this.isCompositionPinIntact(sourceSurface) || - session.blockElements.length !== this.document.value.blocks.length - ) { - return false; - } - this.pendingStructuralIntent = { - compositionId, - mode, - paragraphCount: 1, - unmatchedBeforeInputCount: - evidence === "beforeinput" && mode === "native-fallback" ? 1 : 0, - compositionEndEvidencePending: evidence === "compositionend", - blockId: session.blockId, - sourceElement, - sourceSurface, - sourceText: session.node, - sourcePlaceholder: session.sourcePlaceholder, - blockElements: session.blockElements, - splitOffset, - canonicalText: block.text, - selection, - selectionIsAuthoritative, - normalizeTrailingLineBreak: evidence === "compositionend", - nativeRecords: [], - }; - return true; - } - - private normalizePendingCompositionLineBreak( - session: CompositionSession, - ): void { - const intent = this.pendingStructuralIntent; - if ( - intent === null || - intent.compositionId !== session.id || - !intent.normalizeTrailingLineBreak - ) { - return; - } - const index = findEditableBlockIndex(this.document.value, intent.blockId); - const block = this.document.value.blocks[index]; - if (block === undefined) { - return; - } - const rangeEnd = Math.min(session.range.to, block.text.length); - const width = trailingLineBreakWidth(block.text, rangeEnd); - const offset = rangeEnd - width; - const selection = selectionAt(editableTextPath(index), offset); - intent.splitOffset = offset; - intent.selection = selection; - intent.selectionIsAuthoritative = true; - intent.normalizeTrailingLineBreak = false; - if (width === 0) { - return; - } - const text = block.text.slice(0, offset) + block.text.slice(offset + width); - const change = diffTextNearRange(block.text, text, { - from: offset, - to: offset + width, - }); - if (change === null) { - return; - } - const mergeWithPrevious = - this.lastNativeCompositionHistoryId === session.id; - const result = this.runDocumentChange( - "native", - () => - this.document.commit( - [{ op: "replace", path: editableTextPath(index), value: text }], - { - label: "IME composition", - origin: "native", - mergeKey: `composition:${session.id}`, - selectionAfter: selection, - }, - ), - new Map([[intent.blockId, change]]), - ); - if (!result.ok) { - this.reportFault({ - code: "native_change_commit_failed", - recoverable: false, - reason: result.reason ?? result.code, - }); - this.cancelComposition(false); - return; - } - if (mergeWithPrevious) { - this.document.history.mergeLast({ - mergeKey: `composition:${session.id}`, - }); - } - this.lastNativeCompositionHistoryId = session.id; - intent.canonicalText = text; - } - - private flushPendingStructuralIntent( - compositionId: number, - finalSelection: SelectionSnap | null, - ): void { - const intent = this.pendingStructuralIntent; - this.pendingStructuralIntent = null; - if ( - intent === null || - intent.compositionId !== compositionId || - this.destroyed - ) { - return; - } - this.restoreModelSelection( - intent.selectionIsAuthoritative || intent.mode === "native-fallback" - ? intent.selection - : (finalSelection ?? intent.selection), - ); - for (let index = 0; index < intent.paragraphCount; index += 1) { - const result = this.insertParagraph(); - if (!result.ok) { - this.reportFault({ - code: "native_change_commit_failed", - recoverable: true, - reason: result.reason, - }); - return; - } - } - } - - private flushQueuedRemotePatches(): void { - if ( - this.composition !== null || - this.destroyed || - this.queuedRemotePatches.length === 0 - ) { - return; - } - const queued = this.queuedRemotePatches.splice(0); - for (const entry of queued) { - const result = this.runDocumentChange("remote", () => - this.document.commit(entry.patch, { - label: entry.label, - origin: "remote", - }), - ); - if (!result.ok) { - this.reportFault({ - code: "queued_change_commit_failed", - recoverable: true, - reason: result.reason ?? result.code, - }); - } - } - this.bump(); - } - - private scheduleRemoteFlush(): void { - if (this.remoteFlushQueued || this.queuedRemotePatches.length === 0) { - return; - } - this.remoteFlushQueued = true; - queueMicrotask(() => { - this.remoteFlushQueued = false; - this.flushQueuedRemotePatches(); - }); - } - - private clearSettleTimer(): void { - if (this.settleTimer === null) { - return; - } - this.root.ownerDocument.defaultView?.clearTimeout(this.settleTimer); - this.settleTimer = null; - } - - private openNativeTurn(): void { - this.clearNativeTurnTimer(); - this.nativeEvidenceUntil = performance.now() + 100; - this.setPhase("native-input"); - const view = this.root.ownerDocument.defaultView; - if (view === null) { - return; - } - this.nativeTurnTimer = view.setTimeout(() => { - this.nativeTurnTimer = null; - if (this.composition === null) { - const shouldRecoverDOM = this.pendingNativeIntent !== null; - this.pendingNativeIntent = null; - this.lastBeforeInputBlockId = null; - this.nativeEvidenceUntil = 0; - this.setPhase("idle"); - if (shouldRecoverDOM) { - this.withDOMWrite(() => { - this.renderDocument(this.document.value); - }); - } - } - }, 120); - } - - private commitPendingNativeIntent(): void { - const intent = this.pendingNativeIntent; - if (intent === null) { - return; - } - this.pendingNativeIntent = null; - this.pendingRecords = []; - this.observer.takeRecords(); - this.restoreModelSelection(intent.selection); - const result = this.replaceSelection(intent.text, intent.inputType, "native"); - if (!result.ok) { - this.reportFault({ - code: "native_change_commit_failed", - recoverable: false, - reason: result.reason, - }); - this.withDOMWrite(() => { - this.renderDocument(this.document.value); - }); - } - this.closeNativeTurn(); - restoreDOMSelection( - this.root, - this.document.value, - this.document.selection?.snapshot() ?? null, - ); - } - - private closeNativeTurn(): void { - this.clearNativeTurnTimer(); - this.lastBeforeInputBlockId = null; - this.nativeEvidenceUntil = 0; - this.pendingNativeIntent = null; - this.inputTargetSelection = null; - if (this.composition === null) { - this.setPhase("idle"); - } - } - - private clearNativeTurnTimer(): void { - if (this.nativeTurnTimer === null) { - return; - } - this.root.ownerDocument.defaultView?.clearTimeout(this.nativeTurnTimer); - this.nativeTurnTimer = null; - } - - private syncSelectionFromDOM(): void { - const selection = readDOMSelection(this.root, this.document.value); - if (selection !== null) { - this.restoreModelSelection(selection); - } - } - - private selectionFromInputTarget(event: InputEvent): SelectionSnap | null { - if (typeof event.getTargetRanges !== "function") { - return null; - } - let target: StaticRange | undefined; - try { - target = event.getTargetRanges()[0]; - } catch { - return null; - } - if (target === undefined) { - return null; - } - const start = readDOMPoint( - this.root, - this.document.value, - target.startContainer, - target.startOffset, - ); - const end = readDOMPoint( - this.root, - this.document.value, - target.endContainer, - target.endOffset, - ); - if (start === null || end === null) { - return null; - } - return selectionBetween( - editableTextPath(start.blockIndex), - start.offset, - editableTextPath(end.blockIndex), - end.offset, - ); - } - - private attachEvents(): void { - this.root.addEventListener("beforeinput", this.guardedOnBeforeInput); - this.root.addEventListener("input", this.guardedOnInput); - this.root.addEventListener( - "compositionstart", - this.guardedOnCompositionStart, - ); - this.root.addEventListener( - "compositionupdate", - this.guardedOnCompositionUpdate, - ); - this.root.addEventListener("compositionend", this.guardedOnCompositionEnd); - this.root.addEventListener("paste", this.guardedOnPaste); - this.root.addEventListener("cut", this.guardedOnCut); - this.root.addEventListener("keydown", this.guardedOnKeyDown); - this.root.addEventListener("blur", this.guardedOnBlur); - this.root.ownerDocument.addEventListener( - "selectionchange", - this.guardedOnSelectionChange, - ); - } - - private detachEvents(): void { - this.root.removeEventListener("beforeinput", this.guardedOnBeforeInput); - this.root.removeEventListener("input", this.guardedOnInput); - this.root.removeEventListener( - "compositionstart", - this.guardedOnCompositionStart, - ); - this.root.removeEventListener( - "compositionupdate", - this.guardedOnCompositionUpdate, - ); - this.root.removeEventListener("compositionend", this.guardedOnCompositionEnd); - this.root.removeEventListener("paste", this.guardedOnPaste); - this.root.removeEventListener("cut", this.guardedOnCut); - this.root.removeEventListener("keydown", this.guardedOnKeyDown); - this.root.removeEventListener("blur", this.guardedOnBlur); - this.root.ownerDocument.removeEventListener( - "selectionchange", - this.guardedOnSelectionChange, - ); - } - - private readonly guardedOnBeforeInput = (event: Event): void => { - this.runBrowserEvent(() => this.onBeforeInput(event)); - }; - - private readonly guardedOnInput = (event: Event): void => { - this.runBrowserEvent(() => this.onInput(event)); - }; - - private readonly guardedOnCompositionStart = (): void => { - this.runBrowserEvent(this.onCompositionStart); - }; - - private readonly guardedOnCompositionUpdate = (): void => { - this.runBrowserEvent(this.onCompositionUpdate); - }; - - private readonly guardedOnCompositionEnd = (event: Event): void => { - this.runBrowserEvent(() => this.onCompositionEnd(event)); - }; - - private readonly guardedOnPaste = (event: ClipboardEvent): void => { - this.runBrowserEvent(() => this.onPaste(event)); - }; - - private readonly guardedOnCut = (event: ClipboardEvent): void => { - this.runBrowserEvent(() => this.onCut(event)); - }; - - private readonly guardedOnKeyDown = (event: KeyboardEvent): void => { - this.runBrowserEvent(() => this.onKeyDown(event)); - }; - - private readonly guardedOnBlur = (): void => { - this.runBrowserEvent(this.onBlur); - }; - - private readonly guardedOnSelectionChange = (): void => { - this.runBrowserEvent(this.onSelectionChange); - }; - - private runBrowserEvent(run: () => void): void { - this.browserEventDepth += 1; - try { - run(); - } finally { - this.browserEventDepth -= 1; - } - } - - private readonly onBeforeInput = (rawEvent: Event): void => { - const event = rawEvent as InputEvent; - if (event.isComposing || isCompositionInputType(event.inputType)) { - this.markBrowserCompositionActive(); - } - const targetSelection = isTextMutationInputType(event.inputType) - ? this.selectionFromInputTarget(event) - : null; - if (targetSelection === null) { - this.syncSelectionFromDOM(); - } else { - this.inputTargetSelection = targetSelection; - this.restoreModelSelection(targetSelection); - queueMicrotask(() => { - if (this.inputTargetSelection === targetSelection) { - this.inputTargetSelection = null; - } - }); - } - const selection = orderedEditableSelection( - this.document.value, - this.document.selection, - ); - this.lastBeforeInputBlockId = selection?.end.blockId ?? null; - - if (event.inputType === "historyUndo" || event.inputType === "historyRedo") { - if (event.cancelable) { - event.preventDefault(); - this.dispatch({ - type: event.inputType === "historyUndo" ? "undo" : "redo", - }); - this.closeNativeTurn(); - } else { - this.openNativeTurn(); - } - return; - } - - if ( - selection !== null && - selection.start.blockId !== selection.end.blockId && - isTextMutationInputType(event.inputType) && - (!event.cancelable || isCompositionInputType(event.inputType)) && - (!event.inputType.startsWith("insert") || event.data !== null) - ) { - const snapshot = this.document.selection?.snapshot(); - if (snapshot !== undefined) { - this.cancelComposition(false); - this.pendingNativeIntent = { - selection: snapshot, - text: event.inputType.startsWith("insert") ? (event.data ?? "") : "", - inputType: event.inputType, - }; - this.openNativeTurn(); - return; - } - } - - if (event.inputType === "insertFromComposition") { - this.nativeEvidenceUntil = performance.now() + 100; - if (this.composition === null) { - this.openNativeTurn(); - } - return; - } - - if ( - event.inputType === "insertParagraph" || - event.inputType === "insertLineBreak" - ) { - if (event.isComposing && this.composition === null) { - this.nativeEvidenceUntil = performance.now() + 100; - this.beginComposition(); - } - if (this.composition !== null) { - this.flushNativeMutations([], true); - if (this.captureParagraphIntent( - event.cancelable ? "deferred-command" : "native-fallback", - "beforeinput", - )) { - if (event.cancelable) { - event.preventDefault(); - } - this.endComposition(); - return; - } - } - } - - if (event.isComposing || isCompositionInputType(event.inputType)) { - this.nativeEvidenceUntil = performance.now() + 100; - this.beginComposition(); - return; - } - - if ( - (event.inputType === "insertParagraph" || - event.inputType === "insertLineBreak") - ) { - if (event.cancelable) { - event.preventDefault(); - this.dispatch({ type: "insertParagraph" }); - this.closeNativeTurn(); - } else { - this.openNativeTurn(); - } - return; - } - - if (!event.cancelable) { - this.openNativeTurn(); - return; - } - - if ( - (event.inputType === "insertText" || - event.inputType === "insertReplacementText") && - event.data !== null && - selection !== null - ) { - event.preventDefault(); - this.dispatch({ - type: "replaceSelection", - text: event.data, - label: event.inputType, - }); - this.closeNativeTurn(); - return; - } - - if (event.inputType === "deleteContentBackward" && selection !== null) { - event.preventDefault(); - this.dispatch({ type: "deleteBackward" }); - this.closeNativeTurn(); - return; - } - - if (event.inputType === "deleteContentForward" && selection !== null) { - event.preventDefault(); - this.dispatch({ type: "deleteForward" }); - this.closeNativeTurn(); - return; - } - - if ( - selection !== null && - selection.start.blockId !== selection.end.blockId && - isTextMutationInputType(event.inputType) - ) { - event.preventDefault(); - this.dispatch({ - type: "replaceSelection", - text: event.inputType.startsWith("insert") ? (event.data ?? "") : "", - label: event.inputType, - }); - this.closeNativeTurn(); - return; - } - - this.openNativeTurn(); - }; - - private readonly onInput = (rawEvent: Event): void => { - const event = rawEvent as InputEvent; - if (event.isComposing) { - this.markBrowserCompositionActive(); - } else if (event.inputType === "insertFromComposition") { - this.scheduleBrowserCompositionRelease(); - } - this.inputTargetSelection = null; - if (this.pendingNativeIntent !== null) { - this.commitPendingNativeIntent(); - return; - } - this.nativeEvidenceUntil = performance.now() + 100; - if (event.isComposing && this.composition === null) { - this.beginComposition(); - } - if ( - (event.inputType === "insertParagraph" || - event.inputType === "insertLineBreak") && - this.composition !== null - ) { - if (this.captureParagraphIntent("native-fallback", "input")) { - this.endComposition(); - } - } - this.flushNativeMutations([], true); - if (this.composition === null) { - this.closeNativeTurn(); - } else if ( - !event.isComposing && - (this.composition.ending || event.inputType === "insertFromComposition") - ) { - this.composition.ending = true; - this.setPhase("settling"); - this.scheduleSettle(); - } else { - this.setPhase("composing"); - } - }; - - private captureParagraphIntent( - mode: PendingStructuralIntent["mode"], - evidence: StructuralEvidence, - ): boolean { - const composition = this.composition; - const selection = orderedEditableSelection( - this.document.value, - this.document.selection, - ); - const snapshot = this.document.selection?.snapshot(); - return ( - composition !== null && - selection !== null && - selection.start.blockId === composition.blockId && - selection.end.blockId === composition.blockId && - selection.start.offset === selection.end.offset && - snapshot !== undefined && - this.rememberParagraphIntent( - snapshot, - selection.start.offset, - mode, - evidence, - ) - ); - } - - private readonly onCompositionStart = (): void => { - this.markBrowserCompositionActive(); - this.nativeEvidenceUntil = performance.now() + 100; - if (this.pendingNativeIntent !== null) { - return; - } - this.beginComposition(); - }; - - private readonly onCompositionUpdate = (): void => { - this.markBrowserCompositionActive(); - this.nativeEvidenceUntil = performance.now() + 100; - if (this.pendingNativeIntent !== null) { - return; - } - this.beginComposition(); - }; - - private readonly onCompositionEnd = (rawEvent: Event): void => { - const event = rawEvent as CompositionEvent; - this.scheduleBrowserCompositionRelease(); - this.nativeEvidenceUntil = performance.now() + 100; - const hasParagraphEvidence = hasTrailingLineBreak(event.data); - let session = this.composition; - if (hasParagraphEvidence && session !== null) { - const index = findEditableBlockIndex( - this.document.value, - session.blockId, - ); - const block = this.document.value.blocks[index]; - if (block !== undefined) { - const offset = Math.min(session.range.to, block.text.length); - this.rememberParagraphIntent( - selectionAt(editableTextPath(index), offset), - offset, - this.root.children.length === this.document.value.blocks.length - ? "deferred-command" - : "native-fallback", - "compositionend", - true, - ); - } - } - this.flushNativeMutations([], true); - session = this.composition; - if (hasParagraphEvidence && session !== null) { - const index = findEditableBlockIndex( - this.document.value, - session.blockId, - ); - const block = this.document.value.blocks[index]; - if (block !== undefined) { - const rangeEnd = Math.min(session.range.to, block.text.length); - const lineBreakWidth = trailingLineBreakWidth(block.text, rangeEnd); - const offset = rangeEnd - lineBreakWidth; - this.rememberParagraphIntent( - selectionAt(editableTextPath(index), offset), - offset, - this.pendingStructuralIntent?.mode ?? "deferred-command", - "compositionend", - true, - ); - } - } - this.endComposition(); - }; - - private readonly onPaste = (event: ClipboardEvent): void => { - const text = event.clipboardData?.getData("text/plain"); - if (text === undefined) { - return; - } - event.preventDefault(); - this.syncSelectionFromDOM(); - this.dispatch({ type: "replaceSelection", text, label: "paste" }); - }; - - private readonly onCut = (event: ClipboardEvent): void => { - this.syncSelectionFromDOM(); - const selection = orderedEditableSelection( - this.document.value, - this.document.selection, - ); - if (selection === null) { - return; - } - const selectedText = textForSelection(this.document.value, selection); - if (event.clipboardData !== null) { - event.clipboardData.setData("text/plain", selectedText); - } - event.preventDefault(); - this.dispatch({ type: "replaceSelection", text: "", label: "cut" }); - }; - - private readonly onKeyDown = (event: KeyboardEvent): void => { - if (event.isComposing || !(event.metaKey || event.ctrlKey)) { - return; - } - const key = event.key.toLowerCase(); - if (key !== "z" && key !== "y") { - return; - } - event.preventDefault(); - this.dispatch({ - type: key === "y" || (key === "z" && event.shiftKey) ? "redo" : "undo", - }); - }; - - private readonly onSelectionChange = (): void => { - if (this.destroyed) { - return; - } - this.syncSelectionFromDOM(); - }; - - private readonly onBlur = (): void => { - this.scheduleBrowserCompositionRelease(); - if (this.composition === null) { - this.closeNativeTurn(); - } else { - this.endComposition(); - } - }; - - private markBrowserCompositionActive(): void { - this.clearSettleTimer(); - this.browserCompositionGeneration += 1; - this.browserCompositionActive = true; - } - - private scheduleBrowserCompositionRelease(): void { - if (!this.browserCompositionActive) { - return; - } - const generation = this.browserCompositionGeneration; - queueMicrotask(() => { - if ( - !this.browserCompositionActive || - this.browserCompositionGeneration !== generation - ) { - return; - } - this.browserCompositionActive = false; - if (!this.destroyed) { - this.bump(); - } - }); - } - - private createBlockId(): string { - let id: string; - do { - id = `${this.ownerId}-block-${++this.blockSequence}`; - } while (findEditableBlockIndex(this.document.value, id) >= 0); - return id; - } - - private observe(): void { - this.observer.observe(this.root, { - attributes: true, - childList: true, - characterData: true, - characterDataOldValue: true, - subtree: true, - }); - } - - private withDOMWrite(write: () => void): void { - const outermost = this.domWriteDepth === 0; - this.domWriteDepth += 1; - if (outermost) { - const pending = this.observer.takeRecords(); - if (pending.length > 0) { - this.pendingRecords.push(...pending); - } - this.observer.disconnect(); - } - try { - write(); - } finally { - this.domWriteDepth -= 1; - if (outermost && !this.destroyed) { - this.observer.takeRecords(); - this.observe(); - if (this.pendingRecords.length > 0) { - this.queueMutationFlush(); - } - } - } - } - - private setPhase(phase: EditorPhase): void { - if (this.phase === phase) { - return; - } - this.phase = phase; - this.bump(); - } - - private bump(): void { - this.revision += 1; - const snapshot = this.getSnapshot(); - for (const listener of [...this.listeners]) { - try { - listener(snapshot); - } catch (error) { - this.reportFault({ - code: "subscriber_failed", - recoverable: true, - reason: callbackFailureReason("Editor subscriber", error), - }); - } - } - } - - private reportFault(fault: EditorFault): void { - try { - this.onFault?.(fault); - } catch { - // Fault observers cannot be allowed to interrupt document publication. - } - } -} - -function describeBlockChanges( - before: EditableDocumentValue, - after: EditableDocumentValue, - exactTextChanges: ReadonlyMap | null, -): BlockChange[] { - const beforeById = new Map(before.blocks.map((block) => [block.id, block])); - const afterById = new Map(after.blocks.map((block) => [block.id, block])); - const ids = new Set([...beforeById.keys(), ...afterById.keys()]); - const changes: BlockChange[] = []; - for (const blockId of ids) { - const beforeBlock = beforeById.get(blockId) ?? null; - const afterBlock = afterById.get(blockId) ?? null; - const text = - beforeBlock === null || afterBlock === null - ? null - : exactTextChanges?.get(blockId) ?? - diffText(beforeBlock.text, afterBlock.text); - const typeChanged = - beforeBlock !== null && - afterBlock !== null && - beforeBlock.type !== afterBlock.type; - if (beforeBlock === null || afterBlock === null || text !== null || typeChanged) { - changes.push({ blockId, after: afterBlock, text, typeChanged }); - } - } - return changes; -} - -function sourceFromOrigin(origin: string | undefined): ChangeSource { - return origin === "remote" ? "remote" : "app"; -} - -function actionOrigin(action: EditorAction): string | undefined { - return "origin" in action ? action.origin : undefined; -} - -function selectionAt(path: string, offset: number): SelectionSnap { - return selectionBetween(path, offset, path, offset); -} - -function selectionSnapshotSignature(selection: SelectionSnap | null): string { - return JSON.stringify(selection); -} - -function callbackFailureReason(label: string, error: unknown): string { - return error instanceof Error - ? `${label} failed: ${error.message}` - : `${label} failed with an unknown value.`; -} - -function selectionBetween( - anchorPath: string, - anchorOffset: number, - focusPath: string, - focusOffset: number, -): SelectionSnap { - const anchor = { path: anchorPath, offset: anchorOffset }; - const focus = { path: focusPath, offset: focusOffset }; - return { - selectedPointers: [], - selectionRanges: [{ anchor, focus }], - primaryIndex: 0, - anchor, - focus, - }; -} - -function textForSelection( - value: EditableDocumentValue, - selection: NonNullable>, -): string { - if (selection.start.blockIndex === selection.end.blockIndex) { - const block = value.blocks[selection.start.blockIndex]; - return block?.text.slice(selection.start.offset, selection.end.offset) ?? ""; - } - const parts: string[] = []; - for ( - let index = selection.start.blockIndex; - index <= selection.end.blockIndex; - index += 1 - ) { - const block = value.blocks[index]; - if (block === undefined) { - continue; - } - if (index === selection.start.blockIndex) { - parts.push(block.text.slice(selection.start.offset)); - } else if (index === selection.end.blockIndex) { - parts.push(block.text.slice(0, selection.end.offset)); - } else { - parts.push(block.text); - } - } - return parts.join("\n"); -} - -function isTextMutationInputType(inputType: string): boolean { - return inputType.startsWith("insert") || inputType.startsWith("delete"); -} - -function isCompositionInputType(inputType: string): boolean { - return ( - inputType === "insertCompositionText" || - inputType === "deleteCompositionText" - ); -} - -function hasTrailingLineBreak(value: string): boolean { - if (value.endsWith("\r\n")) { - return true; - } - return value.endsWith("\n") || value.endsWith("\r"); -} - -function trailingLineBreakWidth(value: string, offset: number): number { - const end = Math.max(0, Math.min(offset, value.length)); - if (end >= 2 && value.slice(end - 2, end) === "\r\n") { - return 2; - } - return end >= 1 && (value[end - 1] === "\n" || value[end - 1] === "\r") - ? 1 - : 0; -} - -function ancestorsToRoot(node: Node, root: HTMLElement): Node[] { - const ancestors: Node[] = []; - for (let current: Node | null = node; current !== null; current = current.parentNode) { - ancestors.push(current); - if (current === root) { - break; - } - } - return ancestors; -} - -function success( - change: Extract["change"], - patch: ReadonlyArray, -): EditorResult { - return { ok: true, change, patch }; -} - -function failure( - code: Extract["code"], - reason: string, -): EditorResult { - return { ok: false, code, reason }; -} diff --git a/packages/editable/browser/editorMetadata.ts b/packages/editable/browser/editorMetadata.ts new file mode 100644 index 0000000..474fdb1 --- /dev/null +++ b/packages/editable/browser/editorMetadata.ts @@ -0,0 +1,60 @@ +import type { + JSONAppliedChange, + JSONValue, +} from "@interactive-os/json-document"; +import type { EditorPoint, EditorSelection } from "./contract.js"; + +const EDITOR_METADATA_KEY = "@interactive-os/editable"; + +export function editorMetadata( + transaction: Readonly<{ + id: string; + inputType: string; + origin: "api" | "native"; + selectionAfter: EditorSelection | null; + }>, +): Readonly> { + return { + [EDITOR_METADATA_KEY]: { + transactionId: transaction.id, + inputType: transaction.inputType, + origin: transaction.origin, + selectionAfter: selectionJSON(transaction.selectionAfter), + }, + }; +} + +export function readEditorTransactionId( + change: JSONAppliedChange, +): string | null { + const envelope = change.metadata?.[EDITOR_METADATA_KEY]; + if ( + envelope === null || + typeof envelope !== "object" || + Array.isArray(envelope) + ) { + return null; + } + const record = envelope as Readonly>; + return typeof record.transactionId === "string" ? record.transactionId : null; +} + +function selectionJSON(selection: EditorSelection | null): JSONValue { + return selection === null + ? null + : { + ranges: selection.ranges.map(({ anchor, focus }) => ({ + anchor: pointJSON(anchor), + focus: pointJSON(focus), + })), + primaryIndex: selection.primaryIndex, + }; +} + +function pointJSON(point: EditorPoint): JSONValue { + return { + path: point.path, + offset: point.offset, + ...(point.affinity === undefined ? {} : { affinity: point.affinity }), + }; +} diff --git a/packages/editable/browser/editorSelection.ts b/packages/editable/browser/editorSelection.ts new file mode 100644 index 0000000..5473396 --- /dev/null +++ b/packages/editable/browser/editorSelection.ts @@ -0,0 +1,33 @@ +import type { EditorSelection } from "./contract.js"; + +export function copySelection( + selection: EditorSelection | null, +): EditorSelection | null { + return selection === null + ? null + : { + ranges: selection.ranges.map(({ anchor, focus }) => ({ + anchor: { ...anchor }, + focus: { ...focus }, + })), + primaryIndex: selection.primaryIndex, + }; +} + +export function freezeSelection( + selection: EditorSelection | null, +): EditorSelection | null { + if (selection === null) { + return null; + } + const ranges = selection.ranges.map(({ anchor, focus }) => + Object.freeze({ + anchor: Object.freeze({ ...anchor }), + focus: Object.freeze({ ...focus }), + }), + ); + return Object.freeze({ + ranges: Object.freeze(ranges), + primaryIndex: selection.primaryIndex, + }); +} diff --git a/packages/editable/browser/genericEditor.ts b/packages/editable/browser/genericEditor.ts new file mode 100644 index 0000000..8cfb603 --- /dev/null +++ b/packages/editable/browser/genericEditor.ts @@ -0,0 +1,1198 @@ +import type { JSONDocument, JSONValue } from "@interactive-os/json-document"; +import { applyPatch } from "@interactive-os/json-document"; +import type { + EditIntent, + EditorAdapter, + EditorError, + EditorRange, + EditorSelection, + EditorSnapshot, + EditorView, + EditPlan, + EditResult, + MountEditorOptions, +} from "./contract.js"; +import { editorMetadata, readEditorTransactionId } from "./editorMetadata.js"; +import { copySelection, freezeSelection } from "./editorSelection.js"; + +const OWNED_ROOT_ATTRIBUTES = [ + "contenteditable", + "data-editable-owner", +] as const; +let editorInstanceSequence = 0; + +type Composition = { + value: JSONValue; + selection: EditorSelection | null; +}; + +type PendingNativeInput = Composition & { + inputType: string; +}; + +type RootSnapshot = { + attributes: ReadonlyArray; + children: ReadonlyArray; +}; + +class DOMMappingError extends Error {} + +function adapterFailureCode(error: unknown): string { + return error instanceof DOMMappingError + ? "dom_mapping_failed" + : "adapter_failed"; +} + +export function mountEditor(options: MountEditorOptions): EditorView { + return new GenericEditor(options); +} + +class GenericEditor implements EditorView { + private readonly root: HTMLElement; + private readonly document: JSONDocument; + private readonly adapter: EditorAdapter; + private readonly onError: ((error: EditorError) => void) | undefined; + private readonly listeners = new Set<() => void>(); + private readonly originalAttributes: ReadonlyMap; + private readonly observer: MutationObserver; + private stopDocument: () => void = () => undefined; + private stopAdapter: (() => void) | null = null; + private selection: EditorSelection | null = null; + private composition: Composition | null = null; + private pendingNativeInput: PendingNativeInput | null = null; + private finishCompositionOnInput = false; + private compositionTimer: number | null = null; + private domWriteDepth = 0; + private expectedDOM = ""; + private activeTransaction: { + id: string; + observed: boolean; + recovered: boolean; + superseded: boolean; + selectionAfter: EditorSelection | null; + } | null = null; + private publishedValue: JSONValue; + private snapshot: EditorSnapshot; + private readonly transactionPrefix: string; + private transactionSequence = 0; + private processing = false; + private adapterCallbackDepth = 0; + private destroyed = false; + + constructor({ root, document, adapter, onError }: MountEditorOptions) { + if ( + root.closest("[data-editable-owner]") !== null || + root.querySelector("[data-editable-owner]") !== null + ) { + throw new Error("The editable root overlaps an owned editor subtree."); + } + this.root = root; + this.document = document; + this.adapter = adapter; + this.onError = onError; + this.publishedValue = document.value; + this.snapshot = this.createSnapshot(); + editorInstanceSequence += 1; + this.transactionPrefix = `editor:${Date.now().toString(36)}:${editorInstanceSequence.toString(36)}`; + const initialRoot = snapshotRoot(root); + this.originalAttributes = new Map( + OWNED_ROOT_ATTRIBUTES.map((name) => [name, root.getAttribute(name)]), + ); + const Observer = root.ownerDocument.defaultView?.MutationObserver; + if (Observer === undefined) { + throw new Error("MutationObserver is required to mount an editable."); + } + this.observer = new Observer(() => this.inspectDOM()); + try { + this.enforceRootAttributes(); + this.project(); + this.observer.observe(root, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + this.attach(); + this.stopDocument = document.subscribe((change) => { + const before = this.publishedValue; + const applied = applyPatch(before, change.applied); + if (!applied.ok) { + this.reportError({ + code: "publication_apply_failed", + ...(applied.reason === undefined ? {} : { reason: applied.reason }), + }); + return; + } + const after = applied.value; + this.publishedValue = after; + const transactionId = readEditorTransactionId(change); + const owned = + transactionId !== null && + transactionId === this.activeTransaction?.id; + try { + if (!owned) { + if (this.activeTransaction !== null) { + this.activeTransaction.superseded = true; + } + this.selection = copySelection( + this.callAdapter(() => + this.adapter.mapSelection({ + before, + after, + change, + selection: this.selection, + }), + ), + ); + } + if (owned && this.activeTransaction !== null) { + this.activeTransaction.observed = true; + } + if (owned && this.activeTransaction?.superseded !== true) { + this.selection = copySelection( + this.activeTransaction?.selectionAfter ?? null, + ); + } + if (this.composition === null) { + this.project(); + } + } catch (error) { + this.recoverAdapterPublication(error); + } + if (owned) { + this.refreshSnapshot(); + } else { + this.notify(); + } + }); + this.stopAdapter = this.callAdapter( + () => + adapter.subscribe?.(() => { + try { + if (this.composition === null) { + this.project(); + } + } catch (error) { + this.recoverAdapterPublication(error); + } + this.notify(); + }) ?? null, + ); + } catch (error) { + this.destroyed = true; + this.safeCleanup(this.stopAdapter); + this.safeCleanup(this.stopDocument); + try { + this.detach(); + } catch { + // Continue restoring the root after a partial listener attachment. + } + try { + this.observer.disconnect(); + } catch { + // Continue restoring the root after a partial observer attachment. + } + try { + restoreRoot(root, initialRoot); + } catch { + // Preserve the initialization error if the host DOM rejects rollback. + } + throw error; + } + } + + dispatch(intent: EditIntent): EditResult { + return this.runIntent(intent, "api"); + } + + private runIntent(intent: EditIntent, origin: "api" | "native"): EditResult { + if (this.destroyed) { + return { ok: false, code: "destroyed", reason: "Editor is destroyed." }; + } + if ( + this.processing || + this.adapterCallbackDepth > 0 || + this.activeTransaction !== null + ) { + return { + ok: false, + code: "dispatch_reentrancy", + reason: "Editor dispatch cannot re-enter an active transaction.", + }; + } + if (this.composition !== null) { + return { + ok: false, + code: "composition_conflict", + reason: "The active composition must settle before dispatch.", + }; + } + this.processing = true; + try { + let effect: EditPlan; + try { + effect = this.callAdapter(() => + this.adapter.edit({ + value: this.publishedValue, + selection: this.selection, + intent, + }), + ); + } catch (error) { + return this.recoverDispatchAdapterFailure(error, this.selection); + } + return this.applyPlan(effect, { + inputType: intent.inputType, + origin, + }); + } finally { + this.processing = false; + } + } + + getSnapshot(): EditorSnapshot { + return this.snapshot; + } + + subscribe(listener: () => void): () => void { + if (this.destroyed) { + return () => undefined; + } + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + destroy(): void { + if (this.destroyed) { + return; + } + try { + if (this.composition !== null) { + this.finishComposition(); + } + } catch (error) { + this.reportError({ + code: "cleanup_failed", + reason: error instanceof Error ? error.message : String(error), + }); + } + this.destroyed = true; + this.clearCompositionTimer(); + this.pendingNativeInput = null; + try { + this.detach(); + } catch (error) { + this.reportCleanupFailure(error); + } + try { + this.observer.disconnect(); + } catch (error) { + this.reportCleanupFailure(error); + } + this.safeCleanup(this.stopDocument); + this.safeCleanup(this.stopAdapter); + this.listeners.clear(); + try { + this.restoreRootAttributes(); + } catch (error) { + this.reportCleanupFailure(error); + } + } + + private applyPlan( + plan: EditPlan, + attribution: Readonly<{ + inputType: string; + origin: "api" | "native"; + }>, + ): EditResult { + if (!plan.ok) { + return plan; + } + if (plan.operations.length === 0) { + const selectionBefore = copySelection(this.selection); + this.selection = copySelection(plan.selectionAfter); + try { + this.project(); + } catch (error) { + return this.recoverDispatchAdapterFailure(error, selectionBefore); + } + this.notify(); + return { ok: true, change: null }; + } + const id = this.nextTransactionId(); + this.activeTransaction = { + id, + observed: false, + recovered: false, + superseded: false, + selectionAfter: copySelection(plan.selectionAfter), + }; + const activeTransaction = this.activeTransaction; + let result: ReturnType; + try { + result = this.document.commit(plan.operations, { + metadata: editorMetadata({ + id, + inputType: attribution.inputType, + origin: attribution.origin, + selectionAfter: plan.selectionAfter, + }), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.reportError({ + code: "commit_failed", + reason, + }); + return { ok: false, code: "commit_failed", reason }; + } finally { + this.activeTransaction = null; + } + const { observed, recovered, superseded } = activeTransaction; + if (!result.ok) { + return result; + } + if (!observed && !superseded) { + const applied = applyPatch(this.publishedValue, result.change.applied); + if (applied.ok) { + this.publishedValue = applied.value; + } + } + if (!superseded && !recovered) { + this.selection = copySelection(plan.selectionAfter); + } + if (!observed && !superseded) { + try { + this.project(); + } catch (error) { + this.recoverAdapterPublication(error); + } + } + if (!superseded) { + this.notify(); + } + return { ok: true, change: result.change }; + } + + private project(): void { + const restoreSelection = this.ownsFocus(); + this.withDOMWrite(() => { + this.enforceRootAttributes(); + this.callAdapter(() => + this.adapter.project({ + root: this.root, + value: this.publishedValue, + selection: this.selection, + }), + ); + this.enforceRootAttributes(); + if (restoreSelection) { + this.restoreDOMSelection(); + } + }); + } + + private ownsFocus(): boolean { + try { + return this.root.matches(":focus") || this.root.matches(":focus-within"); + } catch { + const active = this.root.ownerDocument.activeElement; + return ( + active === this.root || (active !== null && this.root.contains(active)) + ); + } + } + + private nextTransactionId(): string { + try { + const randomUUID = this.root.ownerDocument.defaultView?.crypto.randomUUID; + if (randomUUID !== undefined) { + return randomUUID.call(this.root.ownerDocument.defaultView?.crypto); + } + } catch { + // Fall through to the instance-scoped monotonic identifier. + } + this.transactionSequence += 1; + return `${this.transactionPrefix}:${this.transactionSequence.toString(36)}`; + } + + private enforceRootAttributes(): void { + this.root.dataset.editableOwner = "true"; + this.root.setAttribute("contenteditable", "true"); + } + + private restoreRootAttributes(): void { + for (const [name, value] of this.originalAttributes) { + if (value === null) { + this.root.removeAttribute(name); + } else { + this.root.setAttribute(name, value); + } + } + } + + private reportError(error: EditorError): void { + try { + this.onError?.(error); + } catch { + // Error reporting must never interrupt canonical recovery or subscribers. + } + } + + private callAdapter(callback: () => T): T { + this.adapterCallbackDepth += 1; + try { + return callback(); + } finally { + this.adapterCallbackDepth -= 1; + } + } + + private reportCleanupFailure(error: unknown): void { + this.reportError({ + code: "cleanup_failed", + reason: error instanceof Error ? error.message : String(error), + }); + } + + private safeCleanup(cleanup: (() => void) | null): void { + if (cleanup === null) { + return; + } + try { + cleanup(); + } catch (error) { + this.reportCleanupFailure(error); + } + } + + private recoverDispatchAdapterFailure( + error: unknown, + selectionBefore: EditorSelection | null, + ): Extract { + const reason = error instanceof Error ? error.message : String(error); + this.selection = copySelection(selectionBefore); + this.reportError({ + code: "adapter_failed", + reason, + }); + try { + this.project(); + } catch (retryError) { + this.reportError({ + code: "adapter_failed", + reason: + retryError instanceof Error ? retryError.message : String(retryError), + }); + } + return { ok: false, code: "adapter_failed", reason }; + } + + private recoverAdapterPublication(error: unknown): void { + if (this.activeTransaction !== null) { + this.activeTransaction.recovered = true; + } + this.selection = null; + this.reportError({ + code: "adapter_failed", + reason: error instanceof Error ? error.message : String(error), + }); + if (this.composition !== null) { + return; + } + try { + this.project(); + } catch (retryError) { + this.reportError({ + code: "adapter_failed", + reason: + retryError instanceof Error ? retryError.message : String(retryError), + }); + } + } + + private readDOMSelection(): EditorSelection | null { + const native = this.root.ownerDocument.getSelection(); + if ( + native === null || + native.rangeCount === 0 || + native.anchorNode === null || + native.focusNode === null || + (!this.root.contains(native.anchorNode) && + native.anchorNode !== this.root) || + (!this.root.contains(native.focusNode) && native.focusNode !== this.root) + ) { + return null; + } + const anchor = this.callAdapter(() => + this.adapter.fromDOMPoint({ + root: this.root, + node: native.anchorNode as Node, + offset: native.anchorOffset, + }), + ); + const focus = this.callAdapter(() => + this.adapter.fromDOMPoint({ + root: this.root, + node: native.focusNode as Node, + offset: native.focusOffset, + }), + ); + if (anchor === null || focus === null) { + throw new DOMMappingError( + "The adapter could not map the active DOM selection.", + ); + } + const ranges: EditorRange[] = [{ anchor, focus }]; + for (let index = 1; index < native.rangeCount; index += 1) { + const range = native.getRangeAt(index); + const extraAnchor = this.callAdapter(() => + this.adapter.fromDOMPoint({ + root: this.root, + node: range.startContainer, + offset: range.startOffset, + }), + ); + const extraFocus = this.callAdapter(() => + this.adapter.fromDOMPoint({ + root: this.root, + node: range.endContainer, + offset: range.endOffset, + }), + ); + if (extraAnchor === null || extraFocus === null) { + throw new DOMMappingError( + "The adapter could not map every native selection range.", + ); + } + ranges.push({ anchor: extraAnchor, focus: extraFocus }); + } + return { ranges, primaryIndex: 0 }; + } + + private restoreDOMSelection(): void { + if (!this.ownsFocus()) { + return; + } + const range = + this.selection?.ranges[this.selection.primaryIndex ?? 0] ?? null; + if (range === null) { + return; + } + let anchor: Readonly<{ node: Node; offset: number }> | null; + let focus: Readonly<{ node: Node; offset: number }> | null; + try { + anchor = this.callAdapter(() => + this.adapter.toDOMPoint({ + root: this.root, + point: range.anchor, + }), + ); + focus = this.callAdapter(() => + this.adapter.toDOMPoint({ + root: this.root, + point: range.focus, + }), + ); + } catch (error) { + this.reportError({ + code: adapterFailureCode(error), + reason: error instanceof Error ? error.message : String(error), + }); + return; + } + const native = this.root.ownerDocument.getSelection(); + if (anchor === null || focus === null) { + this.reportError({ + code: "selection_restore_failed", + reason: "The adapter could not map the logical selection into the DOM.", + }); + return; + } + if (native === null) { + this.reportError({ + code: "selection_restore_failed", + reason: "The owner document does not expose a native Selection.", + }); + return; + } + try { + native.setBaseAndExtent( + anchor.node, + anchor.offset, + focus.node, + focus.offset, + ); + } catch { + try { + const collapsed = this.root.ownerDocument.createRange(); + collapsed.setStart(anchor.node, anchor.offset); + collapsed.collapse(true); + native.removeAllRanges(); + native.addRange(collapsed); + if (typeof native.extend === "function") { + native.extend(focus.node, focus.offset); + return; + } + + const focusBoundary = this.root.ownerDocument.createRange(); + focusBoundary.setStart(focus.node, focus.offset); + focusBoundary.collapse(true); + const anchorFirst = + collapsed.compareBoundaryPoints(0, focusBoundary) <= 0; + const ordered = this.root.ownerDocument.createRange(); + ordered.setStart( + anchorFirst ? anchor.node : focus.node, + anchorFirst ? anchor.offset : focus.offset, + ); + ordered.setEnd( + anchorFirst ? focus.node : anchor.node, + anchorFirst ? focus.offset : anchor.offset, + ); + native.removeAllRanges(); + native.addRange(ordered); + } catch (error) { + this.reportError({ + code: "selection_restore_failed", + reason: error instanceof Error ? error.message : String(error), + }); + } + } + } + + private beginComposition(): void { + if (this.composition !== null) { + return; + } + this.clearCompositionTimer(); + this.finishCompositionOnInput = false; + this.pendingNativeInput = null; + try { + this.selection = this.readDOMSelection() ?? this.selection; + } catch (error) { + this.reportError({ + code: adapterFailureCode(error), + reason: error instanceof Error ? error.message : String(error), + }); + } + this.composition = { + value: this.publishedValue, + selection: copySelection(this.selection), + }; + this.notify(); + } + + private finishComposition(): void { + this.clearCompositionTimer(); + this.finishCompositionOnInput = false; + const baseline = this.composition; + if (baseline === null) { + return; + } + let observedSelection = copySelection(baseline.selection); + try { + observedSelection = this.readDOMSelection() ?? observedSelection; + } catch (error) { + this.reportError({ + code: adapterFailureCode(error), + reason: error instanceof Error ? error.message : String(error), + }); + } + this.composition = null; + this.reconcileNative(baseline, observedSelection, "insertCompositionText"); + } + + private clearCompositionTimer(): void { + if (this.compositionTimer === null) { + return; + } + this.root.ownerDocument.defaultView?.clearTimeout(this.compositionTimer); + this.compositionTimer = null; + } + + private inspectDOM(): void { + if ( + this.destroyed || + this.domWriteDepth > 0 || + this.composition !== null || + this.signature() === this.expectedDOM + ) { + return; + } + this.reportError({ + code: "foreign_dom_mutation", + reason: "The editable DOM changed outside an admitted input.", + }); + try { + this.project(); + } catch (error) { + this.recoverAdapterPublication(error); + } + } + + private withDOMWrite(write: () => void): void { + this.domWriteDepth += 1; + try { + write(); + } finally { + this.domWriteDepth -= 1; + if (this.domWriteDepth === 0) { + this.expectedDOM = this.signature(); + } + } + } + + private signature(): string { + return JSON.stringify([ + ...OWNED_ROOT_ATTRIBUTES.map((name) => this.root.getAttribute(name)), + this.root.innerHTML, + ]); + } + + private notify(): void { + if (this.destroyed) { + return; + } + this.refreshSnapshot(); + for (const listener of this.listeners) { + try { + listener(); + } catch (error) { + this.reportError({ + code: "subscriber_failed", + reason: error instanceof Error ? error.message : String(error), + }); + } + } + } + + private refreshSnapshot(): void { + this.snapshot = this.createSnapshot(); + } + + private createSnapshot(): EditorSnapshot { + return Object.freeze({ + value: this.publishedValue, + isComposing: this.composition !== null, + selection: freezeSelection(this.selection), + }); + } + + private targetRanges(event: InputEvent): ReadonlyArray { + let ranges: readonly StaticRange[]; + try { + ranges = event.getTargetRanges(); + } catch (error) { + throw new DOMMappingError( + `The user agent target ranges could not be read: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + const mapped: EditorRange[] = []; + for (const target of ranges) { + const anchor = this.callAdapter(() => + this.adapter.fromDOMPoint({ + root: this.root, + node: target.startContainer, + offset: target.startOffset, + }), + ); + const focus = this.callAdapter(() => + this.adapter.fromDOMPoint({ + root: this.root, + node: target.endContainer, + offset: target.endOffset, + }), + ); + if (anchor === null || focus === null) { + throw new DOMMappingError( + "The adapter could not map every user agent target range.", + ); + } + mapped.push({ anchor, focus }); + } + return mapped; + } + + private admitNativeIntent(intent: EditIntent, event: Event): boolean { + event.preventDefault(); + if (this.processing) { + return false; + } + this.processing = true; + let plan: EditPlan; + try { + try { + plan = this.callAdapter(() => + this.adapter.edit({ + value: this.publishedValue, + selection: this.selection, + intent, + }), + ); + } catch (error) { + this.recoverNativeFailure( + "adapter_failed", + error instanceof Error ? error.message : String(error), + ); + return false; + } + if (!plan.ok) { + try { + this.project(); + } catch (error) { + this.recoverAdapterPublication(error); + } + this.notify(); + return false; + } + try { + const result = this.applyPlan(plan, { + inputType: intent.inputType, + origin: "native", + }); + if (!result.ok) { + this.recoverNativeFailure(result.code, result.reason ?? result.code); + } + } catch (error) { + this.recoverNativeFailure( + "commit_failed", + error instanceof Error ? error.message : String(error), + ); + } + return true; + } finally { + this.processing = false; + } + } + + private reconcileNative( + baseline: Composition, + selection: EditorSelection | null, + inputType: string, + ): void { + if (this.processing) { + this.reportError({ + code: "dispatch_reentrancy", + reason: "Editor reconciliation cannot re-enter active processing.", + }); + return; + } + this.processing = true; + try { + try { + const result = this.applyPlan( + this.callAdapter(() => + this.adapter.reconcile({ + root: this.root, + inputType, + value: this.publishedValue, + baseline, + selection, + }), + ), + { inputType, origin: "native" }, + ); + if (!result.ok) { + this.recoverNativeFailure(result.code, result.reason); + } + } catch (error) { + this.recoverNativeFailure( + "reconcile_failed", + error instanceof Error ? error.message : String(error), + ); + } + } finally { + this.processing = false; + } + } + + private recoverNativeFailure(code: string, reason: string | undefined): void { + this.reportError({ + code, + ...(reason === undefined ? {} : { reason }), + }); + try { + this.project(); + } catch (error) { + this.recoverAdapterPublication(error); + } + this.notify(); + } + + private readonly onBeforeInput = (raw: Event): void => { + const event = raw as InputEvent; + const compositionInput = + event.inputType === "insertCompositionText" || + event.inputType === "deleteCompositionText" || + event.inputType === "insertFromComposition"; + if (event.isComposing) { + this.beginComposition(); + return; + } + if (compositionInput) { + this.beginComposition(); + this.clearCompositionTimer(); + this.finishCompositionOnInput = true; + return; + } + if (event.cancelable) { + event.preventDefault(); + } + if (this.composition !== null) { + this.finishComposition(); + } + try { + this.selection = this.readDOMSelection() ?? this.selection; + } catch (error) { + this.recoverNativeFailure( + adapterFailureCode(error), + error instanceof Error ? error.message : String(error), + ); + return; + } + if (!event.cancelable) { + this.pendingNativeInput = { + value: this.publishedValue, + selection: copySelection(this.selection), + inputType: event.inputType, + }; + return; + } + this.pendingNativeInput = null; + try { + const intent: EditIntent = { + inputType: event.inputType, + data: event.data, + dataTransfer: event.dataTransfer, + targetRanges: this.targetRanges(event), + }; + this.admitNativeIntent(intent, event); + } catch (error) { + this.recoverNativeFailure( + adapterFailureCode(error), + error instanceof Error ? error.message : String(error), + ); + } + }; + + private readonly onInput = (raw: Event): void => { + const event = raw as InputEvent; + if (this.composition !== null) { + if (this.finishCompositionOnInput || !event.isComposing) { + this.finishComposition(); + } + return; + } + const pending = this.pendingNativeInput; + this.pendingNativeInput = null; + const baseline: Composition = pending ?? { + value: this.publishedValue, + selection: copySelection(this.selection), + }; + const inputType = pending?.inputType || event.inputType || "reconcile"; + try { + this.reconcileNative(baseline, this.readDOMSelection(), inputType); + } catch (error) { + this.recoverNativeFailure( + adapterFailureCode(error), + error instanceof Error ? error.message : String(error), + ); + } + }; + + private readonly onCompositionStart = (): void => { + this.clearCompositionTimer(); + this.beginComposition(); + }; + private readonly onCompositionEnd = (): void => { + this.clearCompositionTimer(); + const window = this.root.ownerDocument.defaultView; + if (window === null) { + this.finishComposition(); + return; + } + this.compositionTimer = window.setTimeout(() => { + this.compositionTimer = null; + this.finishComposition(); + }, 0); + }; + private readonly onBlur = (): void => this.finishComposition(); + private readonly onSelectionChange = (): void => { + if (this.destroyed || this.domWriteDepth > 0 || this.composition !== null) { + return; + } + try { + const selection = this.readDOMSelection(); + if (selection !== null) { + this.selection = selection; + this.project(); + this.notify(); + } + } catch (error) { + this.recoverNativeFailure( + adapterFailureCode(error), + error instanceof Error ? error.message : String(error), + ); + } + }; + private readonly onCopy = (raw: Event): void => { + const event = raw as ClipboardEvent; + if (this.composition !== null) { + this.finishComposition(); + } + let selection: EditorSelection | null; + try { + selection = this.readDOMSelection(); + } catch (error) { + this.reportError({ + code: adapterFailureCode(error), + reason: error instanceof Error ? error.message : String(error), + }); + return; + } + if (selection === null) { + return; + } + this.writeClipboard(event, selection); + }; + private readonly onCut = (raw: Event): void => { + const event = raw as ClipboardEvent; + event.preventDefault(); + if (this.composition !== null) { + this.finishComposition(); + } + let selection: EditorSelection | null; + try { + selection = this.readDOMSelection(); + } catch (error) { + this.recoverNativeFailure( + adapterFailureCode(error), + error instanceof Error ? error.message : String(error), + ); + return; + } + if (selection === null) { + return; + } + this.selection = copySelection(selection); + if (this.writeClipboard(event, selection)) { + this.admitNativeIntent( + { + inputType: "deleteByCut", + targetRanges: selection.ranges, + }, + event, + ); + } + }; + private readonly onPaste = (raw: Event): void => { + const event = raw as ClipboardEvent; + event.preventDefault(); + if (event.clipboardData === null) { + return; + } + if (this.composition !== null) { + this.finishComposition(); + } + try { + this.selection = this.readDOMSelection() ?? this.selection; + } catch (error) { + this.recoverNativeFailure( + adapterFailureCode(error), + error instanceof Error ? error.message : String(error), + ); + return; + } + const intent: EditIntent = { + inputType: "insertFromPaste", + data: event.clipboardData.getData("text/plain"), + dataTransfer: event.clipboardData, + targetRanges: [], + }; + this.admitNativeIntent(intent, event); + }; + + private writeClipboard( + event: ClipboardEvent, + selection: EditorSelection, + ): boolean { + if ( + event.clipboardData === null || + this.adapter.writeClipboard === undefined + ) { + return false; + } + try { + const written = this.callAdapter( + () => + this.adapter.writeClipboard?.({ + dataTransfer: event.clipboardData as DataTransfer, + value: this.publishedValue, + selection, + }) ?? false, + ); + if (written) { + event.preventDefault(); + } + return written; + } catch (error) { + this.reportError({ + code: "adapter_failed", + reason: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + private attach(): void { + this.root.addEventListener("beforeinput", this.onBeforeInput); + this.root.addEventListener("input", this.onInput); + this.root.addEventListener("compositionstart", this.onCompositionStart); + this.root.addEventListener("compositionend", this.onCompositionEnd); + this.root.addEventListener("blur", this.onBlur); + this.root.addEventListener("copy", this.onCopy); + this.root.addEventListener("cut", this.onCut); + this.root.addEventListener("paste", this.onPaste); + this.root.ownerDocument.addEventListener( + "selectionchange", + this.onSelectionChange, + ); + } + + private detach(): void { + this.root.removeEventListener("beforeinput", this.onBeforeInput); + this.root.removeEventListener("input", this.onInput); + this.root.removeEventListener("compositionstart", this.onCompositionStart); + this.root.removeEventListener("compositionend", this.onCompositionEnd); + this.root.removeEventListener("blur", this.onBlur); + this.root.removeEventListener("copy", this.onCopy); + this.root.removeEventListener("cut", this.onCut); + this.root.removeEventListener("paste", this.onPaste); + this.root.ownerDocument.removeEventListener( + "selectionchange", + this.onSelectionChange, + ); + } +} + +function snapshotRoot(root: HTMLElement): RootSnapshot { + return { + attributes: root + .getAttributeNames() + .map((name) => [name, root.getAttribute(name) ?? ""] as const), + children: Array.from(root.childNodes), + }; +} + +function restoreRoot(root: HTMLElement, snapshot: RootSnapshot): void { + for (const name of root.getAttributeNames()) { + root.removeAttribute(name); + } + for (const [name, value] of snapshot.attributes) { + root.setAttribute(name, value); + } + root.replaceChildren(...snapshot.children); +} diff --git a/packages/editable/browser/index.ts b/packages/editable/browser/index.ts index 741484c..8c6e541 100644 --- a/packages/editable/browser/index.ts +++ b/packages/editable/browser/index.ts @@ -1,28 +1,16 @@ -export { - createEditableDocument, - createInitialEditableValue, - EditableDocumentSchema, - editableBlockIndexFromTextPath, - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - primaryEditablePoint, -} from "../core"; export type { - EditableBlock, - EditableBlockType, - EditableDocumentValue, - EditablePoint, - OrderedEditableSelection, -} from "../core"; -export { getJsonEditableDocumentHost, mountJsonEditable } from "./editor"; -export type { - EditorAction, - EditorFault, - EditorPhase, - EditorResult, + EditIntent, + EditorAdapter, + EditorAffinity, + EditorError, + EditorPoint, + EditorRange, + EditorReconcileInput, + EditorSelection, EditorSnapshot, - JsonEditable, - JsonEditableDocumentHost, - MountJsonEditableOptions, -} from "./editor"; + EditorView, + EditPlan, + EditResult, + MountEditorOptions, +} from "./public.js"; +export { mountEditor } from "./public.js"; diff --git a/packages/editable/browser/markdownLivePreview.test.ts b/packages/editable/browser/markdownLivePreview.test.ts new file mode 100644 index 0000000..1bdf46e --- /dev/null +++ b/packages/editable/browser/markdownLivePreview.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + MarkdownProjectionSyntax, + MarkdownRevealPolicy, +} from "../core/index"; +import { + markdownLivePreview, + resolveMarkdownLivePreview, +} from "./markdownLivePreview"; + +describe("markdownLivePreview", () => { + it("projects Bear as an immutable data preset", () => { + const runtime = resolveMarkdownLivePreview(markdownLivePreview()); + const quote: MarkdownProjectionSyntax = { + id: "quote-marker:0", + kind: "quote-marker", + markerRanges: [ + { from: 0, to: 2 }, + { from: 5, to: 7 }, + ], + ownerRange: { from: 0, to: 9 }, + }; + + expect( + runtime.project({ + source: "> 첫째\n> 둘째", + selection: { anchor: 8, focus: 8 }, + syntaxes: [quote], + }), + ).toEqual([ + expect.objectContaining({ state: "attachment" }), + expect.objectContaining({ state: "attachment" }), + ]); + }); + + it("projects Source Reveal per marker line and Rich as concealed", () => { + const plugin = markdownLivePreview({ preset: "source-reveal" }); + const runtime = resolveMarkdownLivePreview(plugin); + const options = { + source: "> 첫째\n> 둘째", + selection: { anchor: 8, focus: 8 }, + syntaxes: [ + { + id: "quote-marker:0", + kind: "quote-marker" as const, + markerRanges: [ + { from: 0, to: 2 }, + { from: 5, to: 7 }, + ], + ownerRange: { from: 0, to: 9 }, + }, + ], + }; + + expect(runtime.project(options).map(({ state }) => state)).toEqual([ + "concealed", + "revealed", + ]); + + plugin.setPresentation("rich"); + expect(runtime.project(options).map(({ state }) => state)).toEqual([ + "concealed", + "concealed", + ]); + }); + + it("copies injected preset policies instead of retaining mutable input", () => { + const custom = { + fallback: "always" as MarkdownRevealPolicy, + bySyntaxKind: {}, + }; + const runtime = resolveMarkdownLivePreview( + markdownLivePreview({ policies: { bear: custom } }), + ); + custom.fallback = "concealed"; + + expect( + runtime + .project({ + source: "**한글**", + selection: null, + syntaxes: [ + { + id: "strong:0", + kind: "strong", + markerRanges: [ + { from: 0, to: 2 }, + { from: 4, to: 6 }, + ], + ownerRange: { from: 0, to: 6 }, + }, + ], + }) + .map(({ state }) => state), + ).toEqual(["revealed", "revealed"]); + }); + + it("exposes Rich/Live Preview and Bear/Source Reveal as projection state", () => { + const plugin = markdownLivePreview(); + const runtime = resolveMarkdownLivePreview(plugin); + + expect(plugin.presentation).toBe("live-preview"); + expect(plugin.preset).toBe("bear"); + expect(runtime.snapshot()).toEqual({ + presentation: "live-preview", + preset: "bear", + }); + + plugin.setPresentation("rich"); + plugin.setPreset("source-reveal"); + expect(runtime.snapshot()).toEqual({ + presentation: "rich", + preset: "source-reveal", + }); + }); + + it("invalidates presentation without touching a document or history", () => { + const plugin = markdownLivePreview({ presentation: "rich" }); + const runtime = resolveMarkdownLivePreview(plugin); + const listener = vi.fn(); + const unsubscribe = runtime.subscribe(listener); + + plugin.setPresentation("rich"); + expect(listener).not.toHaveBeenCalled(); + + plugin.setPresentation("live-preview"); + plugin.setPreset("source-reveal"); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith({ + presentation: "live-preview", + preset: "source-reveal", + }); + + unsubscribe(); + plugin.setPreset("bear"); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("rejects objects that were not created by the factory", () => { + expect(() => + resolveMarkdownLivePreview({} as ReturnType), + ).toThrow(/factory/u); + }); +}); diff --git a/packages/editable/browser/markdownLivePreview.ts b/packages/editable/browser/markdownLivePreview.ts new file mode 100644 index 0000000..1fdd85b --- /dev/null +++ b/packages/editable/browser/markdownLivePreview.ts @@ -0,0 +1,184 @@ +import type { + MarkdownLivePreviewPreset, + MarkdownMarkerProjection, + MarkdownPresentation, + MarkdownProjectionPolicyDescriptor, + MarkdownProjectionSelection, + MarkdownProjectionSyntax, +} from "../core/markdownProjection.js"; +import { + defineMarkdownProjectionPolicy, + markdownProjection, +} from "../core/markdownProjection.js"; + +const markdownLivePreviewBrand: unique symbol = Symbol("markdown-live-preview"); + +export type MarkdownLivePreviewOptions = { + presentation?: MarkdownPresentation; + preset?: MarkdownLivePreviewPreset; + policies?: Readonly< + Partial< + Record + > + >; +}; + +export type MarkdownLivePreviewPlugin = { + readonly [markdownLivePreviewBrand]: true; + readonly presentation: MarkdownPresentation; + readonly preset: MarkdownLivePreviewPreset; + setPresentation(presentation: MarkdownPresentation): void; + setPreset(preset: MarkdownLivePreviewPreset): void; +}; + +export type MarkdownLivePreviewSnapshot = { + presentation: MarkdownPresentation; + preset: MarkdownLivePreviewPreset; +}; + +export type RuntimeMarkdownLivePreview = { + snapshot(): MarkdownLivePreviewSnapshot; + project(options: { + source: string; + selection: MarkdownProjectionSelection | null; + syntaxes: readonly MarkdownProjectionSyntax[]; + }): readonly MarkdownMarkerProjection[]; + subscribe( + listener: (snapshot: MarkdownLivePreviewSnapshot) => void, + ): () => void; +}; + +const richPolicy = defineMarkdownProjectionPolicy({ + fallback: "concealed", +}); +const livePreviewPolicies = Object.freeze({ + bear: defineMarkdownProjectionPolicy({ + fallback: "active-owner", + bySyntaxKind: { + "code-fence": "attachment", + "heading-marker": "attachment", + "quote-marker": "attachment", + }, + }), + "source-reveal": defineMarkdownProjectionPolicy({ + fallback: "active-owner", + bySyntaxKind: { + "code-fence": "active-line", + "heading-marker": "active-line", + "quote-marker": "active-line", + }, + }), +}); +const richPolicies = Object.freeze({ + bear: richPolicy, + "source-reveal": richPolicy, +}); + +const runtimes = new WeakMap< + MarkdownLivePreviewPlugin, + RuntimeMarkdownLivePreview +>(); + +export function markdownLivePreview( + options: MarkdownLivePreviewOptions = {}, +): MarkdownLivePreviewPlugin { + let presentation = options.presentation ?? "live-preview"; + let preset = options.preset ?? "bear"; + const configuredLivePreviewPolicies = Object.freeze({ + bear: copyPolicy(options.policies?.bear) ?? livePreviewPolicies.bear, + "source-reveal": + copyPolicy(options.policies?.["source-reveal"]) ?? + livePreviewPolicies["source-reveal"], + }); + const policies = Object.freeze({ + rich: richPolicies, + "live-preview": configuredLivePreviewPolicies, + }); + const listeners = new Set<(snapshot: MarkdownLivePreviewSnapshot) => void>(); + const snapshot = (): MarkdownLivePreviewSnapshot => ({ + presentation, + preset, + }); + const invalidate = () => { + const next = snapshot(); + for (const listener of listeners) { + listener(next); + } + }; + const setPresentation = (next: MarkdownPresentation) => { + assertPresentation(next); + if (next === presentation) { + return; + } + presentation = next; + invalidate(); + }; + const plugin: MarkdownLivePreviewPlugin = Object.freeze({ + [markdownLivePreviewBrand]: true as const, + get presentation() { + return presentation; + }, + get preset() { + return preset; + }, + setPresentation, + setPreset(next: MarkdownLivePreviewPreset) { + assertPreset(next); + if (next === preset) { + return; + } + preset = next; + invalidate(); + }, + }); + runtimes.set(plugin, { + snapshot, + project(projectOptions) { + return markdownProjection({ + ...projectOptions, + policy: policies[presentation][preset], + }); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }); + return plugin; +} + +function copyPolicy( + policy: MarkdownProjectionPolicyDescriptor | undefined, +): MarkdownProjectionPolicyDescriptor | undefined { + return policy === undefined + ? undefined + : defineMarkdownProjectionPolicy(policy); +} + +export function resolveMarkdownLivePreview( + plugin: MarkdownLivePreviewPlugin, +): RuntimeMarkdownLivePreview { + const runtime = runtimes.get(plugin); + if (runtime === undefined) { + throw new TypeError( + "Markdown Live Preview plugins must be created by the factory.", + ); + } + return runtime; +} + +function assertPresentation( + value: string, +): asserts value is MarkdownPresentation { + if (value !== "rich" && value !== "live-preview") { + throw new TypeError(`Unknown Markdown presentation: ${value}`); + } +} + +function assertPreset( + value: string, +): asserts value is MarkdownLivePreviewPreset { + if (value !== "bear" && value !== "source-reveal") { + throw new TypeError(`Unknown Markdown Live Preview preset: ${value}`); + } +} diff --git a/packages/editable/browser/nativeParagraph.test.ts b/packages/editable/browser/nativeParagraph.test.ts deleted file mode 100644 index 31992cf..0000000 --- a/packages/editable/browser/nativeParagraph.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it } from "vitest"; -import type { EditableBlock, EditableDocumentValue } from "../core"; -import { - captureCompositionPlaceholder, - inspectNativeParagraphEffect, - type NativeParagraphIntentFacts, -} from "./nativeParagraph"; - -const value: EditableDocumentValue = { - schema: "interactive-os.editable-document@2", - id: "native-paragraph-test", - blocks: [ - { id: "alpha", type: "paragraph", text: "한" }, - { id: "beta", type: "paragraph", text: "second" }, - ], -}; - -describe("native paragraph inspection", () => { - it("accepts the captured empty-surface placeholder beside composing text", () => { - const fixture = nativeParagraphFixture(); - - expect( - inspectNativeParagraphEffect({ - root: fixture.root, - value, - intent: fixture.intent, - session: { id: 1, blockId: "alpha", range: { from: 0, to: 1 } }, - isCompositionPinIntact: (surface) => - surface === fixture.sourceSurface && - surface.firstChild === fixture.sourceText, - }), - ).toEqual({ - text: "한", - splitOffset: 1, - change: null, - }); - }); - - it("rejects an attribute-identical replacement for the captured placeholder", () => { - const fixture = nativeParagraphFixture(); - const forged = document.createElement("br"); - forged.setAttribute("data-editable-placeholder", "true"); - fixture.sourcePlaceholder.replaceWith(forged); - - expect( - inspectNativeParagraphEffect({ - root: fixture.root, - value, - intent: fixture.intent, - session: { id: 1, blockId: "alpha", range: { from: 0, to: 1 } }, - isCompositionPinIntact: () => true, - }), - ).toBeNull(); - }); - - it("accepts the pinned Text after the captured placeholder is removed", () => { - const fixture = nativeParagraphFixture(); - fixture.sourcePlaceholder.remove(); - - expect( - inspectNativeParagraphEffect({ - root: fixture.root, - value, - intent: fixture.intent, - session: { id: 1, blockId: "alpha", range: { from: 0, to: 1 } }, - isCompositionPinIntact: () => true, - }), - ).toEqual({ text: "한", splitOffset: 1, change: null }); - }); -}); - -function nativeParagraphFixture() { - const root = document.createElement("div"); - const alpha = canonicalBlock( - { ...(value.blocks[0] as EditableBlock), text: "" }, - 0, - ); - const beta = canonicalBlock(value.blocks[1] as EditableBlock, 1); - const sourceSurface = alpha.firstChild as HTMLElement; - const sourceText = sourceSurface.firstChild as Text; - const sourcePlaceholder = captureCompositionPlaceholder( - sourceSurface, - sourceText, - ); - if (sourcePlaceholder === null) { - throw new Error("Expected an owned placeholder."); - } - sourceText.data = "한"; - - const nativeBlock = document.createElement("div"); - nativeBlock.append(document.createElement("br")); - root.append(alpha, nativeBlock, beta); - - const intent: NativeParagraphIntentFacts = { - compositionId: 1, - blockId: "alpha", - sourceElement: alpha, - sourceSurface, - sourceText, - sourcePlaceholder, - blockElements: [alpha, beta], - splitOffset: 1, - canonicalText: "한", - nativeRecords: [], - }; - - return { - root, - sourceSurface, - sourceText, - sourcePlaceholder, - intent, - }; -} - -function canonicalBlock(block: EditableBlock, index: number): HTMLElement { - const element = document.createElement("p"); - element.className = "contenteditable-block contenteditable-block-paragraph"; - element.setAttribute("data-editable-block", block.id); - element.setAttribute("data-block-type", block.type); - element.setAttribute("data-block-index", String(index)); - - const surface = document.createElement("span"); - surface.setAttribute("data-editable-text", `/blocks/${index}/text`); - surface.append(document.createTextNode(block.text)); - if (block.text === "") { - const placeholder = document.createElement("br"); - placeholder.setAttribute("data-editable-placeholder", "true"); - surface.append(placeholder); - } - element.append(surface); - return element; -} diff --git a/packages/editable/browser/nativeParagraph.ts b/packages/editable/browser/nativeParagraph.ts deleted file mode 100644 index 67334db..0000000 --- a/packages/editable/browser/nativeParagraph.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { - applyTextChange, - clampTextRange, - diffTextNearRange, - type EditableDocumentValue, - type TextChange, - type TextRange, -} from "../core"; -import { - EDITABLE_PLACEHOLDER_ATTRIBUTE, - EDITABLE_TEXT_ATTRIBUTE, -} from "./editableDOM"; -import { - isCanonicalBlockElement, - isCanonicalSurfaceElement, -} from "./documentProjection"; - -export type NativeParagraphEffect = { - text: string; - splitOffset: number; - change: TextChange | null; -}; - -export type NativeParagraphIntentFacts = { - compositionId: number; - blockId: string; - sourceElement: HTMLElement; - sourceSurface: HTMLElement; - sourceText: Text; - sourcePlaceholder: HTMLBRElement | null; - blockElements: ReadonlyArray; - splitOffset: number; - canonicalText: string; - nativeRecords: ReadonlyArray; -}; - -export type NativeParagraphSessionFacts = { - id: number; - blockId: string; - range: TextRange; -}; - -type InspectNativeParagraphEffectOptions = { - root: HTMLElement; - value: EditableDocumentValue; - intent: NativeParagraphIntentFacts; - session: NativeParagraphSessionFacts; - isCompositionPinIntact: (surface: HTMLElement) => boolean; -}; - -/** - * Captures the identity of the editor-owned placeholder next to a pinned Text - * node. A later native paragraph inspection accepts this exact node, not merely - * another element carrying the same attribute. - */ -export function captureCompositionPlaceholder( - surface: HTMLElement, - sourceText: Text, -): HTMLBRElement | null { - const children = Array.from(surface.childNodes); - const candidate = children[1]; - return children.length === 2 && - children[0] === sourceText && - isCanonicalOwnedPlaceholder(candidate) - ? (candidate as HTMLBRElement) - : null; -} - -/** - * Derives the one native paragraph effect admitted by an active composition - * lease. The caller remains responsible for committing or rejecting it. - */ -export function inspectNativeParagraphEffect({ - root, - value, - intent, - session, - isCompositionPinIntact, -}: InspectNativeParagraphEffectOptions): NativeParagraphEffect | null { - if ( - session.id !== intent.compositionId || - session.blockId !== intent.blockId || - intent.blockElements.length !== value.blocks.length || - intent.nativeRecords.some((record) => record.type === "attributes") - ) { - return null; - } - - const children = Array.from(root.childNodes); - if ( - children.some((node) => node.nodeType !== 1) || - children.length !== intent.blockElements.length + 1 - ) { - return null; - } - - const sourceIndex = intent.blockElements.indexOf(intent.sourceElement); - if (sourceIndex < 0) { - return null; - } - for (let index = 0; index < intent.blockElements.length; index += 1) { - const expected = intent.blockElements[index]; - const actual = children[index <= sourceIndex ? index : index + 1]; - if (actual !== expected) { - return null; - } - } - - const nativeBlock = children[sourceIndex + 1] as HTMLElement; - if ( - (nativeBlock.tagName !== "DIV" && nativeBlock.tagName !== "P") || - nativeBlock.attributes.length !== 0 || - intent.sourceElement.parentNode !== root || - intent.sourceElement.childNodes.length !== 1 || - intent.sourceElement.firstChild !== intent.sourceSurface || - !isCompositionPinIntact(intent.sourceSurface) || - !isCanonicalBlockElement( - intent.sourceElement, - value, - intent.blockId, - ) || - !isCanonicalSurfaceElement( - intent.sourceSurface, - value, - intent.blockId, - ) - ) { - return null; - } - - for (const record of intent.nativeRecords) { - const target = record.target; - if ( - target !== root && - target !== intent.sourceElement && - !intent.sourceElement.contains(target) && - target !== nativeBlock && - !nativeBlock.contains(target) - ) { - return null; - } - } - - const left = readPinnedCompositionText(intent); - const right = textFromNativeBlock(nativeBlock, intent.sourceSurface); - if (left === null || right === null) { - return null; - } - - const text = left + right; - const change = diffTextNearRange( - intent.canonicalText, - text, - session.range, - ); - if (text === intent.canonicalText) { - return left.length === intent.splitOffset - ? { text, splitOffset: left.length, change: null } - : null; - } - - const range = clampTextRange(session.range, intent.canonicalText.length); - if ( - change === null || - applyTextChange(intent.canonicalText, change) !== text || - change.from < range.from || - change.to > range.to || - left.length !== mapTextOffset(intent.splitOffset, change) - ) { - return null; - } - return { text, splitOffset: left.length, change }; -} - -export function readPinnedCompositionText( - intent: NativeParagraphIntentFacts, -): string | null { - const children = Array.from(intent.sourceSurface.childNodes); - if (children[0] !== intent.sourceText) { - return null; - } - if (children.length === 1) { - return intent.sourcePlaceholder === null || - intent.sourcePlaceholder.parentNode === null - ? intent.sourceText.data - : null; - } - if ( - children.length === 2 && - children[1] === intent.sourcePlaceholder && - isCanonicalOwnedPlaceholder(children[1]) - ) { - return intent.sourceText.data; - } - return null; -} - -function textFromNativeBlock( - element: HTMLElement, - sourceSurface: HTMLElement, -): string | null { - const children = Array.from(element.childNodes); - if (children.length === 1 && children[0]?.nodeType === 1) { - const surface = children[0] as HTMLElement; - if (surface.tagName === "BR" && surface.attributes.length === 0) { - return ""; - } - const sourcePath = sourceSurface.getAttribute(EDITABLE_TEXT_ATTRIBUTE); - const hasAllowedAttributes = - surface.attributes.length === 0 || - (surface.attributes.length === 1 && - surface.getAttribute(EDITABLE_TEXT_ATTRIBUTE) === sourcePath); - if (surface.tagName !== "SPAN" || !hasAllowedAttributes) { - return null; - } - return strictTextFromContainer(surface, true); - } - return strictTextFromContainer(element, false); -} - -function strictTextFromContainer( - element: HTMLElement, - allowOwnedPlaceholder: boolean, -): string | null { - let text = ""; - let breakCount = 0; - for (const child of Array.from(element.childNodes)) { - if (child.nodeType === 3) { - text += (child as Text).data; - continue; - } - if (child.nodeType !== 1 || (child as HTMLElement).tagName !== "BR") { - return null; - } - const lineBreak = child as HTMLElement; - const allowedPlaceholder = - allowOwnedPlaceholder && - lineBreak.attributes.length === 1 && - lineBreak.hasAttribute(EDITABLE_PLACEHOLDER_ATTRIBUTE); - if (lineBreak.attributes.length !== 0 && !allowedPlaceholder) { - return null; - } - breakCount += 1; - } - if (breakCount > 0 && (breakCount !== 1 || text !== "")) { - return null; - } - return text; -} - -function isCanonicalOwnedPlaceholder( - node: Node | undefined, -): node is HTMLBRElement { - return ( - node?.nodeType === 1 && - (node as HTMLElement).tagName === "BR" && - (node as HTMLElement).attributes.length === 1 && - (node as HTMLElement).getAttribute(EDITABLE_PLACEHOLDER_ATTRIBUTE) === - "true" - ); -} - -function mapTextOffset(offset: number, change: TextChange): number { - if (offset < change.from) { - return offset; - } - if (offset > change.to) { - return offset + change.insert.length - (change.to - change.from); - } - return change.from + change.insert.length; -} diff --git a/packages/editable/browser/nativeTextMutation.test.ts b/packages/editable/browser/nativeTextMutation.test.ts deleted file mode 100644 index 28df7a7..0000000 --- a/packages/editable/browser/nativeTextMutation.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it } from "vitest"; -import type { EditableDocumentValue } from "../core"; -import { - inspectNativeTextMutations, - type NativeTextMutationInspectionOptions, -} from "./nativeTextMutation"; - -const value: EditableDocumentValue = { - schema: "interactive-os.editable-document@2", - id: "native-text-mutation-test", - blocks: [{ id: "alpha", type: "paragraph", text: "first" }], -}; - -describe("native text mutation inspection", () => { - it("accepts evidenced text from the expected owned surface", () => { - const fixture = createFixture(); - const records = observeMutation(fixture.root, () => { - fixture.text.data = "fiXrst"; - }); - - const result = inspect(records, fixture.root); - - expect(result.rejected).toBe(false); - expect([...result.dirtyBlockIds]).toEqual(["alpha"]); - expect([...result.rejectedBlockIds]).toEqual([]); - expect(result.patch).toEqual([ - { op: "replace", path: "/blocks/0/text", value: "fiXrst" }, - ]); - expect(result.textChanges.get("alpha")).toEqual({ - from: 2, - to: 2, - insert: "X", - }); - }); - - it("rejects foreign structure inside an owned text surface", () => { - const fixture = createFixture(); - const records = observeMutation(fixture.root, () => { - const foreign = window.document.createElement("strong"); - foreign.textContent = "foreign"; - fixture.surface.append(foreign); - }); - - const result = inspect(records, fixture.root); - - expect(result.rejected).toBe(true); - expect([...result.dirtyBlockIds]).toEqual(["alpha"]); - expect([...result.rejectedBlockIds]).toEqual(["alpha"]); - expect(result.patch).toEqual([]); - expect(result.textChanges.size).toBe(0); - }); - - it("converts the browser's bare empty block into an empty text patch", () => { - const fixture = createFixture(); - const records = observeMutation(fixture.root, () => { - fixture.block.replaceChildren(window.document.createElement("br")); - }); - - const result = inspect(records, fixture.root); - - expect(result.rejected).toBe(false); - expect([...result.dirtyBlockIds]).toEqual(["alpha"]); - expect(result.patch).toEqual([ - { op: "replace", path: "/blocks/0/text", value: "" }, - ]); - expect(result.textChanges.get("alpha")).toEqual({ - from: 0, - to: 5, - insert: "", - }); - }); -}); - -function inspect(records: MutationRecord[], root: HTMLElement) { - const options: NativeTextMutationInspectionOptions = { - root, - value, - records, - nativeEvidence: true, - phase: "idle", - nativeEvidenceUntil: 0, - now: 100, - lastBeforeInputBlockId: null, - composition: null, - }; - return inspectNativeTextMutations(options); -} - -function createFixture(): { - root: HTMLElement; - block: HTMLElement; - surface: HTMLElement; - text: Text; -} { - const root = window.document.createElement("div"); - const block = window.document.createElement("p"); - block.setAttribute("data-editable-block", "alpha"); - const surface = window.document.createElement("span"); - surface.setAttribute("data-editable-text", "/blocks/0/text"); - const text = window.document.createTextNode("first"); - surface.append(text); - block.append(surface); - root.append(block); - window.document.body.append(root); - return { root, block, surface, text }; -} - -function observeMutation( - root: HTMLElement, - mutate: () => void, -): MutationRecord[] { - const observer = new MutationObserver(() => undefined); - observer.observe(root, { - childList: true, - characterData: true, - subtree: true, - }); - mutate(); - const records = observer.takeRecords(); - observer.disconnect(); - return records; -} diff --git a/packages/editable/browser/nativeTextMutation.ts b/packages/editable/browser/nativeTextMutation.ts deleted file mode 100644 index 158463f..0000000 --- a/packages/editable/browser/nativeTextMutation.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { JSONPatchOperation } from "@interactive-os/json-document"; -import { - applyTextChange, - diffText, - diffTextNearRange, - editableTextPath, - findEditableBlockIndex, - type EditableDocumentValue, - type TextChange, - type TextRange, -} from "../core"; -import { - EDITABLE_BLOCK_ATTRIBUTE, - EDITABLE_PLACEHOLDER_ATTRIBUTE, - EDITABLE_TEXT_ATTRIBUTE, - editableBlockFromNode, - editableSurfaceFromNode, - textFromSurface, -} from "./editableDOM"; -import { findBlockElement } from "./documentProjection"; - -export type NativeTextMutationInspectionOptions = { - root: HTMLElement; - value: EditableDocumentValue; - records: ReadonlyArray; - nativeEvidence: boolean; - phase: "idle" | "native-input" | "composing" | "settling"; - nativeEvidenceUntil: number; - now: number; - lastBeforeInputBlockId: string | null; - composition: { - blockId: string; - range: TextRange; - } | null; -}; - -export type NativeTextMutationInspection = { - patch: ReadonlyArray; - textChanges: ReadonlyMap; - dirtyBlockIds: ReadonlySet; - rejectedBlockIds: ReadonlySet; - rejected: boolean; -}; - -export function inspectNativeTextMutations({ - root, - value, - records, - nativeEvidence, - phase, - nativeEvidenceUntil, - now, - lastBeforeInputBlockId, - composition, -}: NativeTextMutationInspectionOptions): NativeTextMutationInspection { - const dirtyBlockIds = new Set(); - const rejectedBlockIds = new Set(); - const nativeEmptyCandidateIds = new Set(); - let rejected = false; - - for (const record of records) { - const block = editableBlockFromNode(root, record.target); - const surface = editableSurfaceFromNode(root, record.target); - const blockId = block?.getAttribute(EDITABLE_BLOCK_ATTRIBUTE); - if (blockId === null || blockId === undefined) { - rejected = true; - continue; - } - dirtyBlockIds.add(blockId); - if (surface === null) { - if ( - record.type === "childList" && - record.target === block && - isNativeEmptyBlock(block) - ) { - nativeEmptyCandidateIds.add(blockId); - } else { - rejectedBlockIds.add(blockId); - rejected = true; - } - continue; - } - if (!isAdmittedTextMutation(record, surface)) { - rejectedBlockIds.add(blockId); - rejected = true; - } - } - - const hasNativeEvidence = - nativeEvidence || phase !== "idle" || now <= nativeEvidenceUntil; - const expectedBlockId = - composition?.blockId ?? - lastBeforeInputBlockId ?? - (nativeEvidence && dirtyBlockIds.size === 1 - ? dirtyBlockIds.values().next().value - : undefined); - if (dirtyBlockIds.size === 0 && expectedBlockId !== undefined) { - dirtyBlockIds.add(expectedBlockId); - } - - const patch: JSONPatchOperation[] = []; - const textChanges = new Map(); - for (const blockId of dirtyBlockIds) { - if ( - rejectedBlockIds.has(blockId) || - !hasNativeEvidence || - expectedBlockId === undefined || - blockId !== expectedBlockId - ) { - rejected = rejected || records.length > 0; - continue; - } - - const index = findEditableBlockIndex(value, blockId); - const block = value.blocks[index]; - const element = findBlockElement(root, blockId); - const surface = element?.querySelector( - `[${EDITABLE_TEXT_ATTRIBUTE}]`, - ); - const nativeEmpty = - element !== null && - element !== undefined && - nativeEmptyCandidateIds.has(blockId) && - isNativeEmptyBlock(element); - if ( - block === undefined || - ((surface === null || surface === undefined) && !nativeEmpty) - ) { - rejected = true; - continue; - } - - const text = nativeEmpty ? "" : textFromSurface(surface as HTMLElement); - if (text === block.text) { - continue; - } - const change = - composition?.blockId === blockId - ? diffTextNearRange(block.text, text, composition.range) - : diffText(block.text, text); - if (change === null || applyTextChange(block.text, change) !== text) { - rejected = true; - continue; - } - textChanges.set(blockId, change); - patch.push({ op: "replace", path: editableTextPath(index), value: text }); - } - - return { - patch, - textChanges, - dirtyBlockIds, - rejectedBlockIds, - rejected, - }; -} - -function isAdmittedTextMutation( - record: MutationRecord, - surface: HTMLElement, -): boolean { - if (record.type === "characterData") { - return record.target.nodeType === 3 && surface.contains(record.target); - } - if (record.type !== "childList" || record.target !== surface) { - return false; - } - return [...record.addedNodes, ...record.removedNodes].every( - (node) => - node.nodeType === 3 || - (node.nodeType === 1 && - (node as HTMLElement).tagName === "BR" && - (node as HTMLElement).hasAttribute(EDITABLE_PLACEHOLDER_ATTRIBUTE)), - ); -} - -function isNativeEmptyBlock(element: HTMLElement): boolean { - return Array.from(element.childNodes).every( - (node) => - (node.nodeType === 3 && (node.textContent ?? "") === "") || - (node.nodeType === 1 && (node as HTMLElement).tagName === "BR"), - ); -} diff --git a/packages/editable/browser/public.test.ts b/packages/editable/browser/public.test.ts new file mode 100644 index 0000000..bba1ec6 --- /dev/null +++ b/packages/editable/browser/public.test.ts @@ -0,0 +1,1396 @@ +import type { JSONAppliedChange } from "@interactive-os/json-document"; +import { JSDOM } from "jsdom"; +import { describe, expect, it } from "vitest"; +import { + createMarkdownAdapter, + createMarkdownDocument, +} from "../markdown/index"; +import type { + EditIntent, + EditorAdapter, + EditorRange, + EditorView, +} from "./public"; +import { mountEditor } from "./public"; + +const point = (offset: number) => ({ path: "/source", offset }); +const range = (anchor: number, focus = anchor): EditorRange => ({ + anchor: point(anchor), + focus: point(focus), +}); + +describe("generic editor protocol", () => { + it("publishes canonical value and logical selection in one cached snapshot", () => { + const fixture = markdownFixture("a"); + const publications: JSONAppliedChange[] = []; + let publicationSnapshot: ReturnType | null = + null; + fixture.document.subscribe((change) => { + publications.push(change); + publicationSnapshot = fixture.view.getSnapshot(); + }); + const initial = fixture.view.getSnapshot(); + expect(fixture.view.getSnapshot()).toBe(initial); + + let notifications = 0; + let observed = initial; + fixture.view.subscribe(() => { + notifications += 1; + observed = fixture.view.getSnapshot(); + }); + const result = fixture.view.dispatch({ + inputType: "insertText", + data: "!", + targetRanges: [range(1)], + }); + + expect(result).toMatchObject({ + ok: true, + change: { + applied: [{ op: "replace", path: "/source", value: "a!" }], + }, + }); + expect(notifications).toBe(1); + expect(observed).toBe(fixture.view.getSnapshot()); + expect(observed).not.toBe(initial); + expect(observed.value).toEqual({ source: "a!" }); + expect(observed.selection).toEqual({ + ranges: [range(2)], + primaryIndex: 0, + }); + expect(Object.isFrozen(observed)).toBe(true); + expect(Object.isFrozen(observed.selection)).toBe(true); + expect(Object.isFrozen(observed.selection?.ranges)).toBe(true); + expect(Object.isFrozen(observed.selection?.ranges[0]?.anchor)).toBe(true); + const frozenAnchor = observed.selection?.ranges[0]?.anchor; + if (frozenAnchor === undefined) { + throw new Error("Expected a frozen selection anchor."); + } + expect(() => { + (frozenAnchor as { offset: number }).offset = 99; + }).toThrow(TypeError); + expect(publications).toHaveLength(1); + expect(publicationSnapshot).toMatchObject({ + value: { source: "a!" }, + selection: { ranges: [range(2)], primaryIndex: 0 }, + }); + expect( + publications[0]?.metadata?.["@interactive-os/editable"], + ).toMatchObject({ + inputType: "insertText", + origin: "api", + selectionAfter: { + ranges: [range(2)], + primaryIndex: 0, + }, + }); + fixture.close(); + }); + + it("rejects dispatch reentrancy without blocking nested document commits", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + let view: EditorView; + let reentrant: ReturnType | null = null; + let nested = false; + document.subscribe(() => { + if (nested) { + return; + } + nested = true; + reentrant = view.dispatch({ + inputType: "insertText", + data: "?", + targetRanges: [range(0)], + }); + document.commit([{ op: "replace", path: "/source", value: "za!" }]); + }); + view = mountEditor({ + root: dom.root, + document, + adapter: createMarkdownAdapter(), + }); + let notifications = 0; + view.subscribe(() => { + notifications += 1; + }); + + expect( + view.dispatch({ + inputType: "insertText", + data: "!", + targetRanges: [range(1)], + }).ok, + ).toBe(true); + expect(reentrant).toMatchObject({ + ok: false, + code: "dispatch_reentrancy", + }); + expect(document.value).toEqual({ source: "za!" }); + expect(view.getSnapshot()).toMatchObject({ + value: { source: "za!" }, + selection: { ranges: [range(3)], primaryIndex: 0 }, + }); + expect(notifications).toBe(1); + view.destroy(); + dom.close(); + }); + + it("guards planning reentrancy and totalizes adapter failures", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + const markdown = createMarkdownAdapter(); + let view: EditorView; + let nested: ReturnType | null = null; + let mode: "reenter" | "throw-edit" | "throw-project" | "normal" = "normal"; + const errors: string[] = []; + const adapter: EditorAdapter = { + ...markdown, + edit(input) { + if (mode === "reenter") { + nested = view.dispatch({ + inputType: "insertText", + data: "?", + targetRanges: [range(0)], + }); + return { + ok: true, + operations: [], + selectionAfter: { + ranges: input.intent.targetRanges ?? [], + primaryIndex: 0, + }, + }; + } + if (mode === "throw-edit") { + throw new Error("edit failed"); + } + return markdown.edit(input); + }, + project(input) { + if (mode === "throw-project") { + throw new Error("project failed"); + } + markdown.project(input); + }, + }; + view = mountEditor({ + root: dom.root, + document, + adapter, + onError: (error) => errors.push(error.code), + }); + + mode = "reenter"; + expect( + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }), + ).toMatchObject({ ok: true }); + expect(nested).toMatchObject({ + ok: false, + code: "dispatch_reentrancy", + }); + + mode = "throw-edit"; + expect( + view.dispatch({ + inputType: "insertText", + data: "x", + targetRanges: [range(1)], + }), + ).toMatchObject({ ok: false, code: "adapter_failed" }); + expect(document.value).toEqual({ source: "a" }); + + mode = "throw-project"; + expect( + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(0)], + }), + ).toMatchObject({ ok: false, code: "adapter_failed" }); + expect(document.value).toEqual({ source: "a" }); + expect(errors).toContain("adapter_failed"); + view.destroy(); + dom.close(); + }); + + it("maps external string changes without stealing focus", () => { + const fixture = markdownFixture("a", true); + fixture.view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }); + fixture.other?.focus(); + expect(fixture.dom.window.document.activeElement).toBe(fixture.other); + + fixture.document.commit([{ op: "replace", path: "/source", value: "za" }]); + + expect(fixture.view.getSnapshot()).toMatchObject({ + value: { source: "za" }, + selection: { + ranges: [range(2)], + primaryIndex: 0, + }, + }); + expect(fixture.dom.window.document.activeElement).toBe(fixture.other); + fixture.close(); + }); + + it("preserves a backward selection through the standards fallback", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "ab" }); + const markdown = createMarkdownAdapter(); + const adapter: EditorAdapter = { + ...markdown, + edit(input) { + return input.intent.data === "" + ? { + ok: true, + operations: [], + selectionAfter: + input.intent.targetRanges === undefined + ? input.selection + : { + ranges: input.intent.targetRanges, + primaryIndex: 0, + }, + } + : markdown.edit(input); + }, + }; + const view = mountEditor({ root: dom.root, document, adapter }); + dom.root.focus(); + const native = dom.dom.window.document.getSelection(); + if (native === null) { + throw new Error("Expected a native selection."); + } + Object.defineProperty(native, "setBaseAndExtent", { + configurable: true, + value: () => { + throw new Error("force Selection.extend fallback"); + }, + }); + + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(2, 0)], + }); + + expect(native.anchorOffset).toBe(2); + expect(native.focusOffset).toBe(0); + view.destroy(); + dom.close(); + }); + + it("prevents admitted beforeinput even when commit validation fails", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + const base = createMarkdownAdapter(); + const adapter: EditorAdapter = { + ...base, + edit() { + return { + ok: true, + operations: [{ op: "replace", path: "/source", value: 42 }], + selectionAfter: null, + }; + }, + }; + const errors: string[] = []; + const view = mountEditor({ + root: dom.root, + document, + adapter, + onError: (error) => errors.push(error.code), + }); + const event = new dom.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: "x", + inputType: "insertText", + }); + Object.defineProperty(event, "getTargetRanges", { + value: () => [], + }); + + dom.root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(document.value).toEqual({ source: "a" }); + expect(errors).toContain("invalid_markdown_document"); + view.destroy(); + dom.close(); + }); + + it("fails closed for declined cancelable input and admits text drop", () => { + const fixture = markdownFixture("abc"); + const declined = new fixture.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "formatSuperscript", + }); + + fixture.root.dispatchEvent(declined); + + expect(declined.defaultPrevented).toBe(true); + expect(fixture.document.value).toEqual({ source: "abc" }); + + const transfer = memoryDataTransfer({ + "text/plain": "Z", + }); + expect( + fixture.view.dispatch({ + inputType: "insertFromDrop", + dataTransfer: transfer, + targetRanges: [range(1, 2)], + }), + ).toMatchObject({ ok: true }); + expect(fixture.document.value).toEqual({ source: "aZc" }); + fixture.close(); + }); + + it.each([ + "deleteWordBackward", + "deleteWordForward", + "deleteSoftLineBackward", + "deleteSoftLineForward", + "deleteEntireSoftLine", + "deleteHardLineBackward", + "deleteHardLineForward", + "deleteByDrag", + "deleteContent", + ])("%s honors the user agent target range", (inputType) => { + const fixture = markdownFixture("abcdef"); + + expect( + fixture.view.dispatch({ + inputType, + targetRanges: [range(1, 4)], + }), + ).toMatchObject({ ok: true }); + expect(fixture.document.value).toEqual({ source: "aef" }); + expect(fixture.view.getSnapshot().selection).toEqual({ + ranges: [range(1)], + primaryIndex: 0, + }); + fixture.close(); + }); + + it("does not invent a word boundary without a user agent target range", () => { + const fixture = markdownFixture("one two"); + + expect( + fixture.view.dispatch({ + inputType: "deleteWordBackward", + targetRanges: [range(7)], + }), + ).toEqual({ ok: true, change: null }); + expect(fixture.document.value).toEqual({ source: "one two" }); + fixture.close(); + }); + + it("attributes non-cancelable native reconciliation to its InputEvent type", () => { + const fixture = markdownFixture("a"); + const publications: JSONAppliedChange[] = []; + fixture.document.subscribe((change) => publications.push(change)); + const before = new fixture.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: false, + data: "b", + inputType: "insertReplacementText", + }); + fixture.root.dispatchEvent(before); + fixture.root.textContent = "b"; + fixture.root.dispatchEvent( + new fixture.dom.window.InputEvent("input", { + bubbles: true, + data: "b", + inputType: "insertReplacementText", + }), + ); + + expect(fixture.document.value).toEqual({ source: "b" }); + expect(publications).toHaveLength(1); + expect( + publications[0]?.metadata?.["@interactive-os/editable"], + ).toMatchObject({ + inputType: "insertReplacementText", + origin: "native", + }); + fixture.close(); + }); + + it("uses one current DOM selection for both cut payload and deletion", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "abc" }); + const adapter = createMarkdownAdapter(); + const view = mountEditor({ root: dom.root, document, adapter }); + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(0)], + }); + dom.root.focus(); + const anchor = adapter.toDOMPoint({ + root: dom.root, + point: point(1), + }); + const focus = adapter.toDOMPoint({ + root: dom.root, + point: point(2), + }); + const native = dom.document.getSelection(); + if (anchor === null || focus === null || native === null) { + throw new Error("Expected a mappable DOM selection."); + } + native.setBaseAndExtent( + anchor.node, + anchor.offset, + focus.node, + focus.offset, + ); + const transfer = memoryDataTransfer(); + const cut = new dom.dom.window.Event("cut", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(cut, "clipboardData", { value: transfer }); + + dom.root.dispatchEvent(cut); + + expect(cut.defaultPrevented).toBe(true); + expect(transfer.getData("text/plain")).toBe("b"); + expect(document.value).toEqual({ source: "ac" }); + view.destroy(); + dom.close(); + }); + + it("settles composition with one success notification", async () => { + const fixture = markdownFixture("a"); + fixture.view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }); + let notifications = 0; + fixture.view.subscribe(() => { + notifications += 1; + }); + fixture.root.dispatchEvent( + new fixture.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + notifications = 0; + fixture.root.textContent = "ab"; + fixture.root.dispatchEvent( + new fixture.dom.window.CompositionEvent("compositionend", { + bubbles: true, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fixture.document.value).toEqual({ source: "ab" }); + expect(notifications).toBe(1); + fixture.close(); + }); + + it("settles missing compositionend on final input and blur", () => { + const finalInput = markdownFixture("a"); + finalInput.view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }); + finalInput.root.dispatchEvent( + new finalInput.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + finalInput.root.textContent = "ab"; + finalInput.root.dispatchEvent( + new finalInput.dom.window.InputEvent("input", { + bubbles: true, + inputType: "insertCompositionText", + isComposing: false, + }), + ); + expect(finalInput.document.value).toEqual({ source: "ab" }); + expect(finalInput.view.getSnapshot().isComposing).toBe(false); + finalInput.close(); + + const blurred = markdownFixture("a"); + blurred.view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }); + blurred.root.dispatchEvent( + new blurred.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + blurred.root.textContent = "ab"; + blurred.root.dispatchEvent(new blurred.dom.window.FocusEvent("blur")); + expect(blurred.document.value).toEqual({ source: "ab" }); + expect(blurred.view.getSnapshot().isComposing).toBe(false); + blurred.close(); + }); + + it("coalesces a late final composition input into one publication", async () => { + const fixture = markdownFixture("a"); + fixture.view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }); + const publications: JSONAppliedChange[] = []; + fixture.document.subscribe((change) => publications.push(change)); + fixture.root.dispatchEvent( + new fixture.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + fixture.root.textContent = "ab"; + fixture.root.dispatchEvent( + new fixture.dom.window.CompositionEvent("compositionend", { + bubbles: true, + }), + ); + fixture.root.dispatchEvent( + new fixture.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: "c", + inputType: "insertCompositionText", + isComposing: false, + }), + ); + fixture.root.textContent = "abc"; + fixture.root.dispatchEvent( + new fixture.dom.window.InputEvent("input", { + bubbles: true, + data: "c", + inputType: "insertCompositionText", + isComposing: false, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fixture.document.value).toEqual({ source: "abc" }); + expect(publications).toHaveLength(1); + expect(fixture.view.getSnapshot().isComposing).toBe(false); + fixture.close(); + }); + + it("preserves the browser-owned composition node through settlement", () => { + const fixture = markdownFixture("a"); + fixture.view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }); + const segment = fixture.root.firstElementChild; + const text = segment?.firstChild; + if ( + !(segment instanceof fixture.dom.window.HTMLElement) || + !(text instanceof fixture.dom.window.Text) + ) { + throw new Error("Expected a projected source segment."); + } + const segmentNode = segment as HTMLElement; + const textNode = text as Text; + fixture.root.dispatchEvent( + new fixture.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + textNode.data = "ab"; + fixture.root.dispatchEvent( + new fixture.dom.window.InputEvent("input", { + bubbles: true, + inputType: "insertCompositionText", + isComposing: false, + }), + ); + + expect(fixture.document.value).toEqual({ source: "ab" }); + expect(fixture.root.firstElementChild).toBe(segmentNode); + expect(segmentNode.firstChild).toBe(textNode); + fixture.close(); + }); + + it("rebases disjoint external edits across a composition island", async () => { + const fixture = markdownFixture("abcd"); + fixture.view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(2)], + }); + fixture.root.dispatchEvent( + new fixture.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + fixture.root.textContent = "abXcd"; + const nativeText = fixture.root.firstChild; + if (!(nativeText instanceof fixture.dom.window.Text)) { + throw new Error("Expected native composition text."); + } + fixture.dom.window.document + .getSelection() + ?.setBaseAndExtent(nativeText, 3, nativeText, 3); + fixture.document.commit([ + { op: "replace", path: "/source", value: "Zabcd" }, + ]); + expect(fixture.view.getSnapshot()).toMatchObject({ + value: { source: "Zabcd" }, + isComposing: true, + selection: { ranges: [range(3)], primaryIndex: 0 }, + }); + fixture.root.dispatchEvent( + new fixture.dom.window.CompositionEvent("compositionend", { + bubbles: true, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fixture.document.value).toEqual({ source: "ZabXcd" }); + expect(fixture.view.getSnapshot()).toMatchObject({ + isComposing: false, + selection: { ranges: [range(4)], primaryIndex: 0 }, + }); + fixture.close(); + }); + + it("fails closed when external edits overlap a composition island", async () => { + const fixture = markdownFixture("abcd"); + const errors: string[] = []; + fixture.close(); + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "abcd" }); + const view = mountEditor({ + root: dom.root, + document, + adapter: createMarkdownAdapter(), + onError: (error) => errors.push(error.code), + }); + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(2)], + }); + dom.root.dispatchEvent( + new dom.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + dom.root.textContent = "abXcd"; + document.commit([{ op: "replace", path: "/source", value: "abYcd" }]); + dom.root.dispatchEvent( + new dom.dom.window.CompositionEvent("compositionend", { + bubbles: true, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(document.value).toEqual({ source: "abYcd" }); + expect(dom.root.textContent).toBe("abYcd"); + expect(errors).toContain("composition_conflict"); + view.destroy(); + dom.close(); + }); + + it("does not replace the composition island when external mapping fails", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "ab" }); + const markdown = createMarkdownAdapter(); + const errors: string[] = []; + const adapter: EditorAdapter = { + ...markdown, + mapSelection() { + throw new Error("mapping failed"); + }, + }; + const view = mountEditor({ + root: dom.root, + document, + adapter, + onError: (error) => errors.push(error.code), + }); + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }); + const text = dom.root.firstElementChild?.firstChild; + if (!(text instanceof dom.dom.window.Text)) { + throw new Error("Expected a projected source text node."); + } + const textNode = text as Text; + dom.root.dispatchEvent( + new dom.dom.window.CompositionEvent("compositionstart", { + bubbles: true, + }), + ); + textNode.data = "aXb"; + document.commit([{ op: "replace", path: "/source", value: "Zab" }]); + + expect(dom.root.firstElementChild?.firstChild).toBe(textNode); + expect(textNode.data).toBe("aXb"); + expect(view.getSnapshot().isComposing).toBe(true); + dom.root.dispatchEvent( + new dom.dom.window.InputEvent("input", { + bubbles: true, + inputType: "insertCompositionText", + isComposing: false, + }), + ); + expect(document.value).toEqual({ source: "ZaXb" }); + expect(errors).toContain("adapter_failed"); + view.destroy(); + dom.close(); + }); + + it("rolls back root ownership when the initial projection throws", () => { + const dom = editorDOM(); + dom.root.innerHTML = 'seed'; + dom.root.setAttribute("data-host", "kept"); + dom.root.setAttribute("role", "group"); + dom.root.setAttribute("spellcheck", "true"); + const seed = dom.root.firstChild; + const initial = dom.root.outerHTML; + const base = createMarkdownAdapter(); + const adapter: EditorAdapter = { + ...base, + project({ root }) { + root.setAttribute("data-project", "changed"); + root.replaceChildren(root.ownerDocument.createTextNode("changed")); + throw new Error("projection failed"); + }, + }; + + expect(() => + mountEditor({ + root: dom.root, + document: createMarkdownDocument({ source: "a" }), + adapter, + }), + ).toThrow("projection failed"); + expect(dom.root.outerHTML).toBe(initial); + expect(dom.root.firstChild).toBe(seed); + dom.close(); + }); + + it("rolls back projection, listeners, and subscriptions when mount fails", () => { + const dom = editorDOM(); + dom.root.innerHTML = "seed"; + dom.root.setAttribute("data-host", "kept"); + const seed = dom.root.firstChild; + const initial = dom.root.outerHTML; + const document = createMarkdownDocument({ source: "a" }); + const markdown = createMarkdownAdapter(); + const adapter: EditorAdapter = { + ...markdown, + subscribe() { + throw new Error("subscription failed"); + }, + }; + + expect(() => mountEditor({ root: dom.root, document, adapter })).toThrow( + "subscription failed", + ); + expect(dom.root.outerHTML).toBe(initial); + expect(dom.root.firstChild).toBe(seed); + const event = new dom.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: "x", + inputType: "insertText", + }); + dom.root.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + document.commit([{ op: "replace", path: "/source", value: "b" }]); + expect(dom.root.outerHTML).toBe(initial); + dom.close(); + }); + + it("finishes destroy cleanup when an adapter disposer throws", () => { + const dom = editorDOM(); + dom.root.setAttribute("role", "group"); + const errors: string[] = []; + const markdown = createMarkdownAdapter(); + const adapter: EditorAdapter = { + ...markdown, + subscribe() { + return () => { + throw new Error("dispose failed"); + }; + }, + }; + const view = mountEditor({ + root: dom.root, + document: createMarkdownDocument({ source: "a" }), + adapter, + onError: (error) => errors.push(error.code), + }); + + expect(() => view.destroy()).not.toThrow(); + expect(() => view.destroy()).not.toThrow(); + expect(dom.root.dataset.editableOwner).toBeUndefined(); + expect(dom.root.getAttribute("role")).toBe("group"); + expect(errors).toContain("cleanup_failed"); + const event = new dom.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + }); + dom.root.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + dom.close(); + }); + + it("recovers deterministically when external selection mapping throws", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + const base = createMarkdownAdapter(); + let fail = true; + const adapter: EditorAdapter = { + ...base, + mapSelection(input) { + if (fail) { + fail = false; + throw new Error("mapping failed"); + } + return base.mapSelection(input); + }, + }; + const errors: string[] = []; + const view = mountEditor({ + root: dom.root, + document, + adapter, + onError: (error) => errors.push(error.code), + }); + document.commit([{ op: "replace", path: "/source", value: "ab" }]); + + expect(view.getSnapshot()).toMatchObject({ + value: { source: "ab" }, + selection: null, + }); + expect(dom.root.textContent).toBe("ab"); + expect(errors).toContain("adapter_failed"); + view.destroy(); + dom.close(); + }); + + it("keeps owned projection recovery coherent with its snapshot", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + const markdown = createMarkdownAdapter(); + let failPlannedSelection = true; + const adapter: EditorAdapter = { + ...markdown, + project(input) { + if (input.selection !== null && failPlannedSelection) { + failPlannedSelection = false; + throw new Error("planned selection failed"); + } + markdown.project(input); + }, + }; + const view = mountEditor({ + root: dom.root, + document, + adapter, + }); + + expect( + view.dispatch({ + inputType: "insertText", + data: "b", + targetRanges: [range(1)], + }), + ).toMatchObject({ ok: true }); + expect(document.value).toEqual({ source: "ab" }); + expect(dom.root.textContent).toBe("ab"); + expect(view.getSnapshot()).toMatchObject({ + value: { source: "ab" }, + selection: null, + }); + view.destroy(); + dom.close(); + }); + + it("blocks view dispatch from every external adapter callback", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + const markdown = createMarkdownAdapter(); + let view: EditorView; + let nested: ReturnType | null = null; + const adapter: EditorAdapter = { + ...markdown, + mapSelection(input) { + nested = view.dispatch({ + inputType: "insertText", + data: "?", + targetRanges: [range(0)], + }); + return markdown.mapSelection(input); + }, + }; + view = mountEditor({ root: dom.root, document, adapter }); + + document.commit([{ op: "replace", path: "/source", value: "ab" }]); + + expect(nested).toMatchObject({ + ok: false, + code: "dispatch_reentrancy", + }); + expect(document.value).toEqual({ source: "ab" }); + view.destroy(); + dom.close(); + }); + + it("prevents native input before a throwing DOM range mapper can fail open", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + const markdown = createMarkdownAdapter(); + let failMapping = false; + const errors: string[] = []; + const adapter: EditorAdapter = { + ...markdown, + fromDOMPoint(input) { + if (failMapping) { + throw new Error("DOM mapping failed"); + } + return markdown.fromDOMPoint(input); + }, + }; + const view = mountEditor({ + root: dom.root, + document, + adapter, + onError: (error) => errors.push(error.code), + }); + const text = dom.root.firstElementChild?.firstChild; + if (!(text instanceof dom.dom.window.Text)) { + throw new Error("Expected a projected source text node."); + } + const textNode = text as Text; + failMapping = true; + const event = new dom.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: "x", + inputType: "insertText", + }); + Object.defineProperty(event, "getTargetRanges", { + value: () => [staticRange(textNode, 1, textNode, 1)], + }); + + expect(() => dom.root.dispatchEvent(event)).not.toThrow(); + expect(event.defaultPrevented).toBe(true); + expect(document.value).toEqual({ source: "a" }); + expect(errors).toContain("adapter_failed"); + view.destroy(); + dom.close(); + }); + + it("does not fall back to a stale caret when an in-root selection is unmappable", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "ab" }); + const markdown = createMarkdownAdapter(); + let returnNull = false; + const errors: string[] = []; + const adapter: EditorAdapter = { + ...markdown, + fromDOMPoint(input) { + return returnNull ? null : markdown.fromDOMPoint(input); + }, + }; + const view = mountEditor({ + root: dom.root, + document, + adapter, + onError: (error) => errors.push(error.code), + }); + const pointInDOM = markdown.toDOMPoint({ + root: dom.root, + point: point(1), + }); + const native = dom.document.getSelection(); + if (pointInDOM === null || native === null) { + throw new Error("Expected a mappable native selection."); + } + dom.root.focus(); + native.setBaseAndExtent( + pointInDOM.node, + pointInDOM.offset, + pointInDOM.node, + pointInDOM.offset, + ); + returnNull = true; + const event = new dom.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: "x", + inputType: "insertText", + }); + Object.defineProperty(event, "getTargetRanges", { value: () => [] }); + + dom.root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(document.value).toEqual({ source: "ab" }); + expect(errors).toContain("dom_mapping_failed"); + view.destroy(); + dom.close(); + }); + + it("maps every native selection range or declines clipboard output", () => { + const dom = editorDOM(); + const errors: string[] = []; + const view = mountEditor({ + root: dom.root, + document: createMarkdownDocument({ source: "ab" }), + adapter: createMarkdownAdapter(), + onError: (error) => errors.push(error.code), + }); + const text = dom.root.querySelector("span")?.firstChild; + if (text === null || text === undefined) { + throw new Error("Expected projected Markdown text."); + } + const outside = dom.document.createTextNode("outside"); + const primary = staticRange(text, 0, text, 1); + const extra = staticRange(outside, 0, outside, 1); + const native = { + anchorNode: text, + anchorOffset: 0, + focusNode: text, + focusOffset: 1, + rangeCount: 2, + getRangeAt: (index: number) => (index === 0 ? primary : extra), + } as unknown as Selection; + Object.defineProperty(dom.document, "getSelection", { + configurable: true, + value: () => native, + }); + const transfer = memoryDataTransfer(); + const copy = new dom.dom.window.Event("copy", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(copy, "clipboardData", { value: transfer }); + + dom.root.dispatchEvent(copy); + + expect(copy.defaultPrevented).toBe(false); + expect(transfer.getData("text/plain")).toBe(""); + expect(errors).toContain("dom_mapping_failed"); + view.destroy(); + dom.close(); + }); + + it("reports an unrepresentable logical selection instead of hiding it", () => { + const dom = editorDOM(); + const markdown = createMarkdownAdapter(); + let returnNull = false; + const errors: string[] = []; + const adapter: EditorAdapter = { + ...markdown, + toDOMPoint(input) { + return returnNull ? null : markdown.toDOMPoint(input); + }, + }; + const view = mountEditor({ + root: dom.root, + document: createMarkdownDocument({ source: "ab" }), + adapter, + onError: (error) => errors.push(error.code), + }); + dom.root.focus(); + returnNull = true; + + expect( + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(1)], + }), + ).toEqual({ ok: true, change: null }); + expect(errors).toContain("selection_restore_failed"); + view.destroy(); + dom.close(); + }); + + it("represents a trailing newline as a stable DOM caret boundary", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "ab\n" }); + const adapter = createMarkdownAdapter(); + const view = mountEditor({ root: dom.root, document, adapter }); + dom.root.focus(); + view.dispatch({ + inputType: "insertText", + data: "", + targetRanges: [range(3)], + }); + const native = dom.document.getSelection(); + if (native?.anchorNode === null || native?.anchorNode === undefined) { + throw new Error("Expected a restored native caret."); + } + const mapped = adapter.fromDOMPoint({ + root: dom.root, + node: native.anchorNode, + offset: native.anchorOffset, + }); + + expect( + dom.root.querySelector("[data-editable-trailing-break]"), + ).not.toBeNull(); + expect(mapped?.offset).toBe(3); + view.destroy(); + dom.close(); + }); + + it("isolates throwing error reporters and subscribers during recovery", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "a" }); + const markdown = createMarkdownAdapter(); + const adapter: EditorAdapter = { + ...markdown, + mapSelection() { + throw new Error("mapping failed"); + }, + }; + const view = mountEditor({ + root: dom.root, + document, + adapter, + onError() { + throw new Error("reporter failed"); + }, + }); + let reachedSecondSubscriber = 0; + view.subscribe(() => { + throw new Error("subscriber failed"); + }); + view.subscribe(() => { + reachedSecondSubscriber += 1; + }); + + expect(() => + document.commit([{ op: "replace", path: "/source", value: "ab" }]), + ).not.toThrow(); + expect(view.getSnapshot()).toMatchObject({ + value: { source: "ab" }, + selection: null, + }); + expect(dom.root.textContent).toBe("ab"); + expect(reachedSecondSubscriber).toBe(1); + view.destroy(); + dom.close(); + }); + + it("rejects a target-range sequence atomically when one range is unmappable", () => { + const dom = editorDOM(); + const document = createMarkdownDocument({ source: "ab" }); + const markdown = createMarkdownAdapter(); + let captured: EditIntent | null = null; + const adapter: EditorAdapter = { + ...markdown, + edit(input) { + captured = input.intent; + return { ok: false, code: "captured" }; + }, + }; + const errors: string[] = []; + const view = mountEditor({ + root: dom.root, + document, + adapter, + onError: (error) => errors.push(error.code), + }); + const outside = dom.document.createTextNode("outside"); + const text = dom.root.querySelector("span")?.firstChild; + if (text === null || text === undefined) { + throw new Error("Expected projected Markdown text."); + } + const event = new dom.dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + }); + Object.defineProperty(event, "getTargetRanges", { + value: () => [ + staticRange(text, 0, text, 1), + staticRange(outside, 0, outside, 1), + staticRange(text, 1, text, 2), + ], + }); + dom.root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(captured).toBeNull(); + expect(document.value).toEqual({ source: "ab" }); + expect(errors).toContain("dom_mapping_failed"); + view.destroy(); + dom.close(); + }); + + it("owns only editing-host attributes and repairs them after projection", async () => { + const dom = editorDOM(); + dom.root.setAttribute("role", "group"); + dom.root.setAttribute("spellcheck", "true"); + dom.root.setAttribute("aria-multiline", "false"); + dom.root.setAttribute("tabindex", "7"); + const markdown = createMarkdownAdapter(); + const errors: string[] = []; + const adapter: EditorAdapter = { + ...markdown, + project(input) { + markdown.project(input); + input.root.removeAttribute("contenteditable"); + input.root.removeAttribute("data-editable-owner"); + }, + }; + const view = mountEditor({ + root: dom.root, + document: createMarkdownDocument({ source: "ab" }), + adapter, + onError: (error) => errors.push(error.code), + }); + + expect(dom.root.getAttribute("contenteditable")).toBe("true"); + expect(dom.root.dataset.editableOwner).toBe("true"); + dom.root.setAttribute("role", "button"); + dom.root.removeAttribute("aria-multiline"); + dom.root.setAttribute("tabindex", "3"); + dom.root.removeAttribute("contenteditable"); + dom.root.removeAttribute("data-editable-owner"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(errors).toContain("foreign_dom_mutation"); + expect(dom.root.getAttribute("role")).toBe("button"); + expect(dom.root.getAttribute("spellcheck")).toBe("true"); + expect(dom.root.hasAttribute("aria-multiline")).toBe(false); + expect(dom.root.getAttribute("tabindex")).toBe("3"); + expect(dom.root.getAttribute("contenteditable")).toBe("true"); + expect(dom.root.dataset.editableOwner).toBe("true"); + view.destroy(); + expect(dom.root.hasAttribute("contenteditable")).toBe(false); + expect(dom.root.hasAttribute("data-editable-owner")).toBe(false); + expect(dom.root.getAttribute("role")).toBe("button"); + dom.close(); + }); + + it("rejects editor roots that overlap in either nesting direction", () => { + const dom = new JSDOM( + '
', + ); + const outer = dom.window.document.querySelector("#outer") as HTMLElement; + const inner = dom.window.document.querySelector("#inner") as HTMLElement; + const innerView = mountEditor({ + root: inner, + document: createMarkdownDocument({ source: "inner" }), + adapter: createMarkdownAdapter(), + }); + + expect(() => + mountEditor({ + root: outer, + document: createMarkdownDocument({ source: "outer" }), + adapter: createMarkdownAdapter(), + }), + ).toThrow("overlaps an owned editor subtree"); + innerView.destroy(); + + const outerView = mountEditor({ + root: outer, + document: createMarkdownDocument({ source: "outer" }), + adapter: createMarkdownAdapter(), + }); + const nested = dom.window.document.createElement("div"); + outer.append(nested); + expect(() => + mountEditor({ + root: nested, + document: createMarkdownDocument({ source: "nested" }), + adapter: createMarkdownAdapter(), + }), + ).toThrow("overlaps an owned editor subtree"); + outerView.destroy(); + dom.window.close(); + }); +}); + +function markdownFixture(source: string, withOther = false) { + const dom = editorDOM(withOther); + const document = createMarkdownDocument({ source }); + const view = mountEditor({ + root: dom.root, + document, + adapter: createMarkdownAdapter(), + }); + return { + ...dom, + document, + view, + close() { + view.destroy(); + dom.close(); + }, + }; +} + +function editorDOM(withOther = false) { + const dom = new JSDOM( + `
${withOther ? '' : ""}`, + ); + const root = dom.window.document.querySelector("#editor") as HTMLElement; + const other = dom.window.document.querySelector( + "#other", + ) as HTMLInputElement | null; + return { + dom, + document: dom.window.document, + root, + other, + close: () => dom.window.close(), + }; +} + +function staticRange( + startContainer: Node, + startOffset: number, + endContainer: Node, + endOffset: number, +): StaticRange { + return { + startContainer, + startOffset, + endContainer, + endOffset, + collapsed: startContainer === endContainer && startOffset === endOffset, + }; +} + +function memoryDataTransfer( + initial: Readonly> = {}, +): DataTransfer { + const entries = new Map(Object.entries(initial)); + return { + getData(type) { + return entries.get(type) ?? ""; + }, + setData(type, value) { + entries.set(type, value); + }, + } as DataTransfer; +} diff --git a/packages/editable/browser/public.ts b/packages/editable/browser/public.ts new file mode 100644 index 0000000..4939913 --- /dev/null +++ b/packages/editable/browser/public.ts @@ -0,0 +1,16 @@ +export type { + EditIntent, + EditorAdapter, + EditorAffinity, + EditorError, + EditorPoint, + EditorRange, + EditorReconcileInput, + EditorSelection, + EditorSnapshot, + EditorView, + EditPlan, + EditResult, + MountEditorOptions, +} from "./contract.js"; +export { mountEditor } from "./genericEditor.js"; diff --git a/packages/editable/causalHost.test.ts b/packages/editable/causalHost.test.ts deleted file mode 100644 index 22a07b6..0000000 --- a/packages/editable/causalHost.test.ts +++ /dev/null @@ -1,778 +0,0 @@ -// @vitest-environment jsdom - -import { - createCausalPatchInbox, - type CausalPatchInbox, -} from "@interactive-os/json-document-causal-patch-inbox"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - createEditableDocument, - EditableDocumentSchema, - type EditorFault, - getJsonEditableDocumentHost, - type JsonEditable, - mountJsonEditable, -} from "./index"; - -type Fixture = ReturnType; - -const mountedEditors: JsonEditable[] = []; -const mountedInboxes: CausalPatchInbox[] = []; - -afterEach(() => { - for (const inbox of mountedInboxes.splice(0)) { - inbox.dispose(); - } - for (const editor of mountedEditors.splice(0)) { - editor.destroy(); - } - window.document.body.replaceChildren(); - vi.useRealTimers(); -}); - -function setupEditor( - onFault?: (fault: EditorFault) => void, - root = window.document.createElement("div"), -) { - const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", - id: "causal-host-test", - blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, - ], - }); - const faults: EditorFault[] = []; - if (!root.isConnected) { - window.document.body.append(root); - } - const editor = mountJsonEditable({ - root, - document, - onFault: (fault) => { - faults.push(fault); - onFault?.(fault); - }, - }); - mountedEditors.push(editor); - return { document, editor, faults, root }; -} - -function createInbox(fixture: Fixture) { - const inbox = createCausalPatchInbox(fixture.document, { - host: getJsonEditableDocumentHost(fixture.editor), - positionalSchema: EditableDocumentSchema, - }); - mountedInboxes.push(inbox); - return inbox; -} - -function textNode(fixture: Fixture, blockId: string): Text { - const node = fixture.root.querySelector( - `[data-editable-block="${blockId}"] [data-editable-text]`, - )?.firstChild; - if (!(node instanceof Text)) { - throw new Error(`Missing editable Text node for ${blockId}.`); - } - return node; -} - -function setDOMCaret(node: Text, offset: number): void { - const selection = window.getSelection(); - if (selection === null) { - throw new Error("The test DOM does not expose a Selection."); - } - const range = window.document.createRange(); - range.setStart(node, offset); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); -} - -function inputEvent( - type: "beforeinput" | "input", - inputType: string, - options: { data?: string | null; isComposing?: boolean } = {}, -): InputEvent { - return new InputEvent(type, { - bubbles: true, - cancelable: type === "beforeinput", - data: options.data, - inputType, - isComposing: options.isComposing ?? false, - }); -} - -describe("editable causal document host", () => { - it("rebases a delayed positional edit after an owned local insertion", () => { - const fixture = setupEditor(); - fixture.root.focus(); - const inbox = createInbox(fixture); - const base = fixture.document.value; - const baseRevision = inbox.current().journalRevision; - - expect(baseRevision).toBe(0); - expect( - fixture.editor.dispatch({ - type: "patch", - patch: [ - { - op: "add", - path: "/blocks/0", - value: { id: "local", type: "paragraph", text: "local" }, - }, - ], - }), - ).toMatchObject({ ok: true, change: "document" }); - expect(inbox.current().journalRevision).toBe(1); - - const result = inbox.ingest({ - id: "delayed-beta", - dependsOn: [], - intent: { - kind: "positional", - base, - baseRevision, - operations: [ - { - op: "replace", - path: "/blocks/1/text", - value: "Reviewed", - }, - ], - selectionAfter: { path: "/blocks/1/text", offset: 4 }, - }, - }); - - expect(result).toMatchObject({ - ok: true, - applied: ["delayed-beta"], - diagnostics: expect.arrayContaining([ - expect.objectContaining({ - code: "pointer_shifted", - pointer: "/blocks/1/text", - rebasedPointer: "/blocks/2/text", - }), - ]), - }); - expect(fixture.document.value.blocks).toEqual([ - { id: "local", type: "paragraph", text: "local" }, - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "Reviewed" }, - ]); - expect(fixture.document.selection?.primaryRange).toEqual({ - anchor: { path: "/blocks/2/text", offset: 4 }, - focus: { path: "/blocks/2/text", offset: 4 }, - }); - const beta = textNode(fixture, "beta"); - expect(window.getSelection()?.focusNode).toBe(beta); - expect(window.getSelection()?.focusOffset).toBe(4); - expect(inbox.current()).toMatchObject({ - status: "active", - journalRevision: 2, - frontier: ["delayed-beta"], - }); - expect(fixture.faults).toEqual([]); - }); - - it("lets another causal inbox journal a ready publication as host-owned", () => { - const fixture = setupEditor(); - const applyingInbox = createInbox(fixture); - const observingInbox = createInbox(fixture); - - expect( - applyingInbox.ingest({ - id: "from-first-inbox", - dependsOn: [], - operations: [ - { op: "replace", path: "/blocks/0/text", value: "shared" }, - ], - }), - ).toMatchObject({ ok: true, applied: ["from-first-inbox"] }); - - expect(observingInbox.current()).toMatchObject({ - status: "active", - journalRevision: 1, - frontier: [], - }); - expect(fixture.faults).toEqual([]); - }); - - it("journals pending native input, defers for composition, then retries when idle", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const inbox = createInbox(fixture); - const base = fixture.document.value; - const composingNode = textNode(fixture, "alpha"); - setDOMCaret(composingNode, 2); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertCompositionText", { - data: "한", - isComposing: true, - }), - ); - composingNode.insertData(2, "한"); - setDOMCaret(composingNode, 3); - - let retryResult: ReturnType | undefined; - let retryScheduled = false; - const unsubscribe = fixture.editor.subscribe((snapshot) => { - if ( - retryResult === undefined && - !retryScheduled && - snapshot.phase === "idle" && - snapshot.queuedChanges === 0 - ) { - retryScheduled = true; - queueMicrotask(() => { - retryResult = inbox.ingest([]); - }); - } - }); - - const deferred = inbox.ingest({ - id: "after-composition", - dependsOn: [], - intent: { - kind: "positional", - base, - baseRevision: 0, - operations: [ - { - op: "replace", - path: "/blocks/1/text", - value: "after IME", - }, - ], - }, - }); - - expect(deferred).toMatchObject({ - ok: false, - code: "host_not_ready", - id: "after-composition", - }); - expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); - expect(inbox.current()).toMatchObject({ - journalRevision: 1, - queued: [{ id: "after-composition", missing: [] }], - }); - expect(textNode(fixture, "alpha")).toBe(composingNode); - - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한", - isComposing: true, - }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(retryResult).toMatchObject({ - ok: true, - applied: ["after-composition"], - }); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "after IME", - ]); - expect(inbox.current()).toMatchObject({ - status: "active", - journalRevision: 2, - queued: [], - }); - expect(fixture.faults).toEqual([]); - unsubscribe(); - }); - - it("defers the turn when flushing a damaged composition makes the editor idle", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const inbox = createInbox(fixture); - const base = fixture.document.value; - const pinnedNode = textNode(fixture, "alpha"); - setDOMCaret(pinnedNode, 2); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - const replacement = window.document.createTextNode(pinnedNode.data); - pinnedNode.parentNode?.replaceChild(replacement, pinnedNode); - - const deferred = inbox.ingest({ - id: "after-damaged-composition", - dependsOn: [], - intent: { - kind: "positional", - base, - baseRevision: 0, - operations: [ - { op: "replace", path: "/blocks/1/text", value: "recovered" }, - ], - }, - }); - - expect(deferred).toMatchObject({ - ok: false, - code: "host_not_ready", - id: "after-damaged-composition", - }); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "idle", - composition: null, - }); - expect(fixture.document.value.blocks[1]?.text).toBe("second"); - - expect(inbox.ingest([])).toMatchObject({ - ok: false, - code: "host_not_ready", - id: "after-damaged-composition", - }); - let releaseRetry: ReturnType | undefined; - let retryOnRelease = true; - fixture.editor.subscribe((snapshot) => { - if ( - retryOnRelease && - releaseRetry === undefined && - snapshot.phase === "idle" - ) { - releaseRetry = inbox.ingest([]); - } - }); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "" }), - ); - expect(releaseRetry).toBeUndefined(); - expect(fixture.document.value.blocks[1]?.text).toBe("second"); - await Promise.resolve(); - expect(releaseRetry).toBeUndefined(); - expect(fixture.editor.getSnapshot().phase).toBe("settling"); - await vi.advanceTimersByTimeAsync(31); - retryOnRelease = false; - expect(releaseRetry).toMatchObject({ - ok: true, - applied: ["after-damaged-composition"], - }); - expect(fixture.document.value.blocks[1]?.text).toBe("recovered"); - }); - - it("does not apply when the editor is destroyed during the readiness flush", () => { - const fixture = setupEditor(); - const documentHost = getJsonEditableDocumentHost(fixture.editor); - const pinnedNode = textNode(fixture, "alpha"); - setDOMCaret(pinnedNode, 2); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - let armed = false; - fixture.editor.subscribe((snapshot) => { - if (armed && snapshot.phase === "idle") { - fixture.editor.destroy(); - } - }); - armed = true; - pinnedNode.parentNode?.replaceChild( - window.document.createTextNode(pinnedNode.data), - pinnedNode, - ); - const apply = vi.fn(); - - expect(() => - documentHost.runReady({ id: "destroyed-during-flush", apply }), - ).toThrow("destroyed"); - expect(apply).not.toHaveBeenCalled(); - }); - - it("does not let a damaged-session settle timer end a new composition", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const inbox = createInbox(fixture); - const node = textNode(fixture, "alpha"); - setDOMCaret(node, 2); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - node.parentNode?.replaceChild( - window.document.createTextNode(node.data), - node, - ); - expect( - inbox.ingest({ - id: "wait-for-new-composition", - dependsOn: [], - operations: [ - { op: "replace", path: "/blocks/1/text", value: "later" }, - ], - }), - ).toMatchObject({ ok: false, code: "host_not_ready" }); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "" }), - ); - - const nextNode = textNode(fixture, "alpha"); - setDOMCaret(nextNode, 2); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "composing", - composition: { blockId: "alpha" }, - }); - expect(textNode(fixture, "alpha")).toBe(nextNode); - expect(fixture.document.value.blocks[1]?.text).toBe("second"); - }); - - it("does not apply reentrantly from a native beforeinput subscriber", () => { - const fixture = setupEditor(); - const inbox = createInbox(fixture); - const base = fixture.document.value; - let armed = false; - let nestedResult: ReturnType | undefined; - fixture.editor.subscribe(() => { - if (!armed || nestedResult !== undefined) { - return; - } - nestedResult = inbox.ingest({ - id: "during-beforeinput", - dependsOn: [], - intent: { - kind: "positional", - base, - baseRevision: 0, - operations: [ - { op: "replace", path: "/blocks/1/text", value: "delayed" }, - ], - }, - }); - }); - setDOMCaret(textNode(fixture, "alpha"), 1); - armed = true; - - expect( - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertText", { data: "X" }), - ), - ).toBe(false); - - expect(nestedResult).toMatchObject({ - ok: false, - code: "host_not_ready", - id: "during-beforeinput", - }); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "aXbcdef", - "second", - ]); - expect(inbox.ingest([])).toMatchObject({ - ok: true, - applied: ["during-beforeinput"], - }); - expect(fixture.document.value.blocks[1]?.text).toBe("delayed"); - }); - - it("assigns owned publication sequences before commit and rejects raw writes", () => { - const fixture = setupEditor(); - const documentHost = getJsonEditableDocumentHost(fixture.editor); - const ownerships: Array = []; - let reentrantResult: - | ReturnType - | undefined; - const nestedApply = vi.fn(); - fixture.document.subscribe((operations, metadata) => { - ownerships.push( - documentHost.ownsPublication({ operations, metadata }), - ); - reentrantResult ??= documentHost.runReady({ - id: "nested", - apply: nestedApply, - }); - }); - - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "alpha", - from: 0, - to: 1, - text: "A", - }).ok, - ).toBe(true); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.editor.dispatch({ type: "redo" }).ok).toBe(true); - expect(fixture.editor.dispatch({ type: "reset" }).ok).toBe(true); - - const ownedSequences = ownerships.map((ownership) => { - if (ownership === false) { - throw new Error("Expected a coordinator-owned publication."); - } - return ownership.sequence; - }); - expect(ownedSequences).toHaveLength(4); - expect( - ownedSequences.every( - (sequence, index) => index === 0 || sequence > ownedSequences[index - 1]!, - ), - ).toBe(true); - expect(reentrantResult).toMatchObject({ - ok: false, - code: "host_not_ready", - }); - expect(nestedApply).not.toHaveBeenCalled(); - expect( - documentHost.ownsPublication({ operations: [] }), - ).toBe(false); - - expect( - fixture.document.commit([ - { op: "replace", path: "/blocks/0/text", value: "raw" }, - ]), - ).toEqual({ ok: true }); - expect(ownerships.at(-1)).toBe(false); - expect(fixture.faults).toContainEqual( - expect.objectContaining({ code: "out_of_band_document_write" }), - ); - }); - - it("journals an owned publication even when an editor subscriber throws", () => { - const fixture = setupEditor(() => { - throw new Error("fault observer failed"); - }); - const inbox = createInbox(fixture); - const base = fixture.document.value; - const subscriberFailure = new Error("subscriber failed"); - fixture.editor.subscribe(() => { - throw subscriberFailure; - }); - - expect(() => - fixture.editor.dispatch({ - type: "patch", - patch: [ - { - op: "add", - path: "/blocks/0", - value: { id: "leading", type: "paragraph", text: "leading" }, - }, - ], - }), - ).not.toThrow(); - expect(inbox.current().journalRevision).toBe(1); - - expect( - inbox.ingest({ - id: "after-throwing-subscriber", - dependsOn: [], - intent: { - kind: "positional", - base, - baseRevision: 0, - operations: [ - { op: "replace", path: "/blocks/1/text", value: "correct" }, - ], - }, - }), - ).toMatchObject({ ok: true, applied: ["after-throwing-subscriber"] }); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "leading", - "abcdef", - "correct", - ]); - expect(fixture.faults).toContainEqual( - expect.objectContaining({ code: "subscriber_failed" }), - ); - }); - - it("propagates ready apply failures without losing an already published DOM change", () => { - const fixture = setupEditor(); - const documentHost = getJsonEditableDocumentHost(fixture.editor); - const readyOwnerships: Array = []; - fixture.document.subscribe((operations, metadata) => { - readyOwnerships.push( - documentHost.ownsPublication({ operations, metadata }), - ); - }); - fixture.document.selection?.collapse({ - path: "/blocks/0/text", - offset: 1, - }); - const outside = window.document.createElement("button"); - window.document.body.append(outside); - outside.focus(); - const beforeFailure = new Error("before publication"); - expect(() => - documentHost.runReady({ - id: "before", - apply() { - throw beforeFailure; - }, - }), - ).toThrow(beforeFailure); - expect(fixture.document.value.blocks[0]?.text).toBe("abcdef"); - expect(window.document.activeElement).toBe(outside); - - const afterFailure = new Error("after publication"); - expect(() => - documentHost.runReady({ - id: "after", - apply() { - expect( - fixture.document.commit( - [{ op: "replace", path: "/blocks/0/text", value: "published" }], - { mergeKey: "after", origin: "causal-test" }, - ), - ).toEqual({ ok: true }); - throw afterFailure; - }, - }), - ).toThrow(afterFailure); - expect(fixture.document.value.blocks[0]?.text).toBe("published"); - expect(textNode(fixture, "alpha").data).toBe("published"); - expect(readyOwnerships).toEqual([{ sequence: 2 }]); - expect(window.document.activeElement).toBe(outside); - expect(fixture.faults).toEqual([]); - - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "beta", - from: 0, - to: 0, - text: "still usable ", - }).ok, - ).toBe(true); - }); - - it("keeps ready selection headless while another element owns focus", () => { - const fixture = setupEditor(); - const documentHost = getJsonEditableDocumentHost(fixture.editor); - const publications = vi.fn(); - fixture.document.subscribe(publications); - const outside = window.document.createElement("button"); - window.document.body.append(outside); - outside.focus(); - - expect( - documentHost.runReady({ - id: "test-only", - apply() { - expect( - fixture.document.commit( - [{ op: "test", path: "/blocks/1/id", value: "beta" }], - { mergeKey: "test-only", origin: "causal-test" }, - ), - ).toEqual({ ok: true }); - }, - }), - ).toEqual({ ok: true }); - expect(window.document.activeElement).toBe(outside); - - expect( - documentHost.runReady({ - id: "selection-only", - apply() { - expect( - fixture.document.commit( - [{ op: "test", path: "/blocks/1/id", value: "beta" }], - { - mergeKey: "selection-only", - origin: "causal-test", - selectionAfter: { path: "/blocks/1/text", offset: 3 }, - }, - ), - ).toEqual({ ok: true }); - }, - }), - ).toEqual({ ok: true }); - - expect(publications).not.toHaveBeenCalled(); - expect(fixture.document.selection?.primaryRange).toEqual({ - anchor: { path: "/blocks/1/text", offset: 3 }, - focus: { path: "/blocks/1/text", offset: 3 }, - }); - expect(window.document.activeElement).toBe(outside); - }); - - it("restores ready selection while a shadow-root editor owns focus", () => { - const shadowHost = window.document.createElement("div"); - window.document.body.append(shadowHost); - const shadowRoot = shadowHost.attachShadow({ mode: "open" }); - const root = window.document.createElement("div"); - shadowRoot.append(root); - const fixture = setupEditor(undefined, root); - const documentHost = getJsonEditableDocumentHost(fixture.editor); - fixture.root.focus(); - expect(window.document.activeElement).toBe(shadowHost); - expect(shadowRoot.activeElement).toBe(fixture.root); - const focus = vi.spyOn(fixture.root, "focus"); - - expect( - documentHost.runReady({ - id: "shadow-selection", - apply() { - fixture.document.commit( - [{ op: "test", path: "/blocks/1/id", value: "beta" }], - { - mergeKey: "shadow-selection", - origin: "causal-test", - selectionAfter: { path: "/blocks/1/text", offset: 2 }, - }, - ); - }, - }), - ).toEqual({ ok: true }); - - expect(fixture.document.selection?.primaryRange).toEqual({ - anchor: { path: "/blocks/1/text", offset: 2 }, - focus: { path: "/blocks/1/text", offset: 2 }, - }); - expect(focus).toHaveBeenCalledWith({ preventScroll: true }); - }); - - it("isolates an earlier editor observer and restores selection", () => { - const fixture = setupEditor(); - const documentHost = getJsonEditableDocumentHost(fixture.editor); - const alpha = textNode(fixture, "alpha"); - fixture.root.focus(); - setDOMCaret(alpha, 1); - fixture.document.selection?.collapse({ - path: "/blocks/0/text", - offset: 1, - }); - const observerFailure = new Error("observer failed"); - fixture.editor.subscribe(() => { - throw observerFailure; - }); - - expect( - documentHost.runReady({ - id: "selection-observer-failure", - apply() { - fixture.document.commit( - [{ op: "test", path: "/blocks/1/id", value: "beta" }], - { - mergeKey: "selection-observer-failure", - origin: "causal-test", - selectionAfter: { path: "/blocks/1/text", offset: 2 }, - }, - ); - }, - }), - ).toEqual({ ok: true }); - - expect(fixture.document.selection?.primaryRange).toEqual({ - anchor: { path: "/blocks/1/text", offset: 2 }, - focus: { path: "/blocks/1/text", offset: 2 }, - }); - expect(window.getSelection()?.focusNode).toBe(textNode(fixture, "beta")); - expect(window.getSelection()?.focusOffset).toBe(2); - expect(fixture.faults).toContainEqual( - expect.objectContaining({ code: "subscriber_failed" }), - ); - }); -}); diff --git a/packages/editable/core/editorCommands.test.ts b/packages/editable/core/editorCommands.test.ts deleted file mode 100644 index a2d1a3a..0000000 --- a/packages/editable/core/editorCommands.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { SelectionSnap } from "@interactive-os/json-document"; -import { describe, expect, it, vi } from "vitest"; -import type { EditableDocumentValue } from "./model"; -import { planEditorCommand } from "./editorCommands"; - -const value: EditableDocumentValue = { - schema: "interactive-os.editable-document@2", - id: "command-test", - blocks: [ - { id: "alpha", type: "paragraph", text: "first" }, - { id: "beta", type: "quote", text: "second" }, - ], -}; - -describe("editor command planning", () => { - it("clamps text replacement ranges and preserves remote metadata", () => { - expect( - planEditorCommand( - value, - null, - { - type: "replaceText", - blockId: "alpha", - from: 99, - to: 2, - text: "X", - label: "remote edit", - origin: "remote", - }, - unusedBlockId, - ), - ).toEqual({ - kind: "commit", - patch: [{ op: "replace", path: "/blocks/0/text", value: "fiX" }], - label: "remote edit", - source: "remote", - }); - }); - - it("replaces a multi-block selection and removes covered blocks", () => { - expect( - planEditorCommand( - value, - selectionBetween(0, 2, 1, 3), - { type: "replaceSelection", text: "X" }, - unusedBlockId, - ), - ).toEqual({ - kind: "commit", - patch: [ - { op: "replace", path: "/blocks/0/text", value: "fiXond" }, - { op: "remove", path: "/blocks/1" }, - ], - label: "replace selection", - source: "app", - selectionAfter: selectionBetween(0, 3, 0, 3), - }); - }); - - it("allocates a paragraph only after resolving a valid selection", () => { - const allocateBlockId = vi.fn(() => "new-block"); - - expect( - planEditorCommand( - value, - selectionBetween(0, 2, 1, 1), - { type: "insertParagraph" }, - allocateBlockId, - ), - ).toEqual({ - kind: "commit", - patch: [ - { op: "replace", path: "/blocks/0/text", value: "fi" }, - { op: "remove", path: "/blocks/1" }, - { - op: "add", - path: "/blocks/1", - value: { id: "new-block", type: "paragraph", text: "econd" }, - }, - ], - label: "insert paragraph", - source: "app", - selectionAfter: selectionBetween(1, 0, 1, 0), - }); - expect(allocateBlockId).toHaveBeenCalledOnce(); - - expect( - planEditorCommand(value, null, { type: "insertParagraph" }, allocateBlockId), - ).toMatchObject({ kind: "failure", code: "selection_unavailable" }); - expect(allocateBlockId).toHaveBeenCalledOnce(); - }); - - it("deletes one grapheme instead of one UTF-16 code unit", () => { - const family = "👨‍👩‍👧‍👦"; - const emojiValue: EditableDocumentValue = { - ...value, - blocks: [{ id: "alpha", type: "paragraph", text: `A${family}B` }], - }; - - expect( - planEditorCommand( - emojiValue, - selectionBetween(0, 1 + family.length, 0, 1 + family.length), - { type: "deleteBackward" }, - unusedBlockId, - ), - ).toEqual({ - kind: "commit", - patch: [{ op: "replace", path: "/blocks/0/text", value: "AB" }], - label: "delete backward", - source: "app", - selectionAfter: selectionBetween(0, 1, 0, 1), - }); - }); - - it("plans backward and forward joins with the original caret semantics", () => { - expect( - planEditorCommand( - value, - selectionBetween(1, 0, 1, 0), - { type: "joinBackward" }, - unusedBlockId, - ), - ).toEqual({ - kind: "commit", - patch: [ - { op: "replace", path: "/blocks/0/text", value: "firstsecond" }, - { op: "remove", path: "/blocks/1" }, - ], - label: "join backward", - source: "app", - selectionAfter: selectionBetween(0, 5, 0, 5), - }); - - expect( - planEditorCommand( - value, - selectionBetween(0, 5, 0, 5), - { type: "joinForward" }, - unusedBlockId, - ), - ).toMatchObject({ - kind: "commit", - label: "join forward", - selectionAfter: selectionBetween(0, 5, 0, 5), - }); - }); - - it("targets an explicit block type or the primary focus block", () => { - const selection = selectionBetween(0, 0, 1, 2); - expect( - planEditorCommand( - value, - selection, - { type: "setBlockType", blockType: "code" }, - unusedBlockId, - ), - ).toEqual({ - kind: "commit", - patch: [{ op: "replace", path: "/blocks/1/type", value: "code" }], - label: "set block type: code", - source: "app", - selectionAfter: selection, - }); - - expect( - planEditorCommand( - value, - selection, - { - type: "setBlockType", - blockId: "alpha", - blockType: "paragraph", - }, - unusedBlockId, - ), - ).toEqual({ kind: "none" }); - }); -}); - -function selectionBetween( - anchorBlock: number, - anchorOffset: number, - focusBlock: number, - focusOffset: number, -): SelectionSnap { - const anchor = { path: `/blocks/${anchorBlock}/text`, offset: anchorOffset }; - const focus = { path: `/blocks/${focusBlock}/text`, offset: focusOffset }; - return { - selectedPointers: [], - selectionRanges: [{ anchor, focus }], - primaryIndex: 0, - anchor, - focus, - }; -} - -function unusedBlockId(): string { - throw new Error("This command must not allocate a block id."); -} diff --git a/packages/editable/core/editorCommands.ts b/packages/editable/core/editorCommands.ts deleted file mode 100644 index 012d1ca..0000000 --- a/packages/editable/core/editorCommands.ts +++ /dev/null @@ -1,463 +0,0 @@ -import type { - JSONPatchOperation, - SelectionSnap, -} from "@interactive-os/json-document"; -import { - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - primaryEditablePoint, - type EditableBlock, - type EditableBlockType, - type EditableDocumentValue, - type OrderedEditableSelection, -} from "./model"; -import { clampTextRange } from "./textChange"; - -export type EditorDocumentCommand = - | { - type: "replaceText"; - blockId: string; - from: number; - to: number; - text: string; - label?: string; - origin?: string; - } - | { - type: "replaceSelection"; - text: string; - label?: string; - origin?: string; - } - | { - type: "setBlockType"; - blockType: EditableBlockType; - blockId?: string; - } - | { type: "insertParagraph" } - | { type: "deleteBackward" | "deleteForward" } - | { type: "joinBackward" } - | { type: "joinForward" }; - -export type EditorCommandPlan = - | { - kind: "commit"; - patch: ReadonlyArray; - label: string; - source: "app" | "remote"; - selectionAfter?: SelectionSnap | null; - } - | { kind: "none" } - | { - kind: "failure"; - code: "block_not_found" | "selection_unavailable"; - reason: string; - }; - -export function planEditorCommand( - value: EditableDocumentValue, - selection: SelectionSnap | null, - action: EditorDocumentCommand, - allocateBlockId: () => string, -): EditorCommandPlan { - switch (action.type) { - case "replaceText": - return planTextReplacement(value, action); - case "replaceSelection": - return planSelectionReplacement( - value, - selection, - action.text, - action.label ?? "replace selection", - sourceFromOrigin(action.origin), - ); - case "setBlockType": - return planBlockTypeChange(value, selection, action); - case "insertParagraph": - return planParagraphInsertion(value, selection, allocateBlockId); - case "deleteBackward": - return planDirectionalDelete(value, selection, "backward"); - case "deleteForward": - return planDirectionalDelete(value, selection, "forward"); - case "joinBackward": - return planBackwardJoin(value, selection); - case "joinForward": - return planForwardJoin(value, selection); - } -} - -function planTextReplacement( - value: EditableDocumentValue, - action: Extract, -): EditorCommandPlan { - const index = findEditableBlockIndex(value, action.blockId); - const block = value.blocks[index]; - if (block === undefined) { - return failure("block_not_found", `Unknown block: ${action.blockId}`); - } - const range = clampTextRange( - { - from: Math.min(action.from, action.to), - to: Math.max(action.from, action.to), - }, - block.text.length, - ); - const next = - block.text.slice(0, range.from) + - action.text + - block.text.slice(range.to); - if (next === block.text) { - return { kind: "none" }; - } - return commit( - [{ op: "replace", path: editableTextPath(index), value: next }], - action.label ?? "replace text", - sourceFromOrigin(action.origin), - ); -} - -function planSelectionReplacement( - value: EditableDocumentValue, - selection: SelectionSnap | null, - text: string, - label: string, - source: "app" | "remote", -): EditorCommandPlan { - const ordered = readOrderedSelection(value, selection); - if (ordered === null) { - return noSelection(); - } - - const { start, end } = ordered; - const startBlock = value.blocks[start.blockIndex]; - const endBlock = value.blocks[end.blockIndex]; - if (startBlock === undefined || endBlock === undefined) { - return staleSelection(); - } - - const nextText = - startBlock.text.slice(0, start.offset) + - text + - endBlock.text.slice(end.offset); - const patch: JSONPatchOperation[] = [ - { - op: "replace", - path: editableTextPath(start.blockIndex), - value: nextText, - }, - ]; - for (let index = end.blockIndex; index > start.blockIndex; index -= 1) { - patch.push({ op: "remove", path: `/blocks/${index}` }); - } - - return commit( - patch, - label, - source, - selectionAt(editableTextPath(start.blockIndex), start.offset + text.length), - ); -} - -function planDirectionalDelete( - value: EditableDocumentValue, - selection: SelectionSnap | null, - direction: "backward" | "forward", -): EditorCommandPlan { - const ordered = readOrderedSelection(value, selection); - if (ordered === null) { - return noSelection(); - } - if (!isCollapsed(ordered)) { - return planSelectionReplacement( - value, - selection, - "", - `delete ${direction}`, - "app", - ); - } - - const index = ordered.start.blockIndex; - const block = value.blocks[index]; - if (block === undefined) { - return staleSelection(); - } - const offset = ordered.start.offset; - if (direction === "backward" && offset === 0) { - return planBackwardJoin(value, selection); - } - if (direction === "forward" && offset === block.text.length) { - return planForwardJoin(value, selection); - } - - const from = - direction === "backward" - ? previousGraphemeBoundary(block.text, offset) - : offset; - const to = - direction === "forward" - ? nextGraphemeBoundary(block.text, offset) - : offset; - const next = block.text.slice(0, from) + block.text.slice(to); - return commit( - [{ op: "replace", path: editableTextPath(index), value: next }], - `delete ${direction}`, - "app", - selectionAt(editableTextPath(index), from), - ); -} - -function planBlockTypeChange( - value: EditableDocumentValue, - selection: SelectionSnap | null, - action: Extract, -): EditorCommandPlan { - const point = primaryEditablePoint(value, selectionState(selection)); - const blockId = action.blockId ?? point?.blockId; - const index = - blockId === undefined ? -1 : findEditableBlockIndex(value, blockId); - const block = value.blocks[index]; - if (block === undefined) { - return failure( - "selection_unavailable", - "Select an editable block first.", - ); - } - if (block.type === action.blockType) { - return { kind: "none" }; - } - return commit( - [{ op: "replace", path: `/blocks/${index}/type`, value: action.blockType }], - `set block type: ${action.blockType}`, - "app", - selection, - ); -} - -function planParagraphInsertion( - value: EditableDocumentValue, - selection: SelectionSnap | null, - allocateBlockId: () => string, -): EditorCommandPlan { - const ordered = readOrderedSelection(value, selection); - if (ordered === null) { - return noSelection(); - } - const { start, end } = ordered; - const startBlock = value.blocks[start.blockIndex]; - const endBlock = value.blocks[end.blockIndex]; - if (startBlock === undefined || endBlock === undefined) { - return staleSelection(); - } - - const newBlock: EditableBlock = { - id: allocateBlockId(), - type: "paragraph", - text: endBlock.text.slice(end.offset), - }; - const patch: JSONPatchOperation[] = [ - { - op: "replace", - path: editableTextPath(start.blockIndex), - value: startBlock.text.slice(0, start.offset), - }, - ]; - for (let index = end.blockIndex; index > start.blockIndex; index -= 1) { - patch.push({ op: "remove", path: `/blocks/${index}` }); - } - patch.push({ - op: "add", - path: `/blocks/${start.blockIndex + 1}`, - value: newBlock, - }); - - return commit( - patch, - "insert paragraph", - "app", - selectionAt(editableTextPath(start.blockIndex + 1), 0), - ); -} - -function planBackwardJoin( - value: EditableDocumentValue, - selection: SelectionSnap | null, -): EditorCommandPlan { - const ordered = readOrderedSelection(value, selection); - if (ordered === null) { - return noSelection(); - } - if (!isCollapsed(ordered)) { - return planSelectionReplacement( - value, - selection, - "", - "delete selection", - "app", - ); - } - if (ordered.start.offset !== 0 || ordered.start.blockIndex === 0) { - return { kind: "none" }; - } - - const currentIndex = ordered.start.blockIndex; - const previous = value.blocks[currentIndex - 1]; - const current = value.blocks[currentIndex]; - if (previous === undefined || current === undefined) { - return staleSelection(); - } - const offset = previous.text.length; - return commit( - [ - { - op: "replace", - path: editableTextPath(currentIndex - 1), - value: previous.text + current.text, - }, - { op: "remove", path: `/blocks/${currentIndex}` }, - ], - "join backward", - "app", - selectionAt(editableTextPath(currentIndex - 1), offset), - ); -} - -function planForwardJoin( - value: EditableDocumentValue, - selection: SelectionSnap | null, -): EditorCommandPlan { - const ordered = readOrderedSelection(value, selection); - if (ordered === null) { - return noSelection(); - } - if (!isCollapsed(ordered)) { - return planSelectionReplacement( - value, - selection, - "", - "delete selection", - "app", - ); - } - - const currentIndex = ordered.start.blockIndex; - const current = value.blocks[currentIndex]; - const next = value.blocks[currentIndex + 1]; - if ( - current === undefined || - next === undefined || - ordered.start.offset !== current.text.length - ) { - return { kind: "none" }; - } - return commit( - [ - { - op: "replace", - path: editableTextPath(currentIndex), - value: current.text + next.text, - }, - { op: "remove", path: `/blocks/${currentIndex + 1}` }, - ], - "join forward", - "app", - selectionAt(editableTextPath(currentIndex), current.text.length), - ); -} - -function readOrderedSelection( - value: EditableDocumentValue, - selection: SelectionSnap | null, -): OrderedEditableSelection | null { - return orderedEditableSelection(value, selectionState(selection)); -} - -function selectionState(selection: SelectionSnap | null): { - primaryRange: SelectionSnap["selectionRanges"][number] | null; -} { - return { - primaryRange: - selection?.selectionRanges[selection.primaryIndex] ?? null, - }; -} - -function isCollapsed(selection: OrderedEditableSelection): boolean { - return ( - selection.start.blockIndex === selection.end.blockIndex && - selection.start.offset === selection.end.offset - ); -} - -function sourceFromOrigin(origin: string | undefined): "app" | "remote" { - return origin === "remote" ? "remote" : "app"; -} - -function selectionAt(path: string, offset: number): SelectionSnap { - const anchor = { path, offset }; - const focus = { path, offset }; - return { - selectedPointers: [], - selectionRanges: [{ anchor, focus }], - primaryIndex: 0, - anchor, - focus, - }; -} - -function previousGraphemeBoundary(value: string, offset: number): number { - let previous = 0; - for (const segment of new Intl.Segmenter(undefined, { - granularity: "grapheme", - }).segment(value)) { - if (segment.index >= offset) { - break; - } - previous = segment.index; - } - return previous; -} - -function nextGraphemeBoundary(value: string, offset: number): number { - for (const segment of new Intl.Segmenter(undefined, { - granularity: "grapheme", - }).segment(value)) { - if (segment.index > offset) { - return segment.index; - } - } - return value.length; -} - -function commit( - patch: ReadonlyArray, - label: string, - source: "app" | "remote", - selectionAfter?: SelectionSnap | null, -): EditorCommandPlan { - return { - kind: "commit", - patch, - label, - source, - ...(selectionAfter === undefined ? {} : { selectionAfter }), - }; -} - -function noSelection(): EditorCommandPlan { - return failure( - "selection_unavailable", - "No editable selection is active.", - ); -} - -function staleSelection(): EditorCommandPlan { - return failure("selection_unavailable", "The selection is stale."); -} - -function failure( - code: Extract["code"], - reason: string, -): EditorCommandPlan { - return { kind: "failure", code, reason }; -} diff --git a/packages/editable/core/index.ts b/packages/editable/core/index.ts index 5b71d92..f622f6a 100644 --- a/packages/editable/core/index.ts +++ b/packages/editable/core/index.ts @@ -1,30 +1,39 @@ -export { - createEditableDocument, - createInitialEditableValue, - EditableDocumentSchema, - editableBlockIndexFromTextPath, - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - primaryEditablePoint, -} from "./model"; export type { - EditableBlock, - EditableBlockType, - EditableDocumentValue, - EditablePoint, - OrderedEditableSelection, -} from "./model"; -export { planEditorCommand } from "./editorCommands"; + MarkdownBlockAnalysis, + MarkdownDocumentAnalysis, + MarkdownDocumentBlock, + MarkdownHeadingLevel, + MarkdownSegment, + MarkdownSyntax, + MarkdownSyntaxKind, + SourceRange, +} from "./markdown.js"; +export { analyzeMarkdownBlock, analyzeMarkdownDocument } from "./markdown.js"; +export type { + MarkdownAffinity, + MarkdownBlockType, + MarkdownInlineMarkType, +} from "./markdownModel.js"; +export { MARKDOWN_SOURCE_PATH } from "./markdownModel.js"; export type { - EditorCommandPlan, - EditorDocumentCommand, -} from "./editorCommands"; + MarkdownLivePreviewPreset, + MarkdownMarkerProjection, + MarkdownPresentation, + MarkdownProjectionPolicyDescriptor, + MarkdownProjectionSelection, + MarkdownProjectionState, + MarkdownProjectionSyntax, + MarkdownProjectionUnit, + MarkdownRevealPolicy, +} from "./markdownProjection.js"; +export { + defineMarkdownProjectionPolicy, + markdownProjection, + resolveMarkdownProjectionPolicy, +} from "./markdownProjection.js"; +export type { TextChange, TextRange } from "./textChange.js"; export { - accumulateNativeCompositionRange, applyTextChange, - clampTextRange, diffText, - diffTextNearRange, -} from "./textChange"; -export type { TextChange, TextRange } from "./textChange"; + diffTextAtRange, +} from "./textChange.js"; diff --git a/packages/editable/core/markdown.test.ts b/packages/editable/core/markdown.test.ts new file mode 100644 index 0000000..63a8ec8 --- /dev/null +++ b/packages/editable/core/markdown.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, it } from "vitest"; +import { analyzeMarkdownBlock, analyzeMarkdownDocument } from "./markdown"; + +describe("analyzeMarkdownBlock", () => { + it("preserves strong notation while deriving its semantic projection", () => { + expect(analyzeMarkdownBlock("**한글**")).toEqual({ + blockType: "paragraph", + headingLevel: null, + semanticText: "한글", + source: "**한글**", + syntaxes: [ + { + contentRanges: [{ from: 2, to: 4 }], + id: "strong:0", + kind: "strong", + markerRanges: [ + { from: 0, to: 2 }, + { from: 4, to: 6 }, + ], + ownerRange: { from: 0, to: 6 }, + }, + ], + segments: [ + { + from: 0, + kind: "marker", + marks: [], + ownerId: "strong:0", + role: "open", + to: 2, + }, + { + from: 2, + kind: "text", + marks: ["bold"], + ownerId: "strong:0", + to: 4, + }, + { + from: 4, + kind: "marker", + marks: [], + ownerId: "strong:0", + role: "close", + to: 6, + }, + ], + }); + }); + + it("finds a strong owner without consuming surrounding source", () => { + const analysis = analyzeMarkdownBlock("앞 **한글** 뒤"); + + expect(analysis.semanticText).toBe("앞 한글 뒤"); + expect(analysis.syntaxes).toEqual([ + { + contentRanges: [{ from: 4, to: 6 }], + id: "strong:0", + kind: "strong", + markerRanges: [ + { from: 2, to: 4 }, + { from: 6, to: 8 }, + ], + ownerRange: { from: 2, to: 8 }, + }, + ]); + expect(analysis.segments).toEqual([ + { from: 0, kind: "text", marks: [], to: 2 }, + { + from: 2, + kind: "marker", + marks: [], + ownerId: "strong:0", + role: "open", + to: 4, + }, + { + from: 4, + kind: "text", + marks: ["bold"], + ownerId: "strong:0", + to: 6, + }, + { + from: 6, + kind: "marker", + marks: [], + ownerId: "strong:0", + role: "close", + to: 8, + }, + { from: 8, kind: "text", marks: [], to: 10 }, + ]); + }); + + it.each([ + [1, "# 제목", 2], + [2, "## 제목", 3], + [3, "### 제목", 4], + [4, "#### 제목", 5], + [5, "##### 제목", 6], + [6, "###### 제목", 7], + ] as const)("derives ATX heading level %i without deleting its source marker", (headingLevel, source, contentFrom) => { + const analysis = analyzeMarkdownBlock(source); + + expect(analysis).toMatchObject({ + blockType: "heading", + headingLevel, + semanticText: "제목", + source, + syntaxes: [ + { + contentRanges: [{ from: contentFrom, to: source.length }], + id: "heading-marker:0", + kind: "heading-marker", + markerRanges: [{ from: 0, to: contentFrom }], + ownerRange: { from: 0, to: source.length }, + }, + ], + }); + }); + + it("keeps every line marker owned by one multiline blockquote", () => { + expect(analyzeMarkdownBlock("> 첫째\n> 둘째")).toEqual({ + blockType: "quote", + headingLevel: null, + semanticText: "첫째\n둘째", + source: "> 첫째\n> 둘째", + syntaxes: [ + { + contentRanges: [ + { from: 2, to: 5 }, + { from: 7, to: 9 }, + ], + id: "quote-marker:0", + kind: "quote-marker", + markerRanges: [ + { from: 0, to: 2 }, + { from: 5, to: 7 }, + ], + ownerRange: { from: 0, to: 9 }, + }, + ], + segments: [ + { + from: 0, + kind: "marker", + marks: [], + ownerId: "quote-marker:0", + role: "open", + to: 2, + }, + { + from: 2, + kind: "text", + marks: [], + ownerId: "quote-marker:0", + to: 5, + }, + { + from: 5, + kind: "marker", + marks: [], + ownerId: "quote-marker:0", + role: "open", + to: 7, + }, + { + from: 7, + kind: "text", + marks: [], + ownerId: "quote-marker:0", + to: 9, + }, + ], + }); + }); + + it("derives supported inline marks without exposing parser node names", () => { + const analysis = analyzeMarkdownBlock("**굵게** *기울임* `코드` ~~취소~~"); + + expect(analysis.semanticText).toBe("굵게 기울임 코드 취소"); + expect(analysis.syntaxes.map(({ id, kind }) => ({ id, kind }))).toEqual([ + { id: "strong:0", kind: "strong" }, + { id: "emphasis:0", kind: "emphasis" }, + { id: "inline-code:0", kind: "inline-code" }, + { id: "strikethrough:0", kind: "strikethrough" }, + ]); + expect( + analysis.segments + .filter((segment) => segment.kind === "text") + .map((segment) => segment.marks), + ).toEqual([["bold"], [], ["italic"], [], ["code"], [], ["strike"]]); + }); + + it("partitions nested owners deterministically and composes their marks", () => { + const analysis = analyzeMarkdownBlock("**굵고 *기울임***"); + + expect(analysis.semanticText).toBe("굵고 기울임"); + expect(analysis.syntaxes).toEqual([ + { + contentRanges: [{ from: 2, to: 10 }], + id: "strong:0", + kind: "strong", + markerRanges: [ + { from: 0, to: 2 }, + { from: 10, to: 12 }, + ], + ownerRange: { from: 0, to: 12 }, + }, + { + contentRanges: [{ from: 6, to: 9 }], + id: "emphasis:0", + kind: "emphasis", + markerRanges: [ + { from: 5, to: 6 }, + { from: 9, to: 10 }, + ], + ownerRange: { from: 5, to: 10 }, + }, + ]); + expect(analysis.segments).toEqual([ + { + from: 0, + kind: "marker", + marks: [], + ownerId: "strong:0", + role: "open", + to: 2, + }, + { + from: 2, + kind: "text", + marks: ["bold"], + ownerId: "strong:0", + to: 5, + }, + { + from: 5, + kind: "marker", + marks: ["bold"], + ownerId: "emphasis:0", + role: "open", + to: 6, + }, + { + from: 6, + kind: "text", + marks: ["bold", "italic"], + ownerId: "emphasis:0", + to: 9, + }, + { + from: 9, + kind: "marker", + marks: ["bold"], + ownerId: "emphasis:0", + role: "close", + to: 10, + }, + { + from: 10, + kind: "marker", + marks: [], + ownerId: "strong:0", + role: "close", + to: 12, + }, + ]); + }); + + it.each([ + "**미완성", + "*미완성", + "`미완성", + "~~미완성", + ])("keeps incomplete syntax literal: %s", (source) => { + expect(analyzeMarkdownBlock(source)).toMatchObject({ + blockType: "paragraph", + semanticText: source, + source, + syntaxes: [], + segments: [{ from: 0, kind: "text", marks: [], to: source.length }], + }); + }); + + it.each([ + "#", + "# ", + ">", + "> ", + ])("keeps an empty block marker literal: %j", (source) => { + expect(analyzeMarkdownBlock(source)).toMatchObject({ + blockType: "paragraph", + headingLevel: null, + semanticText: source, + source, + syntaxes: [], + segments: [{ from: 0, kind: "text", marks: [], to: source.length }], + }); + }); + + it("reports source offsets in UTF-16 code units", () => { + const analysis = analyzeMarkdownBlock("😀 **한글**"); + + expect(analysis.syntaxes[0]).toMatchObject({ + contentRanges: [{ from: 5, to: 7 }], + markerRanges: [ + { from: 3, to: 5 }, + { from: 7, to: 9 }, + ], + ownerRange: { from: 3, to: 9 }, + }); + }); + + it("treats backslash escapes as source notation around literal punctuation", () => { + const analysis = analyzeMarkdownBlock(String.raw`\*별표\*`); + + expect(analysis.semanticText).toBe("*별표*"); + expect(analysis.syntaxes).toEqual([ + { + contentRanges: [{ from: 1, to: 2 }], + id: "escape:0", + kind: "escape", + markerRanges: [{ from: 0, to: 1 }], + ownerRange: { from: 0, to: 2 }, + }, + { + contentRanges: [{ from: 5, to: 6 }], + id: "escape:1", + kind: "escape", + markerRanges: [{ from: 4, to: 5 }], + ownerRange: { from: 4, to: 6 }, + }, + ]); + }); + + it("derives fenced code while preserving its exact fence source", () => { + const analysis = analyzeMarkdownBlock("````ts\nconst x = ```\n````"); + + expect(analysis).toMatchObject({ + blockType: "code", + semanticText: "const x = ```", + syntaxes: [ + { + id: "code-fence:0", + kind: "code-fence", + markerRanges: [ + { from: 0, to: 7 }, + { from: 20, to: 25 }, + ], + contentRanges: [{ from: 7, to: 20 }], + ownerRange: { from: 0, to: 25 }, + }, + ], + }); + }); + + it("treats required inline-code padding as notation, not content", () => { + const analysis = analyzeMarkdownBlock("```` ``` ````"); + + expect(analysis.semanticText).toBe("```"); + expect(analysis.syntaxes[0]).toMatchObject({ + kind: "inline-code", + markerRanges: [ + { from: 0, to: 5 }, + { from: 8, to: 13 }, + ], + contentRanges: [{ from: 5, to: 8 }], + }); + }); + + it("supports the explicit Markdown extension without making HTML canonical", () => { + const analysis = analyzeMarkdownBlock("앞 한글 뒤"); + + expect(analysis.semanticText).toBe("앞 한글 뒤"); + expect(analysis.syntaxes).toEqual([ + { + contentRanges: [{ from: 5, to: 7 }], + id: "underline:0", + kind: "underline", + markerRanges: [ + { from: 2, to: 5 }, + { from: 7, to: 11 }, + ], + ownerRange: { from: 2, to: 11 }, + }, + ]); + expect( + analysis.segments + .filter((segment) => segment.kind === "text") + .map((segment) => segment.marks), + ).toEqual([[], ["underline"], []]); + }); +}); + +describe("analyzeMarkdownDocument", () => { + it("normalizes every block into one global UTF-16 source coordinate", () => { + const source = "# 제목\n\n> 인용\n\n**한글**"; + const analysis = analyzeMarkdownDocument(source); + + expect(analysis.source).toBe(source); + expect( + analysis.blocks.map( + ({ id, sourceRange, blockType, semanticText, syntaxes }) => ({ + id, + sourceRange, + blockType, + semanticText, + syntaxes, + }), + ), + ).toEqual([ + { + id: "block:0", + sourceRange: { from: 0, to: 4 }, + blockType: "heading", + semanticText: "제목", + syntaxes: [ + { + contentRanges: [{ from: 2, to: 4 }], + id: "block:0/heading-marker:0", + kind: "heading-marker", + markerRanges: [{ from: 0, to: 2 }], + ownerRange: { from: 0, to: 4 }, + }, + ], + }, + { + id: "block:1", + sourceRange: { from: 6, to: 10 }, + blockType: "quote", + semanticText: "인용", + syntaxes: [ + { + contentRanges: [{ from: 8, to: 10 }], + id: "block:1/quote-marker:0", + kind: "quote-marker", + markerRanges: [{ from: 6, to: 8 }], + ownerRange: { from: 6, to: 10 }, + }, + ], + }, + { + id: "block:2", + sourceRange: { from: 12, to: 18 }, + blockType: "paragraph", + semanticText: "한글", + syntaxes: [ + { + contentRanges: [{ from: 14, to: 16 }], + id: "block:2/strong:0", + kind: "strong", + markerRanges: [ + { from: 12, to: 14 }, + { from: 16, to: 18 }, + ], + ownerRange: { from: 12, to: 18 }, + }, + ], + }, + ]); + }); + + it("keeps an empty source editable as one empty paragraph", () => { + expect(analyzeMarkdownDocument("")).toMatchObject({ + source: "", + blocks: [ + { + id: "block:0", + source: "", + sourceRange: { from: 0, to: 0 }, + blockType: "paragraph", + segments: [], + }, + ], + }); + }); +}); diff --git a/packages/editable/core/markdown.ts b/packages/editable/core/markdown.ts new file mode 100644 index 0000000..3e99912 --- /dev/null +++ b/packages/editable/core/markdown.ts @@ -0,0 +1,654 @@ +import { parser, Strikethrough } from "@lezer/markdown"; +import type { + MarkdownBlockType, + MarkdownInlineMarkType, +} from "./markdownModel.js"; + +export type SourceRange = { + from: number; + to: number; +}; + +export type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +export type MarkdownSyntaxKind = + | "strong" + | "emphasis" + | "inline-code" + | "strikethrough" + | "underline" + | "escape" + | "code-fence" + | "heading-marker" + | "quote-marker"; + +export type MarkdownSyntax = { + id: string; + kind: MarkdownSyntaxKind; + ownerRange: SourceRange; + markerRanges: readonly SourceRange[]; + contentRanges: readonly SourceRange[]; +}; + +export type MarkdownSegment = + | { + kind: "marker"; + from: number; + to: number; + marks: readonly MarkdownInlineMarkType[]; + ownerId: string; + role: "open" | "close"; + } + | { + kind: "text"; + from: number; + to: number; + marks: readonly MarkdownInlineMarkType[]; + ownerId?: string; + }; + +export type MarkdownBlockAnalysis = { + source: string; + blockType: MarkdownBlockType; + headingLevel: MarkdownHeadingLevel | null; + semanticText: string; + syntaxes: readonly MarkdownSyntax[]; + segments: readonly MarkdownSegment[]; +}; + +export type MarkdownDocumentBlock = MarkdownBlockAnalysis & { + id: string; + sourceRange: SourceRange; +}; + +export type MarkdownDocumentAnalysis = { + source: string; + blocks: readonly MarkdownDocumentBlock[]; +}; + +type ParsedNode = ReturnType["topNode"]; + +type SyntaxCandidate = { + depth: number; + kind: MarkdownSyntaxKind; + mark: MarkdownInlineMarkType | null; + ownerRange: SourceRange; + markerRanges: SourceRange[]; +}; + +type OwnedSyntax = SyntaxCandidate & { + id: string; +}; + +type OwnedMarker = { + from: number; + to: number; + owner: OwnedSyntax; + role: "open" | "close"; +}; + +const markdownParser = parser.configure(Strikethrough); + +const markOrder: readonly MarkdownInlineMarkType[] = [ + "bold", + "italic", + "underline", + "strike", + "code", +]; + +export function analyzeMarkdownBlock(source: string): MarkdownBlockAnalysis { + const root = markdownParser.parse(source).topNode; + const parsedBlock = analyzeBlock(root); + const syntaxes = ownSyntaxes( + collectSyntaxCandidates(source, root).filter(hasBlockMarkerContent), + ); + const { blockType, headingLevel } = blockAnalysis(parsedBlock, syntaxes); + const markers = collectOwnedMarkers(syntaxes); + const segments = createSegments(source, syntaxes, markers); + + return { + source, + blockType, + headingLevel, + semanticText: segments + .filter((segment) => segment.kind === "text") + .map((segment) => source.slice(segment.from, segment.to)) + .join(""), + syntaxes: syntaxes.map(({ id, kind, ownerRange, markerRanges }) => ({ + id, + kind, + ownerRange, + markerRanges, + contentRanges: subtractRanges(ownerRange, markerRanges), + })), + segments, + }; +} + +function hasBlockMarkerContent(candidate: SyntaxCandidate): boolean { + if ( + candidate.kind !== "heading-marker" && + candidate.kind !== "quote-marker" + ) { + return true; + } + return !candidate.markerRanges.some( + (marker) => + marker.from === candidate.ownerRange.from && + marker.to === candidate.ownerRange.to, + ); +} + +function blockAnalysis( + parsed: ReturnType, + syntaxes: readonly OwnedSyntax[], +): ReturnType { + const requiredSyntax = + parsed.blockType === "heading" + ? "heading-marker" + : parsed.blockType === "quote" + ? "quote-marker" + : null; + return requiredSyntax === null || + syntaxes.some((syntax) => syntax.kind === requiredSyntax) + ? parsed + : { blockType: "paragraph", headingLevel: null }; +} + +export function analyzeMarkdownDocument( + source: string, +): MarkdownDocumentAnalysis { + const root = markdownParser.parse(source).topNode; + const parsedBlocks = directChildren(root); + if (parsedBlocks.length === 0) { + return { + source, + blocks: [shiftBlockAnalysis(analyzeMarkdownBlock(source), 0, "block:0")], + }; + } + + const blocks = parsedBlocks.map((node, index) => { + const id = `block:${index}`; + return shiftBlockAnalysis( + analyzeMarkdownBlock(source.slice(node.from, node.to)), + node.from, + id, + ); + }); + return { source, blocks }; +} + +function shiftBlockAnalysis( + analysis: MarkdownBlockAnalysis, + offset: number, + id: string, +): MarkdownDocumentBlock { + const shift = ({ from, to }: SourceRange): SourceRange => ({ + from: from + offset, + to: to + offset, + }); + const ownerIds = new Map( + analysis.syntaxes.map((syntax) => [syntax.id, `${id}/${syntax.id}`]), + ); + return { + ...analysis, + id, + sourceRange: { from: offset, to: offset + analysis.source.length }, + syntaxes: analysis.syntaxes.map((syntax) => ({ + ...syntax, + id: ownerIds.get(syntax.id) ?? `${id}/${syntax.id}`, + ownerRange: shift(syntax.ownerRange), + markerRanges: syntax.markerRanges.map(shift), + contentRanges: syntax.contentRanges.map(shift), + })), + segments: analysis.segments.map((segment) => ({ + ...segment, + from: segment.from + offset, + to: segment.to + offset, + ...(segment.ownerId === undefined + ? {} + : { + ownerId: + ownerIds.get(segment.ownerId) ?? `${id}/${segment.ownerId}`, + }), + })), + }; +} + +function analyzeBlock(root: ParsedNode): { + blockType: MarkdownBlockType; + headingLevel: MarkdownHeadingLevel | null; +} { + const block = root.firstChild; + const headingLevel = block === null ? null : readHeadingLevel(block.name); + + if (headingLevel !== null) { + return { blockType: "heading", headingLevel }; + } + if (block?.name === "Blockquote") { + return { blockType: "quote", headingLevel: null }; + } + if (block?.name === "FencedCode" || block?.name === "IndentedCode") { + return { blockType: "code", headingLevel: null }; + } + return { blockType: "paragraph", headingLevel: null }; +} + +function collectSyntaxCandidates( + source: string, + root: ParsedNode, +): SyntaxCandidate[] { + const candidates: SyntaxCandidate[] = []; + const underlineStack: { node: ParsedNode; depth: number }[] = []; + + visitParsedNodes(root, 0, (node, depth) => { + if (node.name === "HTMLTag") { + const tag = source.slice(node.from, node.to).toLowerCase(); + if (tag === "") { + underlineStack.push({ node, depth }); + } else if (tag === "") { + const opening = underlineStack.pop(); + if (opening !== undefined) { + candidates.push({ + depth: opening.depth, + kind: "underline", + mark: "underline", + ownerRange: { from: opening.node.from, to: node.to }, + markerRanges: [rangeOf(opening.node), rangeOf(node)], + }); + } + } + return; + } + + const headingLevel = readHeadingLevel(node.name); + if (headingLevel !== null) { + const marks = directChildrenNamed(node, "HeaderMark"); + if (marks.length > 0) { + candidates.push({ + depth, + kind: "heading-marker", + mark: null, + ownerRange: rangeOf(node), + markerRanges: headingMarkerRanges(source, node, marks), + }); + } + return; + } + + if (node.name === "Blockquote") { + const marks = quoteMarksOwnedBy(node); + if (marks.length > 0) { + candidates.push({ + depth, + kind: "quote-marker", + mark: null, + ownerRange: rangeOf(node), + markerRanges: marks.map((mark) => + extendQuoteMarker(source, node.to, rangeOf(mark)), + ), + }); + } + return; + } + + if (node.name === "FencedCode") { + const codeText = directChildrenNamed(node, "CodeText")[0]; + const contentFrom = codeText?.from ?? node.to; + const contentTo = codeText?.to ?? contentFrom; + candidates.push({ + depth, + kind: "code-fence", + mark: null, + ownerRange: rangeOf(node), + markerRanges: + contentFrom === contentTo + ? [rangeOf(node)] + : [ + { from: node.from, to: contentFrom }, + { from: contentTo, to: node.to }, + ], + }); + return; + } + + if (node.name === "Escape" && node.to - node.from >= 2) { + candidates.push({ + depth, + kind: "escape", + mark: null, + ownerRange: rangeOf(node), + markerRanges: [{ from: node.from, to: node.from + 1 }], + }); + return; + } + + const inline = inlineSyntax(node); + if (inline === null) { + return; + } + + const parsedMarkers = directChildrenNamed(node, inline.markerName); + const markers = + inline.kind === "inline-code" + ? inlineCodeMarkerRanges(source, node, parsedMarkers) + : parsedMarkers.map(rangeOf); + if (markers.length >= 2) { + candidates.push({ + depth, + kind: inline.kind, + mark: inline.mark, + ownerRange: rangeOf(node), + markerRanges: markers, + }); + } + }); + + return candidates.sort(compareSyntaxCandidates); +} + +function inlineCodeMarkerRanges( + source: string, + owner: ParsedNode, + marks: readonly ParsedNode[], +): SourceRange[] { + if (marks.length < 2) { + return marks.map(rangeOf); + } + const first = marks[0]; + const last = marks.at(-1); + if (first === undefined || last === undefined) { + return []; + } + const opening = rangeOf(first); + const closing = rangeOf(last); + const content = source.slice(opening.to, closing.from); + if ( + content.length >= 2 && + content.startsWith(" ") && + content.endsWith(" ") && + !/^ +$/u.test(content) + ) { + opening.to = Math.min(opening.to + 1, owner.to); + closing.from = Math.max(opening.to, closing.from - 1); + } + return [opening, closing]; +} + +function ownSyntaxes(candidates: readonly SyntaxCandidate[]): OwnedSyntax[] { + const counts = new Map(); + return candidates.map((candidate) => { + const count = counts.get(candidate.kind) ?? 0; + counts.set(candidate.kind, count + 1); + return { ...candidate, id: `${candidate.kind}:${count}` }; + }); +} + +function collectOwnedMarkers(syntaxes: readonly OwnedSyntax[]): OwnedMarker[] { + const markers = syntaxes.flatMap((owner) => + owner.markerRanges.map((range, index) => ({ + ...range, + owner, + role: + owner.kind === "quote-marker" || index === 0 + ? ("open" as const) + : ("close" as const), + })), + ); + + return markers.sort( + (left, right) => + left.from - right.from || + left.to - right.to || + right.owner.depth - left.owner.depth || + left.owner.id.localeCompare(right.owner.id), + ); +} + +function createSegments( + source: string, + syntaxes: readonly OwnedSyntax[], + markers: readonly OwnedMarker[], +): MarkdownSegment[] { + const segments: MarkdownSegment[] = []; + let cursor = 0; + + for (const marker of markers) { + // Supported Markdown delimiters never overlap. Keeping the first owner + // makes the projection total even if a future parser extension does. + if (marker.from < cursor) { + continue; + } + if (cursor < marker.from) { + segments.push(createTextSegment(cursor, marker.from, syntaxes)); + } + segments.push({ + kind: "marker", + from: marker.from, + to: marker.to, + marks: marksAt(marker.from, marker.to, syntaxes, marker.owner), + ownerId: marker.owner.id, + role: marker.role, + }); + cursor = marker.to; + } + + if (cursor < source.length) { + segments.push(createTextSegment(cursor, source.length, syntaxes)); + } + return segments; +} + +function createTextSegment( + from: number, + to: number, + syntaxes: readonly OwnedSyntax[], +): MarkdownSegment { + const owners = syntaxes.filter( + (syntax) => syntax.ownerRange.from <= from && syntax.ownerRange.to >= to, + ); + const owner = owners.sort(compareInnermostFirst)[0]; + return { + kind: "text", + from, + to, + marks: semanticMarks(owners), + ...(owner === undefined ? {} : { ownerId: owner.id }), + }; +} + +function marksAt( + from: number, + to: number, + syntaxes: readonly OwnedSyntax[], + markerOwner: OwnedSyntax, +): MarkdownInlineMarkType[] { + return semanticMarks( + syntaxes.filter( + (syntax) => + syntax !== markerOwner && + syntax.ownerRange.from <= from && + syntax.ownerRange.to >= to, + ), + ); +} + +function semanticMarks( + syntaxes: readonly OwnedSyntax[], +): MarkdownInlineMarkType[] { + const active = new Set( + syntaxes.flatMap((syntax) => (syntax.mark === null ? [] : [syntax.mark])), + ); + return markOrder.filter((mark) => active.has(mark)); +} + +function inlineSyntax(node: ParsedNode): { + kind: MarkdownSyntaxKind; + mark: MarkdownInlineMarkType; + markerName: string; +} | null { + switch (node.name) { + case "StrongEmphasis": + return { kind: "strong", mark: "bold", markerName: "EmphasisMark" }; + case "Emphasis": + return { + kind: "emphasis", + mark: "italic", + markerName: "EmphasisMark", + }; + case "InlineCode": + return { kind: "inline-code", mark: "code", markerName: "CodeMark" }; + case "Strikethrough": + return { + kind: "strikethrough", + mark: "strike", + markerName: "StrikethroughMark", + }; + default: + return null; + } +} + +function readHeadingLevel(name: string): MarkdownHeadingLevel | null { + const match = /^ATXHeading([1-6])$/u.exec(name); + return match === null ? null : (Number(match[1]) as MarkdownHeadingLevel); +} + +function headingMarkerRanges( + source: string, + owner: ParsedNode, + marks: readonly ParsedNode[], +): SourceRange[] { + const firstMark = marks[0]; + if (firstMark === undefined) { + return []; + } + const lastMark = marks.at(-1); + const first = rangeOf(firstMark); + const last = + marks.length > 1 && lastMark !== undefined ? rangeOf(lastMark) : null; + const openingLimit = last?.from ?? owner.to; + while (first.to < openingLimit && isHorizontalSpace(source[first.to])) { + first.to += 1; + } + + if (last === null) { + return [first]; + } + while (last.from > first.to && isHorizontalSpace(source[last.from - 1])) { + last.from -= 1; + } + return [first, last]; +} + +function extendQuoteMarker( + source: string, + ownerEnd: number, + marker: SourceRange, +): SourceRange { + return marker.to < ownerEnd && isHorizontalSpace(source[marker.to]) + ? { from: marker.from, to: marker.to + 1 } + : marker; +} + +function isHorizontalSpace(character: string | undefined): boolean { + return character === " " || character === "\t"; +} + +function quoteMarksOwnedBy(owner: ParsedNode): ParsedNode[] { + const result: ParsedNode[] = []; + + function visit(node: ParsedNode): void { + for ( + let child = node.firstChild; + child !== null; + child = child.nextSibling + ) { + if (child.name === "Blockquote") { + continue; + } + if (child.name === "QuoteMark") { + result.push(child); + } else { + visit(child); + } + } + } + + visit(owner); + return result; +} + +function directChildrenNamed(node: ParsedNode, name: string): ParsedNode[] { + const result: ParsedNode[] = []; + for (let child = node.firstChild; child !== null; child = child.nextSibling) { + if (child.name === name) { + result.push(child); + } + } + return result; +} + +function directChildren(node: ParsedNode): ParsedNode[] { + const result: ParsedNode[] = []; + for (let child = node.firstChild; child !== null; child = child.nextSibling) { + result.push(child); + } + return result; +} + +function visitParsedNodes( + node: ParsedNode, + depth: number, + visit: (node: ParsedNode, depth: number) => void, +): void { + visit(node, depth); + for (let child = node.firstChild; child !== null; child = child.nextSibling) { + visitParsedNodes(child, depth + 1, visit); + } +} + +function subtractRanges( + owner: SourceRange, + excluded: readonly SourceRange[], +): SourceRange[] { + const ranges: SourceRange[] = []; + let cursor = owner.from; + for (const range of [...excluded].sort((a, b) => a.from - b.from)) { + if (cursor < range.from) { + ranges.push({ from: cursor, to: range.from }); + } + cursor = Math.max(cursor, range.to); + } + if (cursor < owner.to) { + ranges.push({ from: cursor, to: owner.to }); + } + return ranges; +} + +function rangeOf(node: ParsedNode): SourceRange { + return { from: node.from, to: node.to }; +} + +function compareSyntaxCandidates( + left: SyntaxCandidate, + right: SyntaxCandidate, +): number { + return ( + left.ownerRange.from - right.ownerRange.from || + right.ownerRange.to - left.ownerRange.to || + left.depth - right.depth || + left.kind.localeCompare(right.kind) + ); +} + +function compareInnermostFirst(left: OwnedSyntax, right: OwnedSyntax): number { + return ( + right.depth - left.depth || + left.ownerRange.to - + left.ownerRange.from - + (right.ownerRange.to - right.ownerRange.from) || + left.id.localeCompare(right.id) + ); +} diff --git a/packages/editable/core/markdownModel.ts b/packages/editable/core/markdownModel.ts new file mode 100644 index 0000000..d695607 --- /dev/null +++ b/packages/editable/core/markdownModel.ts @@ -0,0 +1,14 @@ +import type { Pointer } from "@interactive-os/json-document"; + +export const MARKDOWN_SOURCE_PATH: Pointer = "/source"; + +export type MarkdownAffinity = "forward" | "backward"; + +export type MarkdownBlockType = "paragraph" | "heading" | "quote" | "code"; + +export type MarkdownInlineMarkType = + | "bold" + | "italic" + | "underline" + | "strike" + | "code"; diff --git a/packages/editable/core/markdownProjection.test.ts b/packages/editable/core/markdownProjection.test.ts new file mode 100644 index 0000000..d656b08 --- /dev/null +++ b/packages/editable/core/markdownProjection.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + defineMarkdownProjectionPolicy, + type MarkdownProjectionSyntax, + type MarkdownRevealPolicy, + markdownProjection, +} from "./markdownProjection"; + +const strong: MarkdownProjectionSyntax = { + id: "strong:0", + kind: "strong", + markerRanges: [ + { from: 2, to: 4 }, + { from: 6, to: 8 }, + ], + ownerRange: { from: 2, to: 8 }, +}; +const heading: MarkdownProjectionSyntax = { + id: "heading:0", + kind: "heading-marker", + markerRanges: [{ from: 0, to: 2 }], + ownerRange: { from: 0, to: 6 }, +}; +const richPolicy = defineMarkdownProjectionPolicy({ fallback: "concealed" }); +const bearPolicy = defineMarkdownProjectionPolicy({ + fallback: "active-owner", + bySyntaxKind: { + "code-fence": "attachment", + "heading-marker": "attachment", + "quote-marker": "attachment", + }, +}); +const sourceRevealPolicy = defineMarkdownProjectionPolicy({ + fallback: "active-owner", + bySyntaxKind: { + "code-fence": "active-line", + "heading-marker": "active-line", + "quote-marker": "active-line", + }, +}); + +function states(options: { + policy: Parameters[0]["policy"]; + selection: Parameters[0]["selection"]; + syntaxes: readonly MarkdownProjectionSyntax[]; +}): readonly string[] { + return markdownProjection({ + source: "앞 **한글**", + ...options, + }).map(({ state }) => state); +} + +describe("markdownProjection", () => { + it("resolves syntax policy from an immutable injected descriptor", () => { + const rules: Record = { strong: "always" }; + const policy = defineMarkdownProjectionPolicy({ + fallback: "concealed", + bySyntaxKind: rules, + }); + rules.strong = "concealed"; + + expect( + markdownProjection({ + source: "앞 **한글**", + policy, + selection: null, + syntaxes: [strong], + }), + ).toEqual([ + { + ownerId: "strong:0", + markerRange: { from: 2, to: 4 }, + state: "revealed", + }, + { + ownerId: "strong:0", + markerRange: { from: 6, to: 8 }, + state: "revealed", + }, + ]); + }); + + it("reveals only the marker on the active line of a multiline syntax", () => { + const source = "> 첫째\n> 둘째"; + const quote: MarkdownProjectionSyntax = { + id: "quote-marker:0", + kind: "quote-marker", + markerRanges: [ + { from: 0, to: 2 }, + { from: 5, to: 7 }, + ], + ownerRange: { from: 0, to: source.length }, + }; + + expect( + markdownProjection({ + source, + policy: defineMarkdownProjectionPolicy({ + fallback: "concealed", + bySyntaxKind: { "quote-marker": "active-line" }, + }), + selection: { anchor: 8, focus: 8 }, + syntaxes: [quote], + }), + ).toEqual([ + { + ownerId: "quote-marker:0", + markerRange: { from: 0, to: 2 }, + state: "concealed", + }, + { + ownerId: "quote-marker:0", + markerRange: { from: 5, to: 7 }, + state: "revealed", + }, + ]); + }); + + it("keeps rich presentation concealed without changing source state", () => { + expect( + states({ + policy: richPolicy, + selection: { anchor: 4, focus: 4 }, + syntaxes: [strong, heading], + }), + ).toEqual(["concealed", "concealed", "concealed"]); + }); + + it("reveals active inline owners and respects boundary affinity", () => { + const stateAt = (offset: number, affinity?: "forward" | "backward") => + markdownProjection({ + source: "앞 **한글**", + policy: bearPolicy, + selection: { anchor: offset, focus: offset, focusAffinity: affinity }, + syntaxes: [strong], + })[0]?.state; + + expect(stateAt(2, "backward")).toBe("concealed"); + expect(stateAt(2, "forward")).toBe("revealed"); + expect(stateAt(4)).toBe("revealed"); + expect(stateAt(8, "backward")).toBe("revealed"); + expect(stateAt(8, "forward")).toBe("concealed"); + }); + + it("uses attachments for Bear blocks and literal markers for source reveal", () => { + expect( + markdownProjection({ + source: "# 제목", + policy: bearPolicy, + selection: { anchor: 3, focus: 3 }, + syntaxes: [heading], + }), + ).toEqual([ + expect.objectContaining({ + state: "attachment", + }), + ]); + + expect( + markdownProjection({ + source: "# 제목", + policy: sourceRevealPolicy, + selection: { anchor: 3, focus: 3 }, + syntaxes: [heading], + }), + ).toEqual([ + expect.objectContaining({ + state: "revealed", + }), + ]); + }); + + it("reveals only owners containing a selection endpoint", () => { + expect( + markdownProjection({ + source: "앞 **한글**", + policy: bearPolicy, + selection: { anchor: 0, focus: 10 }, + syntaxes: [strong], + }), + ).toEqual([ + expect.objectContaining({ state: "concealed" }), + expect.objectContaining({ state: "concealed" }), + ]); + + expect( + markdownProjection({ + source: "앞 **한글**", + policy: bearPolicy, + selection: { anchor: 0, focus: 4 }, + syntaxes: [strong], + }), + ).toEqual([ + expect.objectContaining({ state: "revealed" }), + expect.objectContaining({ state: "revealed" }), + ]); + }); +}); diff --git a/packages/editable/core/markdownProjection.ts b/packages/editable/core/markdownProjection.ts new file mode 100644 index 0000000..e6eb4ea --- /dev/null +++ b/packages/editable/core/markdownProjection.ts @@ -0,0 +1,185 @@ +import type { SourceRange } from "./markdown.js"; +import type { MarkdownAffinity } from "./markdownModel.js"; + +export type MarkdownPresentation = "rich" | "live-preview"; +export type MarkdownLivePreviewPreset = "bear" | "source-reveal"; +export type MarkdownRevealPolicy = + | "active-owner" + | "active-line" + | "attachment" + | "concealed" + | "always"; +export type MarkdownProjectionState = "revealed" | "concealed" | "attachment"; +export type MarkdownProjectionSyntax = { + id: string; + kind: + | "strong" + | "emphasis" + | "inline-code" + | "strikethrough" + | "underline" + | "escape" + | "code-fence" + | "heading-marker" + | "quote-marker"; + markerRanges: readonly SourceRange[]; + ownerRange: SourceRange; +}; +export type MarkdownProjectionSelection = { + anchor: number; + focus: number; + anchorAffinity?: MarkdownAffinity; + focusAffinity?: MarkdownAffinity; +}; + +export type MarkdownProjectionPolicyDescriptor = Readonly<{ + fallback: MarkdownRevealPolicy; + bySyntaxKind: Readonly< + Partial> + >; +}>; + +export type MarkdownProjectionUnit = Readonly<{ + ownerId: string; + syntaxKind: MarkdownProjectionSyntax["kind"]; + ownerRange: SourceRange; + markerRange: SourceRange; + lineRange: SourceRange; +}>; + +export type MarkdownMarkerProjection = Readonly<{ + ownerId: string; + markerRange: SourceRange; + state: MarkdownProjectionState; +}>; + +export function defineMarkdownProjectionPolicy(options: { + fallback: MarkdownRevealPolicy; + bySyntaxKind?: Readonly< + Partial> + >; +}): MarkdownProjectionPolicyDescriptor { + return Object.freeze({ + fallback: options.fallback, + bySyntaxKind: Object.freeze({ ...options.bySyntaxKind }), + }); +} + +export function resolveMarkdownProjectionPolicy( + policy: MarkdownProjectionPolicyDescriptor, + unit: MarkdownProjectionUnit, +): MarkdownRevealPolicy { + return policy.bySyntaxKind[unit.syntaxKind] ?? policy.fallback; +} + +export function markdownProjection(options: { + source: string; + policy: MarkdownProjectionPolicyDescriptor; + selection: MarkdownProjectionSelection | null; + syntaxes: readonly MarkdownProjectionSyntax[]; +}): readonly MarkdownMarkerProjection[] { + return options.syntaxes.flatMap((syntax) => + syntax.markerRanges.map((markerRange) => { + const unit: MarkdownProjectionUnit = { + ownerId: syntax.id, + syntaxKind: syntax.kind, + ownerRange: syntax.ownerRange, + markerRange, + lineRange: lineRangeForMarker(options.source, markerRange), + }; + const policy = resolveMarkdownProjectionPolicy(options.policy, unit); + return { + ownerId: unit.ownerId, + markerRange: unit.markerRange, + state: projectionState( + policy, + policy === "active-line" ? unit.lineRange : unit.ownerRange, + options.selection, + ), + }; + }), + ); +} + +function lineRangeForMarker( + source: string, + markerRange: SourceRange, +): SourceRange { + let anchor = markerRange.from; + while (anchor < markerRange.to) { + if (source[anchor] === "\r") { + anchor += source[anchor + 1] === "\n" ? 2 : 1; + continue; + } + if (source[anchor] === "\n") { + anchor += 1; + continue; + } + break; + } + + let from = anchor; + while (from > 0 && source[from - 1] !== "\n" && source[from - 1] !== "\r") { + from -= 1; + } + + let to = anchor; + while (to < source.length && source[to] !== "\n" && source[to] !== "\r") { + to += 1; + } + if (source[to] === "\r" && source[to + 1] === "\n") { + to += 2; + } else if (source[to] === "\n" || source[to] === "\r") { + to += 1; + } + return { from, to }; +} + +function projectionState( + policy: MarkdownRevealPolicy, + ownerRange: SourceRange, + selection: MarkdownProjectionSelection | null, +): MarkdownProjectionState { + if (policy === "always") { + return "revealed"; + } + if (policy === "attachment") { + return "attachment"; + } + if (policy === "concealed" || selection === null) { + return "concealed"; + } + if (selection.anchor === selection.focus) { + return endpointInside( + selection.focus, + selection.focusAffinity ?? selection.anchorAffinity, + ownerRange, + ) + ? "revealed" + : "concealed"; + } + return endpointInside( + selection.anchor, + selection.anchorAffinity, + ownerRange, + ) || endpointInside(selection.focus, selection.focusAffinity, ownerRange) + ? "revealed" + : "concealed"; +} + +function endpointInside( + offset: number, + affinity: MarkdownAffinity | undefined, + range: SourceRange, +): boolean { + if (offset > range.from && offset < range.to) { + return true; + } + if (offset === range.from) { + return affinity !== "backward"; + } + if (offset === range.to) { + return affinity === "backward"; + } + return false; +} diff --git a/packages/editable/core/model.test.ts b/packages/editable/core/model.test.ts deleted file mode 100644 index ef13a70..0000000 --- a/packages/editable/core/model.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - EditableDocumentSchema, - createEditableDocument, - createInitialEditableValue, - editableBlockIndexFromTextPath, - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - primaryEditablePoint, -} from "./model"; - -describe("EditableDocumentSchema", () => { - it("accepts the editor document shape and rejects duplicate block ids", () => { - const value = createInitialEditableValue(); - - expect(EditableDocumentSchema.safeParse(value).success).toBe(true); - expect( - EditableDocumentSchema.safeParse({ - ...value, - blocks: [ - value.blocks[0], - { ...value.blocks[1], id: value.blocks[0].id }, - ], - }).success, - ).toBe(false); - }); -}); - -describe("createInitialEditableValue", () => { - it("creates four fresh blocks with Korean and Japanese IME guidance", () => { - const first = createInitialEditableValue(); - const second = createInitialEditableValue(); - - expect(first.schema).toBe("interactive-os.editable-document@2"); - expect(first.blocks).toHaveLength(4); - expect(first.blocks.map((block) => block.type)).toEqual([ - "heading", - "paragraph", - "quote", - "code", - ]); - expect(first.blocks.some((block) => /[가-힣]/u.test(block.text))).toBe(true); - expect(first.blocks.some((block) => /[ぁ-んァ-ヶ一-龯]/u.test(block.text))).toBe( - true, - ); - expect(second).toEqual(first); - expect(second).not.toBe(first); - expect(second.blocks).not.toBe(first.blocks); - }); -}); - -describe("editable text paths", () => { - it("round-trips exact block text paths", () => { - expect(editableTextPath(12)).toBe("/blocks/12/text"); - expect(editableBlockIndexFromTextPath("/blocks/12/text")).toBe(12); - }); - - it("rejects paths that do not point exactly to a block's text", () => { - expect(editableBlockIndexFromTextPath("/blocks/12")).toBeNull(); - expect(editableBlockIndexFromTextPath("/blocks/12/text/0")).toBeNull(); - expect(editableBlockIndexFromTextPath("/blocks/01/text")).toBeNull(); - expect(editableBlockIndexFromTextPath("/blocks/-1/text")).toBeNull(); - }); -}); - -describe("editable selection helpers", () => { - it("resolves the primary focus with stable identity and a clamped offset", () => { - const document = createEditableDocument(); - const selection = document.selection; - const block = document.value.blocks[1]; - if (selection === undefined || block === undefined) { - throw new Error("Expected the editable document defaults."); - } - - selection.setBaseAndExtent( - { path: editableTextPath(0), offset: 1 }, - { path: editableTextPath(1), offset: block.text.length + 100 }, - ); - - expect(primaryEditablePoint(document.value, selection)).toEqual({ - blockId: block.id, - blockIndex: 1, - offset: block.text.length, - }); - }); - - it("orders forward, backward, and same-block selections in document order", () => { - const document = createEditableDocument(); - const selection = document.selection; - const first = document.value.blocks[0]; - const last = document.value.blocks[3]; - if (selection === undefined || first === undefined || last === undefined) { - throw new Error("Expected the editable document defaults."); - } - - selection.setBaseAndExtent( - { path: editableTextPath(3), offset: 4 }, - { path: editableTextPath(0), offset: 2 }, - ); - expect(orderedEditableSelection(document.value, selection)).toEqual({ - start: { blockId: first.id, blockIndex: 0, offset: 2 }, - end: { blockId: last.id, blockIndex: 3, offset: 4 }, - }); - - selection.setBaseAndExtent( - { path: editableTextPath(1), offset: 8 }, - { path: editableTextPath(1), offset: 3 }, - ); - expect(orderedEditableSelection(document.value, selection)).toEqual({ - start: { - blockId: document.value.blocks[1]?.id, - blockIndex: 1, - offset: 3, - }, - end: { - blockId: document.value.blocks[1]?.id, - blockIndex: 1, - offset: 8, - }, - }); - }); - - it("returns null for empty or non-text selections", () => { - const document = createEditableDocument(); - const selection = document.selection; - if (selection === undefined) { - throw new Error("Expected selection support."); - } - - expect(primaryEditablePoint(document.value, selection)).toBeNull(); - expect(orderedEditableSelection(document.value, selection)).toBeNull(); - - selection.collapse("/blocks/0"); - expect(primaryEditablePoint(document.value, selection)).toBeNull(); - expect(orderedEditableSelection(document.value, selection)).toBeNull(); - }); -}); - -describe("createEditableDocument", () => { - it("validates and clones caller-owned initial state", () => { - const initial = createInitialEditableValue(); - const originalText = initial.blocks[0]?.text; - const document = createEditableDocument(initial); - - if (initial.blocks[0] === undefined) { - throw new Error("Expected an initial block."); - } - initial.blocks[0].text = "mutated outside the document"; - - expect(document.value.blocks[0]?.text).toBe(originalText); - expect(() => - createEditableDocument({ - ...initial, - blocks: [ - { id: "duplicate", type: "paragraph", text: "one" }, - { id: "duplicate", type: "paragraph", text: "two" }, - ], - }), - ).toThrow(); - }); - - it("uses the supplied value, extended selection, and a 100-entry history", () => { - const initial = createInitialEditableValue(); - const document = createEditableDocument(initial); - const selection = document.selection; - if (selection === undefined) { - throw new Error("Expected selection support."); - } - - expect(document.value).toEqual(initial); - selection.collapse({ path: editableTextPath(0), offset: 0 }); - selection.extend({ path: editableTextPath(1), offset: 1 }); - expect(selection.primaryRange).toEqual({ - anchor: { path: editableTextPath(0), offset: 0 }, - focus: { path: editableTextPath(1), offset: 1 }, - }); - - for (let index = 0; index < 101; index += 1) { - expect(document.replace(editableTextPath(0), `edit-${index}`).ok).toBe( - true, - ); - } - for (let index = 0; index < 100; index += 1) { - expect(document.undo().ok).toBe(true); - } - expect(document.value.blocks[0]?.text).toBe("edit-0"); - expect(document.canUndo().ok).toBe(false); - }); -}); - -describe("findEditableBlockIndex", () => { - it("finds block ids and preserves Array.findIndex missing semantics", () => { - const value = createInitialEditableValue(); - - expect(findEditableBlockIndex(value, value.blocks[2]?.id ?? "")).toBe(2); - expect(findEditableBlockIndex(value, "missing")).toBe(-1); - }); -}); diff --git a/packages/editable/core/model.ts b/packages/editable/core/model.ts deleted file mode 100644 index e6124b9..0000000 --- a/packages/editable/core/model.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - createJSONDocument, - type JSONDocument, - type Pointer, - type SelectionPoint, - type SelectionState, -} from "@interactive-os/json-document"; -import { z } from "zod"; - -export type EditableBlockType = "paragraph" | "heading" | "quote" | "code"; - -export type EditableBlock = { - id: string; - type: EditableBlockType; - text: string; -}; - -export type EditableDocumentValue = { - schema: "interactive-os.editable-document@2"; - id: string; - blocks: EditableBlock[]; -}; - -export type EditablePoint = { - blockId: string; - blockIndex: number; - offset: number; -}; - -export type OrderedEditableSelection = { - start: EditablePoint; - end: EditablePoint; -}; - -const EditableBlockSchema: z.ZodType = z.object({ - id: z.string().min(1), - type: z.enum(["paragraph", "heading", "quote", "code"]), - text: z.string(), -}); - -export const EditableDocumentSchema: z.ZodType = z - .object({ - schema: z.literal("interactive-os.editable-document@2"), - id: z.string().min(1), - blocks: z.array(EditableBlockSchema), - }) - .refine( - (value) => - new Set(value.blocks.map((block) => block.id)).size === - value.blocks.length, - { - message: "Block ids must be unique.", - path: ["blocks"], - }, - ); - -export function createInitialEditableValue(): EditableDocumentValue { - return { - schema: "interactive-os.editable-document@2", - id: "composition-island-demo", - blocks: [ - { - id: "welcome", - type: "heading", - text: "Composition island editor", - }, - { - id: "korean-ime", - type: "paragraph", - text: "한글 IME로 입력하는 동안 조합 중인 DOM 노드를 그대로 유지합니다.", - }, - { - id: "japanese-ime", - type: "quote", - text: "日本語 IME の変換中も、編集中の DOM ノードを置き換えません。", - }, - { - id: "render-rule", - type: "code", - text: "renderOutside(compositionIsland)", - }, - ], - }; -} - -export function createEditableDocument( - initial?: EditableDocumentValue, -): JSONDocument { - const options = { - history: 100, - selection: { mode: "extended" as const }, - }; - return initial === undefined - ? createJSONDocument(EditableDocumentSchema, createInitialEditableValue(), { - ...options, - trustedInitial: true, - }) - : createJSONDocument(EditableDocumentSchema, initial, options); -} - -export function editableTextPath(blockIndex: number): Pointer { - if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) { - throw new RangeError( - "Editable block index must be a non-negative safe integer.", - ); - } - return `/blocks/${blockIndex}/text`; -} - -export function editableBlockIndexFromTextPath(path: Pointer): number | null { - const match = /^\/blocks\/(0|[1-9]\d*)\/text$/u.exec(path); - if (match === null) { - return null; - } - const blockIndex = Number(match[1]); - return Number.isSafeInteger(blockIndex) ? blockIndex : null; -} - -export function findEditableBlockIndex( - value: EditableDocumentValue, - blockId: string, -): number { - return value.blocks.findIndex((block) => block.id === blockId); -} - -export function primaryEditablePoint( - value: EditableDocumentValue, - selection: Pick | null | undefined, -): EditablePoint | null { - const range = selection?.primaryRange; - return range === null || range === undefined - ? null - : resolveEditablePoint(value, range.focus); -} - -export function orderedEditableSelection( - value: EditableDocumentValue, - selection: Pick | null | undefined, -): OrderedEditableSelection | null { - const range = selection?.primaryRange; - if (range === null || range === undefined) { - return null; - } - - const anchor = resolveEditablePoint(value, range.anchor); - const focus = resolveEditablePoint(value, range.focus); - if (anchor === null || focus === null) { - return null; - } - - return compareEditablePoints(anchor, focus) <= 0 - ? { start: anchor, end: focus } - : { start: focus, end: anchor }; -} - -function resolveEditablePoint( - value: EditableDocumentValue, - point: SelectionPoint, -): EditablePoint | null { - const path = typeof point === "string" ? point : point.path; - const blockIndex = editableBlockIndexFromTextPath(path); - if (blockIndex === null) { - return null; - } - - const block = value.blocks[blockIndex]; - if (block === undefined) { - return null; - } - - const rawOffset = typeof point === "string" ? 0 : (point.offset ?? 0); - if (!Number.isFinite(rawOffset)) { - return null; - } - - return { - blockId: block.id, - blockIndex, - offset: Math.min(block.text.length, Math.max(0, Math.trunc(rawOffset))), - }; -} - -function compareEditablePoints(left: EditablePoint, right: EditablePoint): number { - return left.blockIndex === right.blockIndex - ? left.offset - right.offset - : left.blockIndex - right.blockIndex; -} diff --git a/packages/editable/core/textChange.test.ts b/packages/editable/core/textChange.test.ts index 01ad90d..ea3d920 100644 --- a/packages/editable/core/textChange.test.ts +++ b/packages/editable/core/textChange.test.ts @@ -1,41 +1,23 @@ import { describe, expect, it } from "vitest"; -import { - accumulateNativeCompositionRange, - diffText, - diffTextNearRange, -} from "./textChange"; +import { diffText, diffTextAtRange } from "./textChange"; describe("text change mapping", () => { - it("keeps the complete composition span as native preedit text grows", () => { - const first = diffText("xy", "x한y"); - const second = diffText("x한y", "x한국y"); - if (first === null || second === null) { - throw new Error("Expected native text changes."); - } - - const initial = accumulateNativeCompositionRange( - { from: 1, to: 1 }, - first, - 3, - ); - - expect(initial).toEqual({ from: 1, to: 2 }); - expect(accumulateNativeCompositionRange(initial, second, 4)).toEqual({ - from: 1, - to: 3, - }); - }); - - it("uses the composition range to disambiguate repeated text", () => { + it("uses the stable suffix when repeated text makes a diff ambiguous", () => { expect(diffText("aaa", "aaaa")).toEqual({ from: 3, to: 3, insert: "a", }); - expect(diffTextNearRange("aaa", "aaaa", { from: 1, to: 1 })).toEqual({ + }); + + it("preserves an authoritative replacement range in repeated text", () => { + expect(diffTextAtRange("aaaa", "aaa", { from: 1, to: 3 })).toEqual({ from: 1, - to: 1, + to: 3, insert: "a", }); + expect( + diffTextAtRange("abcd", "unexpected", { from: 1, to: 2 }), + ).toBeNull(); }); }); diff --git a/packages/editable/core/textChange.ts b/packages/editable/core/textChange.ts index 830b100..37ee469 100644 --- a/packages/editable/core/textChange.ts +++ b/packages/editable/core/textChange.ts @@ -38,7 +38,7 @@ export function diffText(before: string, after: string): TextChange | null { }; } -export function diffTextNearRange( +export function diffTextAtRange( before: string, after: string, preferred: TextRange, @@ -46,35 +46,21 @@ export function diffTextNearRange( if (before === after) { return null; } - - let from = 0; - const prefixLimit = Math.min( - before.length, - after.length, - Math.max(preferred.to, 0), - ); - while ( - from < prefixLimit && - before.charCodeAt(from) === after.charCodeAt(from) + const range = clampTextRange(preferred, before.length); + const prefix = before.slice(0, range.from); + const suffix = before.slice(range.to); + const insertEnd = after.length - suffix.length; + if ( + insertEnd < range.from || + !after.startsWith(prefix) || + !after.endsWith(suffix) ) { - from += 1; - } - - let beforeEnd = before.length; - let afterEnd = after.length; - while ( - beforeEnd > from && - afterEnd > from && - before.charCodeAt(beforeEnd - 1) === after.charCodeAt(afterEnd - 1) - ) { - beforeEnd -= 1; - afterEnd -= 1; + return null; } - return { - from, - to: beforeEnd, - insert: after.slice(from, afterEnd), + from: range.from, + to: range.to, + insert: after.slice(range.from, insertEnd), }; } @@ -82,27 +68,7 @@ export function applyTextChange(value: string, change: TextChange): string { return value.slice(0, change.from) + change.insert + value.slice(change.to); } -export function textChangeDelta(change: TextChange): number { - return change.insert.length - (change.to - change.from); -} - -export function accumulateNativeCompositionRange( - range: TextRange, - change: TextChange, - nextLength: number, -): TextRange { - const mappedFrom = mapCompositionBoundary(range.from, change, "start"); - const mappedTo = mapCompositionBoundary(range.to, change, "end"); - return clampTextRange( - { - from: Math.min(mappedFrom, change.from), - to: Math.max(mappedTo, change.from + change.insert.length), - }, - nextLength, - ); -} - -export function clampTextRange(range: TextRange, length: number): TextRange { +function clampTextRange(range: TextRange, length: number): TextRange { const from = clamp(range.from, 0, length); const to = clamp(range.to, from, length); return { from, to }; @@ -111,22 +77,3 @@ export function clampTextRange(range: TextRange, length: number): TextRange { function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } - -function mapCompositionBoundary( - point: number, - change: TextChange, - affinity: "start" | "end", -): number { - if (point < change.from) { - return point; - } - if (point > change.to) { - return point + textChangeDelta(change); - } - if (change.from === change.to) { - return affinity === "start" ? point + change.insert.length : point; - } - return affinity === "start" - ? change.from - : change.from + change.insert.length; -} diff --git a/packages/editable/crossRealm.test.ts b/packages/editable/crossRealm.test.ts index d000fa8..77a31f9 100644 --- a/packages/editable/crossRealm.test.ts +++ b/packages/editable/crossRealm.test.ts @@ -1,24 +1,43 @@ -// @ts-expect-error jsdom does not bundle declarations in this fixture dependency. import { JSDOM } from "jsdom"; import { describe, expect, it } from "vitest"; -import { createEditableDocument, mountJsonEditable } from "./index"; +import { mountEditor } from "./index"; +import { + createMarkdownAdapter, + createMarkdownDocument, +} from "./markdown/index"; describe("owner-document DOM realm", () => { it("mounts without global browser constructors", () => { expect(globalThis.Text).toBeUndefined(); expect(globalThis.HTMLElement).toBeUndefined(); const dom = new JSDOM("
"); - const root = dom.window.document.querySelector("#editor") as HTMLElement | null; + const root = dom.window.document.querySelector( + "#editor", + ) as HTMLElement | null; if (root === null) { throw new Error("Expected the editor host."); } - const editor = mountJsonEditable({ + const editor = mountEditor({ root, - document: createEditableDocument(), + document: createMarkdownDocument({ + id: "cross-realm-test", + source: "Cross-realm **text**", + }), + adapter: createMarkdownAdapter(), }); - expect(root.querySelectorAll("[data-editable-block]")).toHaveLength(4); + expect(root.textContent).toBe("Cross-realm **text**"); + expect( + root.querySelectorAll("[data-markdown-segment]").length, + ).toBeGreaterThan(0); + expect(() => + mountEditor({ + root, + document: createMarkdownDocument({ source: "second" }), + adapter: createMarkdownAdapter(), + }), + ).toThrow(/overlaps an owned editor subtree/u); editor.destroy(); expect(root.hasAttribute("contenteditable")).toBe(false); dom.window.close(); diff --git a/packages/editable/editor.test.ts b/packages/editable/editor.test.ts deleted file mode 100644 index c99b2ec..0000000 --- a/packages/editable/editor.test.ts +++ /dev/null @@ -1,1795 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - createEditableDocument, - mountJsonEditable, - type EditorFault, - type JsonEditable, -} from "./index"; - -type EditorFixture = ReturnType; - -const mountedEditors: JsonEditable[] = []; - -afterEach(() => { - for (const editor of mountedEditors.splice(0)) { - editor.destroy(); - } - window.document.body.replaceChildren(); - vi.useRealTimers(); -}); - -function setupEditor() { - const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", - id: "editor-test", - blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, - ], - }); - const root = window.document.createElement("div"); - const faults: EditorFault[] = []; - window.document.body.append(root); - const editor = mountJsonEditable({ - root, - document, - onFault: (fault) => faults.push(fault), - }); - mountedEditors.push(editor); - return { document, editor, faults, root }; -} - -function textSurface(fixture: EditorFixture, blockId: string): HTMLElement { - const surface = fixture.root.querySelector( - `[data-editable-block="${blockId}"] [data-editable-text]`, - ); - if (!(surface instanceof HTMLElement)) { - throw new Error(`Missing editable surface for ${blockId}.`); - } - return surface; -} - -function textNode(fixture: EditorFixture, blockId: string): Text { - const node = textSurface(fixture, blockId).firstChild; - if (!(node instanceof Text)) { - throw new Error(`Missing editable Text node for ${blockId}.`); - } - return node; -} - -function setDOMCaret(node: Text, offset: number): void { - const selection = window.getSelection(); - if (selection === null) { - throw new Error("The test DOM does not expose a Selection."); - } - const range = window.document.createRange(); - range.setStart(node, offset); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); -} - -function modelFocusOffset(fixture: EditorFixture): number | undefined { - const focus = fixture.document.selection?.primaryRange?.focus; - return typeof focus === "string" ? undefined : focus?.offset; -} - -function inputEvent( - type: "beforeinput" | "input", - inputType: string, - options: { data?: string | null; isComposing?: boolean } = {}, -): InputEvent { - return new InputEvent(type, { - bubbles: true, - cancelable: type === "beforeinput", - data: options.data, - inputType, - isComposing: options.isComposing ?? false, - }); -} - -function startComposition( - fixture: EditorFixture, - offset = 2, - preedit = "한", -): Text { - const node = textNode(fixture, "alpha"); - setDOMCaret(node, offset); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertCompositionText", { - data: preedit, - isComposing: true, - }), - ); - node.insertData(offset, preedit); - setDOMCaret(node, offset + preedit.length); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: preedit, - isComposing: true, - }), - ); - return node; -} - -async function nextDOMTurn(): Promise { - await new Promise((resolve) => window.setTimeout(resolve, 0)); -} - -describe("JSON editable coordinator", () => { - it("commits a native DOM text mutation into the JSON document", () => { - const fixture = setupEditor(); - const node = textNode(fixture, "alpha"); - setDOMCaret(node, 2); - - const accepted = fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertText", { data: "한" }), - ); - - expect(accepted).toBe(false); - expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); - expect(fixture.editor.getSnapshot().phase).toBe("idle"); - expect(fixture.faults).toEqual([]); - }); - - it("keeps the composition node while another block change is queued", () => { - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - - const result = fixture.editor.dispatch({ - type: "replaceText", - blockId: "beta", - from: 0, - to: 0, - text: "remote ", - origin: "remote", - }); - - expect(result).toMatchObject({ ok: true, change: "queued" }); - expect(textNode(fixture, "alpha")).toBe(composingNode); - expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); - expect(fixture.document.value.blocks[1]?.text).toBe("second"); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "composing", - composition: { blockId: "alpha", from: 2, to: 3 }, - }); - expect(fixture.faults).toEqual([]); - }); - - it("rejects a disjoint same-block change while preserving the composition island", () => { - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - - const result = fixture.editor.dispatch({ - type: "replaceText", - blockId: "alpha", - from: 0, - to: 0, - text: "!", - origin: "remote", - }); - - expect(result).toMatchObject({ ok: false, code: "composition_conflict" }); - expect(textNode(fixture, "alpha")).toBe(composingNode); - expect(composingNode.data).toBe("ab한cdef"); - expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "composing", - composition: { blockId: "alpha", from: 2, to: 3 }, - }); - expect(fixture.faults).toEqual([ - expect.objectContaining({ code: "composition_conflict" }), - ]); - }); - - it("rejects an overlapping external change without pretending to cancel the OS IME", () => { - const fixture = setupEditor(); - startComposition(fixture); - - const result = fixture.editor.dispatch({ - type: "replaceText", - blockId: "alpha", - from: 2, - to: 3, - text: "X", - origin: "remote", - }); - - expect(result).toMatchObject({ ok: false, code: "composition_conflict" }); - expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); - expect(textNode(fixture, "alpha").data).toBe("ab한cdef"); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "composing", - composition: { blockId: "alpha", from: 2, to: 3 }, - }); - expect(fixture.faults).toEqual([ - expect.objectContaining({ - code: "composition_conflict", - recoverable: true, - }), - ]); - }); - - it("rejects local commands during composition so its undo unit cannot split", () => { - const fixture = setupEditor(); - startComposition(fixture); - - const result = fixture.editor.dispatch({ - type: "replaceText", - blockId: "beta", - from: 0, - to: 0, - text: "local ", - }); - - expect(result).toMatchObject({ ok: false, code: "composition_conflict" }); - expect(fixture.document.value.blocks[1]?.text).toBe("second"); - expect(fixture.editor.getSnapshot().phase).toBe("composing"); - }); - - it("settles only after the final composition DOM mutation is committed", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "漢", - }), - ); - expect(fixture.editor.getSnapshot().phase).toBe("settling"); - - composingNode.replaceData(2, 1, "漢"); - setDOMCaret(composingNode, 3); - fixture.root.dispatchEvent( - inputEvent("input", "insertFromComposition", { - data: "漢", - isComposing: false, - }), - ); - - expect(fixture.document.value.blocks[0]?.text).toBe("ab漢cdef"); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "settling", - composition: { blockId: "alpha", from: 2, to: 3 }, - }); - - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "idle", - composition: null, - }); - expect(textNode(fixture, "alpha")).toBe(composingNode); - expect(composingNode.data).toBe("ab漢cdef"); - expect(fixture.faults).toEqual([]); - }); - - it("preserves a paragraph intent during composition as a separate undo step", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - - const accepted = fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { - isComposing: true, - }), - ); - - expect(accepted).toBe(false); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(textNode(fixture, "alpha")).toBe(composingNode); - expect(fixture.editor.getSnapshot().phase).toBe("settling"); - expect(fixture.document.history.undoDepth).toBe(1); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect( - fixture.document.value.blocks.map(({ type, text }) => ({ type, text })), - ).toEqual([ - { type: "paragraph", text: "ab한" }, - { type: "paragraph", text: "cdef" }, - { type: "paragraph", text: "second" }, - ]); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "idle", - composition: null, - }); - expect(fixture.document.history.undoDepth).toBe(2); - - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "abcdef", - "second", - ]); - expect(fixture.faults).toEqual([]); - }); - - it("applies a queued remote update to its original block before paragraph replay", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "beta", - from: 0, - to: 0, - text: "remote ", - origin: "remote", - }), - ).toMatchObject({ ok: true, change: "queued" }); - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { - isComposing: true, - }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "remote second", - ]); - expect(fixture.document.history.undoDepth).toBe(3); - - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "remote second", - ]); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "abcdef", - "second", - ]); - expect(fixture.faults).toEqual([]); - }); - - it("keeps normalized composition selection stable across remote and paragraph undo", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "beta", - from: 0, - to: 0, - text: "remote ", - origin: "remote", - }), - ).toMatchObject({ ok: true, change: "queued" }); - composingNode.insertData(3, "\n"); - setDOMCaret(composingNode, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "\n", - isComposing: true, - }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "\n" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(modelFocusOffset(fixture)).toBe(0); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(modelFocusOffset(fixture)).toBe(3); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(modelFocusOffset(fixture)).toBe(3); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(modelFocusOffset(fixture)).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("splits at the final composition caret rather than the captured paragraph caret", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { - isComposing: true, - }), - ); - composingNode.insertData(3, "국"); - setDOMCaret(composingNode, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한국", - isComposing: true, - }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한국", - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한국", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("recognizes a newline-bearing composition result as paragraph evidence", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("normalizes a composition newline in the DOM into a paragraph split", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - composingNode.insertData(3, "\n"); - setDOMCaret(composingNode, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한\n", - isComposing: true, - }), - ); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("locates a composition newline when compositionend reports only the newline delta", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - composingNode.insertData(3, "\n"); - setDOMCaret(composingNode, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "\n", - isComposing: true, - }), - ); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "\n", - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("recalculates a trailing newline after a late final composition input", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture, 2, "ㅎ"); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - composingNode.replaceData(2, 1, "한\n"); - setDOMCaret(composingNode, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertFromComposition", { - data: "한\n", - isComposing: false, - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); - expect(fixture.faults).toEqual([]); - }); - - it("does not strip a newline that existed after the composition range", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "alpha", - from: 2, - to: 2, - text: "\n", - }).ok, - ).toBe(true); - startComposition(fixture, 2); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "\ncdef", - "second", - ]); - expect(fixture.faults).toEqual([]); - }); - - it("does not treat a candidate-confirming Enter key as paragraph evidence", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - const enter = new KeyboardEvent("keydown", { - bubbles: true, - key: "Enter", - isComposing: false, - }); - Object.defineProperty(enter, "keyCode", { value: 229 }); - - fixture.root.dispatchEvent(enter); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(1); - expect(fixture.faults).toEqual([]); - }); - - it("deduplicates paragraph evidence from one composition turn", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { - isComposing: true, - }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - fixture.root.dispatchEvent( - inputEvent("input", "insertParagraph", { - isComposing: false, - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("deduplicates compositionend newline followed by paragraph input", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - fixture.root.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("preserves two distinct paragraph beforeinput intents in one composition turn", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - - const first = fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { isComposing: true }), - ); - const second = fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(first).toBe(false); - expect(second).toBe(false); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(3); - expect(fixture.faults).toEqual([]); - }); - - it("does not pair a prevented paragraph with a later input-only Enter", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - - expect( - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { isComposing: true }), - ), - ).toBe(false); - const right = composingNode.splitText(3); - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(right); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - nativeBlock.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(3); - expect(fixture.faults).toEqual([]); - }); - - it("preserves insertParagraph that arrives after compositionend during settling", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - - const accepted = fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { - isComposing: false, - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(accepted).toBe(false); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("replays one evidenced noncancelable native paragraph split", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - - const accepted = fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - const right = composingNode.splitText(3); - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(right); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - fixture.root.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(accepted).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(nativeBlock.isConnected).toBe(false); - expect(fixture.root.querySelectorAll("[data-editable-block]")).toHaveLength( - 3, - ); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("accepts the final IME text change inside a noncancelable native split", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = textNode(fixture, "alpha"); - setDOMCaret(composingNode, 2); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - composingNode.insertData(2, "ㅎ"); - setDOMCaret(composingNode, 3); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "ㅎ", - isComposing: true, - }), - ); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - - fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - composingNode.replaceData(2, 1, "한"); - const right = composingNode.splitText(3); - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(right); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - fixture.root.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("normalizes a trailing IME newline inside a noncancelable native split", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - - fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - composingNode.insertData(3, "\n"); - const right = composingNode.splitText(4); - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(right); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - nativeBlock.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); - expect(fixture.faults).toEqual([]); - }); - - it("rejects and restores a native split that replaced the pinned Text node", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - const surface = textSurface(fixture, "alpha"); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - const foreignText = window.document.createTextNode("ab한"); - surface.replaceChildren(foreignText); - const nativeBlock = window.document.createElement("div"); - const right = window.document.createTextNode("cdef"); - nativeBlock.append(right); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - nativeBlock.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(textNode(fixture, "alpha")).toBe(composingNode); - expect(foreignText.isConnected).toBe(false); - expect(fixture.faults).toEqual([ - expect.objectContaining({ code: "input_state_lost" }), - expect.objectContaining({ code: "foreign_dom_mutation" }), - ]); - }); - - it("accepts an empty native right paragraph represented by a bare br", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture, 6); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(window.document.createElement("br")); - alpha.after(nativeBlock); - nativeBlock.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "abcdef한", - "", - "second", - ]); - expect(fixture.faults).toEqual([]); - }); - - it("accepts native Enter when an empty composition source keeps its owned placeholder", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "alpha", - from: 0, - to: 6, - text: "", - }), - ).toMatchObject({ ok: true, change: "document" }); - - const sourceSurface = textSurface(fixture, "alpha"); - const composingNode = textNode(fixture, "alpha"); - const ownedPlaceholder = sourceSurface.querySelector( - "[data-editable-placeholder]", - ); - expect(ownedPlaceholder).not.toBeNull(); - setDOMCaret(composingNode, 0); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - composingNode.insertData(0, "한"); - setDOMCaret(composingNode, 1); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한", - isComposing: true, - }), - ); - expect(Array.from(sourceSurface.childNodes)).toEqual( - expect.arrayContaining([composingNode, ownedPlaceholder]), - ); - - fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - const sourceBlock = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (sourceBlock === null) { - throw new Error("Missing alpha block."); - } - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(window.document.createElement("br")); - sourceBlock.after(nativeBlock); - nativeBlock.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "한", - "", - "second", - ]); - expect(fixture.faults).toEqual([]); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "한", - "second", - ]); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "", - "second", - ]); - }); - - it("rejects an evidenced native split with foreign nested markup", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - - fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - const right = composingNode.splitText(3); - const nativeBlock = window.document.createElement("div"); - const foreign = window.document.createElement("aside"); - foreign.append(right); - nativeBlock.append(foreign); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - fixture.root.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(nativeBlock.isConnected).toBe(false); - expect(fixture.document.history.undoDepth).toBe(1); - expect(fixture.faults).toEqual([ - expect.objectContaining({ code: "input_state_lost" }), - expect.objectContaining({ code: "foreign_dom_mutation" }), - ]); - }); - - it("uses paragraph input as fallback evidence when beforeinput is absent", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - - const right = composingNode.splitText(3); - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(right); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - fixture.root.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(nativeBlock.isConnected).toBe(false); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("rejects an input-only split that replaced the pinned block identity", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const originalBlock = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (originalBlock === null) { - throw new Error("Missing alpha block."); - } - const forged = window.document.createElement("p"); - forged.className = "contenteditable-block contenteditable-block-paragraph"; - forged.setAttribute("data-editable-block", "alpha"); - forged.setAttribute("data-block-type", "paragraph"); - forged.setAttribute("data-block-index", "0"); - const forgedSurface = window.document.createElement("span"); - forgedSurface.setAttribute("data-editable-text", "/blocks/0/text"); - forgedSurface.append("ab한"); - forged.append(forgedSurface); - const nativeBlock = window.document.createElement("div"); - const right = window.document.createTextNode("cdef"); - nativeBlock.append(right); - originalBlock.replaceWith(forged, nativeBlock); - setDOMCaret(right, 0); - - nativeBlock.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(forged.isConnected).toBe(false); - expect(textNode(fixture, "alpha")).toBe(composingNode); - expect(fixture.faults).toEqual([ - expect.objectContaining({ code: "input_state_lost" }), - expect.objectContaining({ code: "foreign_dom_mutation" }), - ]); - }); - - it("does not partially adopt a native split without paragraph evidence", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - - const right = composingNode.splitText(3); - const nativeBlock = window.document.createElement("div"); - nativeBlock.append(right); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한", - isComposing: false, - }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(nativeBlock.isConnected).toBe(false); - expect(fixture.document.history.undoDepth).toBe(1); - expect(fixture.faults).toEqual([ - expect.objectContaining({ code: "input_state_lost" }), - expect.objectContaining({ code: "foreign_dom_mutation" }), - ]); - }); - - it("drains a prevented paragraph intent before destroy", () => { - const fixture = setupEditor(); - startComposition(fixture); - const accepted = fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertParagraph", { - isComposing: true, - }), - ); - - fixture.editor.destroy(); - - expect(accepted).toBe(false); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(2); - expect(fixture.faults).toEqual([]); - }); - - it("normalizes a composition newline before destroy drains paragraph intent", () => { - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - composingNode.insertData(3, "\n"); - setDOMCaret(composingNode, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한\n", - isComposing: true, - }), - ); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { - bubbles: true, - data: "한\n", - }), - ); - - fixture.editor.destroy(); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한", - "cdef", - "second", - ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); - expect(fixture.faults).toEqual([]); - }); - - it("rejects an invalid native split when destroy forces an early settle", () => { - const fixture = setupEditor(); - const composingNode = startComposition(fixture); - const alpha = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (alpha === null) { - throw new Error("Missing alpha block."); - } - fixture.root.dispatchEvent( - new InputEvent("beforeinput", { - bubbles: true, - cancelable: false, - inputType: "insertParagraph", - isComposing: true, - }), - ); - const right = composingNode.splitText(2); - const nativeBlock = window.document.createElement("div"); - const foreign = window.document.createElement("aside"); - foreign.append(right); - nativeBlock.append(foreign); - alpha.after(nativeBlock); - setDOMCaret(right, 0); - fixture.root.dispatchEvent( - inputEvent("input", "insertParagraph", { isComposing: false }), - ); - - fixture.editor.destroy(); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(1); - expect(fixture.faults).toEqual([ - expect.objectContaining({ code: "input_state_lost" }), - expect.objectContaining({ code: "foreign_dom_mutation" }), - ]); - }); - - it("does not collect renderer-owned mutations as native input", async () => { - const fixture = setupEditor(); - const documentChanges: string[] = []; - const stop = fixture.document.subscribe((_patch, metadata) => { - documentChanges.push(metadata?.origin ?? "unknown"); - }); - - const result = fixture.editor.dispatch({ - type: "setBlockType", - blockId: "alpha", - blockType: "heading", - }); - await nextDOMTurn(); - stop(); - - expect(result.ok).toBe(true); - expect(fixture.document.value.blocks[0]?.type).toBe("heading"); - expect( - fixture.root.querySelector('[data-editable-block="alpha"]')?.tagName, - ).toBe("H1"); - expect(documentChanges).toEqual(["app"]); - expect(fixture.faults).toEqual([]); - }); - - it("reports and removes a foreign structural DOM mutation", async () => { - const fixture = setupEditor(); - const foreign = window.document.createElement("aside"); - foreign.textContent = "unowned DOM"; - - fixture.root.append(foreign); - await nextDOMTurn(); - - expect(fixture.faults).toEqual([ - expect.objectContaining({ - code: "foreign_dom_mutation", - recoverable: true, - }), - ]); - expect(fixture.root.contains(foreign)).toBe(false); - expect(fixture.root.querySelectorAll("[data-editable-block]")).toHaveLength( - 2, - ); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "abcdef", - "second", - ]); - }); - - it("rejects a foreign element appended directly to an owned block", async () => { - const fixture = setupEditor(); - const block = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (block === null) { - throw new Error("Missing alpha block."); - } - const foreign = window.document.createElement("aside"); - foreign.textContent = "unowned block child"; - - block.append(foreign); - await nextDOMTurn(); - - expect(block.contains(foreign)).toBe(false); - expect(fixture.faults).toEqual([ - expect.objectContaining({ - code: "foreign_dom_mutation", - recoverable: true, - }), - ]); - expect(fixture.document.value.blocks[0]?.text).toBe("abcdef"); - }); - - it("rejects a foreign attribute mutation on an owned block", async () => { - const fixture = setupEditor(); - const block = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - if (block === null) { - throw new Error("Missing alpha block."); - } - - block.setAttribute("data-foreign", "true"); - await nextDOMTurn(); - - expect(block.hasAttribute("data-foreign")).toBe(false); - expect(fixture.faults).toEqual([ - expect.objectContaining({ - code: "foreign_dom_mutation", - recoverable: true, - }), - ]); - }); - - it("rejects an unevidenced Text.data mutation and restores canonical DOM", async () => { - const fixture = setupEditor(); - const node = textNode(fixture, "alpha"); - await nextDOMTurn(); - - node.data = "foreign text"; - await nextDOMTurn(); - - expect(fixture.document.value.blocks[0]?.text).toBe("abcdef"); - expect(textNode(fixture, "alpha")).toBe(node); - expect(node.data).toBe("abcdef"); - expect(fixture.faults).toEqual([ - expect.objectContaining({ - code: "foreign_dom_mutation", - recoverable: true, - }), - ]); - }); - - it("cancels composition when the pinned Text node is replaced", async () => { - const fixture = setupEditor(); - const snapshots: ReturnType[] = []; - fixture.editor.subscribe((snapshot) => snapshots.push(snapshot)); - const composingNode = startComposition(fixture); - const surface = textSurface(fixture, "alpha"); - const replacement = window.document.createTextNode(composingNode.data); - - surface.replaceChild(replacement, composingNode); - await nextDOMTurn(); - - expect(composingNode.isConnected).toBe(false); - expect(textNode(fixture, "alpha")).toBe(replacement); - expect(fixture.document.value.blocks[0]?.text).toBe("ab한cdef"); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "idle", - composition: null, - }); - expect(snapshots.at(-1)).toMatchObject({ - phase: "idle", - composition: null, - }); - expect(fixture.faults).toEqual([ - expect.objectContaining({ - code: "input_state_lost", - recoverable: true, - }), - ]); - }); - - it("queues a disjoint remote patch until the composition undo entry is complete", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const node = startComposition(fixture); - const remote = fixture.editor.dispatch({ - type: "replaceText", - blockId: "beta", - from: 0, - to: 0, - text: "remote ", - origin: "remote", - }); - - expect(remote).toMatchObject({ ok: true, change: "queued" }); - expect(fixture.editor.getSnapshot().queuedChanges).toBe(1); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한cdef", - "second", - ]); - - node.insertData(3, "국"); - setDOMCaret(node, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한국", - isComposing: true, - }), - ); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한국cdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(1); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한국" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한국cdef", - "remote second", - ]); - expect(fixture.editor.getSnapshot().queuedChanges).toBe(0); - expect(fixture.document.history.undoDepth).toBe(2); - - const undoRemote = fixture.editor.dispatch({ type: "undo" }); - expect(undoRemote.ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "ab한국cdef", - "second", - ]); - - const undoComposition = fixture.editor.dispatch({ type: "undo" }); - expect(undoComposition.ok).toBe(true); - expect(fixture.document.value.blocks.map((block) => block.text)).toEqual([ - "abcdef", - "second", - ]); - expect(fixture.document.history.undoDepth).toBe(0); - expect(fixture.faults).toEqual([]); - }); - - it("merges consecutive native updates from one composition into one undo step", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - const node = startComposition(fixture); - - node.insertData(3, "국"); - setDOMCaret(node, 4); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "한국", - isComposing: true, - }), - ); - - expect(fixture.document.value.blocks[0]?.text).toBe("ab한국cdef"); - expect(fixture.editor.getSnapshot().composition).toMatchObject({ - from: 2, - to: 4, - }); - expect(fixture.document.history.undoDepth).toBe(1); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한국" }), - ); - await vi.advanceTimersByTimeAsync(31); - expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); - expect(fixture.document.value.blocks[0]?.text).toBe("abcdef"); - }); - - it("anchors an ambiguous repeated-text composition diff at the native caret", () => { - const fixture = setupEditor(); - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "alpha", - from: 0, - to: 6, - text: "aaa", - }).ok, - ).toBe(true); - - startComposition(fixture, 1, "a"); - - expect(fixture.document.value.blocks[0]?.text).toBe("aaaa"); - expect(fixture.editor.getSnapshot().composition).toMatchObject({ - from: 1, - to: 2, - }); - }); - - it("does not let an earlier settle timer terminate a newly started composition", async () => { - vi.useFakeTimers(); - const fixture = setupEditor(); - startComposition(fixture); - fixture.root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "한" }), - ); - expect(fixture.editor.getSnapshot().phase).toBe("settling"); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - expect(fixture.editor.getSnapshot().phase).toBe("composing"); - - await vi.advanceTimersByTimeAsync(45); - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "composing", - composition: { blockId: "alpha" }, - }); - }); - - it("maps a root-boundary select-all replacement into the document", () => { - const fixture = setupEditor(); - const selection = window.getSelection(); - if (selection === null || typeof selection.setBaseAndExtent !== "function") { - throw new Error("Expected Selection.setBaseAndExtent support."); - } - fixture.root.focus(); - selection.setBaseAndExtent( - fixture.root, - 0, - fixture.root, - fixture.root.childNodes.length, - ); - - const accepted = fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertText", { data: "X" }), - ); - - expect(accepted).toBe(false); - expect(fixture.document.value.blocks).toEqual([ - { id: "alpha", type: "paragraph", text: "X" }, - ]); - }); - - it("maps a block-end element boundary to that block rather than the next one", () => { - const fixture = setupEditor(); - const block = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - const selection = window.getSelection(); - if ( - block === null || - selection === null || - typeof selection.setBaseAndExtent !== "function" - ) { - throw new Error("Expected an editable block and Selection support."); - } - selection.setBaseAndExtent(block, 1, block, 1); - - fixture.root.dispatchEvent( - inputEvent("beforeinput", "insertText", { data: "X" }), - ); - - expect(fixture.document.value.blocks.map((entry) => entry.text)).toEqual([ - "abcdefX", - "second", - ]); - }); - - it("prefers beforeinput target ranges over a stale DOM selection", () => { - const fixture = setupEditor(); - const alpha = textNode(fixture, "alpha"); - const beta = textNode(fixture, "beta"); - setDOMCaret(beta, 0); - const event = inputEvent("beforeinput", "insertText", { data: "X" }); - Object.defineProperty(event, "getTargetRanges", { - value: () => [ - { - startContainer: alpha, - startOffset: 1, - endContainer: alpha, - endOffset: 3, - }, - ], - }); - - fixture.root.dispatchEvent(event); - - expect(fixture.document.value.blocks.map((entry) => entry.text)).toEqual([ - "aXdef", - "second", - ]); - }); - - it("retargets a composition pin when beforeinput provides the authoritative range", () => { - const fixture = setupEditor(); - const alpha = textNode(fixture, "alpha"); - const beta = textNode(fixture, "beta"); - setDOMCaret(beta, 0); - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - expect(fixture.editor.getSnapshot().composition?.blockId).toBe("beta"); - - const beforeInput = inputEvent("beforeinput", "insertCompositionText", { - data: "X", - isComposing: true, - }); - Object.defineProperty(beforeInput, "getTargetRanges", { - value: () => [ - { - startContainer: alpha, - startOffset: 1, - endContainer: alpha, - endOffset: 1, - }, - ], - }); - fixture.root.dispatchEvent(beforeInput); - expect(fixture.document.selection?.primaryRange).toEqual({ - anchor: { path: "/blocks/0/text", offset: 1 }, - focus: { path: "/blocks/0/text", offset: 1 }, - }); - expect(fixture.editor.getSnapshot().composition).toMatchObject({ - blockId: "alpha", - from: 1, - to: 1, - }); - expect(textNode(fixture, "alpha")).toBe(alpha); - alpha.insertData(1, "X"); - setDOMCaret(alpha, 2); - fixture.root.dispatchEvent( - inputEvent("input", "insertCompositionText", { - data: "X", - isComposing: true, - }), - ); - - expect(fixture.document.value.blocks.map((entry) => entry.text)).toEqual([ - "aXbcdef", - "second", - ]); - expect(fixture.editor.getSnapshot().composition).toMatchObject({ - blockId: "alpha", - from: 1, - to: 2, - }); - }); - - it("deletes a complete block selection through the model and keeps its surface", () => { - const fixture = setupEditor(); - const node = textNode(fixture, "alpha"); - const selection = window.getSelection(); - if (selection === null || typeof selection.setBaseAndExtent !== "function") { - throw new Error("Expected Selection.setBaseAndExtent support."); - } - selection.setBaseAndExtent(node, 0, node, node.data.length); - - const accepted = fixture.root.dispatchEvent( - inputEvent("beforeinput", "deleteContentBackward"), - ); - - expect(accepted).toBe(false); - expect(fixture.document.value.blocks[0]?.text).toBe(""); - expect(textSurface(fixture, "alpha").firstChild?.nodeType).toBe(3); - expect(textSurface(fixture, "alpha").textContent).toBe(""); - expect(fixture.faults).toEqual([]); - }); - - it("starts composition from an empty block element boundary", () => { - const fixture = setupEditor(); - expect( - fixture.editor.dispatch({ - type: "replaceText", - blockId: "alpha", - from: 0, - to: 6, - text: "", - }).ok, - ).toBe(true); - const block = fixture.root.querySelector( - '[data-editable-block="alpha"]', - ); - const selection = window.getSelection(); - if ( - block === null || - selection === null || - typeof selection.setBaseAndExtent !== "function" - ) { - throw new Error("Expected an empty editable block and Selection support."); - } - selection.setBaseAndExtent(block, 0, block, 0); - - fixture.root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - - expect(fixture.editor.getSnapshot()).toMatchObject({ - phase: "composing", - composition: { blockId: "alpha", from: 0, to: 0 }, - }); - expect(fixture.faults).toEqual([]); - }); -}); diff --git a/packages/editable/editor.ts b/packages/editable/editor.ts deleted file mode 100644 index c0283a5..0000000 --- a/packages/editable/editor.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { getJsonEditableDocumentHost, mountJsonEditable } from "./browser"; -export type { - EditorAction, - EditorFault, - EditorPhase, - EditorResult, - EditorSnapshot, - JsonEditable, - JsonEditableDocumentHost, - MountJsonEditableOptions, -} from "./browser"; diff --git a/packages/editable/index.ts b/packages/editable/index.ts index 49b3545..e11debc 100644 --- a/packages/editable/index.ts +++ b/packages/editable/index.ts @@ -1,28 +1,16 @@ -export { - createEditableDocument, - createInitialEditableValue, - EditableDocumentSchema, - editableBlockIndexFromTextPath, - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - primaryEditablePoint, -} from "./browser"; export type { - EditableBlock, - EditableBlockType, - EditableDocumentValue, - EditablePoint, - OrderedEditableSelection, -} from "./browser"; -export { getJsonEditableDocumentHost, mountJsonEditable } from "./browser"; -export type { - EditorAction, - EditorFault, - EditorPhase, - EditorResult, + EditIntent, + EditorAdapter, + EditorAffinity, + EditorError, + EditorPoint, + EditorRange, + EditorReconcileInput, + EditorSelection, EditorSnapshot, - JsonEditable, - JsonEditableDocumentHost, - MountJsonEditableOptions, -} from "./browser"; + EditorView, + EditPlan, + EditResult, + MountEditorOptions, +} from "./browser/index.js"; +export { mountEditor } from "./browser/index.js"; diff --git a/packages/editable/markdown/commands.test.ts b/packages/editable/markdown/commands.test.ts new file mode 100644 index 0000000..22e9169 --- /dev/null +++ b/packages/editable/markdown/commands.test.ts @@ -0,0 +1,462 @@ +import { applyPatch } from "@interactive-os/json-document"; +import { describe, expect, it } from "vitest"; +import type { EditorSelection } from "../browser/public"; +import { analyzeMarkdownDocument } from "../core/markdown"; +import { MARKDOWN_SOURCE_PATH } from "../core/markdownModel"; +import { planMarkdownCommand } from "./commands"; + +function value(source: string): { source: string } { + return { source }; +} + +function selection(anchor: number, focus = anchor): EditorSelection { + const anchorPoint = { path: MARKDOWN_SOURCE_PATH, offset: anchor }; + const focusPoint = { path: MARKDOWN_SOURCE_PATH, offset: focus }; + return { + ranges: [{ anchor: anchorPoint, focus: focusPoint }], + primaryIndex: 0, + }; +} + +function apply( + source: string, + command: Parameters[2], +) { + const initial = value(source); + const plan = planMarkdownCommand(initial, selection(0), command); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit, received ${plan.kind}.`); + } + const result = applyPatch(initial, plan.patch); + if (!result.ok) { + throw new Error(result.reason); + } + return { plan, value: result.value }; +} + +function replacementSource( + plan: Extract, { kind: "commit" }>, +): string { + const operation = plan.patch[0]; + if ( + operation?.op !== "replace" || + operation.path !== MARKDOWN_SOURCE_PATH || + typeof operation.value !== "string" + ) { + throw new Error("Expected a Markdown source replacement."); + } + return operation.value; +} + +describe("planMarkdownCommand", () => { + it("replaces a selection without normalizing surrounding notation", () => { + const initial = value("앞 **한글** 뒤"); + const plan = planMarkdownCommand(initial, selection(4, 6), { + type: "replaceSelection", + text: "입력", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "앞 **입력** 뒤", + }, + ], + selectionAfter: selection(6), + }); + }); + + it("inserts Enter as one source line break transaction", () => { + const initial = value("조립중"); + const plan = planMarkdownCommand(initial, selection(2), { + type: "insertParagraph", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "조립\n중", + }, + ], + selectionAfter: selection(3), + }); + }); + + it("places Enter after closing inline delimiters at a formatted content end", () => { + const plan = planMarkdownCommand(value("**한글**"), selection(4), { + type: "insertParagraph", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "**한글**\n", + }, + ], + selectionAfter: selection(7), + }); + }); + + it("keeps Enter inside fenced code at the end of its content", () => { + const plan = planMarkdownCommand(value("```\ncode\n```"), selection(8), { + type: "insertParagraph", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "```\ncode\n\n```", + }, + ], + selectionAfter: selection(9), + }); + }); + + it("deletes a whole grapheme instead of half of a surrogate or ZWJ cluster", () => { + const initial = value("A👨‍👩‍👧‍👦한"); + const afterFamily = 1 + "👨‍👩‍👧‍👦".length; + const plan = planMarkdownCommand(initial, selection(afterFamily), { + type: "deleteBackward", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [{ op: "replace", path: MARKDOWN_SOURCE_PATH, value: "A한" }], + selectionAfter: selection(1), + }); + }); + + it("edits delimiter source directly without a semantic mirror", () => { + const { plan, value: next } = apply("**한글**", { + type: "replaceText", + from: 0, + to: 2, + text: "", + }); + + expect(plan.patch).toEqual([ + { op: "replace", path: MARKDOWN_SOURCE_PATH, value: "한글**" }, + ]); + expect((next as { source: string }).source).toBe("한글**"); + }); + + it("wraps and unwraps strong source without normalizing nearby spelling", () => { + const plain = value("_앞_ 한글 뒤"); + const wrap = planMarkdownCommand(plain, selection(4, 6), { + type: "toggleInlineMark", + mark: "bold", + }); + expect(wrap).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "_앞_ **한글** 뒤", + }, + ], + selectionAfter: selection(6, 8), + }); + + const marked = value("_앞_ **한글** 뒤"); + const unwrap = planMarkdownCommand(marked, selection(6, 8), { + type: "toggleInlineMark", + mark: "bold", + }); + expect(unwrap).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "_앞_ 한글 뒤", + }, + ], + selectionAfter: selection(4, 6), + }); + }); + + it("changes only selected line prefixes for heading and quote commands", () => { + const initial = value("첫째\n둘째"); + const heading = planMarkdownCommand(initial, selection(3, 5), { + type: "setBlockType", + blockType: "heading", + }); + expect(heading).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "첫째\n# 둘째", + }, + ], + selectionAfter: selection(5, 7), + }); + + const quoted = value("# 제목\n> 인용"); + const paragraph = planMarkdownCommand( + quoted, + selection(0, quoted.source.length), + { + type: "setBlockType", + blockType: "paragraph", + }, + ); + expect(paragraph).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "제목\n인용", + }, + ], + }); + }); + + it("maps selection through heterogeneous removed line prefixes", () => { + const initial = value("### a\n> bb"); + const plan = planMarkdownCommand(initial, selection(4, 10), { + type: "setBlockType", + blockType: "paragraph", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "a\nbb", + }, + ], + selectionAfter: selection(0, 4), + }); + }); + + it("clamps points inside removed prefixes to valid content boundaries", () => { + const plan = planMarkdownCommand( + value("### title\n> quote"), + selection(1, 11), + { + type: "setBlockType", + blockType: "paragraph", + }, + ); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "title\nquote", + }, + ], + selectionAfter: selection(0, 6), + }); + }); + + it("wraps the current line as fenced code when the selection is a caret", () => { + const plan = planMarkdownCommand(value("앞줄\n문단\n뒷줄"), selection(4), { + type: "setBlockType", + blockType: "code", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "앞줄\n```\n문단\n```\n뒷줄", + }, + ], + selectionAfter: selection(8), + }); + }); + + it("expands a mid-line code selection to complete line boundaries", () => { + const plan = planMarkdownCommand(value("abc"), selection(1, 2), { + type: "setBlockType", + blockType: "code", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "```\nabc\n```", + }, + ], + selectionAfter: selection(5, 6), + }); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit, received ${plan.kind}.`); + } + const next = replacementSource(plan); + expect( + analyzeMarkdownDocument(next).blocks.map((block) => block.blockType), + ).toEqual(["code"]); + }); + + it("transitions a fenced block through every supported block type", () => { + const initial = value("```\ncode\n```"); + const expected = { + paragraph: "code", + heading: "# code", + quote: "> code", + } as const; + + for (const [blockType, source] of Object.entries(expected)) { + const plan = planMarkdownCommand(initial, selection(5), { + type: "setBlockType", + blockType: blockType as "paragraph" | "heading" | "quote", + }); + expect(plan).toMatchObject({ + kind: "commit", + patch: [{ op: "replace", path: MARKDOWN_SOURCE_PATH, value: source }], + }); + expect( + analyzeMarkdownDocument(source).blocks.map((block) => block.blockType), + ).toEqual([blockType]); + } + + expect( + planMarkdownCommand(initial, selection(5), { + type: "setBlockType", + blockType: "code", + }), + ).toEqual({ kind: "none" }); + }); + + it("removes both ATX heading markers without changing semantic text", () => { + const plan = planMarkdownCommand(value("# a #"), selection(2), { + type: "setBlockType", + blockType: "paragraph", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [{ op: "replace", path: MARKDOWN_SOURCE_PATH, value: "a" }], + }); + expect(analyzeMarkdownDocument("a").blocks[0]).toMatchObject({ + blockType: "paragraph", + semanticText: "a", + }); + }); + + it("chooses a code fence longer than every content run", () => { + const source = "before ``` after"; + const plan = planMarkdownCommand( + value(source), + selection(0, source.length), + { + type: "setBlockType", + blockType: "code", + }, + ); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit, received ${plan.kind}.`); + } + const next = replacementSource(plan); + + expect(next.startsWith("````\n")).toBe(true); + expect(analyzeMarkdownDocument(next).blocks[0]).toMatchObject({ + blockType: "code", + semanticText: source, + }); + }); + + it("handles an empty first line at source offset zero without crossing lines", () => { + const plan = planMarkdownCommand(value("\nnext"), selection(0), { + type: "setBlockType", + blockType: "code", + }); + + expect(plan).toMatchObject({ + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: "```\n```\nnext", + }, + ], + selectionAfter: selection(4), + }); + }); + + it("preserves backward selection direction through inline and block transforms", () => { + const bold = planMarkdownCommand(value("ab"), selection(2, 0), { + type: "toggleInlineMark", + mark: "bold", + }); + expect(bold).toMatchObject({ + kind: "commit", + selectionAfter: selection(4, 2), + }); + + const heading = planMarkdownCommand(value("abc"), selection(2, 1), { + type: "setBlockType", + blockType: "heading", + }); + expect(heading).toMatchObject({ + kind: "commit", + selectionAfter: selection(4, 3), + }); + }); + + it("fails closed when an inline mark has no representable source range", () => { + expect( + planMarkdownCommand(value("text"), selection(2), { + type: "toggleInlineMark", + mark: "bold", + }), + ).toMatchObject({ + kind: "failure", + code: "selection_unavailable", + }); + }); + + it.each([ + "`x", + "x``", + " a ", + "`", + ])("wraps inline-code content %j without changing it", (source) => { + const plan = planMarkdownCommand( + value(source), + selection(0, source.length), + { + type: "toggleInlineMark", + mark: "code", + }, + ); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit, received ${plan.kind}.`); + } + const next = replacementSource(plan); + const block = analyzeMarkdownDocument(next).blocks[0]; + + expect(block?.semanticText).toBe(source); + expect( + block?.syntaxes.some((syntax) => syntax.kind === "inline-code"), + ).toBe(true); + }); +}); diff --git a/packages/editable/markdown/commands.ts b/packages/editable/markdown/commands.ts new file mode 100644 index 0000000..441a3f5 --- /dev/null +++ b/packages/editable/markdown/commands.ts @@ -0,0 +1,763 @@ +import type { JSONPatchOperation } from "@interactive-os/json-document"; +import type { EditorPoint, EditorSelection } from "../browser/public.js"; +import { + analyzeMarkdownDocument, + type MarkdownSyntaxKind, + type SourceRange, +} from "../core/markdown.js"; +import { + MARKDOWN_SOURCE_PATH, + type MarkdownBlockType, + type MarkdownInlineMarkType, +} from "../core/markdownModel.js"; + +type MarkdownSourceValue = Readonly<{ source: string }>; + +export type MarkdownCommand = + | { + type: "replaceText"; + from: number; + to: number; + text: string; + } + | { type: "replaceSelection"; text: string } + | { type: "insertParagraph" } + | { type: "deleteBackward" | "deleteForward" } + | { type: "toggleInlineMark"; mark: MarkdownInlineMarkType } + | { type: "setBlockType"; blockType: MarkdownBlockType }; + +export type MarkdownCommandPlan = + | { + kind: "commit"; + patch: readonly JSONPatchOperation[]; + selectionAfter: EditorSelection; + } + | { kind: "none" } + | { + kind: "failure"; + code: "selection_unavailable" | "unsupported_block_transform"; + reason: string; + }; + +type OrderedMarkdownSelection = Readonly<{ + start: EditorPoint; + end: EditorPoint; + backward: boolean; + anchorAffinity: EditorPoint["affinity"]; + focusAffinity: EditorPoint["affinity"]; +}>; + +export function planMarkdownCommand( + value: MarkdownSourceValue, + selection: EditorSelection | null, + command: MarkdownCommand, +): MarkdownCommandPlan { + if (command.type === "replaceText") { + const from = clampOffset(value.source, Math.min(command.from, command.to)); + const to = clampOffset(value.source, Math.max(command.from, command.to)); + return replaceRange(value, from, to, command.text); + } + + const ordered = orderedMarkdownSelection(value, selection); + if (ordered === null) { + return { + kind: "failure", + code: "selection_unavailable", + reason: "No source selection is active.", + }; + } + + const from = ordered.start.offset; + const to = ordered.end.offset; + if (command.type === "replaceSelection") { + return replaceRange(value, from, to, command.text); + } + if (command.type === "insertParagraph") { + const offset = + from === to ? paragraphInsertionOffset(value.source, from) : from; + return replaceRange(value, offset, from === to ? offset : to, "\n"); + } + if (command.type === "toggleInlineMark") { + return planInlineMarkToggle(value, ordered, command.mark); + } + if (command.type === "setBlockType") { + return planBlockTypeChange(value, ordered, command.blockType); + } + if (from !== to) { + return replaceRange(value, from, to, ""); + } + + const boundary = + command.type === "deleteBackward" + ? previousGraphemeBoundary(value.source, from) + : nextGraphemeBoundary(value.source, to); + return command.type === "deleteBackward" + ? replaceRange(value, boundary, to, "") + : replaceRange(value, from, boundary, ""); +} + +function orderedMarkdownSelection( + value: MarkdownSourceValue, + selection: EditorSelection | null, +): OrderedMarkdownSelection | null { + const range = + selection?.ranges[selection.primaryIndex] ?? selection?.ranges[0]; + if ( + range === undefined || + range.anchor.path !== MARKDOWN_SOURCE_PATH || + range.focus.path !== MARKDOWN_SOURCE_PATH + ) { + return null; + } + const anchor = clampOffset(value.source, range.anchor.offset); + const focus = clampOffset(value.source, range.focus.offset); + return anchor <= focus + ? { + start: { ...range.anchor, offset: anchor }, + end: { ...range.focus, offset: focus }, + backward: false, + anchorAffinity: range.anchor.affinity, + focusAffinity: range.focus.affinity, + } + : { + start: { ...range.focus, offset: focus }, + end: { ...range.anchor, offset: anchor }, + backward: true, + anchorAffinity: range.anchor.affinity, + focusAffinity: range.focus.affinity, + }; +} + +function paragraphInsertionOffset(source: string, offset: number): number { + const syntaxes = analyzeMarkdownDocument(source).blocks.flatMap( + (block) => block.syntaxes, + ); + let insertion = offset; + let advanced = true; + while (advanced) { + advanced = false; + for (const syntax of syntaxes) { + const lastContent = syntax.contentRanges.at(-1); + const closing = syntax.markerRanges.at(-1); + if ( + isInlinePairedSyntax(syntax.kind) && + lastContent?.to === insertion && + closing?.from === insertion && + closing.to > insertion + ) { + insertion = closing.to; + advanced = true; + } + } + } + return insertion; +} + +function isInlinePairedSyntax(kind: MarkdownSyntaxKind): boolean { + return ( + kind === "strong" || + kind === "emphasis" || + kind === "inline-code" || + kind === "strikethrough" || + kind === "underline" + ); +} + +function planInlineMarkToggle( + value: MarkdownSourceValue, + selection: OrderedMarkdownSelection, + mark: MarkdownInlineMarkType, +): MarkdownCommandPlan { + const from = selection.start.offset; + const to = selection.end.offset; + const syntaxKind = syntaxKindForMark(mark); + const active = analyzeMarkdownDocument(value.source) + .blocks.flatMap((block) => block.syntaxes) + .find((syntax) => { + if (syntax.kind !== syntaxKind) { + return false; + } + const first = syntax.contentRanges[0]; + const last = syntax.contentRanges[syntax.contentRanges.length - 1]; + return ( + (first !== undefined && + last !== undefined && + first.from === from && + last.to === to) || + (from === to && + syntax.ownerRange.from < from && + syntax.ownerRange.to > to) + ); + }); + if (active !== undefined) { + const markers = [...active.markerRanges].sort( + (left, right) => right.from - left.from, + ); + let source = value.source; + for (const marker of markers) { + source = source.slice(0, marker.from) + source.slice(marker.to); + } + const mapOffset = (offset: number) => + offset - + active.markerRanges.reduce( + (removed, marker) => + removed + (marker.to <= offset ? marker.to - marker.from : 0), + 0, + ); + return sourceReplacement( + value, + source, + transformedSelection(selection, mapOffset(from), mapOffset(to)), + ); + } + if (from === to) { + return { + kind: "failure", + code: "selection_unavailable", + reason: `The ${mark} mark requires selected source text.`, + }; + } + const notation = notationForMark(value.source.slice(from, to), mark); + if (notation === null) { + return { + kind: "failure", + code: "selection_unavailable", + reason: `The ${mark} mark cannot be represented at this source selection.`, + }; + } + + const source = + value.source.slice(0, from) + + notation.open + + value.source.slice(from, to) + + notation.close + + value.source.slice(to); + const contentFrom = from + notation.open.length; + const contentTo = to + notation.open.length; + const represented = analyzeMarkdownDocument(source) + .blocks.flatMap((block) => block.syntaxes) + .some((syntax) => { + const first = syntax.contentRanges[0]; + const last = syntax.contentRanges.at(-1); + return ( + syntax.kind === syntaxKind && + first?.from === contentFrom && + last?.to === contentTo + ); + }); + if (!represented) { + return { + kind: "failure", + code: "selection_unavailable", + reason: `The ${mark} mark is ambiguous at this source selection.`, + }; + } + return sourceReplacement( + value, + source, + transformedSelection(selection, contentFrom, contentTo), + ); +} + +function planBlockTypeChange( + value: MarkdownSourceValue, + selection: OrderedMarkdownSelection, + blockType: MarkdownBlockType, +): MarkdownCommandPlan { + const from = selection.start.offset; + const to = selection.end.offset; + const firstLineStart = lineStart(value.source, from); + const lastLineEnd = lineEnd( + value.source, + from === to ? to : Math.max(from, to - 1), + ); + const analysis = analyzeMarkdownDocument(value.source); + const initiallySelected = analysis.blocks.filter( + (block) => + block.blockType === "code" && + block.syntaxes.some((syntax) => syntax.kind === "code-fence") && + (from === to + ? block.sourceRange.from <= from && block.sourceRange.to >= from + : rangesIntersect(block.sourceRange, { + from: firstLineStart, + to: lastLineEnd, + })), + ); + const blockFrom = Math.min( + firstLineStart, + ...initiallySelected.map((block) => block.sourceRange.from), + ); + const blockTo = Math.max( + lastLineEnd, + ...initiallySelected.map((block) => block.sourceRange.to), + ); + const selectedBlocks = analysis.blocks.filter((block) => + rangesIntersect(block.sourceRange, { from: blockFrom, to: blockTo }), + ); + + if ( + selectedBlocks.length > 0 && + selectedBlocks.every((block) => block.blockType === blockType) + ) { + return { kind: "none" }; + } + + const undecodableCode = selectedBlocks.find( + (block) => + block.blockType === "code" && + !block.syntaxes.some((syntax) => syntax.kind === "code-fence"), + ); + if (undecodableCode !== undefined) { + return blockTransformFailure( + "Indented code cannot be converted without changing its source semantics.", + ); + } + + const structuralMarkers = mergeRanges( + selectedBlocks.flatMap((block) => + block.syntaxes.flatMap((syntax) => + syntax.kind === "heading-marker" || + syntax.kind === "quote-marker" || + syntax.kind === "code-fence" + ? syntax.markerRanges + : [], + ), + ), + blockFrom, + blockTo, + ); + const decoded = removeRanges( + value.source, + blockFrom, + blockTo, + structuralMarkers, + ); + const encoded = encodeBlock(decoded.text, blockType); + if ("kind" in encoded) { + return encoded; + } + const source = + value.source.slice(0, blockFrom) + + encoded.text + + value.source.slice(blockTo); + if (!hasRequestedBlockType(source, blockFrom, encoded.text, blockType)) { + return blockTransformFailure( + `Markdown cannot represent this selection as ${blockType} without changing its meaning.`, + ); + } + const mapOffset = (offset: number) => { + if (offset < blockFrom) { + return offset; + } + if (offset > blockTo) { + return offset + encoded.text.length - (blockTo - blockFrom); + } + return blockFrom + encoded.mapOffset(decoded.mapOffset(offset)); + }; + return sourceReplacement( + value, + source, + transformedSelection(selection, mapOffset(from), mapOffset(to)), + ); +} + +type BlockEncoding = + | Readonly<{ + ok: true; + text: string; + mapOffset(offset: number): number; + }> + | Extract; + +function encodeBlock( + source: string, + blockType: MarkdownBlockType, +): BlockEncoding { + if (blockType === "code") { + const longestRun = Math.max( + 0, + ...Array.from(source.matchAll(/`+/gu), (match) => match[0].length), + ); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + const opening = `${fence}\n`; + return { + ok: true, + text: + source.length === 0 + ? `${opening}${fence}` + : `${opening}${source}\n${fence}`, + mapOffset: (offset) => + opening.length + Math.min(source.length, Math.max(0, offset)), + }; + } + + if (blockType === "heading") { + const insertions = lineInsertionOffsets(source, false).map((offset) => ({ + offset, + text: "# ", + })); + return insertions.length === 0 + ? blockTransformFailure( + "An empty line cannot be represented as a heading.", + ) + : applyInsertions(source, insertions); + } + + if (blockType === "quote") { + const insertions = lineInsertionOffsets(source, true).map((offset) => ({ + offset, + text: "> ", + })); + return insertions.length === 0 + ? blockTransformFailure("An empty selection cannot become a quote.") + : applyInsertions(source, insertions); + } + + const escapes: Array> = []; + for (const { start, text } of sourceLines(source)) { + if (text.length > 0 && /^(?: {4}|\t)/u.test(text)) { + return blockTransformFailure( + "Indented code cannot become a paragraph without changing its whitespace.", + ); + } + const wrapper = /^( {0,3})(?=#{1,6}(?:[ \t]+|$)|>|`{3,}|~{3,})/u.exec(text); + if (wrapper !== null) { + escapes.push({ offset: start + wrapper[1].length, text: "\\" }); + } + } + return applyInsertions(source, escapes); +} + +function lineInsertionOffsets(source: string, includeBlank: boolean): number[] { + return sourceLines(source).flatMap(({ start, text }) => + includeBlank || /\S/u.test(text) ? [start] : [], + ); +} + +function sourceLines( + source: string, +): Array> { + if (source.length === 0) { + return []; + } + const lines: Array> = []; + let start = 0; + while (start <= source.length) { + const newline = source.indexOf("\n", start); + const end = newline < 0 ? source.length : newline; + lines.push({ start, text: source.slice(start, end) }); + if (newline < 0) { + break; + } + start = newline + 1; + if (start === source.length) { + lines.push({ start, text: "" }); + break; + } + } + return lines; +} + +function applyInsertions( + source: string, + insertions: ReadonlyArray>, +): Extract { + const ordered = [...insertions].sort( + (left, right) => left.offset - right.offset, + ); + let cursor = 0; + let text = ""; + for (const insertion of ordered) { + text += source.slice(cursor, insertion.offset) + insertion.text; + cursor = insertion.offset; + } + text += source.slice(cursor); + return { + ok: true, + text, + mapOffset(offset) { + const clamped = Math.min(source.length, Math.max(0, offset)); + return ( + clamped + + ordered.reduce( + (length, insertion) => + insertion.offset <= clamped + ? length + insertion.text.length + : length, + 0, + ) + ); + }, + }; +} + +function removeRanges( + source: string, + from: number, + to: number, + ranges: readonly SourceRange[], +): Readonly<{ + text: string; + mapOffset(offset: number): number; +}> { + let cursor = from; + let text = ""; + for (const range of ranges) { + text += source.slice(cursor, range.from); + cursor = range.to; + } + text += source.slice(cursor, to); + return { + text, + mapOffset(offset) { + const clamped = Math.min(to, Math.max(from, offset)); + let removed = 0; + for (const range of ranges) { + if (clamped < range.from) { + break; + } + if (clamped <= range.to) { + return range.from - from - removed; + } + removed += range.to - range.from; + } + return clamped - from - removed; + }, + }; +} + +function mergeRanges( + ranges: readonly SourceRange[], + from: number, + to: number, +): SourceRange[] { + const ordered = ranges + .map((range) => ({ + from: Math.max(from, range.from), + to: Math.min(to, range.to), + })) + .filter((range) => range.from < range.to) + .sort((left, right) => left.from - right.from || left.to - right.to); + const merged: SourceRange[] = []; + for (const range of ordered) { + const previous = merged.at(-1); + if (previous === undefined || previous.to < range.from) { + merged.push({ ...range }); + } else { + previous.to = Math.max(previous.to, range.to); + } + } + return merged; +} + +function hasRequestedBlockType( + source: string, + from: number, + replacement: string, + blockType: MarkdownBlockType, +): boolean { + const to = from + replacement.length; + const blocks = analyzeMarkdownDocument(source).blocks.filter((block) => + rangesIntersect(block.sourceRange, { from, to }), + ); + if (blockType === "code") { + return blocks.length === 1 && blocks[0]?.blockType === "code"; + } + if (replacement.length === 0 && blockType === "paragraph") { + return true; + } + return ( + blocks.length > 0 && blocks.every((block) => block.blockType === blockType) + ); +} + +function blockTransformFailure( + reason: string, +): Extract { + return { kind: "failure", code: "unsupported_block_transform", reason }; +} + +function rangesIntersect(left: SourceRange, right: SourceRange): boolean { + if (left.from === left.to || right.from === right.to) { + return left.from <= right.to && right.from <= left.to; + } + return left.from < right.to && right.from < left.to; +} + +function lineStart(source: string, offset: number): number { + return offset <= 0 ? 0 : source.lastIndexOf("\n", offset - 1) + 1; +} + +function lineEnd(source: string, offset: number): number { + const newline = source.indexOf("\n", offset); + return newline < 0 ? source.length : newline; +} + +function notationForMark( + selection: string, + mark: MarkdownInlineMarkType, +): { open: string; close: string } | null { + switch (mark) { + case "bold": + return { open: "**", close: "**" }; + case "italic": + return { open: "_", close: "_" }; + case "strike": + return { open: "~~", close: "~~" }; + case "underline": + return { open: "", close: "" }; + case "code": { + const longestRun = Math.max( + 0, + ...Array.from(selection.matchAll(/`+/gu), (match) => match[0].length), + ); + const fence = "`".repeat(longestRun + 1); + const padded = + selection.startsWith("`") || + selection.endsWith("`") || + (selection.startsWith(" ") && + selection.endsWith(" ") && + !/^ +$/u.test(selection)); + return { + open: `${fence}${padded ? " " : ""}`, + close: `${padded ? " " : ""}${fence}`, + }; + } + } +} + +function syntaxKindForMark( + mark: MarkdownInlineMarkType, +): MarkdownSyntaxKind | null { + switch (mark) { + case "bold": + return "strong"; + case "italic": + return "emphasis"; + case "strike": + return "strikethrough"; + case "code": + return "inline-code"; + case "underline": + return "underline"; + } +} + +function replaceRange( + value: MarkdownSourceValue, + from: number, + to: number, + text: string, +): MarkdownCommandPlan { + if (from === to && text.length === 0) { + return { kind: "none" }; + } + const source = value.source.slice(0, from) + text + value.source.slice(to); + if (source === value.source) { + return { kind: "none" }; + } + return { + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: source, + }, + ], + selectionAfter: selectionAt(from + text.length), + }; +} + +function sourceReplacement( + value: MarkdownSourceValue, + source: string, + selectionAfter: EditorSelection, +): MarkdownCommandPlan { + return source === value.source + ? { kind: "none" } + : { + kind: "commit", + patch: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: source, + }, + ], + selectionAfter, + }; +} + +function clampOffset(source: string, offset: number): number { + return Number.isFinite(offset) + ? Math.min(source.length, Math.max(0, Math.trunc(offset))) + : 0; +} + +function selectionAt(offset: number): EditorSelection { + const anchor = { path: MARKDOWN_SOURCE_PATH, offset }; + const focus = { path: MARKDOWN_SOURCE_PATH, offset }; + return { + ranges: [{ anchor, focus }], + primaryIndex: 0, + }; +} + +function transformedSelection( + selection: OrderedMarkdownSelection, + startOffset: number, + endOffset: number, +): EditorSelection { + const start = pointAt( + startOffset, + selection.backward ? selection.focusAffinity : selection.anchorAffinity, + ); + const end = pointAt( + endOffset, + selection.backward ? selection.anchorAffinity : selection.focusAffinity, + ); + return { + ranges: [ + selection.backward + ? { anchor: end, focus: start } + : { anchor: start, focus: end }, + ], + primaryIndex: 0, + }; +} + +function pointAt( + offset: number, + affinity: EditorPoint["affinity"], +): EditorPoint { + return { + path: MARKDOWN_SOURCE_PATH, + offset, + ...(affinity === undefined ? {} : { affinity }), + }; +} + +function previousGraphemeBoundary(value: string, offset: number): number { + let previous = 0; + for (const segment of new Intl.Segmenter(undefined, { + granularity: "grapheme", + }).segment(value)) { + if (segment.index >= offset) { + break; + } + previous = segment.index; + } + return previous; +} + +function nextGraphemeBoundary(value: string, offset: number): number { + for (const segment of new Intl.Segmenter(undefined, { + granularity: "grapheme", + }).segment(value)) { + if (segment.index > offset) { + return segment.index; + } + } + return value.length; +} diff --git a/packages/editable/markdown/index.ts b/packages/editable/markdown/index.ts new file mode 100644 index 0000000..422b2a9 --- /dev/null +++ b/packages/editable/markdown/index.ts @@ -0,0 +1,479 @@ +import type { JSONDocument, JSONValue } from "@interactive-os/json-document"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { projectDocumentDOM } from "../browser/documentProjection.js"; +import { + domPointToSourceOffset, + sourceOffsetToDOMPoint, +} from "../browser/domSelection.js"; +import { + type MarkdownLivePreviewOptions, + type MarkdownLivePreviewPlugin, + markdownLivePreview, + resolveMarkdownLivePreview, +} from "../browser/markdownLivePreview.js"; +import type { + EditIntent, + EditorAdapter, + EditorPoint, + EditorSelection, + EditPlan, +} from "../browser/public.js"; +import { analyzeMarkdownDocument } from "../core/markdown.js"; +import { + MARKDOWN_SOURCE_PATH, + type MarkdownBlockType, +} from "../core/markdownModel.js"; +import { + applyTextChange, + diffText, + diffTextAtRange, +} from "../core/textChange.js"; +import { type MarkdownCommand, planMarkdownCommand } from "./commands.js"; + +export type { + MarkdownLivePreviewPreset, + MarkdownPresentation, + MarkdownProjectionPolicyDescriptor, + MarkdownRevealPolicy, +} from "../core/markdownProjection.js"; + +export type MarkdownAdapterOptions = MarkdownLivePreviewOptions; + +export type MarkdownDocumentValue = Readonly<{ + source: string; + readonly [key: string]: JSONValue; +}>; + +export interface MarkdownAdapter extends EditorAdapter { + readonly presentation: MarkdownLivePreviewPlugin["presentation"]; + readonly preset: MarkdownLivePreviewPlugin["preset"]; + setPresentation( + presentation: MarkdownLivePreviewPlugin["presentation"], + ): void; + setPreset(preset: MarkdownLivePreviewPlugin["preset"]): void; +} + +export function createMarkdownDocument( + initial: MarkdownDocumentValue, +): JSONDocument { + return createJSONDocument(initial, { + accepts(candidate) { + return isMarkdownDocument(candidate) + ? { ok: true } + : { + ok: false, + code: "invalid_markdown_document", + reason: 'Markdown requires a string at JSON Pointer "/source".', + }; + }, + }); +} + +export function createMarkdownAdapter( + options: MarkdownAdapterOptions = {}, +): MarkdownAdapter { + const preview = markdownLivePreview(options); + const runtime = resolveMarkdownLivePreview(preview); + const adapter: MarkdownAdapter = { + get presentation() { + return preview.presentation; + }, + get preset() { + return preview.preset; + }, + setPresentation(presentation) { + preview.setPresentation(presentation); + }, + setPreset(preset) { + preview.setPreset(preset); + }, + edit(input) { + return planMarkdownEdit(input.value, input.selection, input.intent); + }, + reconcile({ root, inputType, value, baseline, selection }) { + const source = sourceOf(value); + const baselineSource = sourceOf(baseline.value); + if (source === null || baselineSource === null) { + return invalidDocument(); + } + const observed = root.textContent ?? ""; + const compositionRange = primaryRange(baseline.selection); + const nativeChange = + inputType === "insertCompositionText" + ? compositionRange === null + ? null + : diffTextAtRange(baselineSource, observed, { + from: Math.min( + compositionRange.anchor.offset, + compositionRange.focus.offset, + ), + to: Math.max( + compositionRange.anchor.offset, + compositionRange.focus.offset, + ), + }) + : diffText(baselineSource, observed); + if (nativeChange === null) { + if (observed !== baselineSource) { + return { + ok: false, + code: "composition_island_lost", + reason: + "Native composition changed Markdown outside its baseline range.", + }; + } + return { + ok: true, + operations: [], + selectionAfter: mapMarkdownSelection( + baseline.value, + value, + selection, + ), + }; + } + const externalChange = diffText(baselineSource, source); + if ( + externalChange !== null && + !changesAreDisjoint(nativeChange, externalChange) + ) { + return { + ok: false, + code: "composition_conflict", + reason: + "External Markdown and native input changed overlapping source ranges.", + }; + } + const rebasedChange = + externalChange !== null && externalChange.to <= nativeChange.from + ? { + ...nativeChange, + from: + nativeChange.from + + externalChange.insert.length - + (externalChange.to - externalChange.from), + to: + nativeChange.to + + externalChange.insert.length - + (externalChange.to - externalChange.from), + } + : nativeChange; + const nextSource = applyTextChange(source, rebasedChange); + return { + ok: true, + operations: [ + { + op: "replace", + path: MARKDOWN_SOURCE_PATH, + value: nextSource, + }, + ], + selectionAfter: + externalChange === null + ? selection + : mapMarkdownSelection(baseline.value, value, selection), + }; + }, + mapSelection({ before, after, selection }) { + return mapMarkdownSelection(before, after, selection); + }, + project({ root, value, selection }) { + const source = sourceOf(value); + if (source === null) { + root.replaceChildren(); + return; + } + const analysis = analyzeMarkdownDocument(source); + const primary = primaryRange(selection); + const states = runtime.project({ + source, + selection: + primary === null + ? null + : { + anchor: primary.anchor.offset, + focus: primary.focus.offset, + ...(primary.anchor.affinity === undefined + ? {} + : { anchorAffinity: primary.anchor.affinity }), + ...(primary.focus.affinity === undefined + ? {} + : { focusAffinity: primary.focus.affinity }), + }, + syntaxes: analysis.blocks.flatMap((block) => block.syntaxes), + }); + projectDocumentDOM({ root, analysis, states }); + }, + fromDOMPoint({ root, node, offset }) { + const point = domPointToSourceOffset(root, node, offset); + return point === null + ? null + : { + path: MARKDOWN_SOURCE_PATH, + offset: point.offset, + ...(point.affinity === undefined + ? {} + : { affinity: point.affinity }), + }; + }, + toDOMPoint({ root, point }) { + return point.path === MARKDOWN_SOURCE_PATH + ? sourceOffsetToDOMPoint(root, point.offset, point.affinity) + : null; + }, + subscribe(listener) { + return runtime.subscribe(listener); + }, + writeClipboard({ dataTransfer, value, selection }) { + const source = sourceOf(value); + const range = primaryRange(selection); + if (source === null || range === null) { + return false; + } + const from = Math.min(range.anchor.offset, range.focus.offset); + const to = Math.max(range.anchor.offset, range.focus.offset); + const text = source.slice(from, to); + dataTransfer.setData("text/plain", text); + dataTransfer.setData("text/markdown", text); + return true; + }, + }; + return adapter; +} + +function planMarkdownEdit( + value: JSONValue, + selection: EditorSelection | null, + intent: EditIntent, +): EditPlan { + const source = sourceOf(value); + if (source === null) { + return invalidDocument(); + } + const command = markdownCommand(intent); + if (command === null) { + return { + ok: false, + code: "unsupported_input", + reason: `Markdown does not support ${intent.inputType}.`, + }; + } + const plan = planMarkdownCommand( + { source }, + selectionForIntent(intent, selection), + command, + ); + if (plan.kind === "none") { + return { + ok: true, + operations: [], + selectionAfter: copySelection(selectionForIntent(intent, selection)), + }; + } + if (plan.kind === "failure") { + return { ok: false, code: plan.code, reason: plan.reason }; + } + return { + ok: true, + operations: plan.patch, + selectionAfter: plan.selectionAfter, + }; +} + +function markdownCommand(intent: EditIntent): MarkdownCommand | null { + const text = + intent.dataTransfer?.getData("text/markdown") || + intent.dataTransfer?.getData("text/plain") || + intent.data || + ""; + switch (intent.inputType) { + case "insertText": + case "insertReplacementText": + case "insertFromDrop": + case "insertFromPaste": + case "insertFromPasteAsQuotation": + case "insertFromYank": + return { type: "replaceSelection", text }; + case "insertParagraph": + case "insertLineBreak": + return { type: "insertParagraph" }; + case "deleteContentBackward": + return { type: "deleteBackward" }; + case "deleteContentForward": + return { type: "deleteForward" }; + case "deleteWordBackward": + case "deleteWordForward": + case "deleteSoftLineBackward": + case "deleteSoftLineForward": + case "deleteEntireSoftLine": + case "deleteHardLineBackward": + case "deleteHardLineForward": + case "deleteByDrag": + case "deleteContent": + case "deleteByCut": + return { type: "replaceSelection", text: "" }; + case "formatBold": + return { type: "toggleInlineMark", mark: "bold" }; + case "formatItalic": + return { type: "toggleInlineMark", mark: "italic" }; + case "formatUnderline": + return { type: "toggleInlineMark", mark: "underline" }; + case "formatStrikeThrough": + return { type: "toggleInlineMark", mark: "strike" }; + case "formatInlineCode": + return { type: "toggleInlineMark", mark: "code" }; + case "formatBlock": + return isBlockType(intent.data) + ? { type: "setBlockType", blockType: intent.data } + : null; + default: + return null; + } +} + +function selectionForIntent( + intent: EditIntent, + fallback: EditorSelection | null, +): EditorSelection | null { + return intent.targetRanges === undefined || intent.targetRanges.length === 0 + ? fallback + : { ranges: intent.targetRanges, primaryIndex: 0 }; +} + +function sourceOf(value: JSONValue): string | null { + return isMarkdownDocument(value) ? value.source : null; +} + +function isMarkdownDocument(value: JSONValue): value is MarkdownDocumentValue { + const record = value as Readonly>; + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + typeof record.source === "string" + ); +} + +function invalidDocument(): Extract { + return { + ok: false, + code: "invalid_markdown_document", + reason: 'Markdown requires a string at JSON Pointer "/source".', + }; +} + +function primaryRange( + selection: EditorSelection | null, +): { anchor: EditorPoint; focus: EditorPoint } | null { + const range = + selection?.ranges[selection.primaryIndex] ?? selection?.ranges[0] ?? null; + return range !== null && + range.anchor.path === MARKDOWN_SOURCE_PATH && + range.focus.path === MARKDOWN_SOURCE_PATH + ? range + : null; +} + +function copySelection( + selection: EditorSelection | null, +): EditorSelection | null { + return selection === null + ? null + : { + ranges: selection.ranges.map(({ anchor, focus }) => ({ + anchor: { ...anchor }, + focus: { ...focus }, + })), + primaryIndex: selection.primaryIndex, + }; +} + +function mapMarkdownSelection( + before: JSONValue, + after: JSONValue, + selection: EditorSelection | null, +): EditorSelection | null { + const beforeSource = sourceOf(before); + const afterSource = sourceOf(after); + if (selection === null || beforeSource === null || afterSource === null) { + return null; + } + const change = diffText(beforeSource, afterSource); + const ranges = selection.ranges.flatMap(({ anchor, focus }, sourceIndex) => { + if ( + anchor.path !== MARKDOWN_SOURCE_PATH || + focus.path !== MARKDOWN_SOURCE_PATH + ) { + return []; + } + return [ + { + sourceIndex, + anchor: { + ...anchor, + offset: + change === null + ? anchor.offset + : mapOffset(anchor.offset, anchor.affinity, change), + }, + focus: { + ...focus, + offset: + change === null + ? focus.offset + : mapOffset(focus.offset, focus.affinity, change), + }, + }, + ]; + }); + if (ranges.length === 0) { + return null; + } + const primaryIndex = ranges.findIndex( + ({ sourceIndex }) => sourceIndex === selection.primaryIndex, + ); + return { + ranges: ranges.map(({ anchor, focus }) => ({ anchor, focus })), + primaryIndex: primaryIndex < 0 ? 0 : primaryIndex, + }; +} + +function mapOffset( + offset: number, + affinity: EditorPoint["affinity"], + change: { from: number; to: number; insert: string }, +): number { + if (offset < change.from) { + return offset; + } + if (offset > change.to) { + return offset + change.insert.length - (change.to - change.from); + } + if (offset === change.from && affinity === "backward") { + return change.from; + } + return change.from + change.insert.length; +} + +function changesAreDisjoint( + left: { from: number; to: number }, + right: { from: number; to: number }, +): boolean { + if ( + left.from === left.to && + right.from === right.to && + left.from === right.from + ) { + return false; + } + return left.to <= right.from || right.to <= left.from; +} + +function isBlockType(value: JSONValue | undefined): value is MarkdownBlockType { + return ( + value === "paragraph" || + value === "heading" || + value === "quote" || + value === "code" + ); +} diff --git a/packages/editable/model.ts b/packages/editable/model.ts deleted file mode 100644 index 7186b8c..0000000 --- a/packages/editable/model.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - createEditableDocument, - createInitialEditableValue, - EditableDocumentSchema, - editableBlockIndexFromTextPath, - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - primaryEditablePoint, -} from "./browser"; -export type { - EditableBlock, - EditableBlockType, - EditableDocumentValue, - EditablePoint, - OrderedEditableSelection, -} from "./browser"; diff --git a/packages/editable/package.json b/packages/editable/package.json new file mode 100644 index 0000000..2623526 --- /dev/null +++ b/packages/editable/package.json @@ -0,0 +1,52 @@ +{ + "name": "@interactive-os/editable", + "version": "0.1.0", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./markdown": { + "types": "./dist/markdown/index.d.ts", + "import": "./dist/markdown/index.js" + } + }, + "scripts": { + "clean": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\"", + "build": "pnpm run clean && tsc -p tsconfig.build.json", + "test": "pnpm exec vitest run --root ../.. packages/editable", + "smoke": "node tests/smoke/package-smoke.mjs && tsc -p tests/smoke/tsconfig.json && node tests/smoke/clean-import.mjs", + "verify": "pnpm run build && pnpm run test && pnpm run smoke", + "prepack": "pnpm run verify" + }, + "peerDependencies": { + "@interactive-os/json-document": "^2.0.0-rc.0", + "@lezer/markdown": "^1.7.2" + }, + "peerDependenciesMeta": { + "@lezer/markdown": { + "optional": true + } + }, + "devDependencies": { + "@interactive-os/json-document": "file:../../vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz", + "@lezer/markdown": "1.7.2", + "typescript": "^6.0.2" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/editable.git", + "directory": "packages/editable" + } +} diff --git a/packages/editable/publicApi.test.ts b/packages/editable/publicApi.test.ts index 0ef40e2..d53fa2d 100644 --- a/packages/editable/publicApi.test.ts +++ b/packages/editable/publicApi.test.ts @@ -1,222 +1,94 @@ import type { - JSONChangeMetadata, + JSONAppliedChange, JSONDocument, JSONPatchOperation, - Pointer, - SelectionSnap, - SelectionState, + JSONValue, } from "@interactive-os/json-document"; import { describe, expect, expectTypeOf, it } from "vitest"; -import type { ZodType } from "zod"; import type { - EditableBlock, - EditableBlockType, - EditableDocumentValue, - EditablePoint, - EditorAction, - EditorFault, - EditorPhase, - EditorResult, + EditIntent, + EditorAdapter, + EditorError, + EditorSelection, EditorSnapshot, - JsonEditable, - JsonEditableDocumentHost, - MountJsonEditableOptions, - OrderedEditableSelection, + EditorView, + EditPlan, + EditResult, + MountEditorOptions, } from "./index"; -import { getJsonEditableDocumentHost } from "./index"; import * as PublicAPI from "./index"; - -type ExpectedBlockType = "paragraph" | "heading" | "quote" | "code"; -type ExpectedBlock = { - id: string; - type: ExpectedBlockType; - text: string; -}; -type ExpectedDocument = { - schema: "interactive-os.editable-document@2"; - id: string; - blocks: ExpectedBlock[]; -}; -type ExpectedPoint = { blockId: string; blockIndex: number; offset: number }; -type ExpectedOrderedSelection = { - start: ExpectedPoint; - end: ExpectedPoint; -}; -type ExpectedPhase = "idle" | "native-input" | "composing" | "settling"; -type ExpectedSnapshot = { - phase: ExpectedPhase; - revision: number; - queuedChanges: number; - selection: SelectionSnap | null; - composition: { blockId: string; from: number; to: number } | null; -}; -type ExpectedFault = { - code: - | "out_of_band_document_write" - | "foreign_dom_mutation" - | "native_change_commit_failed" - | "input_state_lost" - | "composition_overlap" - | "composition_conflict" - | "queued_change_commit_failed" - | "subscriber_failed"; - recoverable: boolean; - reason: string; -}; -type ExpectedAction = - | { - type: "patch"; - patch: ReadonlyArray; - label?: string; - origin?: string; - selectionAfter?: SelectionSnap | null; - } - | { - type: "replaceText"; - blockId: string; - from: number; - to: number; - text: string; - label?: string; - origin?: string; - } - | { - type: "replaceSelection"; - text: string; - label?: string; - origin?: string; - } - | { - type: "setBlockType"; - blockType: ExpectedBlockType; - blockId?: string; - } - | { type: "insertParagraph" } - | { type: "deleteBackward" | "deleteForward" } - | { type: "joinBackward" } - | { type: "joinForward" } - | { type: "undo" | "redo" | "reset" }; -type ExpectedResult = - | { - ok: true; - change: "none" | "selection" | "document" | "queued"; - patch: ReadonlyArray; - } - | { - ok: false; - code: - | "destroyed" - | "reentrant_transaction" - | "block_not_found" - | "selection_unavailable" - | "composition_conflict" - | "commit_failed"; - reason: string; - }; -type ExpectedDocumentHost = { - ownsPublication(publication: { - operations: ReadonlyArray; - metadata?: JSONChangeMetadata; - }): false | { sequence: number }; - runReady(request: { - id: string; - apply(): void; - }): - | { ok: true } - | { - ok: false; - code: "host_not_ready"; - reason: string; - }; -}; -type ExpectedEditor = { - dispatch(action: ExpectedAction): ExpectedResult; - getSnapshot(): ExpectedSnapshot; - subscribe(listener: (snapshot: ExpectedSnapshot) => void): () => void; - destroy(): void; -}; -type ExpectedMountOptions = { - root: HTMLElement; - document: JSONDocument; - onFault?: (fault: ExpectedFault) => void; -}; +import type { + MarkdownAdapter, + MarkdownAdapterOptions, + MarkdownDocumentValue, +} from "./markdown/index"; +import * as MarkdownAPI from "./markdown/index"; describe("editable public API", () => { - it("exposes only the established runtime surface", () => { - expect(Object.keys(PublicAPI).sort()).toEqual([ - "EditableDocumentSchema", - "createEditableDocument", - "createInitialEditableValue", - "editableBlockIndexFromTextPath", - "editableTextPath", - "findEditableBlockIndex", - "getJsonEditableDocumentHost", - "mountJsonEditable", - "orderedEditableSelection", - "primaryEditablePoint", + it("keeps the root runtime surface protocol-only", () => { + expect(Object.keys(PublicAPI).sort()).toEqual(["mountEditor"]); + expect(Object.keys(MarkdownAPI).sort()).toEqual([ + "createMarkdownAdapter", + "createMarkdownDocument", ]); }); - it("preserves the established public type contracts", () => { - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf< - OrderedEditableSelection - >().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf< - MountJsonEditableOptions - >().toEqualTypeOf(); - - expectTypeOf(PublicAPI.EditableDocumentSchema).toEqualTypeOf< - ZodType - >(); - expectTypeOf(PublicAPI.createEditableDocument).toEqualTypeOf< - (initial?: ExpectedDocument) => JSONDocument + it("pins the stable editor protocol", () => { + expectTypeOf().toEqualTypeOf< + Readonly<{ + inputType: string; + data?: string | null; + dataTransfer?: DataTransfer | null; + targetRanges?: ReadonlyArray; + }> >(); - expectTypeOf(PublicAPI.createInitialEditableValue).toEqualTypeOf< - () => ExpectedDocument + expectTypeOf().toEqualTypeOf< + | Readonly<{ + ok: true; + operations: ReadonlyArray; + selectionAfter: EditorSelection | null; + }> + | Readonly<{ ok: false; code: string; reason?: string }> >(); - expectTypeOf(PublicAPI.editableTextPath).toEqualTypeOf< - (blockIndex: number) => Pointer + expectTypeOf().toEqualTypeOf< + | Readonly<{ ok: true; change: JSONAppliedChange | null }> + | Readonly<{ ok: false; code: string; reason?: string }> >(); - expectTypeOf(PublicAPI.editableBlockIndexFromTextPath).toEqualTypeOf< - (path: Pointer) => number | null + expectTypeOf().toEqualTypeOf< + Readonly<{ code: string; reason?: string }> >(); - expectTypeOf(PublicAPI.findEditableBlockIndex).toEqualTypeOf< - (value: ExpectedDocument, blockId: string) => number + expectTypeOf().toEqualTypeOf< + Readonly<{ + value: JSONValue; + selection: EditorSelection | null; + isComposing: boolean; + }> >(); - expectTypeOf(getJsonEditableDocumentHost).toEqualTypeOf< - (editor: ExpectedEditor) => ExpectedDocumentHost + expectTypeOf().toEqualTypeOf< + (listener: () => void) => () => void >(); - expectTypeOf(PublicAPI.primaryEditablePoint).toEqualTypeOf< - ( - value: ExpectedDocument, - selection: - | Pick - | null - | undefined, - ) => ExpectedPoint | null - >(); - expectTypeOf(PublicAPI.orderedEditableSelection).toEqualTypeOf< - ( - value: ExpectedDocument, - selection: - | Pick - | null - | undefined, - ) => ExpectedOrderedSelection | null - >(); - expectTypeOf(PublicAPI.mountJsonEditable).toEqualTypeOf< - (options: ExpectedMountOptions) => ExpectedEditor + expectTypeOf().toEqualTypeOf< + Readonly<{ + root: HTMLElement; + document: JSONDocument; + adapter: EditorAdapter; + onError?: (error: EditorError) => void; + }> >(); }); + + it("keeps Markdown behind its candidate subpath", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf<{ + presentation?: "rich" | "live-preview"; + preset?: "bear" | "source-reveal"; + }>(); + expectTypeOf( + MarkdownAPI.createMarkdownDocument, + ).returns.toEqualTypeOf(); + expectTypeOf( + MarkdownAPI.createMarkdownAdapter, + ).returns.toEqualTypeOf(); + }); }); diff --git a/packages/editable/tests/smoke/clean-import.mjs b/packages/editable/tests/smoke/clean-import.mjs new file mode 100644 index 0000000..a8ff536 --- /dev/null +++ b/packages/editable/tests/smoke/clean-import.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const packageDirectory = resolve(import.meta.dirname, "../.."); +const jsonDocumentTarball = resolve( + packageDirectory, + "../../vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz", +); +const sandbox = mkdtempSync(join(tmpdir(), "editable-clean-import-")); +const rootSandbox = join(sandbox, "root"); +const markdownSandbox = join(sandbox, "markdown"); + +try { + mkdirSync(rootSandbox); + mkdirSync(markdownSandbox); + const editableTarball = pack(packageDirectory); + readFileSync(jsonDocumentTarball); + writeManifest(rootSandbox, { + "@interactive-os/editable": `file:${editableTarball}`, + "@interactive-os/json-document": `file:${jsonDocumentTarball}`, + }); + install(rootSandbox); + run(rootSandbox, process.execPath, [ + "--input-type=module", + "--eval", + [ + 'const root = await import("@interactive-os/editable");', + 'if (Object.keys(root).join() !== "mountEditor") process.exit(2);', + ].join("\n"), + ]); + const lezer = spawnSync("npm", ["ls", "@lezer/markdown", "--json"], { + cwd: rootSandbox, + encoding: "utf8", + env: cleanImportEnvironment(), + }); + assert.notEqual( + lezer.status, + 0, + "The root-only install must not include the optional Markdown parser.", + ); + const zod = spawnSync("npm", ["ls", "zod", "--json"], { + cwd: rootSandbox, + encoding: "utf8", + env: cleanImportEnvironment(), + }); + assert.notEqual(zod.status, 0, "The packed runtime must not install zod."); + + writeManifest(markdownSandbox, { + "@interactive-os/editable": `file:${editableTarball}`, + "@interactive-os/json-document": `file:${jsonDocumentTarball}`, + "@lezer/markdown": "1.7.2", + }); + install(markdownSandbox); + run(markdownSandbox, process.execPath, [ + "--input-type=module", + "--eval", + [ + 'const markdown = await import("@interactive-os/editable/markdown");', + "if (Object.keys(markdown).sort().join() !==", + '"createMarkdownAdapter,createMarkdownDocument") process.exit(3);', + ].join("\n"), + ]); +} finally { + rmSync(sandbox, { recursive: true, force: true }); +} + +function writeManifest(directory, dependencies) { + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ + private: true, + type: "module", + dependencies, + }), + ); +} + +function install(directory) { + run(directory, "npm", [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + ]); +} + +function pack(directory) { + const packed = spawnSync( + "npm", + ["pack", "--ignore-scripts", "--json", "--pack-destination", sandbox], + { + cwd: directory, + encoding: "utf8", + env: cleanImportEnvironment(), + }, + ); + assert.equal( + packed.status, + 0, + packed.stderr || `Could not pack ${directory}.`, + ); + const result = JSON.parse(packed.stdout); + const filename = result[0]?.filename; + assert.equal(typeof filename, "string"); + const tarball = join(sandbox, filename); + readFileSync(tarball); + return tarball; +} + +function run(directory, command, args) { + const result = spawnSync(command, args, { + cwd: directory, + encoding: "utf8", + env: cleanImportEnvironment(), + }); + assert.equal( + result.status, + 0, + result.stderr || result.stdout || `${command} failed.`, + ); +} + +function cleanImportEnvironment() { + const environment = { ...process.env }; + delete environment.npm_config_dry_run; + delete environment.NPM_CONFIG_DRY_RUN; + return environment; +} diff --git a/packages/editable/tests/smoke/consumer.ts b/packages/editable/tests/smoke/consumer.ts new file mode 100644 index 0000000..7084327 --- /dev/null +++ b/packages/editable/tests/smoke/consumer.ts @@ -0,0 +1,35 @@ +import { type EditorView, mountEditor } from "@interactive-os/editable"; +import { + createMarkdownAdapter, + createMarkdownDocument, + type MarkdownDocumentValue, +} from "@interactive-os/editable/markdown"; + +const initial: MarkdownDocumentValue = { + id: "consumer-document", + source: "# Consumer-owned content\n\n**source preserved**", +}; + +const document = createMarkdownDocument(initial); +const root = globalThis.document.createElement("div"); +const adapter = createMarkdownAdapter({ + presentation: "live-preview", + preset: "source-reveal", +}); +const editor: EditorView = mountEditor({ + root, + document, + adapter, +}); + +adapter.setPresentation("rich"); +adapter.setPresentation("live-preview"); +adapter.setPreset("bear"); +editor.dispatch({ inputType: "insertText", data: "x" }); +editor.destroy(); + +// @ts-expect-error Callers must provide an initial document. +createMarkdownDocument(); + +// @ts-expect-error EditIntent deliberately follows InputEvent.inputType. +editor.dispatch({ type: "insertText", data: "x" }); diff --git a/packages/editable/tests/smoke/package-smoke.mjs b/packages/editable/tests/smoke/package-smoke.mjs new file mode 100644 index 0000000..219a260 --- /dev/null +++ b/packages/editable/tests/smoke/package-smoke.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +const publicModule = await import("@interactive-os/editable"); +const markdownModule = await import("@interactive-os/editable/markdown"); + +assert.deepEqual(Object.keys(publicModule).sort(), ["mountEditor"]); +assert.deepEqual(Object.keys(markdownModule).sort(), [ + "createMarkdownAdapter", + "createMarkdownDocument", +]); + +for (const subpath of ["browser", "core", "model"]) { + const attemptedImport = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import("@interactive-os/editable/${subpath}")`, + ], + { encoding: "utf8" }, + ); + assert.notEqual(attemptedImport.status, 0); + assert.match(attemptedImport.stderr, /ERR_PACKAGE_PATH_NOT_EXPORTED/u); +} diff --git a/packages/editable/tests/smoke/tsconfig.json b/packages/editable/tests/smoke/tsconfig.json new file mode 100644 index 0000000..c353738 --- /dev/null +++ b/packages/editable/tests/smoke/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": false, + "lib": ["DOM", "ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": false, + "strict": true, + "target": "ES2022", + "types": [] + }, + "files": ["consumer.ts"] +} diff --git a/packages/editable/tsconfig.build.json b/packages/editable/tsconfig.build.json new file mode 100644 index 0000000..bd58032 --- /dev/null +++ b/packages/editable/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "declaration": true, + "types": [] + }, + "include": ["index.ts", "markdown/index.ts"], + "exclude": ["**/*.test.ts", "tests/**", "dist/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 845a5e2..4e5f8f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,23 +4,16 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - '@interactive-os/json-document-patch-rebase': file:vendor/json-document-218aa475/interactive-os-json-document-patch-rebase-0.1.0.tgz - '@interactive-os/json-document-stable-id-rebase': file:vendor/json-document-218aa475/interactive-os-json-document-stable-id-rebase-0.1.0.tgz - importers: .: dependencies: + '@interactive-os/editable': + specifier: workspace:* + version: link:packages/editable '@interactive-os/json-document': - specifier: 1.1.0-rc.0 - version: 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) - '@interactive-os/json-document-causal-patch-inbox': - specifier: file:vendor/json-document-218aa475/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz - version: file:vendor/json-document-218aa475/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) - '@interactive-os/json-document-id-resolver': - specifier: file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz - version: file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) + specifier: file:vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz + version: file:vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz '@tanstack/react-router': specifier: ^1.170.16 version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -36,9 +29,6 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.7(react@19.2.7) - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@biomejs/biome': specifier: 2.4.5 @@ -52,6 +42,9 @@ importers: '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/jsdom': + specifier: ^28.0.3 + version: 28.0.3 '@types/node': specifier: ^22.10.2 version: 22.19.21 @@ -77,6 +70,18 @@ importers: specifier: ^4.1.5 version: 4.1.9(@types/node@22.19.21)(jsdom@28.1.0)(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0)) + packages/editable: + devDependencies: + '@interactive-os/json-document': + specifier: file:../../vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz + version: file:vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz + '@lezer/markdown': + specifier: 1.7.2 + version: 1.7.2 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + packages: '@acemir/cssom@0.9.31': @@ -193,28 +198,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.5': resolution: {integrity: sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.5': resolution: {integrity: sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.5': resolution: {integrity: sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.5': resolution: {integrity: sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw==} @@ -286,39 +287,9 @@ packages: '@noble/hashes': optional: true - '@interactive-os/json-document-causal-patch-inbox@file:vendor/json-document-218aa475/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz': - resolution: {integrity: sha512-wCJ9KyWQGOobmU8Ox75UyuJCOZAMU1PBSt0fe4BlcJetCNadICf39ZRAXJzmiOov9cjxY4m4mul4qo1r1gAJqw==, tarball: file:vendor/json-document-218aa475/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz} - version: 0.1.0 - peerDependencies: - '@interactive-os/json-document': ^1.0.0 - - '@interactive-os/json-document-id-resolver@file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz': - resolution: {integrity: sha512-dKQ9kj2hj0SyyxN1aV5bThqfopNX9YGBMVkjPh42GlnJgGI0tj1qetlgjj6cGHSAWw8RwR5mtBOBVTAfMYQhQA==, tarball: file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz} - version: 0.1.0 - peerDependencies: - '@interactive-os/json-document': ^1.0.0 - - '@interactive-os/json-document-patch-rebase@file:vendor/json-document-218aa475/interactive-os-json-document-patch-rebase-0.1.0.tgz': - resolution: {integrity: sha512-6ZZXG/6tB4DZns11lEo5yIkMVnS4m/+SVcb9tHBKCNWbTHP9zX8x9ahlrSL6kzfoEbcFXNzJPj73+tqzPZbY2Q==, tarball: file:vendor/json-document-218aa475/interactive-os-json-document-patch-rebase-0.1.0.tgz} - version: 0.1.0 - peerDependencies: - '@interactive-os/json-document': ^1.0.0 - - '@interactive-os/json-document-stable-id-rebase@file:vendor/json-document-218aa475/interactive-os-json-document-stable-id-rebase-0.1.0.tgz': - resolution: {integrity: sha512-ChCf+Mf/wzC5NHV3uk1RwNgGa/YbLlyD2S/mOfjyyGRw5vLRvV3jCA1qutO8jVlU6bKpKXsv/+3ei2ZZLTzQAQ==, tarball: file:vendor/json-document-218aa475/interactive-os-json-document-stable-id-rebase-0.1.0.tgz} - version: 0.1.0 - peerDependencies: - '@interactive-os/json-document': ^1.0.0 - '@interactive-os/json-document-id-resolver': ^0.1.0 - - '@interactive-os/json-document@1.1.0-rc.0': - resolution: {integrity: sha512-gC88ujdx95iCQWQBzkcNHRL76pGmNbjgVX5tSyawok+HKYfIp8b2pIQWEJNKm1kERARwX2CQLDo+fg6MgFVPFg==} - peerDependencies: - react: '>=18' - zod: ^4.0.0 - peerDependenciesMeta: - react: - optional: true + '@interactive-os/json-document@file:vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz': + resolution: {integrity: sha512-6i79QCVJlNDe3FphLN3/EBS+TlYOxh0rWAAHLM7bwpEY4EZhDlbSmu7I4ifOahB0Pd2wqI1M4tSs9DIBvRwXaQ==, tarball: file:vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz} + version: 2.0.0-rc.0 '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -336,6 +307,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/markdown@1.7.2': + resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==} + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -401,42 +381,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -634,6 +608,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/jsdom@28.0.3': + resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} @@ -645,6 +622,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -932,28 +912,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1167,6 +1143,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.29.0: + resolution: {integrity: sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -1532,32 +1511,7 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@interactive-os/json-document-causal-patch-inbox@file:vendor/json-document-218aa475/interactive-os-json-document-causal-patch-inbox-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': - dependencies: - '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) - '@interactive-os/json-document-patch-rebase': file:vendor/json-document-218aa475/interactive-os-json-document-patch-rebase-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) - '@interactive-os/json-document-stable-id-rebase': file:vendor/json-document-218aa475/interactive-os-json-document-stable-id-rebase-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) - transitivePeerDependencies: - - '@interactive-os/json-document-id-resolver' - - '@interactive-os/json-document-id-resolver@file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': - dependencies: - '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) - - '@interactive-os/json-document-patch-rebase@file:vendor/json-document-218aa475/interactive-os-json-document-patch-rebase-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': - dependencies: - '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) - - '@interactive-os/json-document-stable-id-rebase@file:vendor/json-document-218aa475/interactive-os-json-document-stable-id-rebase-0.1.0.tgz(@interactive-os/json-document-id-resolver@file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)))(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3))': - dependencies: - '@interactive-os/json-document': 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) - '@interactive-os/json-document-id-resolver': file:vendor/json-document-218aa475/interactive-os-json-document-id-resolver-0.1.0.tgz(@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)) - - '@interactive-os/json-document@1.1.0-rc.0(react@19.2.7)(zod@4.4.3)': - dependencies: - zod: 4.4.3 - optionalDependencies: - react: 19.2.7 + '@interactive-os/json-document@file:vendor/json-document-v2/interactive-os-json-document-2.0.0-rc.0.tgz': {} '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -1578,6 +1532,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.7.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1888,6 +1853,13 @@ snapshots: '@types/estree@1.0.9': {} + '@types/jsdom@28.0.3': + dependencies: + '@types/node': 22.19.21 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 7.29.0 + '@types/node@22.19.21': dependencies: undici-types: 6.21.0 @@ -1900,6 +1872,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/tough-cookie@4.0.5': {} + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@22.19.21)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -2337,6 +2311,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.29.0: {} + undici@7.28.0: {} unplugin@3.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..dee51e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/src/editable-lab/JsonEditableDemo.tsx b/src/editable-lab/JsonEditableDemo.tsx deleted file mode 100644 index e224475..0000000 --- a/src/editable-lab/JsonEditableDemo.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { - Code2, - Heading1, - Pilcrow, - Quote, - Redo2, - RefreshCw, - RotateCcw, - Undo2, -} from "lucide-react"; -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - createEditableDocument, - type EditableBlockType, - type EditorFault, - type EditorSnapshot, - type JsonEditable, - mountJsonEditable, -} from "../../packages/editable"; -import { createEditableCausalInbox } from "./causalDocumentInbox"; - -type EditableDocument = ReturnType; -type EditableCausalInbox = ReturnType; - -declare global { - interface Window { - __jsonEditableLab?: { - document: EditableDocument; - editor: JsonEditable; - causalInbox: EditableCausalInbox; - }; - } -} - -export function JsonEditableDemo() { - const document = useMemo(() => createEditableDocument(), []); - const rootRef = useRef(null); - const editorRef = useRef(null); - const causalInboxRef = useRef(null); - const causalTracerSequenceRef = useRef(0); - const [snapshot, setSnapshot] = useState(null); - const [lastFault, setLastFault] = useState(null); - - useEffect(() => { - const root = rootRef.current; - if (root === null) { - return; - } - - const editor = mountJsonEditable({ - root, - document, - onFault: setLastFault, - }); - const causalInbox = createEditableCausalInbox(document, editor); - editorRef.current = editor; - causalInboxRef.current = causalInbox; - setSnapshot(editor.getSnapshot()); - const unsubscribe = editor.subscribe(setSnapshot); - window.__jsonEditableLab = { document, editor, causalInbox }; - - return () => { - if (window.__jsonEditableLab?.editor === editor) { - delete window.__jsonEditableLab; - } - unsubscribe(); - causalInbox.dispose(); - editor.destroy(); - editorRef.current = null; - causalInboxRef.current = null; - }; - }, [document]); - - const setBlockType = useCallback((blockType: EditableBlockType) => { - editorRef.current?.dispatch({ type: "setBlockType", blockType }); - }, []); - - const updateOtherBlock = useCallback(() => { - const editor = editorRef.current; - const currentSnapshot = editor?.getSnapshot(); - const block = [...document.value.blocks] - .reverse() - .find( - (candidate) => candidate.id !== currentSnapshot?.composition?.blockId, - ); - if (editor === null || block === undefined) { - return; - } - editor.dispatch({ - type: "replaceText", - blockId: block.id, - from: block.text.length, - to: block.text.length, - text: ` · 외부 변경 ${(currentSnapshot?.revision ?? 0) + 1}`, - label: "다른 블록 업데이트", - origin: "remote", - }); - }, [document]); - - const overlapComposition = useCallback(() => { - const editor = editorRef.current; - const composition = editor?.getSnapshot().composition; - if (editor === null || composition === null || composition === undefined) { - return; - } - editor.dispatch({ - type: "replaceText", - blockId: composition.blockId, - from: composition.from, - to: composition.to, - text: "[충돌]", - label: "현재 조합 범위 업데이트", - origin: "remote", - }); - }, []); - - const runCausalTracer = useCallback(() => { - const editor = editorRef.current; - const inbox = causalInboxRef.current; - const editorSnapshot = editor?.getSnapshot(); - const inboxSnapshot = inbox?.current(); - const block = [...document.value.blocks] - .reverse() - .find( - (candidate) => candidate.id !== editorSnapshot?.composition?.blockId, - ); - if ( - editor === null || - inbox === null || - block === undefined || - inboxSnapshot?.journalRevision === undefined - ) { - return; - } - - causalTracerSequenceRef.current += 1; - const sequence = causalTracerSequenceRef.current; - const base = document.value; - const blockIndex = base.blocks.findIndex( - (candidate) => candidate.id === block.id, - ); - if (blockIndex < 0) { - return; - } - const delayedText = `${block.text} · 지연 변경 ${sequence}`; - if (editorSnapshot?.composition === null) { - const local = editor.dispatch({ - type: "patch", - patch: [ - { - op: "add", - path: "/blocks/0", - value: { - id: `causal-local-${sequence}`, - type: "paragraph", - text: `로컬 선행 변경 ${sequence}`, - }, - }, - ], - label: "causal tracer local insertion", - }); - if (!local.ok) { - return; - } - } - - inbox.ingest({ - id: `causal-delayed-${sequence}`, - dependsOn: inboxSnapshot.frontier, - intent: { - kind: "positional", - base, - baseRevision: inboxSnapshot.journalRevision, - operations: [ - { - op: "replace", - path: `/blocks/${blockIndex}/text`, - value: delayedText, - }, - ], - selectionAfter: { - path: `/blocks/${blockIndex}/text`, - offset: delayedText.length, - }, - }, - }); - }, [document]); - - return ( -
-
-
-
event.preventDefault()} - > - setBlockType("paragraph")} - > - - - setBlockType("heading")}> - - - setBlockType("quote")}> - - - setBlockType("code")}> - - -
- - {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: mountJsonEditable assigns the empty host's editing semantics. */} -
-
- - -
-
- ); -} - -function IconButton({ - children, - label, - onClick, -}: { - children: ReactNode; - label: string; - onClick: () => void; -}) { - return ( - - ); -} - -function StateBlock({ label, value }: { label: string; value: unknown }) { - return ( -
-

{label}

-
{stringify(value)}
-
- ); -} - -function stringify(value: unknown): string { - if (value === null) { - return "null"; - } - try { - return JSON.stringify(value, null, 2) ?? String(value); - } catch { - return String(value); - } -} diff --git a/src/editable-lab/causalDocumentInbox.test.ts b/src/editable-lab/causalDocumentInbox.test.ts deleted file mode 100644 index 55ca8f8..0000000 --- a/src/editable-lab/causalDocumentInbox.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - createEditableDocument, - type JsonEditable, - mountJsonEditable, -} from "../../packages/editable"; -import { createEditableCausalInbox } from "./causalDocumentInbox"; - -let editor: JsonEditable | null = null; - -afterEach(() => { - editor?.destroy(); - editor = null; - window.document.body.replaceChildren(); - vi.useRealTimers(); -}); - -describe("editable lab causal inbox", () => { - it("retries a deferred envelope in a microtask after composition settles", async () => { - vi.useFakeTimers(); - const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", - id: "causal-lab-test", - blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, - ], - }); - const root = window.document.createElement("div"); - window.document.body.append(root); - editor = mountJsonEditable({ root, document }); - const inbox = createEditableCausalInbox(document, editor); - const base = document.value; - const node = root.querySelector( - '[data-editable-block="alpha"] [data-editable-text]', - )?.firstChild; - if (!(node instanceof Text)) { - throw new Error("Missing alpha Text node."); - } - setDOMCaret(node, 2); - root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - - const result = inbox.ingest({ - id: "delayed", - dependsOn: [], - intent: { - kind: "positional", - base, - baseRevision: 0, - operations: [ - { op: "replace", path: "/blocks/1/text", value: "settled" }, - ], - }, - }); - expect(result).toMatchObject({ ok: false, code: "host_not_ready" }); - - root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "" }), - ); - await vi.advanceTimersByTimeAsync(31); - - expect(document.value.blocks[1]?.text).toBe("settled"); - expect(inbox.current()).toMatchObject({ - status: "active", - frontier: ["delayed"], - queued: [], - }); - inbox.dispose(); - }); - - it("does not retry a recovered pin until the browser composition ends", async () => { - vi.useFakeTimers(); - const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", - id: "causal-recovery-test", - blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, - ], - }); - const root = window.document.createElement("div"); - window.document.body.append(root); - editor = mountJsonEditable({ root, document }); - const inbox = createEditableCausalInbox(document, editor); - const base = document.value; - const node = root.querySelector( - '[data-editable-block="alpha"] [data-editable-text]', - )?.firstChild; - if (!(node instanceof Text)) { - throw new Error("Missing alpha Text node."); - } - setDOMCaret(node, 2); - root.dispatchEvent( - new CompositionEvent("compositionstart", { bubbles: true }), - ); - node.parentNode?.replaceChild( - window.document.createTextNode(node.data), - node, - ); - - expect( - inbox.ingest({ - id: "after-recovery", - dependsOn: [], - intent: { - kind: "positional", - base, - baseRevision: 0, - operations: [ - { op: "replace", path: "/blocks/1/text", value: "released" }, - ], - }, - }), - ).toMatchObject({ ok: false, code: "host_not_ready" }); - await Promise.resolve(); - expect(document.value.blocks[1]?.text).toBe("second"); - - root.dispatchEvent( - new CompositionEvent("compositionend", { bubbles: true, data: "" }), - ); - await Promise.resolve(); - expect(document.value.blocks[1]?.text).toBe("second"); - await vi.advanceTimersByTimeAsync(31); - - expect(document.value.blocks[1]?.text).toBe("released"); - expect(inbox.current()).toMatchObject({ - status: "active", - frontier: ["after-recovery"], - queued: [], - }); - inbox.dispose(); - }); -}); - -function setDOMCaret(node: Text, offset: number): void { - const selection = window.getSelection(); - if (selection === null) { - throw new Error("The test DOM does not expose a Selection."); - } - const range = window.document.createRange(); - range.setStart(node, offset); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); -} diff --git a/src/editable-lab/causalDocumentInbox.ts b/src/editable-lab/causalDocumentInbox.ts deleted file mode 100644 index 33f5861..0000000 --- a/src/editable-lab/causalDocumentInbox.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { JSONDocument } from "@interactive-os/json-document"; -import { - type CausalPatchInbox, - createCausalPatchInbox, -} from "@interactive-os/json-document-causal-patch-inbox"; -import { - EditableDocumentSchema, - type EditableDocumentValue, - getJsonEditableDocumentHost, - type JsonEditable, -} from "../../packages/editable"; - -export function createEditableCausalInbox( - document: JSONDocument, - editor: JsonEditable, -): CausalPatchInbox { - const inbox = createCausalPatchInbox(document, { - host: getJsonEditableDocumentHost(editor), - positionalSchema: EditableDocumentSchema, - stableIdScopes: [ - { - scope: "editable-block", - query: "/blocks/*", - readId: (value) => readBlockId(value), - }, - ], - }); - let disposed = false; - let retryPending = false; - let retryScheduled = false; - let lastAttemptedRevision: number | null = null; - - const scheduleRetry = ( - snapshot: ReturnType, - allowCurrentRevision = false, - ): void => { - if ( - disposed || - !retryPending || - retryScheduled || - snapshot.phase !== "idle" || - snapshot.queuedChanges !== 0 || - (!allowCurrentRevision && lastAttemptedRevision === snapshot.revision) - ) { - return; - } - retryScheduled = true; - queueMicrotask(() => { - retryScheduled = false; - if (disposed || !retryPending) { - return; - } - const latest = editor.getSnapshot(); - if (latest.phase !== "idle" || latest.queuedChanges !== 0) { - return; - } - retryPending = false; - lastAttemptedRevision = latest.revision; - const result = inbox.ingest([]); - if (!result.ok && result.code === "host_not_ready") { - retryPending = true; - } - }); - }; - - const stopEditorSubscription = editor.subscribe((snapshot) => { - scheduleRetry(snapshot); - }); - - return { - ingest(input) { - const result = inbox.ingest(input); - if (!result.ok && result.code === "host_not_ready") { - retryPending = true; - scheduleRetry(editor.getSnapshot(), true); - } else if (result.ok) { - retryPending = false; - } - return result; - }, - current: () => inbox.current(), - dispose() { - if (disposed) { - return; - } - disposed = true; - stopEditorSubscription(); - inbox.dispose(); - }, - }; -} - -function readBlockId(value: unknown): string | null { - if ( - typeof value !== "object" || - value === null || - !("id" in value) || - typeof value.id !== "string" - ) { - return null; - } - return value.id; -} diff --git a/src/note-editor/index.ts b/src/note-editor/index.ts new file mode 100644 index 0000000..65f1f35 --- /dev/null +++ b/src/note-editor/index.ts @@ -0,0 +1 @@ +export { NoteEditor } from "./ui/NoteEditor"; diff --git a/src/note-editor/model/localNote.test.ts b/src/note-editor/model/localNote.test.ts new file mode 100644 index 0000000..999eed9 --- /dev/null +++ b/src/note-editor/model/localNote.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LOCAL_NOTE_STORAGE_KEY, openLocalNote } from "./localNote"; +import { createInitialNoteValue } from "./noteDocument"; + +const RECOVERY_KEY = `${LOCAL_NOTE_STORAGE_KEY}.recovery`; + +const sourceNote = { + id: "saved-note", + source: "# Saved\n\n**body**", +}; + +function store(value: unknown): string { + const serialized = JSON.stringify(value); + window.localStorage.setItem(LOCAL_NOTE_STORAGE_KEY, serialized); + return serialized; +} + +function legacyNote( + overrides: Record = {}, +): Record { + return { + schema: "interactive-os.editable-document@3", + id: "legacy-note", + blocks: [ + { + id: "title", + type: "heading", + text: "Legacy", + marks: {}, + }, + { + id: "body", + type: "paragraph", + text: "body", + marks: {}, + }, + ], + ...overrides, + }; +} + +describe("openLocalNote", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("loads a valid source document without scheduling a rewrite", () => { + store(sourceNote); + + const session = openLocalNote(); + + expect(session.value).toEqual(sourceNote); + expect(session.needsSave).toBe(false); + expect(session.recoveryData).toBeNull(); + expect(session.activate()).toBe("saved"); + expect(window.localStorage.getItem(RECOVERY_KEY)).toBeNull(); + }); + + it("accepts an empty source as a valid saved note", () => { + const emptyNote = { ...sourceNote, source: "" }; + const serialized = store(emptyNote); + + const session = openLocalNote(); + + expect(session.value).toEqual(emptyNote); + expect(session.needsSave).toBe(false); + expect(session.recoveryData).toBeNull(); + expect(window.localStorage.getItem(LOCAL_NOTE_STORAGE_KEY)).toBe( + serialized, + ); + }); + + it("preserves unsupported legacy data before replacing it with a fresh note", async () => { + const original = store(legacyNote()); + + const session = openLocalNote(); + + expect(session.value).toEqual(createInitialNoteValue()); + expect(session.needsSave).toBe(false); + expect(JSON.parse(session.recoveryData ?? "null")).toEqual({ + drafts: [], + unreadable: [original], + }); + expect(window.localStorage.getItem(RECOVERY_KEY)).toBeNull(); + + expect(session.activate()).toBe("recovered"); + expect(window.localStorage.getItem(RECOVERY_KEY)).toBe(original); + await expect(session.save(session.value, () => true)).resolves.toBe( + "saved", + ); + expect( + JSON.parse(window.localStorage.getItem(LOCAL_NOTE_STORAGE_KEY) ?? "null"), + ).toEqual(session.value); + expect(window.localStorage.getItem(RECOVERY_KEY)).toBe(original); + }); + + it("fails closed when a valid @3 shape cannot be represented as source", async () => { + const original = store( + legacyNote({ + blocks: [ + { + id: "body", + type: "paragraph", + text: "abcdef", + marks: { + bold: { type: "bold", start: 0, end: 4 }, + italic: { type: "italic", start: 2, end: 6 }, + }, + }, + ], + }), + ); + + const session = openLocalNote(); + + expect(session.value).toEqual(createInitialNoteValue()); + expect(session.needsSave).toBe(false); + expect(JSON.parse(session.recoveryData ?? "null")).toEqual({ + drafts: [], + unreadable: [original], + }); + expect(window.localStorage.getItem(LOCAL_NOTE_STORAGE_KEY)).toBe(original); + + expect(session.activate()).toBe("recovered"); + expect(window.localStorage.getItem(RECOVERY_KEY)).toBe(original); + expect(window.localStorage.getItem(LOCAL_NOTE_STORAGE_KEY)).toBe(original); + }); + + it("does not overwrite a concurrent writer while replacing legacy data", async () => { + const original = store(legacyNote()); + const session = openLocalNote(); + const concurrent = JSON.stringify({ ...sourceNote, source: "concurrent" }); + window.localStorage.setItem(LOCAL_NOTE_STORAGE_KEY, concurrent); + + await expect(session.save(session.value, () => true)).resolves.toBe( + "conflict", + ); + + expect(window.localStorage.getItem(LOCAL_NOTE_STORAGE_KEY)).toBe( + concurrent, + ); + expect(window.localStorage.getItem(RECOVERY_KEY)).toBe(original); + }); +}); diff --git a/src/note-editor/model/localNote.ts b/src/note-editor/model/localNote.ts new file mode 100644 index 0000000..8bc6c56 --- /dev/null +++ b/src/note-editor/model/localNote.ts @@ -0,0 +1,386 @@ +import { + createInitialNoteValue, + decodeNoteDocument, + type NoteDocumentValue, +} from "./noteDocument"; + +export const LOCAL_NOTE_STORAGE_KEY = "interactive-os.editable.note"; +const RECOVERY_KEY = `${LOCAL_NOTE_STORAGE_KEY}.recovery`; +const DRAFT_PREFIX = `${LOCAL_NOTE_STORAGE_KEY}.draft.`; +const WRITE_LOCK_KEY = `${LOCAL_NOTE_STORAGE_KEY}.writer`; + +export type NotePersistenceStatus = + | "checking" + | "saving" + | "saved" + | "recovered" + | "conflict" + | "unavailable"; + +export type LocalNoteSession = { + value: NoteDocumentValue; + needsSave: boolean; + recoveryData: string | null; + activate(): NotePersistenceStatus; + observe(newValue: string | null): NotePersistenceStatus | null; + preserve(value: NoteDocumentValue): void; + save( + value: NoteDocumentValue, + isSafeToWrite: () => boolean, + ): Promise; +}; + +type StoredDraft = { + baseline: string | null; + key: string; + savedAt: number; + value: NoteDocumentValue; +}; + +type DraftScan = { + drafts: StoredDraft[]; + unreadable: string[]; +}; + +type SessionOptions = { + baseline?: string | null; + draftKey?: string | null; + locks?: LockManager | null; + needsSave?: boolean; + recovery?: string | null; + recoveryData?: string | null; + restoredDraftKey?: string | null; + status: NotePersistenceStatus; + storage?: Storage | null; + value: NoteDocumentValue; +}; + +export function openLocalNote(): LocalNoteSession { + const fallback = createInitialNoteValue(); + if (typeof window === "undefined") { + return createSession({ status: "checking", value: fallback }); + } + + let storage: Storage; + let serialized: string | null; + try { + storage = window.localStorage; + serialized = storage.getItem(LOCAL_NOTE_STORAGE_KEY); + } catch { + return createSession({ status: "unavailable", value: fallback }); + } + + const common = { + draftKey: `${DRAFT_PREFIX}${crypto.randomUUID()}`, + locks: getLocks(), + storage, + }; + const storedDocument = readDocument(serialized); + const storedValue = storedDocument?.value ?? null; + const draftScan = readDrafts(storage); + const drafts = draftScan.drafts.filter((draft) => { + if (JSON.stringify(draft.value) !== serialized) { + return true; + } + remove(storage, draft.key); + return false; + }); + const unreadable = storedValue === null ? serialized : null; + const migrationRecovery = + storedDocument?.migrated === true ? serialized : null; + const recoveryData = createRecoveryData( + [ + ...readRecoveries(storage), + unreadable, + migrationRecovery, + ...draftScan.unreadable, + ].filter((entry): entry is string => entry !== null), + drafts, + ); + const draft = drafts.find((candidate) => candidate.baseline === serialized); + if (draft !== undefined) { + return createSession({ + ...common, + baseline: serialized, + needsSave: true, + recovery: unreadable ?? migrationRecovery, + recoveryData, + restoredDraftKey: draft.key, + status: "recovered", + value: draft.value, + }); + } + if (storedValue !== null) { + return createSession({ + ...common, + baseline: serialized, + needsSave: storedDocument?.migrated === true, + recovery: migrationRecovery, + recoveryData, + status: recoveryData === null ? "saved" : "recovered", + value: storedValue, + }); + } + if (serialized === null) { + return createSession({ + ...common, + recoveryData, + status: recoveryData === null ? "saved" : "recovered", + value: fallback, + }); + } + return createSession({ + ...common, + baseline: serialized, + recoveryData, + recovery: serialized, + status: "recovered", + value: fallback, + }); +} + +function createSession({ + baseline = null, + draftKey = null, + locks = null, + needsSave = false, + recovery = null, + recoveryData = null, + restoredDraftKey = null, + status: initialStatus, + storage = null, + value, +}: SessionOptions): LocalNoteSession { + let expected = baseline; + let status = initialStatus; + let writable = + storage !== null && recovery === null && initialStatus !== "conflict"; + let saveGeneration = 0; + + const activate = (): NotePersistenceStatus => { + if (recovery === null || writable || storage === null) { + return status; + } + try { + const preserved = storage.getItem(RECOVERY_KEY); + if (preserved === null) { + storage.setItem(RECOVERY_KEY, recovery); + } else if (preserved !== recovery) { + const recoveries = readRecoveries(storage); + if (!recoveries.includes(recovery)) { + storage.setItem(`${RECOVERY_KEY}.${crypto.randomUUID()}`, recovery); + } + } + writable = true; + } catch { + status = "unavailable"; + } + return status; + }; + + const clearDrafts = () => { + if (storage === null) { + return; + } + if (draftKey !== null) { + remove(storage, draftKey); + } + if (restoredDraftKey !== null) { + remove(storage, restoredDraftKey); + } + }; + + const write = (nextValue: NoteDocumentValue) => { + if (activate() === "conflict" || storage === null || !writable) { + return status === "conflict" ? status : "unavailable"; + } + try { + if (storage.getItem(LOCAL_NOTE_STORAGE_KEY) !== expected) { + status = "conflict"; + writable = false; + return status; + } + const serialized = JSON.stringify(nextValue); + storage.setItem(LOCAL_NOTE_STORAGE_KEY, serialized); + expected = serialized; + status = "saved"; + clearDrafts(); + } catch { + status = "unavailable"; + } + return status; + }; + + return { + value, + needsSave, + recoveryData, + activate, + observe(newValue) { + if (newValue === expected) { + return null; + } + status = "conflict"; + writable = false; + return status; + }, + preserve(nextValue) { + saveGeneration += 1; + if (storage === null || draftKey === null) { + return; + } + try { + storage.setItem( + draftKey, + JSON.stringify({ + baseline: expected, + savedAt: Date.now(), + value: nextValue, + }), + ); + } catch { + // Editing remains available even when browser storage is unavailable. + } + }, + async save(nextValue, isSafeToWrite) { + const generation = ++saveGeneration; + if (locks === null) { + return isSafeToWrite() ? write(nextValue) : null; + } + try { + return await locks.request(WRITE_LOCK_KEY, () => + generation === saveGeneration && isSafeToWrite() + ? write(nextValue) + : null, + ); + } catch { + status = "unavailable"; + return status; + } + }, + }; +} + +type DecodedDocument = { + value: NoteDocumentValue; + migrated: boolean; +}; + +function readDocument(serialized: string | null): DecodedDocument | null { + if (serialized === null) { + return null; + } + try { + return decodeDocument(JSON.parse(serialized)); + } catch { + return null; + } +} + +function decodeDocument(value: unknown): DecodedDocument | null { + const decoded = decodeNoteDocument(value); + return decoded === null ? null : { value: decoded, migrated: false }; +} + +function readRecoveries(storage: Storage): string[] { + const recoveries: string[] = []; + try { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if ( + key !== null && + (key === RECOVERY_KEY || key.startsWith(`${RECOVERY_KEY}.`)) + ) { + const value = storage.getItem(key); + if (value !== null) { + recoveries.push(value); + } + } + } + } catch { + // Return every recovery record collected before browser storage failed. + } + return recoveries; +} + +function readDrafts(storage: Storage): DraftScan { + const drafts: StoredDraft[] = []; + const unreadable: string[] = []; + try { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key === null || !key.startsWith(DRAFT_PREFIX)) { + continue; + } + const serialized = storage.getItem(key); + if (serialized === null) { + continue; + } + try { + const record = JSON.parse(serialized) as Record; + const parsed = decodeDocument(record.value); + if ( + (typeof record.baseline !== "string" && record.baseline !== null) || + typeof record.savedAt !== "number" || + parsed === null + ) { + unreadable.push(serialized); + continue; + } + drafts.push({ + baseline: record.baseline, + key, + savedAt: record.savedAt, + value: parsed.value, + }); + if (parsed.migrated) { + unreadable.push(serialized); + } + } catch { + unreadable.push(serialized); + } + } + } catch { + // Return every recovery record collected before browser storage failed. + } + return { + drafts: drafts.sort( + (left, right) => + right.savedAt - left.savedAt || left.key.localeCompare(right.key), + ), + unreadable, + }; +} + +function createRecoveryData( + unreadable: string[], + drafts: StoredDraft[], +): string | null { + if (unreadable.length === 0 && drafts.length === 0) { + return null; + } + return JSON.stringify( + { + drafts: drafts.map(({ baseline, savedAt, value }) => ({ + baseline, + savedAt, + value, + })), + unreadable: [...new Set(unreadable)], + }, + null, + 2, + ); +} + +function remove(storage: Storage, key: string): void { + try { + storage.removeItem(key); + } catch { + // A stale duplicate is ignored when it matches the canonical value. + } +} + +function getLocks(): LockManager | null { + return "locks" in navigator ? navigator.locks : null; +} diff --git a/src/note-editor/model/noteDocument.test.ts b/src/note-editor/model/noteDocument.test.ts new file mode 100644 index 0000000..f55c0f9 --- /dev/null +++ b/src/note-editor/model/noteDocument.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { createInitialNoteValue } from "./noteDocument"; + +describe("createInitialNoteValue", () => { + it("creates a fresh local note", () => { + const first = createInitialNoteValue(); + const second = createInitialNoteValue(); + + expect(first).toEqual({ + id: "local-note", + source: "# 제목 없는 노트\n\n여기에 생각을 적어보세요.", + }); + expect(second).toEqual(first); + expect(second).not.toBe(first); + }); +}); diff --git a/src/note-editor/model/noteDocument.ts b/src/note-editor/model/noteDocument.ts new file mode 100644 index 0000000..df5dc08 --- /dev/null +++ b/src/note-editor/model/noteDocument.ts @@ -0,0 +1,26 @@ +import type { JSONValue } from "@interactive-os/json-document"; + +export type NoteDocumentValue = Readonly<{ + id: string; + source: string; + readonly [key: string]: JSONValue; +}>; + +export function createInitialNoteValue(): NoteDocumentValue { + return { + id: "local-note", + source: "# 제목 없는 노트\n\n여기에 생각을 적어보세요.", + }; +} + +export function decodeNoteDocument(value: unknown): NoteDocumentValue | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + return typeof record.id === "string" && + record.id.length > 0 && + typeof record.source === "string" + ? (record as NoteDocumentValue) + : null; +} diff --git a/src/note-editor/runtime/testHarness.ts b/src/note-editor/runtime/testHarness.ts new file mode 100644 index 0000000..2141c50 --- /dev/null +++ b/src/note-editor/runtime/testHarness.ts @@ -0,0 +1,29 @@ +import type { EditorView } from "@interactive-os/editable"; +import type { JSONDocument } from "@interactive-os/json-document"; + +export type EditableLabHandle = { + document: JSONDocument; + editor: EditorView; +}; + +declare global { + interface Window { + __jsonEditableLab?: EditableLabHandle; + } +} + +export function exposeEditableLab( + document: JSONDocument, + editor: EditorView, +): () => void { + if (!import.meta.env.DEV) { + return () => {}; + } + const handle = { document, editor }; + window.__jsonEditableLab = handle; + return () => { + if (window.__jsonEditableLab === handle) { + delete window.__jsonEditableLab; + } + }; +} diff --git a/src/note-editor/ui/NoteEditor.tsx b/src/note-editor/ui/NoteEditor.tsx new file mode 100644 index 0000000..66099d6 --- /dev/null +++ b/src/note-editor/ui/NoteEditor.tsx @@ -0,0 +1,449 @@ +import { + type EditorError, + type EditorSnapshot, + type EditorView, + mountEditor, +} from "@interactive-os/editable"; +import { + createMarkdownAdapter, + createMarkdownDocument, +} from "@interactive-os/editable/markdown"; +import { + Bold, + Code, + Code2, + Heading1, + Italic, + Pilcrow, + Quote, + Strikethrough, + Underline, +} from "lucide-react"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { + LOCAL_NOTE_STORAGE_KEY, + type NotePersistenceStatus, + openLocalNote, +} from "../model/localNote"; +import type { NoteDocumentValue } from "../model/noteDocument"; +import { exposeEditableLab } from "../runtime/testHarness"; + +type InlineMark = "bold" | "italic" | "underline" | "strike" | "code"; +type BlockType = "paragraph" | "heading" | "quote" | "code"; +const SAVE_DELAY_MS = 250; + +export function NoteEditor() { + const localNote = useMemo(openLocalNote, []); + const adapter = useMemo(() => createMarkdownAdapter(), []); + const document = useMemo( + () => createMarkdownDocument(localNote.value), + [localNote], + ); + const rootRef = useRef(null); + const editorRef = useRef(null); + const [snapshot, setSnapshot] = useState(null); + const [lastFault, setLastFault] = useState(null); + const [presentation, setPresentation] = useState(adapter.presentation); + const [preset, setPreset] = useState(adapter.preset); + const [persistenceStatus, setPersistenceStatus] = + useState("checking"); + const canFormat = snapshot?.selection !== null; + + useEffect(() => { + const root = rootRef.current; + if (root === null) { + return; + } + + const editor = mountEditor({ + root, + document, + adapter, + onError: setLastFault, + }); + editorRef.current = editor; + setSnapshot(editor.getSnapshot()); + setPersistenceStatus(localNote.activate()); + let saveTimer: number | null = null; + let hasDocumentChanges = localNote.needsSave; + let documentVersion = 0; + let saveInFlight = false; + let stopped = false; + const cancelScheduledSave = () => { + if (saveTimer !== null) { + window.clearTimeout(saveTimer); + saveTimer = null; + } + }; + const scheduleSave = () => { + cancelScheduledSave(); + saveTimer = window.setTimeout(() => { + saveTimer = null; + void flush(); + }, SAVE_DELAY_MS); + }; + const flush = async () => { + cancelScheduledSave(); + if ( + !hasDocumentChanges || + saveInFlight || + editor.getSnapshot().isComposing + ) { + return; + } + const version = documentVersion; + saveInFlight = true; + const nextStatus = await localNote.save( + localNoteValue(document.value), + () => !editor.getSnapshot().isComposing, + ); + saveInFlight = false; + if (nextStatus === "saved" && version === documentVersion) { + hasDocumentChanges = false; + } + if (!stopped && nextStatus !== null) { + setPersistenceStatus(nextStatus); + } + if ( + !stopped && + hasDocumentChanges && + !editor.getSnapshot().isComposing && + (nextStatus === null || nextStatus === "saved") + ) { + scheduleSave(); + } + }; + const unsubscribe = editor.subscribe(() => { + const nextSnapshot = editor.getSnapshot(); + setSnapshot(nextSnapshot); + if ( + !nextSnapshot.isComposing && + hasDocumentChanges && + !saveInFlight && + saveTimer === null + ) { + scheduleSave(); + } + }); + const stopPersistence = document.subscribe(() => { + hasDocumentChanges = true; + documentVersion += 1; + setPersistenceStatus("saving"); + scheduleSave(); + }); + const onStorage = (event: StorageEvent) => { + if (event.key !== LOCAL_NOTE_STORAGE_KEY) { + return; + } + const nextStatus = localNote.observe(event.newValue); + if (nextStatus !== null) { + cancelScheduledSave(); + setPersistenceStatus(nextStatus); + } + }; + const preserveDraft = () => { + cancelScheduledSave(); + if (hasDocumentChanges) { + localNote.preserve(localNoteValue(document.value)); + } + }; + const onPageHide = () => preserveDraft(); + window.addEventListener("pagehide", onPageHide); + window.addEventListener("storage", onStorage); + const hideTestHarness = exposeEditableLab(document, editor); + if (hasDocumentChanges) { + scheduleSave(); + } + + return () => { + stopped = true; + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("storage", onStorage); + hideTestHarness(); + stopPersistence(); + cancelScheduledSave(); + unsubscribe(); + const valueBeforeDestroy = document.value; + editor.destroy(); + hasDocumentChanges ||= document.value !== valueBeforeDestroy; + preserveDraft(); + editorRef.current = null; + }; + }, [adapter, document, localNote]); + + const setBlockType = (blockType: BlockType) => { + editorRef.current?.dispatch({ + inputType: "formatBlock", + data: blockType, + }); + }; + + const toggleInlineMark = (mark: InlineMark) => { + const inputType = { + bold: "formatBold", + italic: "formatItalic", + underline: "formatUnderline", + strike: "formatStrikeThrough", + code: "formatInlineCode", + }[mark]; + editorRef.current?.dispatch({ inputType }); + }; + + const togglePresentation = () => { + const next = presentation === "rich" ? "live-preview" : "rich"; + adapter.setPresentation(next); + setPresentation(next); + }; + + const togglePreset = () => { + const next = preset === "bear" ? "source-reveal" : "bear"; + adapter.setPreset(next); + setPreset(next); + }; + + const persistenceStatusLabel = persistenceStatusText(persistenceStatus); + const recoveryData = localNote.recoveryData; + const status = + persistenceStatus === "conflict" || persistenceStatus === "unavailable" + ? lastFault === null + ? persistenceStatusLabel + : `${persistenceStatusLabel} · 편집 오류: ${lastFault.code}` + : lastFault === null + ? persistenceStatusLabel + : `편집 오류: ${lastFault.code}`; + + return ( +
+
+
+

Editable

+
+ + {status} + + {persistenceStatus !== "checking" && recoveryData !== null && ( + + )} +
+
+
+ + {presentation === "rich" ? "Rich" : "Live"} + + + {preset === "bear" ? "Bear" : "Source"} + +