From 8f3e988e7a945717877fc0d45d52dc31fc23247c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 14 Jul 2026 14:02:27 +0900 Subject: [PATCH 01/11] refactor: separate editable package from demo --- README.md | 27 ++- biome.json | 4 + docs/composition-island-architecture.md | 16 +- package.json | 9 +- packages/editable/README.md | 60 +++++++ .../browser/documentProjection.test.ts | 4 +- .../editable/browser/documentProjection.ts | 18 +- packages/editable/browser/domSelection.ts | 14 +- packages/editable/browser/editableDOM.ts | 4 +- packages/editable/browser/editor.ts | 18 +- .../editable/browser/editorCoordinator.ts | 170 +++++++++++------- packages/editable/browser/index.ts | 22 +-- packages/editable/browser/nativeParagraph.ts | 30 +--- .../editable/browser/nativeTextMutation.ts | 8 +- packages/editable/causalHost.test.ts | 27 ++- packages/editable/core/editorCommands.test.ts | 9 +- packages/editable/core/editorCommands.ts | 43 ++--- packages/editable/core/index.ts | 32 ++-- packages/editable/core/model.test.ts | 53 +++--- packages/editable/core/model.ts | 46 +---- packages/editable/crossRealm.test.ts | 18 +- packages/editable/editor.test.ts | 46 +++-- packages/editable/editor.ts | 11 -- packages/editable/index.ts | 29 ++- packages/editable/model.ts | 17 -- packages/editable/package.json | 42 +++++ packages/editable/publicApi.test.ts | 40 ++--- packages/editable/tests/smoke/consumer.ts | 33 ++++ .../editable/tests/smoke/package-smoke.mjs | 30 ++++ packages/editable/tests/smoke/tsconfig.json | 14 ++ packages/editable/tsconfig.build.json | 15 ++ pnpm-lock.yaml | 29 +-- pnpm-workspace.yaml | 2 + src/editable-lab/index.ts | 1 + src/editable-lab/model/demoDocument.test.ts | 39 ++++ src/editable-lab/model/demoDocument.ts | 30 ++++ .../{ => runtime}/causalDocumentInbox.test.ts | 4 +- .../{ => runtime}/causalDocumentInbox.ts | 12 +- src/editable-lab/runtime/testHarness.ts | 27 +++ .../{ => ui}/JsonEditableDemo.tsx | 46 +++-- src/routes/index.tsx | 2 +- tests/browser/editable.spec.ts | 30 ++-- tools/check-editable-layers.mjs | 38 +++- tools/check-editable-layers.test.mjs | 78 ++++++++ tsconfig.json | 5 +- vite.config.ts | 8 + 46 files changed, 816 insertions(+), 444 deletions(-) create mode 100644 packages/editable/README.md delete mode 100644 packages/editable/editor.ts delete mode 100644 packages/editable/model.ts create mode 100644 packages/editable/package.json create mode 100644 packages/editable/tests/smoke/consumer.ts create mode 100644 packages/editable/tests/smoke/package-smoke.mjs create mode 100644 packages/editable/tests/smoke/tsconfig.json create mode 100644 packages/editable/tsconfig.build.json create mode 100644 pnpm-workspace.yaml create mode 100644 src/editable-lab/index.ts create mode 100644 src/editable-lab/model/demoDocument.test.ts create mode 100644 src/editable-lab/model/demoDocument.ts rename src/editable-lab/{ => runtime}/causalDocumentInbox.test.ts (99%) rename src/editable-lab/{ => runtime}/causalDocumentInbox.ts (98%) create mode 100644 src/editable-lab/runtime/testHarness.ts rename src/editable-lab/{ => ui}/JsonEditableDemo.tsx (93%) diff --git a/README.md b/README.md index 3473d49..1657d32 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ # 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. +This workspace develops `@interactive-os/editable`, a JSON-backed +contenteditable package focused on one hard invariant: native IME input and +application rendering must never replace the same live DOM node at the same +time. + +The reusable package lives under `packages/editable`. It owns the document +schema, command planning, DOM projection, and browser event coordinator, but it +does not own React, demo content, sample ids, causal scenarios, or an E2E +window bridge. Those app-owned responsibilities live under `src/editable-lab`. +Callers must pass an explicit initial document to `createEditableDocument`. The current implementation keeps `@interactive-os/json-document` as canonical state and mounts one coordinator that owns the entire editable subtree. Pure @@ -20,8 +27,8 @@ 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 +sequence. The app-owned demo wires the SHA-pinned `json-document` causal inbox +through that capability; “지연 편집 추적” exercises 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 @@ -37,6 +44,14 @@ 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. +```txt +packages/editable reusable package + public -> browser -> core enforced internal direction + +src/editable-lab app-owned demo + model + runtime + ui sample data, causal adapter, React surface +``` + The package enforces a direct-child layer rule: ```txt @@ -63,6 +78,8 @@ Open `http://localhost:3000/`. ```bash pnpm run test:core +pnpm run test:package +pnpm run build:package pnpm run check:editable-layers pnpm run verify:browser pnpm run verify:internal 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..9b4990b 100644 --- a/docs/composition-island-architecture.md +++ b/docs/composition-island-architecture.md @@ -246,7 +246,8 @@ The package encodes that public seam as a direct-child layer hierarchy: ```txt packages/editable/ -├─ index.ts, editor.ts, model.ts public facade layer +├─ package.json publishable package contract +├─ index.ts only public package entrypoint ├─ browser/ │ ├─ index.ts public -> browser seam │ ├─ editor.ts editor contract and mount facade @@ -275,11 +276,14 @@ reason. The DOM platform, `@interactive-os/json-document`, and Zod are declared external dependencies rather than package child layers; core remains DOM-free. -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. +The package owns no default document content. `createEditableDocument` requires +an explicit caller-owned value, while the composition-island sample ids and +Korean/Japanese text live in `src/editable-lab/model`. Package production code +cannot import `src`, `tests`, or `tools`; the layer checker rejects those +outward edges. 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. `destroy()` uses the same mandatory native-effect validation, trailing-newline normalization, queued-remote ordering, and structural replay as timed settling diff --git a/package.json b/package.json index a7160fa..43dcff1 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { - "name": "editable", + "name": "@interactive-os/editable-demo", "private": true, "type": "module", "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,6 +22,7 @@ "check:editable-layers": "node tools/check-editable-layers.mjs" }, "dependencies": { + "@interactive-os/editable": "workspace:*", "@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", diff --git a/packages/editable/README.md b/packages/editable/README.md new file mode 100644 index 0000000..baf8bc2 --- /dev/null +++ b/packages/editable/README.md @@ -0,0 +1,60 @@ +# @interactive-os/editable + +`@interactive-os/editable` is a JSON-backed `contenteditable` coordinator that +keeps native IME composition DOM stable while application changes continue +through `@interactive-os/json-document`. + +The package is ESM-only and is intended for modern browser builds. + +## Install + +```sh +pnpm add @interactive-os/editable @interactive-os/json-document@1.1.0-rc.0 zod +``` + +## Use + +```ts +import { + createEditableDocument, + mountJsonEditable, +} from "@interactive-os/editable"; + +const document = createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "article-1", + blocks: [ + { id: "intro", type: "paragraph", text: "Start writing" }, + ], +}); +const editor = mountJsonEditable({ root, document }); + +editor.subscribe((snapshot) => { + console.log(snapshot.phase); +}); + +editor.destroy(); +``` + +## External changes + +While mounted, send application changes through `editor.dispatch()`. Set +`origin: "remote"` for changes received from another actor; remote changes to +other blocks can wait until the current IME composition settles. A +`composition_conflict` is recoverable and the caller may retry after settling. + +Do not call `document.commit()` directly while the editor is mounted. Adapters +that must publish through the document, such as a causal inbox, should use +`getJsonEditableDocumentHost(editor).runReady(...)` so native input is flushed +first. If it returns `host_not_ready`, retry after the current browser event or +composition settles. `onFault` reports recoverable integrity problems, +including out-of-band document writes. + +The package exposes one public entrypoint: `@interactive-os/editable`. +`browser` and `core` are internal source layers and are not package subpaths. +Callers own initial content and persistent ids; the package never creates demo +documents or sample text implicitly. + +The editor owns descendants of the mounted root. Framework renderers may own +the surrounding UI, but must leave the root subtree to the editor until +`destroy()` is called. diff --git a/packages/editable/browser/documentProjection.test.ts b/packages/editable/browser/documentProjection.test.ts index 408c2f4..8d262ad 100644 --- a/packages/editable/browser/documentProjection.test.ts +++ b/packages/editable/browser/documentProjection.test.ts @@ -9,9 +9,7 @@ import { projectDocumentDOM, } from "./documentProjection"; -function value( - blocks: EditableDocumentValue["blocks"], -): EditableDocumentValue { +function value(blocks: EditableDocumentValue["blocks"]): EditableDocumentValue { return { schema: "interactive-os.editable-document@2", id: "projection-test", diff --git a/packages/editable/browser/documentProjection.ts b/packages/editable/browser/documentProjection.ts index 678caa3..198084e 100644 --- a/packages/editable/browser/documentProjection.ts +++ b/packages/editable/browser/documentProjection.ts @@ -1,15 +1,15 @@ import { - editableTextPath, - findEditableBlockIndex, type EditableBlock, type EditableBlockType, type EditableDocumentValue, -} from "../core"; + editableTextPath, + findEditableBlockIndex, +} from "../core/index.js"; import { EDITABLE_BLOCK_ATTRIBUTE, EDITABLE_TEXT_ATTRIBUTE, setCanonicalSurfaceText, -} from "./editableDOM"; +} from "./editableDOM.js"; const OWNED_BLOCK_ATTRIBUTES = new Set([ "class", @@ -59,7 +59,10 @@ export function projectDocumentDOM({ 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)) { + if ( + activeComposition !== null && + element.contains(activeComposition.node) + ) { invalidateComposition( "The composing block lost its keyed DOM identity.", ); @@ -111,7 +114,10 @@ export function projectDocumentDOM({ ) as HTMLElement | undefined; if (surface === undefined) { surface = root.ownerDocument.createElement("span"); - surface.setAttribute(EDITABLE_TEXT_ATTRIBUTE, editableTextPath(blockIndex)); + surface.setAttribute( + EDITABLE_TEXT_ATTRIBUTE, + editableTextPath(blockIndex), + ); element.append(surface); } if ( diff --git a/packages/editable/browser/domSelection.ts b/packages/editable/browser/domSelection.ts index 1259ae5..e10e754 100644 --- a/packages/editable/browser/domSelection.ts +++ b/packages/editable/browser/domSelection.ts @@ -3,10 +3,10 @@ import type { SelectionSnap, } from "@interactive-os/json-document"; import { + type EditableDocumentValue, editableBlockIndexFromTextPath, editableTextPath, - type EditableDocumentValue, -} from "../core"; +} from "../core/index.js"; import { EDITABLE_BLOCK_ATTRIBUTE, EDITABLE_TEXT_ATTRIBUTE, @@ -14,7 +14,7 @@ import { editableSurfaceFromNode, textFromSurface, textNodes, -} from "./editableDOM"; +} from "./editableDOM.js"; export type DOMSelectionPoint = { blockId: string; @@ -87,7 +87,9 @@ export function readDOMPoint( if (blockId === null) { return null; } - const blockIndex = value.blocks.findIndex((candidate) => candidate.id === blockId); + const blockIndex = value.blocks.findIndex( + (candidate) => candidate.id === blockId, + ); if (blockIndex < 0) { return null; } @@ -159,7 +161,8 @@ function selectionPointToDOM( const element = Array.from( root.querySelectorAll(`[${EDITABLE_BLOCK_ATTRIBUTE}]`), ).find( - (candidate) => candidate.getAttribute(EDITABLE_BLOCK_ATTRIBUTE) === block.id, + (candidate) => + candidate.getAttribute(EDITABLE_BLOCK_ATTRIBUTE) === block.id, ); const surface = element?.querySelector( `[${EDITABLE_TEXT_ATTRIBUTE}]`, @@ -198,7 +201,6 @@ function resolveOwnedSurfacePoint( }; } - const containingBlock = editableBlockFromNode(root, node); const blockSurface = containingBlock?.querySelector( `[${EDITABLE_TEXT_ATTRIBUTE}]`, diff --git a/packages/editable/browser/editableDOM.ts b/packages/editable/browser/editableDOM.ts index 775f71b..1c3ec57 100644 --- a/packages/editable/browser/editableDOM.ts +++ b/packages/editable/browser/editableDOM.ts @@ -6,7 +6,7 @@ export function editableSurfaceFromNode( root: HTMLElement, node: Node | null, ): HTMLElement | null { - const element = isElementNode(node) ? node : node?.parentElement ?? null; + const element = isElementNode(node) ? node : (node?.parentElement ?? null); const surface = element?.closest(`[${EDITABLE_TEXT_ATTRIBUTE}]`); return surface != null && root.contains(surface) ? surface : null; } @@ -15,7 +15,7 @@ export function editableBlockFromNode( root: HTMLElement, node: Node | null, ): HTMLElement | null { - const element = isElementNode(node) ? node : node?.parentElement ?? null; + const element = isElementNode(node) ? node : (node?.parentElement ?? null); const block = element?.closest(`[${EDITABLE_BLOCK_ATTRIBUTE}]`); return block != null && root.contains(block) ? block : null; } diff --git a/packages/editable/browser/editor.ts b/packages/editable/browser/editor.ts index 7e41706..211ca22 100644 --- a/packages/editable/browser/editor.ts +++ b/packages/editable/browser/editor.ts @@ -6,19 +6,16 @@ import type { } from "@interactive-os/json-document"; import type { EditableDocumentValue, + EditorChangeOrigin, EditorDocumentCommand, -} from "../core"; +} from "../core/index.js"; export { getJsonEditableDocumentHost, mountJsonEditable, -} from "./editorCoordinator"; +} from "./editorCoordinator.js"; -export type EditorPhase = - | "idle" - | "native-input" - | "composing" - | "settling"; +export type EditorPhase = "idle" | "native-input" | "composing" | "settling"; export type EditorSnapshot = { phase: EditorPhase; @@ -51,7 +48,7 @@ export type EditorAction = type: "patch"; patch: ReadonlyArray; label?: string; - origin?: string; + origin?: EditorChangeOrigin; selectionAfter?: SelectionSnap | null; } | EditorDocumentCommand @@ -80,10 +77,7 @@ export type JsonEditableDocumentHost = { operations: ReadonlyArray; metadata?: JSONChangeMetadata; }): false | { sequence: number }; - runReady(request: { - id: string; - apply(): void; - }): + runReady(request: { id: string; apply(): void }): | { ok: true } | { ok: false; diff --git a/packages/editable/browser/editorCoordinator.ts b/packages/editable/browser/editorCoordinator.ts index 71a8248..9130697 100644 --- a/packages/editable/browser/editorCoordinator.ts +++ b/packages/editable/browser/editorCoordinator.ts @@ -6,56 +6,57 @@ import { type SelectionSnap, } from "@interactive-os/json-document"; import { - EditableDocumentSchema, accumulateNativeCompositionRange, diffText, diffTextNearRange, + type EditableBlock, + EditableDocumentSchema, + type EditableDocumentValue, + type EditorChangeOrigin, + type EditorDocumentCommand, 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"; +} from "../core/index.js"; +import { + findBlockElement, + isCanonicalBlockElement, + isCanonicalSurfaceElement, + projectDocumentDOM, +} from "./documentProjection.js"; import { readDOMPoint, readDOMSelection, restoreDOMSelection, -} from "./domSelection"; +} from "./domSelection.js"; import { EDITABLE_BLOCK_ATTRIBUTE, EDITABLE_TEXT_ATTRIBUTE, editableBlockFromNode, editableSurfaceFromNode, ensureCompositionTextNode, -} from "./editableDOM"; -import { - findBlockElement, - isCanonicalBlockElement, - isCanonicalSurfaceElement, - projectDocumentDOM, -} from "./documentProjection"; +} from "./editableDOM.js"; +import type { + EditorAction, + EditorFault, + EditorPhase, + EditorResult, + EditorSnapshot, + JsonEditable, + JsonEditableDocumentHost, + MountJsonEditableOptions, +} from "./editor.js"; import { captureCompositionPlaceholder, inspectNativeParagraphEffect, - readPinnedCompositionText, type NativeParagraphEffect, -} from "./nativeParagraph"; -import { inspectNativeTextMutations } from "./nativeTextMutation"; + readPinnedCompositionText, +} from "./nativeParagraph.js"; +import { inspectNativeTextMutations } from "./nativeTextMutation.js"; type ChangeSource = | "native" @@ -237,9 +238,11 @@ class JsonEditableCoordinator implements JsonEditable { this.observe(); this.attachEvents(); - this.stopDocumentSubscription = document.subscribe((_operations, metadata) => { - this.onDocumentChange(metadata); - }); + this.stopDocumentSubscription = document.subscribe( + (_operations, metadata) => { + this.onDocumentChange(metadata); + }, + ); this.stopSelectionSubscription = document.selection?.subscribe(() => { this.bump(); @@ -401,7 +404,10 @@ class JsonEditableCoordinator implements JsonEditable { return action.blockId === blockId; } if (action.type === "patch") { - const composingIndex = findEditableBlockIndex(this.document.value, blockId); + const composingIndex = findEditableBlockIndex( + this.document.value, + blockId, + ); return action.patch.some((operation) => { if (operation.op !== "replace" && operation.op !== "test") { return true; @@ -527,8 +533,7 @@ class JsonEditableCoordinator implements JsonEditable { ): T { const previousSource = this.commitSource; const previousTextChanges = this.commitTextChanges; - const previousPublicationSequence = - this.activeDocumentPublicationSequence; + const previousPublicationSequence = this.activeDocumentPublicationSequence; const publicationSequence = this.reserveDocumentPublicationSequence(); this.activeDocumentPublicationSequence = publicationSequence; this.commitSource = source; @@ -675,11 +680,7 @@ class JsonEditableCoordinator implements JsonEditable { "The JSON document changed outside editor.dispatch(); the editor recovered conservatively.", }); } - const changes = describeBlockChanges( - before, - after, - this.commitTextChanges, - ); + const changes = describeBlockChanges(before, after, this.commitTextChanges); this.reconcileComposition(after, changes, source); this.withDOMWrite(() => { this.renderDocument(after); @@ -721,12 +722,17 @@ class JsonEditableCoordinator implements JsonEditable { if (session === null) { return; } - const change = changes.find((candidate) => candidate.blockId === session.blockId); + 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."); + this.cancelComposition( + true, + "The composing block was structurally changed.", + ); return; } if (change.text === null) { @@ -825,8 +831,7 @@ class JsonEditableCoordinator implements JsonEditable { value: this.document.value, intent, session, - isCompositionPinIntact: (surface) => - this.isCompositionPinIntact(surface), + isCompositionPinIntact: (surface) => this.isCompositionPinIntact(surface), }); } @@ -870,10 +875,7 @@ class JsonEditableCoordinator implements JsonEditable { ); return false; } - const selection = selectionAt( - editableTextPath(index), - effect.splitOffset, - ); + const selection = selectionAt(editableTextPath(index), effect.splitOffset); if (effect.change !== null) { const mergeWithPrevious = this.lastNativeCompositionHistoryId === session.id; @@ -924,7 +926,9 @@ class JsonEditableCoordinator implements JsonEditable { const children = Array.from(this.root.children); return ( children.length === intent.blockElements.length && - children.every((element, index) => element === intent.blockElements[index]) && + children.every( + (element, index) => element === intent.blockElements[index], + ) && intent.sourceElement.childNodes.length === 1 && intent.sourceElement.firstChild === intent.sourceSurface && readPinnedCompositionText(intent) === intent.canonicalText && @@ -981,9 +985,7 @@ class JsonEditableCoordinator implements JsonEditable { ); } - private restoreCompositionSessionBaseline( - session: CompositionSession, - ): void { + private restoreCompositionSessionBaseline(session: CompositionSession): void { this.restoreBlockIdentityBaseline(session.blockElements); this.restoreCompositionSourceBaseline( session.sourceElement, @@ -1133,7 +1135,8 @@ class JsonEditableCoordinator implements JsonEditable { this.reportFault({ code: "input_state_lost", recoverable: true, - reason: "The composing surface received an unowned structural change.", + reason: + "The composing surface received an unowned structural change.", }); this.cancelComposition(false); } else { @@ -1247,10 +1250,11 @@ class JsonEditableCoordinator implements JsonEditable { ? directSurface : focusPoint === null ? null - : findBlockElement( + : (findBlockElement( this.root, focusPoint.blockId, - )?.querySelector(`[${EDITABLE_TEXT_ATTRIBUTE}]`) ?? null; + )?.querySelector(`[${EDITABLE_TEXT_ATTRIBUTE}]`) ?? + null); if (ordered === null || surface === null) { this.reportFault({ code: "input_state_lost", @@ -1262,7 +1266,10 @@ class JsonEditableCoordinator implements JsonEditable { let textNode: Text | null = null; this.withDOMWrite(() => { - textNode = ensureCompositionTextNode(surface, domSelection?.focusNode ?? null); + textNode = ensureCompositionTextNode( + surface, + domSelection?.focusNode ?? null, + ); }); if (textNode === null) { return; @@ -1715,7 +1722,11 @@ class JsonEditableCoordinator implements JsonEditable { this.pendingRecords = []; this.observer.takeRecords(); this.restoreModelSelection(intent.selection); - const result = this.replaceSelection(intent.text, intent.inputType, "native"); + const result = this.replaceSelection( + intent.text, + intent.inputType, + "native", + ); if (!result.ok) { this.reportFault({ code: "native_change_commit_failed", @@ -1829,7 +1840,10 @@ class JsonEditableCoordinator implements JsonEditable { "compositionupdate", this.guardedOnCompositionUpdate, ); - this.root.removeEventListener("compositionend", this.guardedOnCompositionEnd); + this.root.removeEventListener( + "compositionend", + this.guardedOnCompositionEnd, + ); this.root.removeEventListener("paste", this.guardedOnPaste); this.root.removeEventListener("cut", this.guardedOnCut); this.root.removeEventListener("keydown", this.guardedOnKeyDown); @@ -1914,7 +1928,10 @@ class JsonEditableCoordinator implements JsonEditable { ); this.lastBeforeInputBlockId = selection?.end.blockId ?? null; - if (event.inputType === "historyUndo" || event.inputType === "historyRedo") { + if ( + event.inputType === "historyUndo" || + event.inputType === "historyRedo" + ) { if (event.cancelable) { event.preventDefault(); this.dispatch({ @@ -1965,10 +1982,12 @@ class JsonEditableCoordinator implements JsonEditable { } if (this.composition !== null) { this.flushNativeMutations([], true); - if (this.captureParagraphIntent( - event.cancelable ? "deferred-command" : "native-fallback", - "beforeinput", - )) { + if ( + this.captureParagraphIntent( + event.cancelable ? "deferred-command" : "native-fallback", + "beforeinput", + ) + ) { if (event.cancelable) { event.preventDefault(); } @@ -1985,8 +2004,8 @@ class JsonEditableCoordinator implements JsonEditable { } if ( - (event.inputType === "insertParagraph" || - event.inputType === "insertLineBreak") + event.inputType === "insertParagraph" || + event.inputType === "insertLineBreak" ) { if (event.cancelable) { event.preventDefault(); @@ -2356,24 +2375,31 @@ function describeBlockChanges( const text = beforeBlock === null || afterBlock === null ? null - : exactTextChanges?.get(blockId) ?? - diffText(beforeBlock.text, afterBlock.text); + : (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) { + if ( + beforeBlock === null || + afterBlock === null || + text !== null || + typeChanged + ) { changes.push({ blockId, after: afterBlock, text, typeChanged }); } } return changes; } -function sourceFromOrigin(origin: string | undefined): ChangeSource { +function sourceFromOrigin( + origin: EditorChangeOrigin | undefined, +): ChangeSource { return origin === "remote" ? "remote" : "app"; } -function actionOrigin(action: EditorAction): string | undefined { +function actionOrigin(action: EditorAction): EditorChangeOrigin | undefined { return "origin" in action ? action.origin : undefined; } @@ -2414,7 +2440,9 @@ function textForSelection( ): 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) ?? ""; + return ( + block?.text.slice(selection.start.offset, selection.end.offset) ?? "" + ); } const parts: string[] = []; for ( @@ -2467,7 +2495,11 @@ function trailingLineBreakWidth(value: string, offset: number): number { function ancestorsToRoot(node: Node, root: HTMLElement): Node[] { const ancestors: Node[] = []; - for (let current: Node | null = node; current !== null; current = current.parentNode) { + for ( + let current: Node | null = node; + current !== null; + current = current.parentNode + ) { ancestors.push(current); if (current === root) { break; diff --git a/packages/editable/browser/index.ts b/packages/editable/browser/index.ts index 741484c..a233cc3 100644 --- a/packages/editable/browser/index.ts +++ b/packages/editable/browser/index.ts @@ -1,21 +1,20 @@ +export type { + EditableBlock, + EditableBlockType, + EditableDocumentValue, + EditablePoint, + EditorChangeOrigin, + OrderedEditableSelection, +} from "../core/index.js"; 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"; +} from "../core/index.js"; export type { EditorAction, EditorFault, @@ -25,4 +24,5 @@ export type { JsonEditable, JsonEditableDocumentHost, MountJsonEditableOptions, -} from "./editor"; +} from "./editor.js"; +export { getJsonEditableDocumentHost, mountJsonEditable } from "./editor.js"; diff --git a/packages/editable/browser/nativeParagraph.ts b/packages/editable/browser/nativeParagraph.ts index 67334db..c65a37d 100644 --- a/packages/editable/browser/nativeParagraph.ts +++ b/packages/editable/browser/nativeParagraph.ts @@ -5,15 +5,15 @@ import { type EditableDocumentValue, type TextChange, type TextRange, -} from "../core"; -import { - EDITABLE_PLACEHOLDER_ATTRIBUTE, - EDITABLE_TEXT_ATTRIBUTE, -} from "./editableDOM"; +} from "../core/index.js"; import { isCanonicalBlockElement, isCanonicalSurfaceElement, -} from "./documentProjection"; +} from "./documentProjection.js"; +import { + EDITABLE_PLACEHOLDER_ATTRIBUTE, + EDITABLE_TEXT_ATTRIBUTE, +} from "./editableDOM.js"; export type NativeParagraphEffect = { text: string; @@ -114,16 +114,8 @@ export function inspectNativeParagraphEffect({ 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, - ) + !isCanonicalBlockElement(intent.sourceElement, value, intent.blockId) || + !isCanonicalSurfaceElement(intent.sourceSurface, value, intent.blockId) ) { return null; } @@ -148,11 +140,7 @@ export function inspectNativeParagraphEffect({ } const text = left + right; - const change = diffTextNearRange( - intent.canonicalText, - text, - session.range, - ); + const change = diffTextNearRange(intent.canonicalText, text, session.range); if (text === intent.canonicalText) { return left.length === intent.splitOffset ? { text, splitOffset: left.length, change: null } diff --git a/packages/editable/browser/nativeTextMutation.ts b/packages/editable/browser/nativeTextMutation.ts index 158463f..a23aca3 100644 --- a/packages/editable/browser/nativeTextMutation.ts +++ b/packages/editable/browser/nativeTextMutation.ts @@ -3,12 +3,13 @@ import { applyTextChange, diffText, diffTextNearRange, + type EditableDocumentValue, editableTextPath, findEditableBlockIndex, - type EditableDocumentValue, type TextChange, type TextRange, -} from "../core"; +} from "../core/index.js"; +import { findBlockElement } from "./documentProjection.js"; import { EDITABLE_BLOCK_ATTRIBUTE, EDITABLE_PLACEHOLDER_ATTRIBUTE, @@ -16,8 +17,7 @@ import { editableBlockFromNode, editableSurfaceFromNode, textFromSurface, -} from "./editableDOM"; -import { findBlockElement } from "./documentProjection"; +} from "./editableDOM.js"; export type NativeTextMutationInspectionOptions = { root: HTMLElement; diff --git a/packages/editable/causalHost.test.ts b/packages/editable/causalHost.test.ts index 22a07b6..c2a50d7 100644 --- a/packages/editable/causalHost.test.ts +++ b/packages/editable/causalHost.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { - createCausalPatchInbox, type CausalPatchInbox, + createCausalPatchInbox, } from "@interactive-os/json-document-causal-patch-inbox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -404,9 +404,7 @@ describe("editable causal document host", () => { inbox.ingest({ id: "wait-for-new-composition", dependsOn: [], - operations: [ - { op: "replace", path: "/blocks/1/text", value: "later" }, - ], + operations: [{ op: "replace", path: "/blocks/1/text", value: "later" }], }), ).toMatchObject({ ok: false, code: "host_not_ready" }); fixture.root.dispatchEvent( @@ -480,14 +478,10 @@ describe("editable causal document host", () => { const fixture = setupEditor(); const documentHost = getJsonEditableDocumentHost(fixture.editor); const ownerships: Array = []; - let reentrantResult: - | ReturnType - | undefined; + let reentrantResult: ReturnType | undefined; const nestedApply = vi.fn(); fixture.document.subscribe((operations, metadata) => { - ownerships.push( - documentHost.ownsPublication({ operations, metadata }), - ); + ownerships.push(documentHost.ownsPublication({ operations, metadata })); reentrantResult ??= documentHost.runReady({ id: "nested", apply: nestedApply, @@ -514,19 +508,20 @@ describe("editable causal document host", () => { return ownership.sequence; }); expect(ownedSequences).toHaveLength(4); + let previousSequence = Number.NEGATIVE_INFINITY; expect( - ownedSequences.every( - (sequence, index) => index === 0 || sequence > ownedSequences[index - 1]!, - ), + ownedSequences.every((sequence) => { + const isIncreasing = sequence > previousSequence; + previousSequence = sequence; + return isIncreasing; + }), ).toBe(true); expect(reentrantResult).toMatchObject({ ok: false, code: "host_not_ready", }); expect(nestedApply).not.toHaveBeenCalled(); - expect( - documentHost.ownsPublication({ operations: [] }), - ).toBe(false); + expect(documentHost.ownsPublication({ operations: [] })).toBe(false); expect( fixture.document.commit([ diff --git a/packages/editable/core/editorCommands.test.ts b/packages/editable/core/editorCommands.test.ts index a2d1a3a..7b30732 100644 --- a/packages/editable/core/editorCommands.test.ts +++ b/packages/editable/core/editorCommands.test.ts @@ -1,7 +1,7 @@ import type { SelectionSnap } from "@interactive-os/json-document"; import { describe, expect, it, vi } from "vitest"; -import type { EditableDocumentValue } from "./model"; import { planEditorCommand } from "./editorCommands"; +import type { EditableDocumentValue } from "./model"; const value: EditableDocumentValue = { schema: "interactive-os.editable-document@2", @@ -85,7 +85,12 @@ describe("editor command planning", () => { expect(allocateBlockId).toHaveBeenCalledOnce(); expect( - planEditorCommand(value, null, { type: "insertParagraph" }, allocateBlockId), + planEditorCommand( + value, + null, + { type: "insertParagraph" }, + allocateBlockId, + ), ).toMatchObject({ kind: "failure", code: "selection_unavailable" }); expect(allocateBlockId).toHaveBeenCalledOnce(); }); diff --git a/packages/editable/core/editorCommands.ts b/packages/editable/core/editorCommands.ts index 012d1ca..051ed5d 100644 --- a/packages/editable/core/editorCommands.ts +++ b/packages/editable/core/editorCommands.ts @@ -3,16 +3,18 @@ import type { SelectionSnap, } from "@interactive-os/json-document"; import { - editableTextPath, - findEditableBlockIndex, - orderedEditableSelection, - primaryEditablePoint, type EditableBlock, type EditableBlockType, type EditableDocumentValue, + editableTextPath, + findEditableBlockIndex, type OrderedEditableSelection, -} from "./model"; -import { clampTextRange } from "./textChange"; + orderedEditableSelection, + primaryEditablePoint, +} from "./model.js"; +import { clampTextRange } from "./textChange.js"; + +export type EditorChangeOrigin = "app" | "remote"; export type EditorDocumentCommand = | { @@ -22,13 +24,13 @@ export type EditorDocumentCommand = to: number; text: string; label?: string; - origin?: string; + origin?: EditorChangeOrigin; } | { type: "replaceSelection"; text: string; label?: string; - origin?: string; + origin?: EditorChangeOrigin; } | { type: "setBlockType"; @@ -104,9 +106,7 @@ function planTextReplacement( block.text.length, ); const next = - block.text.slice(0, range.from) + - action.text + - block.text.slice(range.to); + block.text.slice(0, range.from) + action.text + block.text.slice(range.to); if (next === block.text) { return { kind: "none" }; } @@ -196,9 +196,7 @@ function planDirectionalDelete( ? previousGraphemeBoundary(block.text, offset) : offset; const to = - direction === "forward" - ? nextGraphemeBoundary(block.text, offset) - : offset; + 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 }], @@ -219,10 +217,7 @@ function planBlockTypeChange( blockId === undefined ? -1 : findEditableBlockIndex(value, blockId); const block = value.blocks[index]; if (block === undefined) { - return failure( - "selection_unavailable", - "Select an editable block first.", - ); + return failure("selection_unavailable", "Select an editable block first."); } if (block.type === action.blockType) { return { kind: "none" }; @@ -377,8 +372,7 @@ function selectionState(selection: SelectionSnap | null): { primaryRange: SelectionSnap["selectionRanges"][number] | null; } { return { - primaryRange: - selection?.selectionRanges[selection.primaryIndex] ?? null, + primaryRange: selection?.selectionRanges[selection.primaryIndex] ?? null, }; } @@ -389,7 +383,9 @@ function isCollapsed(selection: OrderedEditableSelection): boolean { ); } -function sourceFromOrigin(origin: string | undefined): "app" | "remote" { +function sourceFromOrigin( + origin: EditorChangeOrigin | undefined, +): "app" | "remote" { return origin === "remote" ? "remote" : "app"; } @@ -445,10 +441,7 @@ function commit( } function noSelection(): EditorCommandPlan { - return failure( - "selection_unavailable", - "No editable selection is active.", - ); + return failure("selection_unavailable", "No editable selection is active."); } function staleSelection(): EditorCommandPlan { diff --git a/packages/editable/core/index.ts b/packages/editable/core/index.ts index 5b71d92..032b94c 100644 --- a/packages/editable/core/index.ts +++ b/packages/editable/core/index.ts @@ -1,30 +1,30 @@ +export type { + EditorChangeOrigin, + EditorCommandPlan, + EditorDocumentCommand, +} from "./editorCommands.js"; +export { planEditorCommand } from "./editorCommands.js"; +export type { + EditableBlock, + EditableBlockType, + EditableDocumentValue, + EditablePoint, + OrderedEditableSelection, +} from "./model.js"; export { createEditableDocument, - createInitialEditableValue, EditableDocumentSchema, editableBlockIndexFromTextPath, editableTextPath, findEditableBlockIndex, orderedEditableSelection, primaryEditablePoint, -} from "./model"; -export type { - EditableBlock, - EditableBlockType, - EditableDocumentValue, - EditablePoint, - OrderedEditableSelection, -} from "./model"; -export { planEditorCommand } from "./editorCommands"; -export type { - EditorCommandPlan, - EditorDocumentCommand, -} from "./editorCommands"; +} from "./model.js"; +export type { TextChange, TextRange } from "./textChange.js"; export { accumulateNativeCompositionRange, applyTextChange, clampTextRange, diffText, diffTextNearRange, -} from "./textChange"; -export type { TextChange, TextRange } from "./textChange"; +} from "./textChange.js"; diff --git a/packages/editable/core/model.test.ts b/packages/editable/core/model.test.ts index ef13a70..885890e 100644 --- a/packages/editable/core/model.test.ts +++ b/packages/editable/core/model.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "vitest"; import { - EditableDocumentSchema, createEditableDocument, - createInitialEditableValue, + EditableDocumentSchema, editableBlockIndexFromTextPath, editableTextPath, findEditableBlockIndex, @@ -10,9 +9,22 @@ import { primaryEditablePoint, } from "./model"; +function createTestEditableValue() { + return { + schema: "interactive-os.editable-document@2" as const, + id: "model-test", + blocks: [ + { id: "alpha", type: "heading" as const, text: "Alpha heading" }, + { id: "bravo", type: "paragraph" as const, text: "Bravo paragraph" }, + { id: "charlie", type: "quote" as const, text: "Charlie quote" }, + { id: "delta", type: "code" as const, text: "Delta code" }, + ], + }; +} + describe("EditableDocumentSchema", () => { it("accepts the editor document shape and rejects duplicate block ids", () => { - const value = createInitialEditableValue(); + const value = createTestEditableValue(); expect(EditableDocumentSchema.safeParse(value).success).toBe(true); expect( @@ -27,29 +39,6 @@ describe("EditableDocumentSchema", () => { }); }); -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"); @@ -66,7 +55,7 @@ describe("editable text paths", () => { describe("editable selection helpers", () => { it("resolves the primary focus with stable identity and a clamped offset", () => { - const document = createEditableDocument(); + const document = createEditableDocument(createTestEditableValue()); const selection = document.selection; const block = document.value.blocks[1]; if (selection === undefined || block === undefined) { @@ -86,7 +75,7 @@ describe("editable selection helpers", () => { }); it("orders forward, backward, and same-block selections in document order", () => { - const document = createEditableDocument(); + const document = createEditableDocument(createTestEditableValue()); const selection = document.selection; const first = document.value.blocks[0]; const last = document.value.blocks[3]; @@ -122,7 +111,7 @@ describe("editable selection helpers", () => { }); it("returns null for empty or non-text selections", () => { - const document = createEditableDocument(); + const document = createEditableDocument(createTestEditableValue()); const selection = document.selection; if (selection === undefined) { throw new Error("Expected selection support."); @@ -139,7 +128,7 @@ describe("editable selection helpers", () => { describe("createEditableDocument", () => { it("validates and clones caller-owned initial state", () => { - const initial = createInitialEditableValue(); + const initial = createTestEditableValue(); const originalText = initial.blocks[0]?.text; const document = createEditableDocument(initial); @@ -161,7 +150,7 @@ describe("createEditableDocument", () => { }); it("uses the supplied value, extended selection, and a 100-entry history", () => { - const initial = createInitialEditableValue(); + const initial = createTestEditableValue(); const document = createEditableDocument(initial); const selection = document.selection; if (selection === undefined) { @@ -191,7 +180,7 @@ describe("createEditableDocument", () => { describe("findEditableBlockIndex", () => { it("finds block ids and preserves Array.findIndex missing semantics", () => { - const value = createInitialEditableValue(); + const value = createTestEditableValue(); 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 index e6124b9..ab2a1e3 100644 --- a/packages/editable/core/model.ts +++ b/packages/editable/core/model.ts @@ -54,48 +54,13 @@ export const EditableDocumentSchema: z.ZodType = z }, ); -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, + initial: EditableDocumentValue, ): JSONDocument { - const options = { + return createJSONDocument(EditableDocumentSchema, initial, { 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 { @@ -180,7 +145,10 @@ function resolveEditablePoint( }; } -function compareEditablePoints(left: EditablePoint, right: EditablePoint): number { +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/crossRealm.test.ts b/packages/editable/crossRealm.test.ts index d000fa8..aa06bac 100644 --- a/packages/editable/crossRealm.test.ts +++ b/packages/editable/crossRealm.test.ts @@ -8,17 +8,29 @@ describe("owner-document DOM realm", () => { 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({ root, - document: createEditableDocument(), + document: createEditableDocument({ + schema: "interactive-os.editable-document@2", + id: "cross-realm-test", + blocks: [ + { + id: "block-1", + type: "paragraph", + text: "Cross-realm text", + }, + ], + }), }); - expect(root.querySelectorAll("[data-editable-block]")).toHaveLength(4); + expect(root.querySelectorAll("[data-editable-block]")).toHaveLength(1); 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 index c99b2ec..d23f460 100644 --- a/packages/editable/editor.test.ts +++ b/packages/editable/editor.test.ts @@ -3,9 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createEditableDocument, - mountJsonEditable, type EditorFault, type JsonEditable, + mountJsonEditable, } from "./index"; type EditorFixture = ReturnType; @@ -492,9 +492,9 @@ describe("JSON editable coordinator", () => { "cdef", "second", ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); + expect( + fixture.document.value.blocks.some((block) => block.text.includes("\n")), + ).toBe(false); expect(fixture.document.history.undoDepth).toBe(2); expect(fixture.faults).toEqual([]); }); @@ -525,9 +525,9 @@ describe("JSON editable coordinator", () => { "cdef", "second", ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); + expect( + fixture.document.value.blocks.some((block) => block.text.includes("\n")), + ).toBe(false); expect(fixture.document.history.undoDepth).toBe(2); expect(fixture.faults).toEqual([]); }); @@ -558,9 +558,9 @@ describe("JSON editable coordinator", () => { "cdef", "second", ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); + expect( + fixture.document.value.blocks.some((block) => block.text.includes("\n")), + ).toBe(false); expect(fixture.faults).toEqual([]); }); @@ -909,9 +909,9 @@ describe("JSON editable coordinator", () => { "cdef", "second", ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); + expect( + fixture.document.value.blocks.some((block) => block.text.includes("\n")), + ).toBe(false); expect(fixture.faults).toEqual([]); }); @@ -1286,9 +1286,9 @@ describe("JSON editable coordinator", () => { "cdef", "second", ]); - expect(fixture.document.value.blocks.some((block) => block.text.includes("\n"))).toBe( - false, - ); + expect( + fixture.document.value.blocks.some((block) => block.text.includes("\n")), + ).toBe(false); expect(fixture.faults).toEqual([]); }); @@ -1612,7 +1612,10 @@ describe("JSON editable coordinator", () => { 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") { + if ( + selection === null || + typeof selection.setBaseAndExtent !== "function" + ) { throw new Error("Expected Selection.setBaseAndExtent support."); } fixture.root.focus(); @@ -1742,7 +1745,10 @@ describe("JSON editable coordinator", () => { const fixture = setupEditor(); const node = textNode(fixture, "alpha"); const selection = window.getSelection(); - if (selection === null || typeof selection.setBaseAndExtent !== "function") { + if ( + selection === null || + typeof selection.setBaseAndExtent !== "function" + ) { throw new Error("Expected Selection.setBaseAndExtent support."); } selection.setBaseAndExtent(node, 0, node, node.data.length); @@ -1778,7 +1784,9 @@ describe("JSON editable coordinator", () => { selection === null || typeof selection.setBaseAndExtent !== "function" ) { - throw new Error("Expected an empty editable block and Selection support."); + throw new Error( + "Expected an empty editable block and Selection support.", + ); } selection.setBaseAndExtent(block, 0, block, 0); 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..532294b 100644 --- a/packages/editable/index.ts +++ b/packages/editable/index.ts @@ -1,23 +1,10 @@ -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, + EditorChangeOrigin, EditorFault, EditorPhase, EditorResult, @@ -25,4 +12,16 @@ export type { JsonEditable, JsonEditableDocumentHost, MountJsonEditableOptions, -} from "./browser"; + OrderedEditableSelection, +} from "./browser/index.js"; +export { + createEditableDocument, + EditableDocumentSchema, + editableBlockIndexFromTextPath, + editableTextPath, + findEditableBlockIndex, + getJsonEditableDocumentHost, + mountJsonEditable, + orderedEditableSelection, + primaryEditablePoint, +} from "./browser/index.js"; 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..c83d745 --- /dev/null +++ b/packages/editable/package.json @@ -0,0 +1,42 @@ +{ + "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" + } + }, + "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", + "smoke": "node tests/smoke/package-smoke.mjs && tsc -p tests/smoke/tsconfig.json", + "verify": "pnpm run build && pnpm run smoke", + "prepack": "pnpm run verify" + }, + "peerDependencies": { + "@interactive-os/json-document": "1.1.0-rc.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@interactive-os/json-document": "1.1.0-rc.0", + "typescript": "^6.0.2", + "zod": "^4.4.3" + }, + "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..30be0cb 100644 --- a/packages/editable/publicApi.test.ts +++ b/packages/editable/publicApi.test.ts @@ -14,6 +14,7 @@ import type { EditableDocumentValue, EditablePoint, EditorAction, + EditorChangeOrigin, EditorFault, EditorPhase, EditorResult, @@ -23,8 +24,8 @@ import type { MountJsonEditableOptions, OrderedEditableSelection, } from "./index"; -import { getJsonEditableDocumentHost } from "./index"; import * as PublicAPI from "./index"; +import { getJsonEditableDocumentHost } from "./index"; type ExpectedBlockType = "paragraph" | "heading" | "quote" | "code"; type ExpectedBlock = { @@ -43,6 +44,7 @@ type ExpectedOrderedSelection = { end: ExpectedPoint; }; type ExpectedPhase = "idle" | "native-input" | "composing" | "settling"; +type ExpectedChangeOrigin = "app" | "remote"; type ExpectedSnapshot = { phase: ExpectedPhase; revision: number; @@ -68,7 +70,7 @@ type ExpectedAction = type: "patch"; patch: ReadonlyArray; label?: string; - origin?: string; + origin?: ExpectedChangeOrigin; selectionAfter?: SelectionSnap | null; } | { @@ -78,13 +80,13 @@ type ExpectedAction = to: number; text: string; label?: string; - origin?: string; + origin?: ExpectedChangeOrigin; } | { type: "replaceSelection"; text: string; label?: string; - origin?: string; + origin?: ExpectedChangeOrigin; } | { type: "setBlockType"; @@ -118,10 +120,7 @@ type ExpectedDocumentHost = { operations: ReadonlyArray; metadata?: JSONChangeMetadata; }): false | { sequence: number }; - runReady(request: { - id: string; - apply(): void; - }): + runReady(request: { id: string; apply(): void }): | { ok: true } | { ok: false; @@ -146,7 +145,6 @@ describe("editable public API", () => { expect(Object.keys(PublicAPI).sort()).toEqual([ "EditableDocumentSchema", "createEditableDocument", - "createInitialEditableValue", "editableBlockIndexFromTextPath", "editableTextPath", "findEditableBlockIndex", @@ -162,28 +160,22 @@ describe("editable public API", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf< - OrderedEditableSelection - >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf< - MountJsonEditableOptions - >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf(PublicAPI.EditableDocumentSchema).toEqualTypeOf< ZodType >(); expectTypeOf(PublicAPI.createEditableDocument).toEqualTypeOf< - (initial?: ExpectedDocument) => JSONDocument - >(); - expectTypeOf(PublicAPI.createInitialEditableValue).toEqualTypeOf< - () => ExpectedDocument + (initial: ExpectedDocument) => JSONDocument >(); expectTypeOf(PublicAPI.editableTextPath).toEqualTypeOf< (blockIndex: number) => Pointer @@ -200,19 +192,13 @@ describe("editable public API", () => { expectTypeOf(PublicAPI.primaryEditablePoint).toEqualTypeOf< ( value: ExpectedDocument, - selection: - | Pick - | null - | undefined, + selection: Pick | null | undefined, ) => ExpectedPoint | null >(); expectTypeOf(PublicAPI.orderedEditableSelection).toEqualTypeOf< ( value: ExpectedDocument, - selection: - | Pick - | null - | undefined, + selection: Pick | null | undefined, ) => ExpectedOrderedSelection | null >(); expectTypeOf(PublicAPI.mountJsonEditable).toEqualTypeOf< diff --git a/packages/editable/tests/smoke/consumer.ts b/packages/editable/tests/smoke/consumer.ts new file mode 100644 index 0000000..3249cd5 --- /dev/null +++ b/packages/editable/tests/smoke/consumer.ts @@ -0,0 +1,33 @@ +import { + createEditableDocument, + type EditableDocumentValue, + type JsonEditable, + mountJsonEditable, +} from "@interactive-os/editable"; + +const initial: EditableDocumentValue = { + schema: "interactive-os.editable-document@2", + id: "consumer-document", + blocks: [ + { + id: "first-block", + type: "paragraph", + text: "Consumer-owned content", + }, + ], +}; + +const editableDocument = createEditableDocument(initial); +const root = globalThis.document.createElement("div"); +const editor: JsonEditable = mountJsonEditable({ + root, + document: editableDocument, +}); + +editor.destroy(); + +// @ts-expect-error Callers must provide an initial document. +createEditableDocument(); + +// @ts-expect-error Change provenance is closed so typos cannot become local edits. +editor.dispatch({ type: "patch", patch: [], origin: "collaboration" }); diff --git a/packages/editable/tests/smoke/package-smoke.mjs b/packages/editable/tests/smoke/package-smoke.mjs new file mode 100644 index 0000000..d13eb72 --- /dev/null +++ b/packages/editable/tests/smoke/package-smoke.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +const publicModule = await import("@interactive-os/editable"); + +assert.deepEqual(Object.keys(publicModule).sort(), [ + "EditableDocumentSchema", + "createEditableDocument", + "editableBlockIndexFromTextPath", + "editableTextPath", + "findEditableBlockIndex", + "getJsonEditableDocumentHost", + "mountJsonEditable", + "orderedEditableSelection", + "primaryEditablePoint", +]); + +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..9705e6b --- /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", "browser/**/*.ts", "core/**/*.ts"], + "exclude": ["**/*.test.ts", "tests/**", "dist/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 845a5e2..8dd9272 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ 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) @@ -77,6 +80,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: 1.1.0-rc.0 + version: 1.1.0-rc.0(react@19.2.7)(zod@4.4.3) + typescript: + specifier: ^6.0.2 + version: 6.0.3 + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages: '@acemir/cssom@0.9.31': @@ -193,28 +208,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==} @@ -401,42 +412,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==} @@ -932,28 +937,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==} 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/index.ts b/src/editable-lab/index.ts new file mode 100644 index 0000000..72ea8ac --- /dev/null +++ b/src/editable-lab/index.ts @@ -0,0 +1 @@ +export { JsonEditableDemo } from "./ui/JsonEditableDemo"; diff --git a/src/editable-lab/model/demoDocument.test.ts b/src/editable-lab/model/demoDocument.test.ts new file mode 100644 index 0000000..13f67ae --- /dev/null +++ b/src/editable-lab/model/demoDocument.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { createDemoEditableValue } from "./demoDocument"; + +describe("createDemoEditableValue", () => { + it("creates the IME lab document with fresh object identity", () => { + const first = createDemoEditableValue(); + const second = createDemoEditableValue(); + + expect(first).toEqual({ + 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)", + }, + ], + }); + expect(second).toEqual(first); + expect(second).not.toBe(first); + expect(second.blocks).not.toBe(first.blocks); + }); +}); diff --git a/src/editable-lab/model/demoDocument.ts b/src/editable-lab/model/demoDocument.ts new file mode 100644 index 0000000..45e2854 --- /dev/null +++ b/src/editable-lab/model/demoDocument.ts @@ -0,0 +1,30 @@ +import type { EditableDocumentValue } from "@interactive-os/editable"; + +export function createDemoEditableValue(): 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)", + }, + ], + }; +} diff --git a/src/editable-lab/causalDocumentInbox.test.ts b/src/editable-lab/runtime/causalDocumentInbox.test.ts similarity index 99% rename from src/editable-lab/causalDocumentInbox.test.ts rename to src/editable-lab/runtime/causalDocumentInbox.test.ts index 55ca8f8..1565008 100644 --- a/src/editable-lab/causalDocumentInbox.test.ts +++ b/src/editable-lab/runtime/causalDocumentInbox.test.ts @@ -1,11 +1,11 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from "vitest"; import { createEditableDocument, type JsonEditable, mountJsonEditable, -} from "../../packages/editable"; +} from "@interactive-os/editable"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createEditableCausalInbox } from "./causalDocumentInbox"; let editor: JsonEditable | null = null; diff --git a/src/editable-lab/causalDocumentInbox.ts b/src/editable-lab/runtime/causalDocumentInbox.ts similarity index 98% rename from src/editable-lab/causalDocumentInbox.ts rename to src/editable-lab/runtime/causalDocumentInbox.ts index 33f5861..2724148 100644 --- a/src/editable-lab/causalDocumentInbox.ts +++ b/src/editable-lab/runtime/causalDocumentInbox.ts @@ -1,14 +1,14 @@ -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"; +} from "@interactive-os/editable"; +import type { JSONDocument } from "@interactive-os/json-document"; +import { + type CausalPatchInbox, + createCausalPatchInbox, +} from "@interactive-os/json-document-causal-patch-inbox"; export function createEditableCausalInbox( document: JSONDocument, diff --git a/src/editable-lab/runtime/testHarness.ts b/src/editable-lab/runtime/testHarness.ts new file mode 100644 index 0000000..546e11e --- /dev/null +++ b/src/editable-lab/runtime/testHarness.ts @@ -0,0 +1,27 @@ +import type { + EditableDocumentValue, + JsonEditable, +} from "@interactive-os/editable"; +import type { JSONDocument } from "@interactive-os/json-document"; +import type { createEditableCausalInbox } from "./causalDocumentInbox"; + +export type EditableLabHandle = { + document: JSONDocument; + editor: JsonEditable; + causalInbox: ReturnType; +}; + +declare global { + interface Window { + __jsonEditableLab?: EditableLabHandle; + } +} + +export function exposeEditableLab(handle: EditableLabHandle): () => void { + window.__jsonEditableLab = handle; + return () => { + if (window.__jsonEditableLab === handle) { + delete window.__jsonEditableLab; + } + }; +} diff --git a/src/editable-lab/JsonEditableDemo.tsx b/src/editable-lab/ui/JsonEditableDemo.tsx similarity index 93% rename from src/editable-lab/JsonEditableDemo.tsx rename to src/editable-lab/ui/JsonEditableDemo.tsx index e224475..d4ef8d5 100644 --- a/src/editable-lab/JsonEditableDemo.tsx +++ b/src/editable-lab/ui/JsonEditableDemo.tsx @@ -1,3 +1,11 @@ +import { + createEditableDocument, + type EditableBlockType, + type EditorFault, + type EditorSnapshot, + type JsonEditable, + mountJsonEditable, +} from "@interactive-os/editable"; import { Code2, Heading1, @@ -16,31 +24,17 @@ import { useRef, useState, } from "react"; -import { - createEditableDocument, - type EditableBlockType, - type EditorFault, - type EditorSnapshot, - type JsonEditable, - mountJsonEditable, -} from "../../packages/editable"; -import { createEditableCausalInbox } from "./causalDocumentInbox"; +import { createDemoEditableValue } from "../model/demoDocument"; +import { createEditableCausalInbox } from "../runtime/causalDocumentInbox"; +import { exposeEditableLab } from "../runtime/testHarness"; -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 document = useMemo( + () => createEditableDocument(createDemoEditableValue()), + [], + ); const rootRef = useRef(null); const editorRef = useRef(null); const causalInboxRef = useRef(null); @@ -64,12 +58,14 @@ export function JsonEditableDemo() { causalInboxRef.current = causalInbox; setSnapshot(editor.getSnapshot()); const unsubscribe = editor.subscribe(setSnapshot); - window.__jsonEditableLab = { document, editor, causalInbox }; + const hideTestHarness = exposeEditableLab({ + document, + editor, + causalInbox, + }); return () => { - if (window.__jsonEditableLab?.editor === editor) { - delete window.__jsonEditableLab; - } + hideTestHarness(); unsubscribe(); causalInbox.dispose(); editor.destroy(); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index eea0fb1..f8182ee 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { JsonEditableDemo } from "../editable-lab/JsonEditableDemo"; +import { JsonEditableDemo } from "../editable-lab"; export const Route = createFileRoute("/")({ component: JsonEditableDemo, diff --git a/tests/browser/editable.spec.ts b/tests/browser/editable.spec.ts index cfeca48..49e55d7 100644 --- a/tests/browser/editable.spec.ts +++ b/tests/browser/editable.spec.ts @@ -8,8 +8,7 @@ import { import type { EditableDocumentValue, EditorSnapshot, - JsonEditable, -} from "../../packages/editable"; +} from "@interactive-os/editable"; const EDITOR_NAME = "JSON document editor"; const COMPOSING_BLOCK_ID = "korean-ime"; @@ -19,13 +18,6 @@ const INITIAL_COMPOSING_TEXT = const INITIAL_REMOTE_TEXT = "日本語 IME の変換中も、編集中の DOM ノードを置き換えません。"; -type DemoWindow = Window & { - __jsonEditableLab?: { - document: { readonly value: EditableDocumentValue }; - editor: JsonEditable; - }; -}; - test.beforeEach(async ({ page }) => { await page.goto("/"); const editor = editorRoot(page); @@ -171,7 +163,7 @@ test.describe("synthetic composition protocol — not an OS IME reproduction", ( const update = await page.evaluate( ({ blockId, suffix }) => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -396,7 +388,7 @@ test.describe("synthetic composition protocol — not an OS IME reproduction", ( const outcome = await page.evaluate( ({ blockId, from, to }) => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -466,7 +458,7 @@ test.describe("synthetic composition protocol — not an OS IME reproduction", ( data, }), ); - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -538,7 +530,7 @@ test.describe("synthetic composition protocol — not an OS IME reproduction", ( await expect(lastFault(page)).toHaveText("null"); await page.evaluate(() => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -546,7 +538,7 @@ test.describe("synthetic composition protocol — not an OS IME reproduction", ( }); await expect.poll(() => readBlockText(page, COMPOSING_BLOCK_ID)).toBe(composed); await page.evaluate(() => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -570,7 +562,7 @@ test.describe("synthetic composition protocol — not an OS IME reproduction", ( inserted, ); const queued = await page.evaluate((blockId) => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -824,7 +816,7 @@ test.describe("synthetic composition protocol — not an OS IME reproduction", ( }) => { const initialCount = (await readDocumentBlocks(page)).length; await page.evaluate((blockId) => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -1055,7 +1047,7 @@ async function beginSyntheticComposition( async function readSnapshot(page: Page): Promise { return page.evaluate(() => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -1065,7 +1057,7 @@ async function readSnapshot(page: Page): Promise { async function readBlockText(page: Page, blockId: string): Promise { return page.evaluate((requestedBlockId) => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } @@ -1083,7 +1075,7 @@ async function readDocumentBlocks( page: Page, ): Promise { return page.evaluate(() => { - const lab = (window as DemoWindow).__jsonEditableLab; + const lab = window.__jsonEditableLab; if (lab === undefined) { throw new Error("JSON editable lab is not mounted."); } diff --git a/tools/check-editable-layers.mjs b/tools/check-editable-layers.mjs index e527912..af1ed4e 100644 --- a/tools/check-editable-layers.mjs +++ b/tools/check-editable-layers.mjs @@ -6,6 +6,7 @@ import ts from "typescript"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const DEFAULT_ROOT = path.resolve(path.dirname(SCRIPT_PATH), ".."); const EDITABLE_ROOT = "packages/editable"; +const EDITABLE_PACKAGE_NAME = "@interactive-os/editable"; const PUBLIC_SEAM = `${EDITABLE_ROOT}/index.ts`; const BROWSER_SEAM = `${EDITABLE_ROOT}/browser/index.ts`; const CORE_SEAM = `${EDITABLE_ROOT}/core/index.ts`; @@ -139,6 +140,21 @@ export function auditEditableLayers({ root = DEFAULT_ROOT } = {}) { }); continue; } + if ( + isEditableProductionLayer(importerLayer) && + isPackageOutwardTarget(target) + ) { + violations.push({ + code: "package-outward-import", + importer, + target, + line: location.line + 1, + column: location.character + 1, + message: + "Editable production layers cannot import application, test, or tool source.", + }); + continue; + } if (!isInsideEditable(target)) { continue; } @@ -245,11 +261,7 @@ function classifySource(file) { if (file.startsWith(`${EDITABLE_ROOT}/core/`)) { return "core"; } - if ( - file === PUBLIC_SEAM || - file === `${EDITABLE_ROOT}/editor.ts` || - file === `${EDITABLE_ROOT}/model.ts` - ) { + if (file === PUBLIC_SEAM) { return "public"; } if (isPublicTest(file)) { @@ -272,13 +284,21 @@ function isInsideEditable(file) { return file === EDITABLE_ROOT || file.startsWith(`${EDITABLE_ROOT}/`); } +function isEditableProductionLayer(layer) { + return layer === "public" || layer === "browser" || layer === "core"; +} + +function isPackageOutwardTarget(file) { + return ["src/", "tests/", "tools/"].some((prefix) => file.startsWith(prefix)); +} + function isPublicTest(file) { const prefix = `${EDITABLE_ROOT}/`; if (!file.startsWith(prefix)) { return false; } const relative = file.slice(prefix.length); - return /^(?:[^/]+\.(?:test|spec)\.[cm]?[jt]sx?|__tests__\/.*\.[cm]?[jt]sx?)$/u.test( + return /^(?:[^/]+\.(?:test|spec)\.[cm]?[jt]sx?|__tests__\/.*\.[cm]?[jt]sx?|tests\/.*\.[cm]?[jt]sx?)$/u.test( relative, ); } @@ -543,6 +563,12 @@ function stripImportSuffix(specifier) { function mayReferenceEditable(repoRoot, importer, specifier) { const resolvedSpecifier = stripImportSuffix(specifier); + if ( + resolvedSpecifier === EDITABLE_PACKAGE_NAME || + resolvedSpecifier.startsWith(`${EDITABLE_PACKAGE_NAME}/`) + ) { + return true; + } if (resolvedSpecifier.startsWith("/packages/editable")) { return true; } diff --git a/tools/check-editable-layers.test.mjs b/tools/check-editable-layers.test.mjs index bc13b14..d82f265 100644 --- a/tools/check-editable-layers.test.mjs +++ b/tools/check-editable-layers.test.mjs @@ -98,6 +98,84 @@ describe("editable layer rules", () => { expect(auditEditableLayers().violations).toEqual([]); }); + it("recognizes only index.ts as public and rejects package imports into app source", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "editable-layers-")); + try { + writeFixture(root, "tsconfig.json", { + compilerOptions: { moduleResolution: "bundler" }, + }); + writeFixture( + root, + "packages/editable/index.ts", + 'export * from "./browser";\n', + ); + writeFixture( + root, + "packages/editable/model.ts", + 'export * from "./browser";\n', + ); + writeFixture( + root, + "packages/editable/browser/index.ts", + 'export * from "../core";\n', + ); + writeFixture( + root, + "packages/editable/core/index.ts", + 'export * from "./appLeak";\n', + ); + writeFixture( + root, + "packages/editable/core/appLeak.ts", + 'export { labValue } from "../../../src/editable-lab/runtime";\n', + ); + writeFixture( + root, + "src/editable-lab/runtime.ts", + "export const labValue = true;\n", + ); + + expect(auditEditableLayers({ root }).violations).toEqual([ + expect.objectContaining({ + code: "package-outward-import", + importer: "packages/editable/core/appLeak.ts", + target: "src/editable-lab/runtime.ts", + }), + expect.objectContaining({ + code: "unclassified-package-source", + importer: "packages/editable/model.ts", + target: "packages/editable/model.ts", + }), + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects an unresolved bare deep package import", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "editable-layers-")); + try { + writeFixture(root, "tsconfig.json", { + compilerOptions: { moduleResolution: "bundler" }, + }); + writeFixture( + root, + "src/deepConsumer.ts", + 'import "@interactive-os/editable/core";\n', + ); + + expect(auditEditableLayers({ root }).violations).toEqual([ + expect.objectContaining({ + code: "unresolved-editable-import", + importer: "src/deepConsumer.ts", + target: "@interactive-os/editable/core", + }), + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("detects a type-only browser bypass through the real resolver", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "editable-layers-")); try { diff --git a/tsconfig.json b/tsconfig.json index 1f82d77..520581e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["archive/**", "src/vendor/**"], + "exclude": ["archive/**", "src/vendor/**", "**/dist/**"], "compilerOptions": { "target": "ES2022", "jsx": "react-jsx", @@ -13,6 +13,9 @@ "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, + "paths": { + "@interactive-os/editable": ["./packages/editable/index.ts"] + }, /* Linting */ "skipLibCheck": true, diff --git a/vite.config.ts b/vite.config.ts index 2f256c7..88d58e9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; @@ -5,6 +6,13 @@ import { defineConfig } from "vitest/config"; const config = defineConfig({ plugins: [tanstackStart(), viteReact()], + resolve: { + alias: { + "@interactive-os/editable": fileURLToPath( + new URL("./packages/editable/index.ts", import.meta.url), + ), + }, + }, test: { testTimeout: 30_000, exclude: [ From e1accfde96ab6f5f70dc92e03d59fde5a056ed41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 14 Jul 2026 14:51:53 +0900 Subject: [PATCH 02/11] feat: add rich text formatting engine and demo --- packages/editable/README.md | 18 +- .../browser/documentProjection.test.ts | 170 +++++- .../editable/browser/documentProjection.ts | 158 ++++- .../editable/browser/domSelection.test.ts | 130 ++++ packages/editable/browser/domSelection.ts | 48 +- packages/editable/browser/editableDOM.test.ts | 26 + packages/editable/browser/editableDOM.ts | 123 +++- packages/editable/browser/editor.ts | 21 + .../editable/browser/editorCoordinator.ts | 577 +++++++++++++++--- packages/editable/browser/index.ts | 5 + .../editable/browser/nativeParagraph.test.ts | 82 ++- packages/editable/browser/nativeParagraph.ts | 145 +++-- .../browser/nativeTextMutation.test.ts | 207 ++++++- .../editable/browser/nativeTextMutation.ts | 71 ++- packages/editable/causalHost.test.ts | 26 +- packages/editable/core/editorCommands.test.ts | 281 ++++++++- packages/editable/core/editorCommands.ts | 367 +++++++++-- packages/editable/core/index.ts | 8 + packages/editable/core/inlineMarks.test.ts | 123 ++++ packages/editable/core/inlineMarks.ts | 390 ++++++++++++ packages/editable/core/model.test.ts | 61 +- packages/editable/core/model.ts | 45 +- packages/editable/core/textChange.test.ts | 12 + packages/editable/core/textChange.ts | 26 + packages/editable/crossRealm.test.ts | 3 +- packages/editable/editor.test.ts | 463 +++++++++++++- packages/editable/index.ts | 5 + packages/editable/publicApi.test.ts | 34 +- packages/editable/tests/smoke/consumer.ts | 3 +- src/editable-lab/model/demoDocument.test.ts | 6 +- src/editable-lab/model/demoDocument.ts | 6 +- .../runtime/causalDocumentInbox.test.ts | 12 +- src/editable-lab/ui/JsonEditableDemo.tsx | 200 ++++-- src/styles.css | 35 +- tests/browser/editable.spec.ts | 230 ++++++- 35 files changed, 3800 insertions(+), 317 deletions(-) create mode 100644 packages/editable/browser/domSelection.test.ts create mode 100644 packages/editable/browser/editableDOM.test.ts create mode 100644 packages/editable/core/inlineMarks.test.ts create mode 100644 packages/editable/core/inlineMarks.ts diff --git a/packages/editable/README.md b/packages/editable/README.md index baf8bc2..6337e9f 100644 --- a/packages/editable/README.md +++ b/packages/editable/README.md @@ -21,10 +21,10 @@ import { } from "@interactive-os/editable"; const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "article-1", blocks: [ - { id: "intro", type: "paragraph", text: "Start writing" }, + { id: "intro", marks: {}, type: "paragraph", text: "Start writing" }, ], }); const editor = mountJsonEditable({ root, document }); @@ -36,6 +36,20 @@ editor.subscribe((snapshot) => { editor.destroy(); ``` +## Rich text + +Schema `@3` stores inline formatting as half-open ranges in each block's +`marks` record. Use `toggleInlineMark` for `bold`, `italic`, `underline`, +`strike`, and inline `code`; `snapshot.formatting` and `snapshot.history` drive +toolbar state. + +Use `replaceText` or `replaceSelection` for text edits so mark ranges move with +the text. The low-level `patch` action is an escape hatch: a patch that changes +`text` must update that block's `marks` in the same transaction. + +To migrate an `@2` value, change its schema to `@3` and add `marks: {}` to every +block before calling `createEditableDocument`. + ## External changes While mounted, send application changes through `editor.dispatch()`. Set diff --git a/packages/editable/browser/documentProjection.test.ts b/packages/editable/browser/documentProjection.test.ts index 8d262ad..f587419 100644 --- a/packages/editable/browser/documentProjection.test.ts +++ b/packages/editable/browser/documentProjection.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; -import type { EditableDocumentValue } from "../core"; +import type { EditableBlock, EditableDocumentValue } from "../core"; import { findBlockElement, isCanonicalBlockElement, @@ -9,11 +9,14 @@ import { projectDocumentDOM, } from "./documentProjection"; -function value(blocks: EditableDocumentValue["blocks"]): EditableDocumentValue { +type BlockInput = Omit & + Partial>; + +function value(blocks: BlockInput[]): EditableDocumentValue { return { - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "projection-test", - blocks, + blocks: blocks.map((block) => ({ ...block, marks: block.marks ?? {} })), }; } @@ -26,6 +29,122 @@ function surface(block: HTMLElement): HTMLElement { } describe("document projection", () => { + it("projects inline marks as canonical semantic wrappers", () => { + const root = window.document.createElement("div"); + const documentValue = value([ + { + id: "alpha", + type: "paragraph", + text: "plain rich text", + marks: { + bold: { type: "bold", start: 6, end: 15 }, + }, + }, + ]); + + projectDocumentDOM({ root, value: documentValue }); + + const alpha = findBlockElement(root, "alpha") as HTMLElement; + const bold = surface(alpha).querySelector( + "strong[data-editable-mark='bold']", + ); + expect(surface(alpha).textContent).toBe("plain rich text"); + expect(bold?.textContent).toBe("rich text"); + }); + + it("segments overlapping marks in a deterministic wrapper order", () => { + const root = window.document.createElement("div"); + projectDocumentDOM({ + root, + value: value([ + { + id: "alpha", + type: "paragraph", + text: "abcdef", + marks: { + bold: { type: "bold", start: 0, end: 4 }, + italic: { type: "italic", start: 2, end: 6 }, + underline: { type: "underline", start: 1, end: 5 }, + strike: { type: "strike", start: 2, end: 5 }, + code: { type: "code", start: 3, end: 4 }, + }, + }, + ]), + }); + + const alpha = findBlockElement(root, "alpha") as HTMLElement; + const code = surface(alpha).querySelector( + "strong > em > u > s > code[data-editable-mark='code']", + ); + expect(code?.textContent).toBe("d"); + expect(surface(alpha).textContent).toBe("abcdef"); + }); + + it("preserves an unchanged marked surface while another block updates", () => { + const root = window.document.createElement("div"); + const marked = { + bold: { type: "bold" as const, start: 0, end: 5 }, + }; + projectDocumentDOM({ + root, + value: value([ + { id: "alpha", type: "paragraph", text: "first", marks: marked }, + { id: "beta", type: "paragraph", text: "second" }, + ]), + }); + const alpha = findBlockElement(root, "alpha") as HTMLElement; + const alphaSurface = surface(alpha); + const wrapper = alphaSurface.firstChild; + const text = wrapper?.firstChild; + + projectDocumentDOM({ + root, + value: value([ + { id: "alpha", type: "paragraph", text: "first", marks: marked }, + { id: "beta", type: "paragraph", text: "remote" }, + ]), + }); + + expect(alphaSurface.firstChild).toBe(wrapper); + expect(alphaSurface.firstChild?.firstChild).toBe(text); + expect(findBlockElement(root, "beta")?.textContent).toBe("remote"); + }); + + it("updates marked text without replacing a compatible run tree", () => { + const root = window.document.createElement("div"); + projectDocumentDOM({ + root, + value: value([ + { + id: "alpha", + type: "paragraph", + text: "first", + marks: { bold: { type: "bold", start: 1, end: 4 } }, + }, + ]), + }); + const alpha = findBlockElement(root, "alpha") as HTMLElement; + const wrapper = surface(alpha).querySelector("strong") as HTMLElement; + const markedText = wrapper.firstChild as Text; + + projectDocumentDOM({ + root, + value: value([ + { + id: "alpha", + type: "paragraph", + text: "fiXrst", + marks: { bold: { type: "bold", start: 1, end: 5 } }, + }, + ]), + }); + + expect(surface(alpha).querySelector("strong")).toBe(wrapper); + expect(wrapper.firstChild).toBe(markedText); + expect(markedText.data).toBe("iXrs"); + expect(surface(alpha).textContent).toBe("fiXrst"); + }); + it("reuses keyed block and Text identities while updating canonical content", () => { const root = window.document.createElement("div"); const initial = value([ @@ -80,6 +199,49 @@ describe("document projection", () => { expect(invalidate).not.toHaveBeenCalled(); }); + it("keeps a marked composition Text and its wrapper ancestors opaque", () => { + const root = window.document.createElement("div"); + const marked = { + bold: { type: "bold" as const, start: 0, end: 5 }, + }; + projectDocumentDOM({ + root, + value: value([ + { id: "alpha", type: "paragraph", text: "first", marks: marked }, + { id: "beta", type: "paragraph", text: "second" }, + ]), + }); + const alpha = findBlockElement(root, "alpha") as HTMLElement; + const alphaSurface = surface(alpha); + const wrapper = alphaSurface.firstChild as HTMLElement; + const composingText = wrapper.firstChild as Text; + composingText.insertData(5, "한"); + const invalidate = vi.fn(); + + projectDocumentDOM({ + root, + value: value([ + { id: "alpha", type: "paragraph", text: "first", marks: marked }, + { id: "beta", type: "paragraph", text: "remote" }, + ]), + composition: { + blockId: "alpha", + node: composingText, + isPinIntact: (candidate) => + candidate === alphaSurface && + composingText.parentNode === wrapper && + wrapper.parentNode === alphaSurface, + invalidate, + }, + }); + + expect(alphaSurface.firstChild).toBe(wrapper); + expect(wrapper.firstChild).toBe(composingText); + expect(composingText.data).toBe("first한"); + expect(findBlockElement(root, "beta")?.textContent).toBe("remote"); + expect(invalidate).not.toHaveBeenCalled(); + }); + it("can explicitly canonicalize the composing block when its lease settles", () => { const root = window.document.createElement("div"); const documentValue = value([ diff --git a/packages/editable/browser/documentProjection.ts b/packages/editable/browser/documentProjection.ts index 198084e..7365ec5 100644 --- a/packages/editable/browser/documentProjection.ts +++ b/packages/editable/browser/documentProjection.ts @@ -6,9 +6,13 @@ import { findEditableBlockIndex, } from "../core/index.js"; import { + createOwnedMarkElement, EDITABLE_BLOCK_ATTRIBUTE, EDITABLE_TEXT_ATTRIBUTE, + type EditableDOMMarkType, setCanonicalSurfaceText, + setCanonicalTextNodeData, + textNodes, } from "./editableDOM.js"; const OWNED_BLOCK_ATTRIBUTES = new Set([ @@ -20,6 +24,14 @@ const OWNED_BLOCK_ATTRIBUTES = new Set([ const OWNED_SURFACE_ATTRIBUTES = new Set([EDITABLE_TEXT_ATTRIBUTE]); +const MARK_ORDER = [ + "bold", + "italic", + "underline", + "strike", + "code", +] as const satisfies ReadonlyArray; + export type DocumentProjectionComposition = { blockId: string; node: Text; @@ -147,7 +159,7 @@ export function projectDocumentDOM({ activeComposition.isPinIntact(surface) && forceCanonicalBlockId !== block.id; if (!protectedSurface) { - setCanonicalSurfaceText(surface, block.text); + projectSurfaceContent(surface, block); } const reference = root.children[blockIndex] ?? null; @@ -209,6 +221,27 @@ export function isCanonicalSurfaceElement( ); } +export function isCanonicalSurfaceContent( + surface: HTMLElement, + value: EditableDocumentValue, + blockId: string, +): boolean { + const index = findEditableBlockIndex(value, blockId); + const block = value.blocks[index]; + if (block === undefined || surface.textContent !== block.text) { + return false; + } + const expected = surface.ownerDocument.createElement("span"); + projectSurfaceContent(expected, block); + const actualText = textNodes(surface); + const expectedText = textNodes(expected); + return ( + sameDOMStructure(surface, expected) && + actualText.length === expectedText.length && + actualText.every((node, index) => node.data === expectedText[index]?.data) + ); +} + function createBlockElement( root: HTMLElement, block: EditableBlock, @@ -220,10 +253,131 @@ function createBlockElement( const surface = root.ownerDocument.createElement("span"); surface.setAttribute(EDITABLE_TEXT_ATTRIBUTE, editableTextPath(blockIndex)); element.append(surface); - setCanonicalSurfaceText(surface, block.text); + projectSurfaceContent(surface, block); return element; } +function projectSurfaceContent( + surface: HTMLElement, + block: EditableBlock, +): void { + const marks = Object.values(block.marks).filter((mark) => + isProjectableMark(mark, block.text.length), + ); + if (marks.length === 0) { + setCanonicalSurfaceText(surface, block.text); + return; + } + + const boundaries = new Set([0, block.text.length]); + for (const mark of marks) { + boundaries.add(mark.start); + boundaries.add(mark.end); + } + const ordered = [...boundaries].sort((left, right) => left - right); + const fragment = surface.ownerDocument.createDocumentFragment(); + for (let index = 0; index < ordered.length - 1; index += 1) { + const start = ordered[index]; + const end = ordered[index + 1]; + if (start === undefined || end === undefined || start === end) { + continue; + } + const active = new Set( + marks + .filter((mark) => mark.start <= start && end <= mark.end) + .map((mark) => mark.type), + ); + let node: Node = surface.ownerDocument.createTextNode( + block.text.slice(start, end), + ); + for (const type of [...MARK_ORDER].reverse()) { + if (!active.has(type)) { + continue; + } + const wrapper = createOwnedMarkElement(surface.ownerDocument, type); + wrapper.append(node); + node = wrapper; + } + fragment.append(node); + } + if (reconcileCompatibleDOMTree(surface, fragment)) { + return; + } + surface.replaceChildren(fragment); +} + +function reconcileCompatibleDOMTree(left: Node, right: Node): boolean { + if (!sameDOMStructure(left, right)) { + return false; + } + synchronizeDOMText(left, right); + return true; +} + +function sameDOMStructure(left: Node, right: Node): boolean { + const leftChildren = Array.from(left.childNodes); + const rightChildren = Array.from(right.childNodes); + if (leftChildren.length !== rightChildren.length) { + return false; + } + return leftChildren.every((leftChild, index) => { + const rightChild = rightChildren[index]; + if ( + rightChild === undefined || + leftChild.nodeType !== rightChild.nodeType + ) { + return false; + } + if (leftChild.nodeType === 3) { + return true; + } + if (leftChild.nodeType !== 1) { + return false; + } + const leftElement = leftChild as HTMLElement; + const rightElement = rightChild as HTMLElement; + return ( + leftElement.tagName === rightElement.tagName && + leftElement.attributes.length === rightElement.attributes.length && + Array.from(leftElement.attributes).every( + (attribute) => + rightElement.getAttribute(attribute.name) === attribute.value, + ) && + sameDOMStructure(leftElement, rightElement) + ); + }); +} + +function synchronizeDOMText(left: Node, right: Node): void { + const leftChildren = Array.from(left.childNodes); + const rightChildren = Array.from(right.childNodes); + leftChildren.forEach((leftChild, index) => { + const rightChild = rightChildren[index]; + if (rightChild === undefined) { + return; + } + if (leftChild.nodeType === 3 && rightChild.nodeType === 3) { + setCanonicalTextNodeData(leftChild as Text, (rightChild as Text).data); + return; + } + synchronizeDOMText(leftChild, rightChild); + }); +} + +function isProjectableMark( + mark: EditableBlock["marks"][string], + textLength: number, +): boolean { + return ( + MARK_ORDER.includes(mark.type) && + Number.isSafeInteger(mark.start) && + Number.isSafeInteger(mark.end) && + mark.start >= 0 && + mark.start < mark.end && + mark.end <= textLength + ); +} + function configureBlockElement( element: HTMLElement, block: EditableBlock, diff --git a/packages/editable/browser/domSelection.test.ts b/packages/editable/browser/domSelection.test.ts new file mode 100644 index 0000000..e526f04 --- /dev/null +++ b/packages/editable/browser/domSelection.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import type { SelectionSnap } from "@interactive-os/json-document"; +import { describe, expect, it } from "vitest"; +import { + type EditableDocumentValue, + inlineMarkStateForSelection, +} from "../core"; +import { findBlockElement, projectDocumentDOM } from "./documentProjection"; +import { readDOMSelection, restoreDOMSelection } from "./domSelection"; + +const value: EditableDocumentValue = { + schema: "interactive-os.editable-document@3", + id: "dom-selection-test", + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "abcdef", + marks: { + bold: { type: "bold", start: 1, end: 4 }, + italic: { type: "italic", start: 3, end: 6 }, + }, + }, + ], +}; + +describe("DOM selection", () => { + it("round-trips a backward selection across nested mark wrappers", () => { + const root = createRoot(); + const snapshot = selectionBetween(5, 2); + + expect(restoreDOMSelection(root, value, snapshot)).toBe(true); + expect(readDOMSelection(root, value)?.selectionRanges[0]).toEqual( + snapshot.selectionRanges[0], + ); + }); + + it("maps an element child boundary to its semantic text offset", () => { + const root = createRoot(); + const block = findBlockElement(root, "alpha") as HTMLElement; + const surface = block.querySelector("[data-editable-text]"); + const selection = window.getSelection(); + if (surface === null || selection === null) { + throw new Error("Missing marked selection fixture."); + } + selection.setBaseAndExtent(surface, 2, surface, 2); + + expect(readDOMSelection(root, value)?.selectionRanges[0]).toEqual({ + anchor: { path: "/blocks/0/text", offset: 3, affinity: "forward" }, + focus: { path: "/blocks/0/text", offset: 3, affinity: "forward" }, + }); + }); + + it("preserves which side of an inline-mark boundary owns the caret", () => { + const root = createRoot(); + const surface = root.querySelector("[data-editable-text]"); + const plain = surface?.firstChild; + const bold = surface?.querySelector("strong")?.firstChild; + const selection = window.getSelection(); + if ( + surface === null || + !(plain instanceof Text) || + !(bold instanceof Text) || + selection === null + ) { + throw new Error("Missing marked boundary fixture."); + } + + selection.setBaseAndExtent(bold, 0, bold, 0); + const inside = readDOMSelection(root, value); + expect(inside?.selectionRanges[0]).toEqual({ + anchor: { path: "/blocks/0/text", offset: 1, affinity: "forward" }, + focus: { path: "/blocks/0/text", offset: 1, affinity: "forward" }, + }); + expect(inlineMarkStateForSelection(value, inside, "bold")).toBe("on"); + expect(restoreDOMSelection(root, value, inside)).toBe(true); + expect(selection.focusNode).toBe(bold); + expect(selection.focusOffset).toBe(0); + + const strong = surface.querySelector("strong"); + if (strong === null) { + throw new Error("Missing bold wrapper."); + } + selection.setBaseAndExtent(strong, 0, strong, 0); + const wrapperInside = readDOMSelection(root, value); + expect(wrapperInside?.selectionRanges[0]).toEqual({ + anchor: { path: "/blocks/0/text", offset: 1, affinity: "forward" }, + focus: { path: "/blocks/0/text", offset: 1, affinity: "forward" }, + }); + expect(inlineMarkStateForSelection(value, wrapperInside, "bold")).toBe( + "on", + ); + + selection.setBaseAndExtent( + plain, + plain.data.length, + plain, + plain.data.length, + ); + const outside = readDOMSelection(root, value); + expect(outside?.selectionRanges[0]).toEqual({ + anchor: { path: "/blocks/0/text", offset: 1, affinity: "backward" }, + focus: { path: "/blocks/0/text", offset: 1, affinity: "backward" }, + }); + expect(inlineMarkStateForSelection(value, outside, "bold")).toBe("off"); + }); +}); + +function createRoot(): HTMLElement { + const root = window.document.createElement("div"); + window.document.body.append(root); + projectDocumentDOM({ root, value }); + return root; +} + +function selectionBetween( + anchorOffset: number, + focusOffset: number, +): SelectionSnap { + const anchor = { path: "/blocks/0/text", offset: anchorOffset }; + const focus = { path: "/blocks/0/text", offset: focusOffset }; + return { + selectedPointers: [], + selectionRanges: [{ anchor, focus }], + primaryIndex: 0, + anchor, + focus, + }; +} diff --git a/packages/editable/browser/domSelection.ts b/packages/editable/browser/domSelection.ts index e10e754..285bf8f 100644 --- a/packages/editable/browser/domSelection.ts +++ b/packages/editable/browser/domSelection.ts @@ -1,4 +1,5 @@ import type { + SelectionAffinity, SelectionPoint, SelectionSnap, } from "@interactive-os/json-document"; @@ -20,6 +21,7 @@ export type DOMSelectionPoint = { blockId: string; blockIndex: number; offset: number; + affinity?: SelectionAffinity; }; export function readDOMSelection( @@ -56,10 +58,12 @@ export function readDOMSelection( const anchorPoint = { path: editableTextPath(anchor.blockIndex), offset: anchor.offset, + ...(anchor.affinity === undefined ? {} : { affinity: anchor.affinity }), }; const focusPoint = { path: editableTextPath(focus.blockIndex), offset: focus.offset, + ...(focus.affinity === undefined ? {} : { affinity: focus.affinity }), }; return { @@ -99,6 +103,7 @@ export function readDOMPoint( blockId, blockIndex, offset: Math.min(Math.max(resolved.offset, 0), textLength), + ...affinityAtDOMPoint(resolved.surface, node, offset), }; } @@ -170,7 +175,39 @@ function selectionPointToDOM( if (surface === null || surface === undefined) { return null; } - return domPointAtOffset(surface, point.offset ?? 0); + return domPointAtOffset(surface, point.offset ?? 0, point.affinity); +} + +function affinityAtDOMPoint( + surface: HTMLElement, + node: Node, + offset: number, +): { affinity?: SelectionAffinity } { + if (node.nodeType === 1 && (node === surface || surface.contains(node))) { + const childCount = node.childNodes.length; + if (offset < childCount) { + return { affinity: "forward" }; + } + if (offset > 0) { + return { affinity: "backward" }; + } + return {}; + } + if (node.nodeType !== 3) { + return {}; + } + const nodes = textNodes(surface); + const index = nodes.indexOf(node as Text); + if (index < 0) { + return {}; + } + if (offset <= 0 && index > 0) { + return { affinity: "forward" }; + } + if (offset >= (node as Text).data.length && index < nodes.length - 1) { + return { affinity: "backward" }; + } + return {}; } function domOffsetWithin( @@ -258,6 +295,7 @@ function resolveOwnedSurfacePoint( function domPointAtOffset( surface: HTMLElement, requestedOffset: number, + affinity?: SelectionAffinity, ): { node: Node; offset: number } { const nodes = textNodes(surface); if (nodes.length === 0) { @@ -265,8 +303,12 @@ function domPointAtOffset( } let remaining = Math.max(requestedOffset, 0); - for (const node of nodes) { - if (remaining <= node.data.length) { + for (const [index, node] of nodes.entries()) { + if ( + remaining < node.data.length || + (remaining === node.data.length && + (affinity !== "forward" || index === nodes.length - 1)) + ) { return { node, offset: remaining }; } remaining -= node.data.length; diff --git a/packages/editable/browser/editableDOM.test.ts b/packages/editable/browser/editableDOM.test.ts new file mode 100644 index 0000000..6085553 --- /dev/null +++ b/packages/editable/browser/editableDOM.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { + EDITABLE_MARK_ATTRIBUTE, + ensureCompositionTextNode, +} from "./editableDOM"; + +describe("editable DOM", () => { + it("pins a new Text at an element-boundary composition caret", () => { + const surface = window.document.createElement("span"); + const before = window.document.createTextNode("a"); + const mark = window.document.createElement("strong"); + mark.setAttribute(EDITABLE_MARK_ATTRIBUTE, "bold"); + mark.append(window.document.createTextNode("bc")); + surface.append(before, mark, window.document.createTextNode("d")); + window.document.body.append(surface); + window.getSelection()?.setBaseAndExtent(surface, 1, surface, 1); + + const pinned = ensureCompositionTextNode(surface, surface); + + expect(pinned.data).toBe(""); + expect(surface.childNodes[1]).toBe(pinned); + expect(surface.childNodes[2]).toBe(mark); + }); +}); diff --git a/packages/editable/browser/editableDOM.ts b/packages/editable/browser/editableDOM.ts index 1c3ec57..c8758a3 100644 --- a/packages/editable/browser/editableDOM.ts +++ b/packages/editable/browser/editableDOM.ts @@ -1,6 +1,24 @@ 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 const EDITABLE_MARK_ATTRIBUTE = "data-editable-mark"; + +export type EditableDOMMarkType = + | "bold" + | "italic" + | "underline" + | "strike" + | "code"; + +const MARK_TAG_NAMES: Readonly< + Record +> = { + bold: "strong", + italic: "em", + underline: "u", + strike: "s", + code: "code", +}; export function editableSurfaceFromNode( root: HTMLElement, @@ -24,6 +42,27 @@ export function textFromSurface(surface: HTMLElement): string { return surface.textContent ?? ""; } +export function isOwnedMarkElement(node: Node): node is HTMLElement { + if (!isElementNode(node)) { + return false; + } + const type = node.getAttribute(EDITABLE_MARK_ATTRIBUTE); + return ( + isEditableDOMMarkType(type) && + node.tagName.toLowerCase() === MARK_TAG_NAMES[type] && + node.attributes.length === 1 + ); +} + +export function createOwnedMarkElement( + document: Document, + type: EditableDOMMarkType, +): HTMLElement { + const element = document.createElement(MARK_TAG_NAMES[type]); + element.setAttribute(EDITABLE_MARK_ATTRIBUTE, type); + return element; +} + export function ensureCompositionTextNode( surface: HTMLElement, focusNode: Node | null, @@ -32,7 +71,29 @@ export function ensureCompositionTextNode( return focusNode; } - const first = textNodes(surface)[0]; + const existing = textNodes(surface); + if (existing.length === 1 && existing[0]?.data === "") { + return existing[0]; + } + + if ( + isElementNode(focusNode) && + (focusNode === surface || + (surface.contains(focusNode) && isOwnedMarkElement(focusNode))) + ) { + const selection = surface.ownerDocument.getSelection(); + if (selection?.focusNode === focusNode) { + const offset = Math.min( + Math.max(selection.focusOffset, 0), + focusNode.childNodes.length, + ); + const node = surface.ownerDocument.createTextNode(""); + focusNode.insertBefore(node, focusNode.childNodes[offset] ?? null); + return node; + } + } + + const first = existing[0]; if (first !== undefined) { return first; } @@ -56,31 +117,7 @@ export function setCanonicalSurfaceText( 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)); - } + setCanonicalTextNodeData(text, value); syncPlaceholder(surface); return text; } @@ -94,6 +131,34 @@ export function setCanonicalSurfaceText( return text; } +export function setCanonicalTextNodeData(text: Text, value: string): void { + 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)); + } +} + export function textNodes(root: Node): Text[] { const nodes: Text[] = []; const showText = root.ownerDocument?.defaultView?.NodeFilter.SHOW_TEXT ?? 0x4; @@ -131,3 +196,9 @@ function isElementNode(node: Node | null | undefined): node is Element { function isTextNode(node: Node | null | undefined): node is Text { return node?.nodeType === 3; } + +function isEditableDOMMarkType( + value: string | null, +): value is EditableDOMMarkType { + return value !== null && Object.hasOwn(MARK_TAG_NAMES, value); +} diff --git a/packages/editable/browser/editor.ts b/packages/editable/browser/editor.ts index 211ca22..e1e3d8e 100644 --- a/packages/editable/browser/editor.ts +++ b/packages/editable/browser/editor.ts @@ -5,7 +5,9 @@ import type { SelectionSnap, } from "@interactive-os/json-document"; import type { + EditableBlockType, EditableDocumentValue, + EditableInlineMarkType, EditorChangeOrigin, EditorDocumentCommand, } from "../core/index.js"; @@ -17,6 +19,19 @@ export { export type EditorPhase = "idle" | "native-input" | "composing" | "settling"; +export type EditorInlineMarkState = "off" | "on" | "mixed"; + +export type EditorFormattingState = { + canFormat: boolean; + blockType: EditableBlockType | "mixed" | null; + marks: Record; +}; + +export type EditorHistoryState = { + canUndo: boolean; + canRedo: boolean; +}; + export type EditorSnapshot = { phase: EditorPhase; revision: number; @@ -27,6 +42,8 @@ export type EditorSnapshot = { from: number; to: number; } | null; + formatting: EditorFormattingState; + history: EditorHistoryState; }; export type EditorFault = { @@ -45,6 +62,10 @@ export type EditorFault = { export type EditorAction = | { + /** + * Low-level document escape hatch. Text patches must update `marks` in + * the same transaction; prefer `replaceText` for mark-aware editing. + */ type: "patch"; patch: ReadonlyArray; label?: string; diff --git a/packages/editable/browser/editorCoordinator.ts b/packages/editable/browser/editorCoordinator.ts index 9130697..d1caaf7 100644 --- a/packages/editable/browser/editorCoordinator.ts +++ b/packages/editable/browser/editorCoordinator.ts @@ -3,22 +3,29 @@ import { type JSONChangeMetadata, type JSONDocument, type JSONPatchOperation, + type SelectionAffinity, type SelectionSnap, } from "@interactive-os/json-document"; import { accumulateNativeCompositionRange, diffText, diffTextNearRange, + EDITABLE_INLINE_MARK_TYPES, type EditableBlock, + type EditableBlockType, EditableDocumentSchema, type EditableDocumentValue, + type EditableInlineMarkType, type EditorChangeOrigin, type EditorDocumentCommand, editableTextPath, findEditableBlockIndex, + inlineMarkStateForSelection, + type OrderedEditableSelection, orderedEditableSelection, planEditorCommand, primaryEditablePoint, + replaceEditableTextSelection, type TextChange, type TextRange, } from "../core/index.js"; @@ -74,8 +81,10 @@ type CompositionSession = { sourceElement: HTMLElement; sourceSurface: HTMLElement; sourcePlaceholder: HTMLBRElement | null; + sourceBaseline: DOMTreeBaseline; blockElements: ReadonlyArray; range: TextRange; + typingMarks: ReadonlySet; ending: boolean; }; @@ -96,6 +105,7 @@ type PendingStructuralIntent = { sourceSurface: HTMLElement; sourceText: Text; sourcePlaceholder: HTMLBRElement | null; + sourceBaseline: DOMTreeBaseline; blockElements: ReadonlyArray; splitOffset: number; canonicalText: string; @@ -117,6 +127,7 @@ type BlockChange = { after: EditableBlock | null; text: TextChange | null; typeChanged: boolean; + marksChanged: boolean; }; type ReadyDocumentChange = { @@ -124,6 +135,12 @@ type ReadyDocumentChange = { publicationCount: number; }; +type DOMTreeBaseline = ReadonlyArray<{ + element: HTMLElement; + attributes: ReadonlyArray; + children: ReadonlyArray; +}>; + const OWNED_HOST_ATTRIBUTES = [ "contenteditable", "spellcheck", @@ -192,12 +209,20 @@ class JsonEditableCoordinator implements JsonEditable { private pendingRecords: MutationRecord[] = []; private mutationFlushQueued = false; private lastBeforeInputBlockId: string | null = null; + private lastBeforeInputRange: { + blockId: string; + range: TextRange; + } | null = null; private nativeEvidenceUntil = 0; private pendingNativeIntent: PendingNativeIntent | null = null; + private pendingNativeTypingMarks: ReadonlySet | null = + null; private pendingStructuralIntent: PendingStructuralIntent | null = null; private inputTargetSelection: SelectionSnap | null = null; private queuedRemotePatches: QueuedRemotePatch[] = []; private remoteFlushQueued = false; + private storedTypingMarks: ReadonlySet | null = null; + private storedTypingMarksSelection: string | null = null; constructor({ root, document, onFault }: MountJsonEditableOptions) { if (root.dataset.jsonEditableOwner !== undefined) { @@ -296,11 +321,12 @@ class JsonEditableCoordinator implements JsonEditable { } getSnapshot(): EditorSnapshot { + const selection = this.document.selection?.snapshot() ?? null; return { phase: this.phase, revision: this.revision, queuedChanges: this.queuedRemotePatches.length, - selection: this.document.selection?.snapshot() ?? null, + selection, composition: this.composition === null ? null @@ -309,6 +335,11 @@ class JsonEditableCoordinator implements JsonEditable { from: this.composition.range.from, to: this.composition.range.to, }, + formatting: this.formattingSnapshot(selection), + history: { + canUndo: this.canUseHistory() && this.document.canUndo().ok, + canRedo: this.canUseHistory() && this.document.canRedo().ok, + }, }; } @@ -341,6 +372,7 @@ class JsonEditableCoordinator implements JsonEditable { this.stopSelectionSubscription?.(); this.listeners.clear(); this.pendingNativeIntent = null; + this.pendingNativeTypingMarks = null; this.pendingStructuralIntent = null; this.inputTargetSelection = null; this.pendingRecords = []; @@ -373,6 +405,8 @@ class JsonEditableCoordinator implements JsonEditable { return this.applyDocumentCommand(action); case "setBlockType": return this.applyDocumentCommand(action); + case "toggleInlineMark": + return this.applyDocumentCommand(action); case "insertParagraph": return this.applyDocumentCommand(action); case "deleteBackward": @@ -412,9 +446,10 @@ class JsonEditableCoordinator implements JsonEditable { if (operation.op !== "replace" && operation.op !== "test") { return true; } - const match = /^\/blocks\/(0|[1-9]\d*)\/(text|type)$/u.exec( - operation.path, - ); + const match = + /^\/blocks\/(0|[1-9]\d*)\/(text|type|marks(?:\/.*)?)$/u.exec( + operation.path, + ); return match === null || Number(match[1]) === composingIndex; }); } @@ -425,24 +460,43 @@ class JsonEditableCoordinator implements JsonEditable { action: EditorDocumentCommand, sourceOverride?: ChangeSource, ): EditorResult { + const typingMarks = + action.type === "replaceSelection" || action.type === "toggleInlineMark" + ? this.effectiveTypingMarks() + : undefined; const plan = planEditorCommand( this.document.value, this.document.selection?.snapshot() ?? null, action, () => this.createBlockId(), + typingMarks === undefined ? {} : { typingMarks }, ); switch (plan.kind) { case "none": return success("none", []); + case "typing-marks": + this.storeTypingMarks(plan.marks); + return success("selection", []); case "failure": return failure(plan.code, plan.reason); - case "commit": - return this.commitPatch( + case "commit": { + if (action.type === "toggleInlineMark") { + this.clearStoredTypingMarks(); + } + const result = this.commitPatch( plan.patch, plan.label, sourceOverride ?? plan.source, plan.selectionAfter, ); + if (result.ok && this.storedTypingMarks !== null) { + this.storedTypingMarksSelection = selectionSnapshotSignature( + this.document.selection?.snapshot() ?? null, + ); + this.bump(); + } + return result; + } } } @@ -462,6 +516,7 @@ class JsonEditableCoordinator implements JsonEditable { } private applyHistory(command: "undo" | "redo"): EditorResult { + this.clearStoredTypingMarks(); this.cancelComposition(false); const result = this.runDocumentChange("history", () => command === "undo" ? this.document.undo() : this.document.redo(), @@ -473,6 +528,7 @@ class JsonEditableCoordinator implements JsonEditable { } private reset(): EditorResult { + this.clearStoredTypingMarks(); this.cancelComposition(false); const result = this.runDocumentChange("authoritative", () => this.document.reset(), @@ -735,6 +791,13 @@ class JsonEditableCoordinator implements JsonEditable { ); return; } + if (change.marksChanged && source !== "native") { + this.cancelComposition( + true, + `An unexpected ${source} write changed marks in the browser-owned composing block.`, + ); + return; + } if (change.text === null) { return; } @@ -877,26 +940,32 @@ class JsonEditableCoordinator implements JsonEditable { } const selection = selectionAt(editableTextPath(index), effect.splitOffset); if (effect.change !== null) { + const replacement = replaceEditableTextSelection( + this.document.value, + selectionBetween( + editableTextPath(index), + effect.change.from, + editableTextPath(index), + effect.change.to, + ), + effect.change.insert, + session.typingMarks, + ); + if (!replacement.ok) { + this.rejectPendingNativeParagraphMutation(replacement.reason); + return false; + } 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, - }, - ), + this.document.commit(replacement.patch, { + label: "IME composition", + origin: "native", + mergeKey: `composition:${session.id}`, + selectionAfter: selection, + }), new Map([[intent.blockId, effect.change]]), ); if (!result.ok) { @@ -978,39 +1047,12 @@ class JsonEditableCoordinator implements JsonEditable { intent: PendingStructuralIntent, ): void { this.restoreBlockIdentityBaseline(intent.blockElements); - this.restoreCompositionSourceBaseline( - intent.sourceElement, - intent.sourceSurface, - intent.sourceText, - ); + restoreDOMTreeBaseline(intent.sourceBaseline); } 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); - } + restoreDOMTreeBaseline(session.sourceBaseline); } private restoreBlockIdentityBaseline( @@ -1060,6 +1102,7 @@ class JsonEditableCoordinator implements JsonEditable { nativeEvidenceUntil: this.nativeEvidenceUntil, now: performance.now(), lastBeforeInputBlockId: this.lastBeforeInputBlockId, + beforeInputRange: this.lastBeforeInputRange, composition: this.composition === null ? null @@ -1067,6 +1110,11 @@ class JsonEditableCoordinator implements JsonEditable { blockId: this.composition.blockId, range: this.composition.range, }, + ...(this.composition !== null + ? { typingMarks: this.composition.typingMarks } + : this.pendingNativeTypingMarks === null + ? {} + : { typingMarks: this.pendingNativeTypingMarks }), }); const { patch, @@ -1120,6 +1168,13 @@ class JsonEditableCoordinator implements JsonEditable { this.lastNativeCompositionHistoryId = compositionId; } else { this.lastNativeCompositionHistoryId = null; + if (selection !== null) { + restoreDOMSelection( + this.root, + this.document.value, + this.document.selection?.snapshot() ?? selection, + ); + } } } else if (!rejectedMutation && selection !== null) { this.restoreModelSelection(selection); @@ -1298,8 +1353,10 @@ class JsonEditableCoordinator implements JsonEditable { sourceElement, sourceSurface: surface, sourcePlaceholder: captureCompositionPlaceholder(surface, node), + sourceBaseline: captureDOMTreeBaseline(sourceElement), blockElements: blockElements as HTMLElement[], range: { from: ordered.start.offset, to: ordered.end.offset }, + typingMarks: this.effectiveTypingMarks(), ending: false, }; this.lastBeforeInputBlockId = ordered.start.blockId; @@ -1431,6 +1488,8 @@ class JsonEditableCoordinator implements JsonEditable { const blockId = session.blockId; this.composition = null; this.lastBeforeInputBlockId = null; + this.lastBeforeInputRange = null; + this.pendingNativeTypingMarks = null; this.nativeEvidenceUntil = 0; this.setPhase("idle"); this.withDOMWrite(() => { @@ -1449,6 +1508,8 @@ class JsonEditableCoordinator implements JsonEditable { this.composition = null; this.pendingStructuralIntent = null; this.lastBeforeInputBlockId = null; + this.lastBeforeInputRange = null; + this.pendingNativeTypingMarks = null; this.nativeEvidenceUntil = 0; this.phase = "idle"; this.bump(); @@ -1531,6 +1592,7 @@ class JsonEditableCoordinator implements JsonEditable { sourceSurface, sourceText: session.node, sourcePlaceholder: session.sourcePlaceholder, + sourceBaseline: session.sourceBaseline, blockElements: session.blockElements, splitOffset, canonicalText: block.text, @@ -1577,20 +1639,37 @@ class JsonEditableCoordinator implements JsonEditable { if (change === null) { return; } + const replacement = replaceEditableTextSelection( + this.document.value, + selectionBetween( + editableTextPath(index), + change.from, + editableTextPath(index), + change.to, + ), + change.insert, + session.typingMarks, + ); + if (!replacement.ok) { + this.reportFault({ + code: "native_change_commit_failed", + recoverable: false, + reason: replacement.reason, + }); + this.cancelComposition(false); + 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, - }, - ), + this.document.commit(replacement.patch, { + label: "IME composition", + origin: "native", + mergeKey: `composition:${session.id}`, + selectionAfter: selection, + }), new Map([[intent.blockId, change]]), ); if (!result.ok) { @@ -1701,7 +1780,9 @@ class JsonEditableCoordinator implements JsonEditable { if (this.composition === null) { const shouldRecoverDOM = this.pendingNativeIntent !== null; this.pendingNativeIntent = null; + this.pendingNativeTypingMarks = null; this.lastBeforeInputBlockId = null; + this.lastBeforeInputRange = null; this.nativeEvidenceUntil = 0; this.setPhase("idle"); if (shouldRecoverDOM) { @@ -1748,8 +1829,10 @@ class JsonEditableCoordinator implements JsonEditable { private closeNativeTurn(): void { this.clearNativeTurnTimer(); this.lastBeforeInputBlockId = null; + this.lastBeforeInputRange = null; this.nativeEvidenceUntil = 0; this.pendingNativeIntent = null; + this.pendingNativeTypingMarks = null; this.inputTargetSelection = null; if (this.composition === null) { this.setPhase("idle"); @@ -1799,11 +1882,29 @@ class JsonEditableCoordinator implements JsonEditable { if (start === null || end === null) { return null; } + let anchorAffinity = start.affinity; + let focusAffinity = end.affinity; + if (start.blockIndex === end.blockIndex && start.offset === end.offset) { + const live = readDOMSelection(this.root, this.document.value); + const liveFocus = live?.selectionRanges[live.primaryIndex]?.focus ?? null; + if ( + liveFocus !== null && + typeof liveFocus !== "string" && + liveFocus.path === editableTextPath(start.blockIndex) && + (liveFocus.offset ?? 0) === start.offset && + liveFocus.affinity !== undefined + ) { + anchorAffinity = liveFocus.affinity; + focusAffinity = liveFocus.affinity; + } + } return selectionBetween( editableTextPath(start.blockIndex), start.offset, editableTextPath(end.blockIndex), end.offset, + anchorAffinity, + focusAffinity, ); } @@ -1927,6 +2028,16 @@ class JsonEditableCoordinator implements JsonEditable { this.document.selection, ); this.lastBeforeInputBlockId = selection?.end.blockId ?? null; + this.lastBeforeInputRange = beforeInputTextRange( + this.document.value, + selection, + event.inputType, + targetSelection !== null, + ); + this.pendingNativeTypingMarks = + selection !== null && event.inputType.startsWith("insert") + ? this.effectiveTypingMarks() + : null; if ( event.inputType === "historyUndo" || @@ -1944,6 +2055,24 @@ class JsonEditableCoordinator implements JsonEditable { return; } + const inlineMark = inlineMarkForBeforeInput(event.inputType); + if (inlineMark !== null) { + if (!event.cancelable) { + this.openNativeTurn(); + return; + } + event.preventDefault(); + if ( + selection !== null && + this.composition === null && + !this.browserCompositionActive + ) { + this.dispatch({ type: "toggleInlineMark", mark: inlineMark }); + } + this.closeNativeTurn(); + return; + } + if ( selection !== null && selection.start.blockId !== selection.end.blockId && @@ -2231,17 +2360,26 @@ class JsonEditableCoordinator implements JsonEditable { }; private readonly onKeyDown = (event: KeyboardEvent): void => { - if (event.isComposing || !(event.metaKey || event.ctrlKey)) { + if ( + event.isComposing || + event.key === "Process" || + event.keyCode === 229 || + event.repeat || + this.composition !== null || + this.browserCompositionActive || + event.getModifierState("AltGraph") || + !(event.metaKey || event.ctrlKey) + ) { return; } const key = event.key.toLowerCase(); - if (key !== "z" && key !== "y") { + const action = keyboardEditorAction(key, event); + if (action === null) { return; } event.preventDefault(); - this.dispatch({ - type: key === "y" || (key === "z" && event.shiftKey) ? "redo" : "undo", - }); + this.syncSelectionFromDOM(); + this.dispatch(action); }; private readonly onSelectionChange = (): void => { @@ -2249,6 +2387,9 @@ class JsonEditableCoordinator implements JsonEditable { return; } this.syncSelectionFromDOM(); + if (this.discardStaleStoredTypingMarks()) { + this.bump(); + } }; private readonly onBlur = (): void => { @@ -2285,6 +2426,110 @@ class JsonEditableCoordinator implements JsonEditable { }); } + private formattingSnapshot( + selection: SelectionSnap | null, + ): EditorSnapshot["formatting"] { + const ordered = orderedSelectionFromSnapshot( + this.document.value, + selection, + ); + const stored = this.currentStoredTypingMarks(selection, ordered); + const blockType = selectionBlockType(this.document.value, ordered); + const marks = Object.fromEntries( + EDITABLE_INLINE_MARK_TYPES.map((type) => [ + type, + stored === null + ? inlineMarkStateForSelection(this.document.value, selection, type) + : stored.has(type) + ? "on" + : "off", + ]), + ) as EditorSnapshot["formatting"]["marks"]; + return { + canFormat: + ordered !== null && + this.phase === "idle" && + this.composition === null && + !this.browserCompositionActive && + !this.destroyed, + blockType, + marks, + }; + } + + private canUseHistory(): boolean { + return ( + !this.destroyed && + this.phase === "idle" && + this.composition === null && + !this.browserCompositionActive + ); + } + + private effectiveTypingMarks(): ReadonlySet { + const selection = this.document.selection?.snapshot() ?? null; + const ordered = orderedSelectionFromSnapshot( + this.document.value, + selection, + ); + const stored = this.currentStoredTypingMarks(selection, ordered); + if (stored !== null) { + return new Set(stored); + } + return new Set( + EDITABLE_INLINE_MARK_TYPES.filter( + (type) => + inlineMarkStateForSelection(this.document.value, selection, type) === + "on", + ), + ); + } + + private currentStoredTypingMarks( + selection: SelectionSnap | null, + ordered = orderedSelectionFromSnapshot(this.document.value, selection), + ): ReadonlySet | null { + return this.storedTypingMarks !== null && + ordered !== null && + isCollapsedEditableSelection(ordered) && + this.storedTypingMarksSelection === selectionSnapshotSignature(selection) + ? this.storedTypingMarks + : null; + } + + private storeTypingMarks(marks: ReadonlyArray): void { + this.storedTypingMarks = new Set(marks); + this.storedTypingMarksSelection = selectionSnapshotSignature( + this.document.selection?.snapshot() ?? null, + ); + this.bump(); + } + + private clearStoredTypingMarks(): void { + this.storedTypingMarks = null; + this.storedTypingMarksSelection = null; + } + + private discardStaleStoredTypingMarks(): boolean { + if (this.storedTypingMarks === null) { + return false; + } + const selection = this.document.selection?.snapshot() ?? null; + const ordered = orderedSelectionFromSnapshot( + this.document.value, + selection, + ); + if ( + ordered !== null && + isCollapsedEditableSelection(ordered) && + this.storedTypingMarksSelection === selectionSnapshotSignature(selection) + ) { + return false; + } + this.clearStoredTypingMarks(); + return true; + } + private createBlockId(): string { let id: string; do { @@ -2381,13 +2626,24 @@ function describeBlockChanges( beforeBlock !== null && afterBlock !== null && beforeBlock.type !== afterBlock.type; + const marksChanged = + beforeBlock !== null && + afterBlock !== null && + JSON.stringify(beforeBlock.marks) !== JSON.stringify(afterBlock.marks); if ( beforeBlock === null || afterBlock === null || text !== null || - typeChanged + typeChanged || + marksChanged ) { - changes.push({ blockId, after: afterBlock, text, typeChanged }); + changes.push({ + blockId, + after: afterBlock, + text, + typeChanged, + marksChanged, + }); } } return changes; @@ -2411,6 +2667,48 @@ function selectionSnapshotSignature(selection: SelectionSnap | null): string { return JSON.stringify(selection); } +function orderedSelectionFromSnapshot( + value: EditableDocumentValue, + selection: SelectionSnap | null, +): OrderedEditableSelection | null { + return orderedEditableSelection(value, { + primaryRange: selection?.selectionRanges[selection.primaryIndex] ?? null, + }); +} + +function isCollapsedEditableSelection( + selection: OrderedEditableSelection, +): boolean { + return ( + selection.start.blockIndex === selection.end.blockIndex && + selection.start.offset === selection.end.offset + ); +} + +function selectionBlockType( + value: EditableDocumentValue, + selection: OrderedEditableSelection | null, +): EditableBlockType | "mixed" | null { + if (selection === null) { + return null; + } + const types = new Set(); + for ( + let index = selection.start.blockIndex; + index <= selection.end.blockIndex; + index += 1 + ) { + const type = value.blocks[index]?.type; + if (type !== undefined) { + types.add(type); + } + } + if (types.size !== 1) { + return types.size === 0 ? null : "mixed"; + } + return types.values().next().value ?? null; +} + function callbackFailureReason(label: string, error: unknown): string { return error instanceof Error ? `${label} failed: ${error.message}` @@ -2422,9 +2720,19 @@ function selectionBetween( anchorOffset: number, focusPath: string, focusOffset: number, + anchorAffinity?: SelectionAffinity, + focusAffinity?: SelectionAffinity, ): SelectionSnap { - const anchor = { path: anchorPath, offset: anchorOffset }; - const focus = { path: focusPath, offset: focusOffset }; + const anchor = { + path: anchorPath, + offset: anchorOffset, + ...(anchorAffinity === undefined ? {} : { affinity: anchorAffinity }), + }; + const focus = { + path: focusPath, + offset: focusOffset, + ...(focusAffinity === undefined ? {} : { affinity: focusAffinity }), + }; return { selectedPointers: [], selectionRanges: [{ anchor, focus }], @@ -2469,6 +2777,33 @@ function isTextMutationInputType(inputType: string): boolean { return inputType.startsWith("insert") || inputType.startsWith("delete"); } +function beforeInputTextRange( + value: EditableDocumentValue, + selection: OrderedEditableSelection | null, + inputType: string, + authoritative: boolean, +): { blockId: string; range: TextRange } | null { + if (selection === null || selection.start.blockId !== selection.end.blockId) { + return null; + } + let from = selection.start.offset; + let to = selection.end.offset; + if (from === to && !authoritative) { + if (inputType === "deleteContentBackward") { + from = Math.max(0, from - 1); + } else if (inputType === "deleteContentForward") { + const block = value.blocks[selection.start.blockIndex]; + to = Math.min(block?.text.length ?? to, to + 1); + } else if (inputType.startsWith("delete")) { + return null; + } + } + return { + blockId: selection.start.blockId, + range: { from, to }, + }; +} + function isCompositionInputType(inputType: string): boolean { return ( inputType === "insertCompositionText" || @@ -2476,6 +2811,79 @@ function isCompositionInputType(inputType: string): boolean { ); } +function inlineMarkForBeforeInput( + inputType: string, +): EditableInlineMarkType | null { + switch (inputType) { + case "formatBold": + return "bold"; + case "formatItalic": + return "italic"; + case "formatUnderline": + return "underline"; + case "formatStrikeThrough": + return "strike"; + default: + return null; + } +} + +function keyboardEditorAction( + key: string, + event: KeyboardEvent, +): EditorAction | null { + if (!event.altKey && keyboardKeyIs(key, event, "z", "KeyZ")) { + return { type: event.shiftKey ? "redo" : "undo" }; + } + if (!event.altKey && keyboardKeyIs(key, event, "y", "KeyY")) { + return { type: "redo" }; + } + if (event.altKey && !event.shiftKey) { + if (keyboardKeyIs(key, event, "0", "Digit0")) { + return { type: "setBlockType", blockType: "paragraph" }; + } + if (keyboardKeyIs(key, event, "1", "Digit1")) { + return { type: "setBlockType", blockType: "heading" }; + } + return keyboardKeyIs(key, event, "c", "KeyC") + ? { type: "setBlockType", blockType: "code" } + : null; + } + if (event.altKey) { + return null; + } + if (event.shiftKey) { + if (keyboardKeyIs(key, event, "x", "KeyX")) { + return { type: "toggleInlineMark", mark: "strike" }; + } + if (keyboardKeyIs(key, event, "b", "KeyB")) { + return { type: "setBlockType", blockType: "quote" }; + } + return null; + } + if (keyboardKeyIs(key, event, "b", "KeyB")) { + return { type: "toggleInlineMark", mark: "bold" }; + } + if (keyboardKeyIs(key, event, "i", "KeyI")) { + return { type: "toggleInlineMark", mark: "italic" }; + } + if (keyboardKeyIs(key, event, "u", "KeyU")) { + return { type: "toggleInlineMark", mark: "underline" }; + } + return keyboardKeyIs(key, event, "e", "KeyE") + ? { type: "toggleInlineMark", mark: "code" } + : null; +} + +function keyboardKeyIs( + key: string, + event: KeyboardEvent, + expectedKey: string, + expectedCode: string, +): boolean { + return key === expectedKey || event.code === expectedCode; +} + function hasTrailingLineBreak(value: string): boolean { if (value.endsWith("\r\n")) { return true; @@ -2508,6 +2916,39 @@ function ancestorsToRoot(node: Node, root: HTMLElement): Node[] { return ancestors; } +function captureDOMTreeBaseline(root: HTMLElement): DOMTreeBaseline { + return [root, ...root.querySelectorAll("*")].map((element) => ({ + element, + attributes: Array.from( + element.attributes, + ({ name, value }) => [name, value] as const, + ), + children: Array.from(element.childNodes), + })); +} + +function restoreDOMTreeBaseline(baseline: DOMTreeBaseline): void { + for (const { element, attributes, children } of baseline) { + if ( + element.childNodes.length !== children.length || + children.some((child, index) => element.childNodes[index] !== child) + ) { + element.replaceChildren(...children); + } + const expected = new Map(attributes); + for (const attribute of Array.from(element.attributes)) { + if (!expected.has(attribute.name)) { + element.removeAttribute(attribute.name); + } + } + for (const [name, value] of attributes) { + if (element.getAttribute(name) !== value) { + element.setAttribute(name, value); + } + } + } +} + function success( change: Extract["change"], patch: ReadonlyArray, diff --git a/packages/editable/browser/index.ts b/packages/editable/browser/index.ts index a233cc3..f35de79 100644 --- a/packages/editable/browser/index.ts +++ b/packages/editable/browser/index.ts @@ -2,6 +2,8 @@ export type { EditableBlock, EditableBlockType, EditableDocumentValue, + EditableInlineMark, + EditableInlineMarkType, EditablePoint, EditorChangeOrigin, OrderedEditableSelection, @@ -18,6 +20,9 @@ export { export type { EditorAction, EditorFault, + EditorFormattingState, + EditorHistoryState, + EditorInlineMarkState, EditorPhase, EditorResult, EditorSnapshot, diff --git a/packages/editable/browser/nativeParagraph.test.ts b/packages/editable/browser/nativeParagraph.test.ts index 31992cf..664a70c 100644 --- a/packages/editable/browser/nativeParagraph.test.ts +++ b/packages/editable/browser/nativeParagraph.test.ts @@ -6,18 +6,94 @@ import { captureCompositionPlaceholder, inspectNativeParagraphEffect, type NativeParagraphIntentFacts, + readPinnedCompositionText, } from "./nativeParagraph"; const value: EditableDocumentValue = { - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "native-paragraph-test", blocks: [ - { id: "alpha", type: "paragraph", text: "한" }, - { id: "beta", type: "paragraph", text: "second" }, + { id: "alpha", type: "paragraph", text: "한", marks: {} }, + { id: "beta", type: "paragraph", text: "second", marks: {} }, ], }; describe("native paragraph inspection", () => { + it("captures an owned placeholder beside a nested empty composition Text", () => { + const surface = document.createElement("span"); + const mark = document.createElement("strong"); + mark.setAttribute("data-editable-mark", "bold"); + const pinned = document.createTextNode(""); + mark.append(pinned); + const placeholder = document.createElement("br"); + placeholder.setAttribute("data-editable-placeholder", "true"); + surface.append(mark, placeholder); + + expect(captureCompositionPlaceholder(surface, pinned)).toBe(placeholder); + }); + + it("reads a pinned Text through owned mark wrappers", () => { + const fixture = nativeParagraphFixture(); + const before = document.createTextNode("a"); + const mark = document.createElement("strong"); + mark.setAttribute("data-editable-mark", "bold"); + const pinned = document.createTextNode("bc"); + mark.append(pinned); + fixture.sourceSurface.replaceChildren( + before, + mark, + document.createTextNode("d"), + ); + fixture.intent.sourceText = pinned; + fixture.intent.sourcePlaceholder = null; + + expect(readPinnedCompositionText(fixture.intent)).toBe("abcd"); + }); + + it("accepts a native paragraph split containing owned mark wrappers", () => { + const fixture = nativeParagraphFixture(); + const sourceMark = document.createElement("strong"); + sourceMark.setAttribute("data-editable-mark", "bold"); + const pinned = document.createTextNode("b"); + sourceMark.append(pinned); + fixture.sourceSurface.replaceChildren( + document.createTextNode("a"), + sourceMark, + ); + fixture.intent.sourceText = pinned; + fixture.intent.sourcePlaceholder = null; + fixture.intent.canonicalText = "abcd"; + fixture.intent.splitOffset = 2; + const nativeBlock = fixture.root.children[1] as HTMLElement; + const nativeMark = document.createElement("strong"); + nativeMark.setAttribute("data-editable-mark", "bold"); + nativeMark.textContent = "cd"; + nativeBlock.replaceChildren(nativeMark); + const markedValue: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "abcd", + marks: { bold: { type: "bold", start: 1, end: 4 } }, + }, + value.blocks[1] as EditableBlock, + ], + }; + + expect( + inspectNativeParagraphEffect({ + root: fixture.root, + value: markedValue, + intent: fixture.intent, + session: { id: 1, blockId: "alpha", range: { from: 1, to: 2 } }, + isCompositionPinIntact: (surface) => + surface === fixture.sourceSurface && surface.contains(pinned), + }), + ).toEqual({ text: "abcd", splitOffset: 2, change: null }); + }); + it("accepts the captured empty-surface placeholder beside composing text", () => { const fixture = nativeParagraphFixture(); diff --git a/packages/editable/browser/nativeParagraph.ts b/packages/editable/browser/nativeParagraph.ts index c65a37d..0724291 100644 --- a/packages/editable/browser/nativeParagraph.ts +++ b/packages/editable/browser/nativeParagraph.ts @@ -13,6 +13,7 @@ import { import { EDITABLE_PLACEHOLDER_ATTRIBUTE, EDITABLE_TEXT_ATTRIBUTE, + isOwnedMarkElement, } from "./editableDOM.js"; export type NativeParagraphEffect = { @@ -57,12 +58,16 @@ 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) + if (!surface.contains(sourceText)) { + return null; + } + const candidates = Array.from(surface.childNodes).filter( + isCanonicalOwnedPlaceholder, + ); + const candidate = candidates.length === 1 ? candidates[0] : undefined; + return candidate !== undefined && + readOwnedCompositionText(surface, candidate) === "" + ? candidate : null; } @@ -163,24 +168,58 @@ export function inspectNativeParagraphEffect({ export function readPinnedCompositionText( intent: NativeParagraphIntentFacts, ): string | null { - const children = Array.from(intent.sourceSurface.childNodes); - if (children[0] !== intent.sourceText) { + if (!intent.sourceSurface.contains(intent.sourceText)) { return null; } - if (children.length === 1) { - return intent.sourcePlaceholder === null || - intent.sourcePlaceholder.parentNode === null - ? intent.sourceText.data - : null; + const placeholder = intent.sourcePlaceholder; + if ( + placeholder !== null && + placeholder.parentNode !== null && + placeholder.parentNode !== intent.sourceSurface + ) { + return null; } + const text = readOwnedCompositionText(intent.sourceSurface, placeholder); + if (text === null) { + return null; + } + const currentPlaceholder = intent.sourceSurface.querySelector( + `[${EDITABLE_PLACEHOLDER_ATTRIBUTE}]`, + ); if ( - children.length === 2 && - children[1] === intent.sourcePlaceholder && - isCanonicalOwnedPlaceholder(children[1]) + (placeholder === null && currentPlaceholder !== null) || + (placeholder?.parentNode === intent.sourceSurface && + currentPlaceholder !== placeholder) || + (placeholder?.parentNode === null && currentPlaceholder !== null) ) { - return intent.sourceText.data; + return null; } - return null; + return text; +} + +function readOwnedCompositionText( + root: Node, + placeholder: HTMLBRElement | null, +): string | null { + let text = ""; + for (const child of Array.from(root.childNodes)) { + if (child.nodeType === 3) { + text += (child as Text).data; + continue; + } + if (child === placeholder && isCanonicalOwnedPlaceholder(child)) { + continue; + } + if (!isOwnedMarkElement(child)) { + return null; + } + const nested = readOwnedCompositionText(child, placeholder); + if (nested === null) { + return null; + } + text += nested; + } + return text; } function textFromNativeBlock( @@ -193,15 +232,17 @@ function textFromNativeBlock( 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; + if (surface.tagName === "SPAN") { + const sourcePath = sourceSurface.getAttribute(EDITABLE_TEXT_ATTRIBUTE); + const hasAllowedAttributes = + surface.attributes.length === 0 || + (surface.attributes.length === 1 && + surface.getAttribute(EDITABLE_TEXT_ATTRIBUTE) === sourcePath); + if (!hasAllowedAttributes) { + return null; + } + return strictTextFromContainer(surface, true); } - return strictTextFromContainer(surface, true); } return strictTextFromContainer(element, false); } @@ -212,23 +253,43 @@ function strictTextFromContainer( ): 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; + let valid = true; + const visit = (root: Node): void => { + for (const child of Array.from(root.childNodes)) { + if (child.nodeType === 3) { + text += (child as Text).data; + continue; + } + if (child.nodeType !== 1) { + valid = false; + return; + } + const element = child as HTMLElement; + if (element.tagName === "BR") { + const allowedPlaceholder = + allowOwnedPlaceholder && + element.attributes.length === 1 && + element.hasAttribute(EDITABLE_PLACEHOLDER_ATTRIBUTE); + if (element.attributes.length !== 0 && !allowedPlaceholder) { + valid = false; + return; + } + breakCount += 1; + continue; + } + if (!isOwnedMarkElement(element)) { + valid = false; + return; + } + visit(element); + if (!valid) { + return; + } } - 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; + }; + visit(element); + if (!valid) { + return null; } if (breakCount > 0 && (breakCount !== 1 || text !== "")) { return null; diff --git a/packages/editable/browser/nativeTextMutation.test.ts b/packages/editable/browser/nativeTextMutation.test.ts index 28df7a7..3b6a654 100644 --- a/packages/editable/browser/nativeTextMutation.test.ts +++ b/packages/editable/browser/nativeTextMutation.test.ts @@ -1,16 +1,17 @@ // @vitest-environment jsdom +import { applyPatch } from "@interactive-os/json-document"; import { describe, expect, it } from "vitest"; -import type { EditableDocumentValue } from "../core"; +import { EditableDocumentSchema, type EditableDocumentValue } from "../core"; import { inspectNativeTextMutations, type NativeTextMutationInspectionOptions, } from "./nativeTextMutation"; const value: EditableDocumentValue = { - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "native-text-mutation-test", - blocks: [{ id: "alpha", type: "paragraph", text: "first" }], + blocks: [{ id: "alpha", type: "paragraph", text: "first", marks: {} }], }; describe("native text mutation inspection", () => { @@ -35,6 +36,176 @@ describe("native text mutation inspection", () => { }); }); + it("rebases marks in the same patch as native text", () => { + const markedValue: EditableDocumentValue = { + ...value, + blocks: [ + { + ...value.blocks[0], + marks: { bold: { type: "bold", start: 1, end: 4 } }, + }, + ], + }; + const fixture = createMarkedFixture(); + const records = observeMutation(fixture.root, () => { + fixture.markedText.data = "iXrs"; + }); + + const result = inspect(records, fixture.root, markedValue); + + expect(result.rejected).toBe(false); + expect(result.patch).toEqual([ + { op: "replace", path: "/blocks/0/text", value: "fiXrst" }, + { op: "replace", path: "/blocks/0/marks/bold/end", value: 5 }, + ]); + expect(result.textChanges.get("alpha")).toEqual({ + from: 2, + to: 2, + insert: "X", + }); + }); + + it("accepts Text-only child-list changes inside an owned mark wrapper", () => { + const markedValue: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "first", + marks: { bold: { type: "bold", start: 1, end: 4 } }, + }, + ], + }; + const fixture = createMarkedFixture(); + const records = observeMutation(fixture.root, () => { + const right = fixture.markedText.splitText(1); + fixture.mark.insertBefore(window.document.createTextNode("X"), right); + }); + + const result = inspect(records, fixture.root, markedValue); + + expect(result.rejected).toBe(false); + expect(result.patch).toEqual([ + { op: "replace", path: "/blocks/0/text", value: "fiXrst" }, + { op: "replace", path: "/blocks/0/marks/bold/end", value: 5 }, + ]); + }); + + it("uses the beforeinput range to place repeated native text exactly", () => { + const repeatedValue: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "aaaa", + marks: { bold: { type: "bold", start: 2, end: 4 } }, + }, + ], + }; + const fixture = createFixture("aaaa"); + const records = observeMutation(fixture.root, () => { + fixture.text.insertData(1, "a"); + }); + + const result = inspect(records, fixture.root, repeatedValue, { + beforeInputRange: { + blockId: "alpha", + range: { from: 1, to: 1 }, + }, + typingMarks: new Set(), + }); + const applied = applyPatch( + EditableDocumentSchema, + repeatedValue, + result.patch, + ); + + expect(result.textChanges.get("alpha")).toEqual({ + from: 1, + to: 1, + insert: "a", + }); + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks[0]).toMatchObject({ + text: "aaaaa", + marks: { bold: { type: "bold", start: 3, end: 5 } }, + }); + }); + + it("preserves a repeated native selection replacement as one exact change", () => { + const repeatedValue: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "aaaa", + marks: { bold: { type: "bold", start: 2, end: 4 } }, + }, + ], + }; + const fixture = createFixture("aaaa"); + const records = observeMutation(fixture.root, () => { + fixture.text.replaceData(1, 2, "a"); + }); + + const result = inspect(records, fixture.root, repeatedValue, { + beforeInputRange: { + blockId: "alpha", + range: { from: 1, to: 3 }, + }, + typingMarks: new Set(), + }); + const applied = applyPatch( + EditableDocumentSchema, + repeatedValue, + result.patch, + ); + + expect(result.textChanges.get("alpha")).toEqual({ + from: 1, + to: 3, + insert: "a", + }); + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks[0]).toMatchObject({ + text: "aaa", + marks: { "bold-right": { type: "bold", start: 2, end: 3 } }, + }); + }); + + it("rejects a text-equal mutation that changes owned mark coverage", () => { + const markedValue: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "first", + marks: { bold: { type: "bold", start: 0, end: 1 } }, + }, + ], + }; + const fixture = createFixture(); + const mark = window.document.createElement("strong"); + mark.setAttribute("data-editable-mark", "bold"); + const plain = fixture.text.splitText(1); + mark.append(fixture.text); + fixture.surface.prepend(mark); + const records = observeMutation(fixture.root, () => { + fixture.text.appendData(plain.data.slice(0, 1)); + plain.deleteData(0, 1); + }); + + const result = inspect(records, fixture.root, markedValue); + + expect(result.rejected).toBe(true); + expect([...result.rejectedBlockIds]).toEqual(["alpha"]); + expect(result.patch).toEqual([]); + }); + it("rejects foreign structure inside an owned text surface", () => { const fixture = createFixture(); const records = observeMutation(fixture.root, () => { @@ -73,10 +244,15 @@ describe("native text mutation inspection", () => { }); }); -function inspect(records: MutationRecord[], root: HTMLElement) { +function inspect( + records: MutationRecord[], + root: HTMLElement, + inspectedValue: EditableDocumentValue = value, + overrides: Partial = {}, +) { const options: NativeTextMutationInspectionOptions = { root, - value, + value: inspectedValue, records, nativeEvidence: true, phase: "idle", @@ -84,11 +260,28 @@ function inspect(records: MutationRecord[], root: HTMLElement) { now: 100, lastBeforeInputBlockId: null, composition: null, + ...overrides, }; return inspectNativeTextMutations(options); } -function createFixture(): { +function createMarkedFixture(): { + root: HTMLElement; + mark: HTMLElement; + markedText: Text; +} { + const fixture = createFixture(); + const before = window.document.createTextNode("f"); + const mark = window.document.createElement("strong"); + mark.setAttribute("data-editable-mark", "bold"); + const markedText = window.document.createTextNode("irs"); + mark.append(markedText); + const after = window.document.createTextNode("t"); + fixture.surface.replaceChildren(before, mark, after); + return { root: fixture.root, mark, markedText }; +} + +function createFixture(initialText = "first"): { root: HTMLElement; block: HTMLElement; surface: HTMLElement; @@ -99,7 +292,7 @@ function createFixture(): { 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"); + const text = window.document.createTextNode(initialText); surface.append(text); block.append(surface); root.append(block); diff --git a/packages/editable/browser/nativeTextMutation.ts b/packages/editable/browser/nativeTextMutation.ts index a23aca3..cf29de5 100644 --- a/packages/editable/browser/nativeTextMutation.ts +++ b/packages/editable/browser/nativeTextMutation.ts @@ -1,21 +1,31 @@ -import type { JSONPatchOperation } from "@interactive-os/json-document"; +import type { + JSONPatchOperation, + SelectionSnap, +} from "@interactive-os/json-document"; import { applyTextChange, diffText, + diffTextAtRange, diffTextNearRange, type EditableDocumentValue, + type EditableInlineMarkType, editableTextPath, findEditableBlockIndex, + replaceEditableTextSelection, type TextChange, type TextRange, } from "../core/index.js"; -import { findBlockElement } from "./documentProjection.js"; +import { + findBlockElement, + isCanonicalSurfaceContent, +} from "./documentProjection.js"; import { EDITABLE_BLOCK_ATTRIBUTE, EDITABLE_PLACEHOLDER_ATTRIBUTE, EDITABLE_TEXT_ATTRIBUTE, editableBlockFromNode, editableSurfaceFromNode, + isOwnedMarkElement, textFromSurface, } from "./editableDOM.js"; @@ -28,10 +38,15 @@ export type NativeTextMutationInspectionOptions = { nativeEvidenceUntil: number; now: number; lastBeforeInputBlockId: string | null; + beforeInputRange?: { + blockId: string; + range: TextRange; + } | null; composition: { blockId: string; range: TextRange; } | null; + typingMarks?: ReadonlySet; }; export type NativeTextMutationInspection = { @@ -51,7 +66,9 @@ export function inspectNativeTextMutations({ nativeEvidenceUntil, now, lastBeforeInputBlockId, + beforeInputRange = null, composition, + typingMarks, }: NativeTextMutationInspectionOptions): NativeTextMutationInspection { const dirtyBlockIds = new Set(); const rejectedBlockIds = new Set(); @@ -132,18 +149,39 @@ export function inspectNativeTextMutations({ const text = nativeEmpty ? "" : textFromSurface(surface as HTMLElement); if (text === block.text) { + if ( + composition?.blockId !== blockId && + surface !== null && + surface !== undefined && + !isCanonicalSurfaceContent(surface, value, blockId) + ) { + rejectedBlockIds.add(blockId); + rejected = true; + } continue; } const change = composition?.blockId === blockId ? diffTextNearRange(block.text, text, composition.range) - : diffText(block.text, text); + : beforeInputRange?.blockId === blockId + ? diffTextAtRange(block.text, text, beforeInputRange.range) + : diffText(block.text, text); if (change === null || applyTextChange(block.text, change) !== text) { rejected = true; continue; } + const synced = replaceEditableTextSelection( + value, + selectionAtRange(editableTextPath(index), change.from, change.to), + change.insert, + typingMarks, + ); + if (!synced.ok) { + rejected = true; + continue; + } textChanges.set(blockId, change); - patch.push({ op: "replace", path: editableTextPath(index), value: text }); + patch.push(...synced.patch); } return { @@ -155,6 +193,22 @@ export function inspectNativeTextMutations({ }; } +function selectionAtRange( + path: string, + start: number, + end: number, +): SelectionSnap { + const anchor = { path, offset: start }; + const focus = { path, offset: end }; + return { + selectedPointers: [], + selectionRanges: [{ anchor, focus }], + primaryIndex: 0, + anchor, + focus, + }; +} + function isAdmittedTextMutation( record: MutationRecord, surface: HTMLElement, @@ -162,13 +216,18 @@ function isAdmittedTextMutation( if (record.type === "characterData") { return record.target.nodeType === 3 && surface.contains(record.target); } - if (record.type !== "childList" || record.target !== surface) { + if ( + record.type !== "childList" || + (record.target !== surface && + (!surface.contains(record.target) || !isOwnedMarkElement(record.target))) + ) { return false; } return [...record.addedNodes, ...record.removedNodes].every( (node) => node.nodeType === 3 || - (node.nodeType === 1 && + (record.target === surface && + node.nodeType === 1 && (node as HTMLElement).tagName === "BR" && (node as HTMLElement).hasAttribute(EDITABLE_PLACEHOLDER_ATTRIBUTE)), ); diff --git a/packages/editable/causalHost.test.ts b/packages/editable/causalHost.test.ts index c2a50d7..4a0155c 100644 --- a/packages/editable/causalHost.test.ts +++ b/packages/editable/causalHost.test.ts @@ -35,11 +35,11 @@ function setupEditor( root = window.document.createElement("div"), ) { const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "causal-host-test", blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, + { id: "alpha", marks: {}, type: "paragraph", text: "abcdef" }, + { id: "beta", marks: {}, type: "paragraph", text: "second" }, ], }); const faults: EditorFault[] = []; @@ -119,7 +119,12 @@ describe("editable causal document host", () => { { op: "add", path: "/blocks/0", - value: { id: "local", type: "paragraph", text: "local" }, + value: { + id: "local", + marks: {}, + type: "paragraph", + text: "local", + }, }, ], }), @@ -156,9 +161,9 @@ describe("editable causal document host", () => { ]), }); expect(fixture.document.value.blocks).toEqual([ - { id: "local", type: "paragraph", text: "local" }, - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "Reviewed" }, + { id: "local", marks: {}, type: "paragraph", text: "local" }, + { id: "alpha", marks: {}, type: "paragraph", text: "abcdef" }, + { id: "beta", marks: {}, type: "paragraph", text: "Reviewed" }, ]); expect(fixture.document.selection?.primaryRange).toEqual({ anchor: { path: "/blocks/2/text", offset: 4 }, @@ -552,7 +557,12 @@ describe("editable causal document host", () => { { op: "add", path: "/blocks/0", - value: { id: "leading", type: "paragraph", text: "leading" }, + value: { + id: "leading", + marks: {}, + type: "paragraph", + text: "leading", + }, }, ], }), diff --git a/packages/editable/core/editorCommands.test.ts b/packages/editable/core/editorCommands.test.ts index 7b30732..c79c8d5 100644 --- a/packages/editable/core/editorCommands.test.ts +++ b/packages/editable/core/editorCommands.test.ts @@ -1,14 +1,14 @@ -import type { SelectionSnap } from "@interactive-os/json-document"; +import { applyPatch, type SelectionSnap } from "@interactive-os/json-document"; import { describe, expect, it, vi } from "vitest"; import { planEditorCommand } from "./editorCommands"; -import type { EditableDocumentValue } from "./model"; +import { EditableDocumentSchema, type EditableDocumentValue } from "./model"; const value: EditableDocumentValue = { - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "command-test", blocks: [ - { id: "alpha", type: "paragraph", text: "first" }, - { id: "beta", type: "quote", text: "second" }, + { id: "alpha", type: "paragraph", text: "first", marks: {} }, + { id: "beta", type: "quote", text: "second", marks: {} }, ], }; @@ -75,7 +75,12 @@ describe("editor command planning", () => { { op: "add", path: "/blocks/1", - value: { id: "new-block", type: "paragraph", text: "econd" }, + value: { + id: "new-block", + type: "paragraph", + text: "econd", + marks: {}, + }, }, ], label: "insert paragraph", @@ -99,7 +104,9 @@ describe("editor command planning", () => { const family = "👨‍👩‍👧‍👦"; const emojiValue: EditableDocumentValue = { ...value, - blocks: [{ id: "alpha", type: "paragraph", text: `A${family}B` }], + blocks: [ + { id: "alpha", type: "paragraph", text: `A${family}B`, marks: {} }, + ], }; expect( @@ -151,7 +158,7 @@ describe("editor command planning", () => { }); }); - it("targets an explicit block type or the primary focus block", () => { + it("targets an explicit block or every block in the selection", () => { const selection = selectionBetween(0, 0, 1, 2); expect( planEditorCommand( @@ -162,7 +169,10 @@ describe("editor command planning", () => { ), ).toEqual({ kind: "commit", - patch: [{ op: "replace", path: "/blocks/1/type", value: "code" }], + patch: [ + { op: "replace", path: "/blocks/0/type", value: "code" }, + { op: "replace", path: "/blocks/1/type", value: "code" }, + ], label: "set block type: code", source: "app", selectionAfter: selection, @@ -181,6 +191,259 @@ describe("editor command planning", () => { ), ).toEqual({ kind: "none" }); }); + + it("toggles a partially formatted multi-block selection as one commit", () => { + const formatted: EditableDocumentValue = { + ...value, + blocks: [ + { + ...value.blocks[0], + marks: { existing: { type: "bold", start: 1, end: 3 } }, + }, + { ...value.blocks[1], marks: {} }, + ], + }; + const selection = selectionBetween(0, 1, 1, 2); + + expect( + planEditorCommand( + formatted, + selection, + { type: "toggleInlineMark", mark: "bold" }, + unusedBlockId, + ), + ).toEqual({ + kind: "commit", + patch: [ + { + op: "replace", + path: "/blocks/0/marks", + value: { existing: { type: "bold", start: 1, end: 5 } }, + }, + { + op: "replace", + path: "/blocks/1/marks", + value: { "mark-bold": { type: "bold", start: 0, end: 2 } }, + }, + ], + label: "toggle inline mark: bold", + source: "app", + selectionAfter: selection, + }); + }); + + it("returns authoritative typing marks for a collapsed toggle", () => { + expect( + planEditorCommand( + value, + selectionBetween(0, 2, 0, 2), + { type: "toggleInlineMark", mark: "italic" }, + unusedBlockId, + { typingMarks: new Set(["bold"]) }, + ), + ).toEqual({ + kind: "typing-marks", + marks: ["bold", "italic"], + }); + }); + + it("keeps text and authoritative typing marks in one replacement plan", () => { + const formatted: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "abcdef", + marks: { bold: { type: "bold", start: 1, end: 5 } }, + }, + ], + }; + const plan = planEditorCommand( + formatted, + selectionBetween(0, 3, 0, 3), + { type: "replaceSelection", text: "X" }, + unusedBlockId, + { typingMarks: new Set() }, + ); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit plan, received ${plan.kind}.`); + } + const applied = applyPatch(EditableDocumentSchema, formatted, plan.patch); + + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks[0]).toMatchObject({ + text: "abcXdef", + marks: { + bold: { type: "bold", start: 1, end: 3 }, + "bold-right": { type: "bold", start: 4, end: 6 }, + }, + }); + expect(plan.selectionAfter).toEqual(selectionBetween(0, 4, 0, 4)); + }); + + it("rebases marks when deleting a grapheme", () => { + const formatted: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "abc", + marks: { bold: { type: "bold", start: 0, end: 3 } }, + }, + ], + }; + const plan = planEditorCommand( + formatted, + selectionBetween(0, 2, 0, 2), + { type: "deleteBackward" }, + unusedBlockId, + ); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit plan, received ${plan.kind}.`); + } + const applied = applyPatch(EditableDocumentSchema, formatted, plan.patch); + + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks[0]).toMatchObject({ + text: "ac", + marks: { bold: { type: "bold", start: 0, end: 2 } }, + }); + }); + + it("preserves prefix, inserted, and suffix marks across block replacement", () => { + const formatted: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "abcd", + marks: { bold: { type: "bold", start: 0, end: 4 } }, + }, + { + id: "beta", + type: "paragraph", + text: "efgh", + marks: { italic: { type: "italic", start: 0, end: 4 } }, + }, + ], + }; + const plan = planEditorCommand( + formatted, + selectionBetween(0, 2, 1, 2), + { type: "replaceSelection", text: "X" }, + unusedBlockId, + { typingMarks: new Set(["bold"]) }, + ); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit plan, received ${plan.kind}.`); + } + const applied = applyPatch(EditableDocumentSchema, formatted, plan.patch); + + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks).toEqual([ + { + id: "alpha", + type: "paragraph", + text: "abXgh", + marks: { + bold: { type: "bold", start: 0, end: 3 }, + italic: { type: "italic", start: 3, end: 5 }, + }, + }, + ]); + }); + + it("splits inline marks with their text when inserting a paragraph", () => { + const formatted: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "abcdef", + marks: { + bold: { type: "bold", start: 1, end: 5 }, + italic: { type: "italic", start: 3, end: 6 }, + }, + }, + ], + }; + const plan = planEditorCommand( + formatted, + selectionBetween(0, 3, 0, 3), + { type: "insertParagraph" }, + () => "new-block", + ); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit plan, received ${plan.kind}.`); + } + const applied = applyPatch(EditableDocumentSchema, formatted, plan.patch); + + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks).toEqual([ + { + id: "alpha", + type: "paragraph", + text: "abc", + marks: { bold: { type: "bold", start: 1, end: 3 } }, + }, + { + id: "new-block", + type: "paragraph", + text: "def", + marks: { + bold: { type: "bold", start: 0, end: 2 }, + italic: { type: "italic", start: 0, end: 3 }, + }, + }, + ]); + }); + + it("shifts and merges inline marks when joining blocks", () => { + const formatted: EditableDocumentValue = { + ...value, + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "ab", + marks: { shared: { type: "bold", start: 0, end: 2 } }, + }, + { + id: "beta", + type: "paragraph", + text: "cd", + marks: { shared: { type: "italic", start: 0, end: 2 } }, + }, + ], + }; + const plan = planEditorCommand( + formatted, + selectionBetween(1, 0, 1, 0), + { type: "joinBackward" }, + unusedBlockId, + ); + if (plan.kind !== "commit") { + throw new Error(`Expected a commit plan, received ${plan.kind}.`); + } + const applied = applyPatch(EditableDocumentSchema, formatted, plan.patch); + + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks).toEqual([ + { + id: "alpha", + type: "paragraph", + text: "abcd", + marks: { + shared: { type: "bold", start: 0, end: 2 }, + "shared-2": { type: "italic", start: 2, end: 4 }, + }, + }, + ]); + }); }); function selectionBetween( diff --git a/packages/editable/core/editorCommands.ts b/packages/editable/core/editorCommands.ts index 051ed5d..828af62 100644 --- a/packages/editable/core/editorCommands.ts +++ b/packages/editable/core/editorCommands.ts @@ -2,15 +2,24 @@ import type { JSONPatchOperation, SelectionSnap, } from "@interactive-os/json-document"; +import { + EDITABLE_INLINE_MARK_TYPES, + editableTextFragment, + inlineMarkStateForSelection, + mergeInlineMarks, + replaceEditableTextSelection, + setInlineMarkCoverage, +} from "./inlineMarks.js"; import { type EditableBlock, type EditableBlockType, type EditableDocumentValue, + type EditableInlineMark, + type EditableInlineMarkType, editableTextPath, findEditableBlockIndex, type OrderedEditableSelection, orderedEditableSelection, - primaryEditablePoint, } from "./model.js"; import { clampTextRange } from "./textChange.js"; @@ -37,6 +46,7 @@ export type EditorDocumentCommand = blockType: EditableBlockType; blockId?: string; } + | { type: "toggleInlineMark"; mark: EditableInlineMarkType } | { type: "insertParagraph" } | { type: "deleteBackward" | "deleteForward" } | { type: "joinBackward" } @@ -50,6 +60,10 @@ export type EditorCommandPlan = source: "app" | "remote"; selectionAfter?: SelectionSnap | null; } + | { + kind: "typing-marks"; + marks: ReadonlyArray; + } | { kind: "none" } | { kind: "failure"; @@ -57,15 +71,20 @@ export type EditorCommandPlan = reason: string; }; +export type EditorCommandPlanningOptions = { + typingMarks?: ReadonlySet; +}; + export function planEditorCommand( value: EditableDocumentValue, selection: SelectionSnap | null, action: EditorDocumentCommand, allocateBlockId: () => string, + options: EditorCommandPlanningOptions = {}, ): EditorCommandPlan { switch (action.type) { case "replaceText": - return planTextReplacement(value, action); + return planTextReplacement(value, action, options.typingMarks); case "replaceSelection": return planSelectionReplacement( value, @@ -73,9 +92,12 @@ export function planEditorCommand( action.text, action.label ?? "replace selection", sourceFromOrigin(action.origin), + options.typingMarks, ); case "setBlockType": return planBlockTypeChange(value, selection, action); + case "toggleInlineMark": + return planInlineMarkToggle(value, selection, action.mark, options); case "insertParagraph": return planParagraphInsertion(value, selection, allocateBlockId); case "deleteBackward": @@ -89,9 +111,72 @@ export function planEditorCommand( } } +function planInlineMarkToggle( + value: EditableDocumentValue, + selection: SelectionSnap | null, + type: EditableInlineMarkType, + options: EditorCommandPlanningOptions, +): EditorCommandPlan { + const ordered = readOrderedSelection(value, selection); + if (ordered === null) { + return noSelection(); + } + if (isCollapsed(ordered)) { + const marks = new Set( + options.typingMarks ?? + EDITABLE_INLINE_MARK_TYPES.filter( + (candidate) => + inlineMarkStateForSelection(value, selection, candidate) === "on", + ), + ); + if (marks.has(type)) { + marks.delete(type); + } else { + marks.add(type); + } + return { + kind: "typing-marks", + marks: EDITABLE_INLINE_MARK_TYPES.filter((candidate) => + marks.has(candidate), + ), + }; + } + + const active = inlineMarkStateForSelection(value, selection, type) === "on"; + const patch: JSONPatchOperation[] = []; + for ( + let blockIndex = ordered.start.blockIndex; + blockIndex <= ordered.end.blockIndex; + blockIndex += 1 + ) { + const block = value.blocks[blockIndex]; + if (block === undefined) { + continue; + } + const start = + blockIndex === ordered.start.blockIndex ? ordered.start.offset : 0; + const end = + blockIndex === ordered.end.blockIndex + ? ordered.end.offset + : block.text.length; + if (start >= end) { + continue; + } + patch.push({ + op: "replace", + path: `/blocks/${blockIndex}/marks`, + value: setInlineMarkCoverage(block.marks, type, start, end, !active), + }); + } + return patch.length === 0 + ? { kind: "none" } + : commit(patch, `toggle inline mark: ${type}`, "app", selection); +} + function planTextReplacement( value: EditableDocumentValue, action: Extract, + typingMarks?: ReadonlySet, ): EditorCommandPlan { const index = findEditableBlockIndex(value, action.blockId); const block = value.blocks[index]; @@ -105,13 +190,23 @@ function planTextReplacement( }, block.text.length, ); - const next = - block.text.slice(0, range.from) + action.text + block.text.slice(range.to); - if (next === block.text) { + if (range.from === range.to && action.text.length === 0) { + return { kind: "none" }; + } + const replacement = replaceEditableTextSelection( + value, + selectionAtRange(editableTextPath(index), range.from, range.to), + action.text, + typingMarks, + ); + if (!replacement.ok) { + return failure("selection_unavailable", replacement.reason); + } + if (replacement.patch.length === 0) { return { kind: "none" }; } return commit( - [{ op: "replace", path: editableTextPath(index), value: next }], + replacement.patch, action.label ?? "replace text", sourceFromOrigin(action.origin), ); @@ -123,6 +218,7 @@ function planSelectionReplacement( text: string, label: string, source: "app" | "remote", + typingMarks?: ReadonlySet, ): EditorCommandPlan { const ordered = readOrderedSelection(value, selection); if (ordered === null) { @@ -135,11 +231,81 @@ function planSelectionReplacement( if (startBlock === undefined || endBlock === undefined) { return staleSelection(); } + if (start.blockIndex === end.blockIndex && selection !== null) { + const replacement = replaceEditableTextSelection( + value, + selection, + text, + typingMarks, + ); + return replacement.ok + ? commit( + replacement.patch, + label, + source, + selectionAt( + editableTextPath(start.blockIndex), + start.offset + text.length, + ), + ) + : failure("selection_unavailable", replacement.reason); + } const nextText = startBlock.text.slice(0, start.offset) + text + endBlock.text.slice(end.offset); + const prefix = editableTextFragment( + value, + selectionAtRange(editableTextPath(start.blockIndex), 0, start.offset), + start.blockIndex, + ); + const suffix = editableTextFragment( + value, + selectionAtRange( + editableTextPath(end.blockIndex), + end.offset, + endBlock.text.length, + ), + end.blockIndex, + ); + if (!prefix.ok || !suffix.ok) { + return failure( + "selection_unavailable", + !prefix.ok + ? prefix.reason + : !suffix.ok + ? suffix.reason + : "The editable text fragments could not be resolved.", + ); + } + const inheritedTypes = + typingMarks ?? + new Set( + EDITABLE_INLINE_MARK_TYPES.filter( + (type) => inlineMarkStateForSelection(value, selection, type) === "on", + ), + ); + const insertedMarks: Record = {}; + if (text.length > 0) { + for (const type of inheritedTypes) { + insertedMarks[`mark-${type}`] = { + type, + start: 0, + end: text.length, + }; + } + } + const prefixMarks = fragmentMarks(prefix.fragment.ranges); + const suffixMarks = fragmentMarks(suffix.fragment.ranges); + const nextMarks = mergeInlineMarks( + { marks: prefixMarks, offset: 0 }, + { marks: insertedMarks, offset: prefix.fragment.text.length }, + { + marks: suffixMarks, + offset: prefix.fragment.text.length + text.length, + }, + ); const patch: JSONPatchOperation[] = [ { op: "replace", @@ -147,6 +313,13 @@ function planSelectionReplacement( value: nextText, }, ]; + if (!sameMarks(startBlock.marks, nextMarks)) { + patch.push({ + op: "replace", + path: `/blocks/${start.blockIndex}/marks`, + value: nextMarks, + }); + } for (let index = end.blockIndex; index > start.blockIndex; index -= 1) { patch.push({ op: "remove", path: `/blocks/${index}` }); } @@ -197,9 +370,16 @@ function planDirectionalDelete( : offset; const to = direction === "forward" ? nextGraphemeBoundary(block.text, offset) : offset; - const next = block.text.slice(0, from) + block.text.slice(to); + const replacement = replaceEditableTextSelection( + value, + selectionAtRange(editableTextPath(index), from, to), + "", + ); + if (!replacement.ok) { + return failure("selection_unavailable", replacement.reason); + } return commit( - [{ op: "replace", path: editableTextPath(index), value: next }], + replacement.patch, `delete ${direction}`, "app", selectionAt(editableTextPath(index), from), @@ -211,23 +391,38 @@ function planBlockTypeChange( 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) { + let indices: number[]; + if (action.blockId !== undefined) { + const index = findEditableBlockIndex(value, action.blockId); + indices = index < 0 ? [] : [index]; + } else { + const ordered = readOrderedSelection(value, selection); + indices = + ordered === null + ? [] + : Array.from( + { + length: ordered.end.blockIndex - ordered.start.blockIndex + 1, + }, + (_, offset) => ordered.start.blockIndex + offset, + ); + } + if (indices.length === 0) { return failure("selection_unavailable", "Select an editable block first."); } - if (block.type === action.blockType) { - return { kind: "none" }; + const patch: JSONPatchOperation[] = []; + for (const index of indices) { + if (value.blocks[index]?.type !== action.blockType) { + patch.push({ + op: "replace", + path: `/blocks/${index}/type`, + value: action.blockType, + }); + } } - return commit( - [{ op: "replace", path: `/blocks/${index}/type`, value: action.blockType }], - `set block type: ${action.blockType}`, - "app", - selection, - ); + return patch.length === 0 + ? { kind: "none" } + : commit(patch, `set block type: ${action.blockType}`, "app", selection); } function planParagraphInsertion( @@ -245,11 +440,38 @@ function planParagraphInsertion( if (startBlock === undefined || endBlock === undefined) { return staleSelection(); } + const prefix = editableTextFragment( + value, + selectionAtRange(editableTextPath(start.blockIndex), 0, start.offset), + start.blockIndex, + ); + const suffix = editableTextFragment( + value, + selectionAtRange( + editableTextPath(end.blockIndex), + end.offset, + endBlock.text.length, + ), + end.blockIndex, + ); + if (!prefix.ok || !suffix.ok) { + return failure( + "selection_unavailable", + !prefix.ok + ? prefix.reason + : !suffix.ok + ? suffix.reason + : "The editable text fragments could not be resolved.", + ); + } + const prefixMarks = fragmentMarks(prefix.fragment.ranges); + const suffixMarks = fragmentMarks(suffix.fragment.ranges); const newBlock: EditableBlock = { id: allocateBlockId(), type: "paragraph", - text: endBlock.text.slice(end.offset), + text: suffix.fragment.text, + marks: suffixMarks, }; const patch: JSONPatchOperation[] = [ { @@ -258,6 +480,13 @@ function planParagraphInsertion( value: startBlock.text.slice(0, start.offset), }, ]; + if (!sameMarks(startBlock.marks, prefixMarks)) { + patch.push({ + op: "replace", + path: `/blocks/${start.blockIndex}/marks`, + value: prefixMarks, + }); + } for (let index = end.blockIndex; index > start.blockIndex; index -= 1) { patch.push({ op: "remove", path: `/blocks/${index}` }); } @@ -303,15 +532,27 @@ function planBackwardJoin( return staleSelection(); } const offset = previous.text.length; + const marks = mergeInlineMarks( + { marks: previous.marks, offset: 0 }, + { marks: current.marks, offset }, + ); + const patch: JSONPatchOperation[] = [ + { + op: "replace", + path: editableTextPath(currentIndex - 1), + value: previous.text + current.text, + }, + ]; + if (!sameMarks(previous.marks, marks)) { + patch.push({ + op: "replace", + path: `/blocks/${currentIndex - 1}/marks`, + value: marks, + }); + } + patch.push({ op: "remove", path: `/blocks/${currentIndex}` }); return commit( - [ - { - op: "replace", - path: editableTextPath(currentIndex - 1), - value: previous.text + current.text, - }, - { op: "remove", path: `/blocks/${currentIndex}` }, - ], + patch, "join backward", "app", selectionAt(editableTextPath(currentIndex - 1), offset), @@ -346,15 +587,27 @@ function planForwardJoin( ) { return { kind: "none" }; } + const marks = mergeInlineMarks( + { marks: current.marks, offset: 0 }, + { marks: next.marks, offset: current.text.length }, + ); + const patch: JSONPatchOperation[] = [ + { + op: "replace", + path: editableTextPath(currentIndex), + value: current.text + next.text, + }, + ]; + if (!sameMarks(current.marks, marks)) { + patch.push({ + op: "replace", + path: `/blocks/${currentIndex}/marks`, + value: marks, + }); + } + patch.push({ op: "remove", path: `/blocks/${currentIndex + 1}` }); return commit( - [ - { - op: "replace", - path: editableTextPath(currentIndex), - value: current.text + next.text, - }, - { op: "remove", path: `/blocks/${currentIndex + 1}` }, - ], + patch, "join forward", "app", selectionAt(editableTextPath(currentIndex), current.text.length), @@ -401,6 +654,42 @@ function selectionAt(path: string, offset: number): SelectionSnap { }; } +function selectionAtRange( + path: string, + start: number, + end: number, +): SelectionSnap { + const anchor = { path, offset: start }; + const focus = { path, offset: end }; + return { + selectedPointers: [], + selectionRanges: [{ anchor, focus }], + primaryIndex: 0, + anchor, + focus, + }; +} + +function fragmentMarks( + ranges: + | Record + | undefined, +): Record { + return Object.fromEntries( + Object.entries(ranges ?? {}).map(([id, range]) => [ + id, + range as EditableInlineMark, + ]), + ); +} + +function sameMarks( + left: Readonly>, + right: Readonly>, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + function previousGraphemeBoundary(value: string, offset: number): number { let previous = 0; for (const segment of new Intl.Segmenter(undefined, { diff --git a/packages/editable/core/index.ts b/packages/editable/core/index.ts index 032b94c..5997472 100644 --- a/packages/editable/core/index.ts +++ b/packages/editable/core/index.ts @@ -4,10 +4,17 @@ export type { EditorDocumentCommand, } from "./editorCommands.js"; export { planEditorCommand } from "./editorCommands.js"; +export { + EDITABLE_INLINE_MARK_TYPES, + inlineMarkStateForSelection, + replaceEditableTextSelection, +} from "./inlineMarks.js"; export type { EditableBlock, EditableBlockType, EditableDocumentValue, + EditableInlineMark, + EditableInlineMarkType, EditablePoint, OrderedEditableSelection, } from "./model.js"; @@ -26,5 +33,6 @@ export { applyTextChange, clampTextRange, diffText, + diffTextAtRange, diffTextNearRange, } from "./textChange.js"; diff --git a/packages/editable/core/inlineMarks.test.ts b/packages/editable/core/inlineMarks.test.ts new file mode 100644 index 0000000..ffae310 --- /dev/null +++ b/packages/editable/core/inlineMarks.test.ts @@ -0,0 +1,123 @@ +import type { SelectionSnap } from "@interactive-os/json-document"; +import { applyPatch } from "@interactive-os/json-document"; +import { describe, expect, it } from "vitest"; +import { + editableTextFragment, + inlineMarkStateForSelection, + inlineMarkTypesAtPoint, + replaceEditableTextSelection, +} from "./inlineMarks"; +import { EditableDocumentSchema, type EditableDocumentValue } from "./model"; + +const value: EditableDocumentValue = { + schema: "interactive-os.editable-document@3", + id: "inline-mark-test", + blocks: [ + { + id: "alpha", + type: "paragraph", + text: "abcdef", + marks: { + bold: { type: "bold", start: 1, end: 5 }, + italic: { type: "italic", start: 2, end: 4 }, + }, + }, + { + id: "beta", + type: "paragraph", + text: "ghij", + marks: { + bold: { type: "bold", start: 0, end: 2 }, + }, + }, + ], +}; + +describe("editable inline mark text surfaces", () => { + it("reuses text-surface fragments and rebases marks with replacement", () => { + const selection = selectionBetween(0, 2, 0, 4); + + expect(editableTextFragment(value, selection, 0)).toMatchObject({ + ok: true, + fragment: { + text: "cd", + ranges: { + bold: { type: "bold", start: 0, end: 2 }, + italic: { type: "italic", start: 0, end: 2 }, + }, + }, + }); + + const plan = replaceEditableTextSelection(value, selection, "X"); + if (!plan.ok) { + throw new Error(plan.reason); + } + const applied = applyPatch(EditableDocumentSchema, value, plan.patch); + + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks[0]).toMatchObject({ + text: "abXef", + marks: { + bold: { type: "bold", start: 1, end: 4 }, + italic: { type: "italic", start: 2, end: 3 }, + }, + }); + }); + + it("treats typing marks as authoritative inside an existing mark", () => { + const selection = selectionBetween(0, 3, 0, 3); + const plan = replaceEditableTextSelection(value, selection, "X", new Set()); + if (!plan.ok) { + throw new Error(plan.reason); + } + const applied = applyPatch(EditableDocumentSchema, value, plan.patch); + + expect(applied.result.ok).toBe(true); + expect(applied.state.blocks[0]?.marks).toEqual({ + bold: { type: "bold", start: 1, end: 3 }, + "bold-right": { type: "bold", start: 4, end: 6 }, + italic: { type: "italic", start: 2, end: 3 }, + "italic-right": { type: "italic", start: 4, end: 5 }, + }); + }); + + it("reports point and selection formatting across blocks", () => { + expect(inlineMarkTypesAtPoint(value, { blockIndex: 0, offset: 5 })).toEqual( + ["bold"], + ); + expect( + inlineMarkTypesAtPoint(value, { blockIndex: 0, offset: 1 }, "forward"), + ).toEqual(["bold"]); + + expect( + inlineMarkStateForSelection(value, selectionBetween(0, 1, 0, 5), "bold"), + ).toBe("on"); + expect( + inlineMarkStateForSelection(value, selectionBetween(0, 0, 1, 2), "bold"), + ).toBe("mixed"); + expect( + inlineMarkStateForSelection( + value, + selectionBetween(0, 0, 1, 4), + "underline", + ), + ).toBe("off"); + }); +}); + +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, + }; +} diff --git a/packages/editable/core/inlineMarks.ts b/packages/editable/core/inlineMarks.ts new file mode 100644 index 0000000..ac94ec4 --- /dev/null +++ b/packages/editable/core/inlineMarks.ts @@ -0,0 +1,390 @@ +import { + applyPatch, + type JSONPatchOperation, + type Pointer, + replaceTextSurfaceSelection, + type SelectionAffinity, + type SelectionSnap, + type TextSurface, + type TextSurfaceFragmentResult, + type TextSurfaceReplaceResult, + textSurfaceFragment, +} from "@interactive-os/json-document"; +import { + EditableDocumentSchema, + type EditableDocumentValue, + type EditableInlineMark, + type EditableInlineMarkType, + type EditablePoint, + editableBlockIndexFromTextPath, + editableTextPath, + orderedEditableSelection, +} from "./model.js"; + +export type InlineMarkState = "off" | "on" | "mixed"; + +export const EDITABLE_INLINE_MARK_TYPES = [ + "bold", + "italic", + "underline", + "strike", + "code", +] as const satisfies ReadonlyArray; + +export function editableMarksPath(blockIndex: number): Pointer { + editableTextPath(blockIndex); + return `/blocks/${blockIndex}/marks`; +} + +export function editableTextSurface(blockIndex: number): TextSurface { + return { + textPath: editableTextPath(blockIndex), + rangesPath: editableMarksPath(blockIndex), + }; +} + +export function editableTextFragment( + value: EditableDocumentValue, + selection: SelectionSnap, + blockIndex: number, +): TextSurfaceFragmentResult { + return textSurfaceFragment(selection, value, editableTextSurface(blockIndex)); +} + +export function replaceEditableTextSelection( + value: EditableDocumentValue, + selection: SelectionSnap, + text: string, + typingMarks?: ReadonlySet, +): TextSurfaceReplaceResult { + const blockIndex = selectionBlockIndex(selection); + if (blockIndex === null) { + return textSurfaceFailure("Text replacement requires one editable block."); + } + const result = replaceTextSurfaceSelection( + selection, + value, + editableTextSurface(blockIndex), + text, + ); + if (!result.ok || typingMarks === undefined || text.length === 0) { + return result; + } + return withAuthoritativeInsertionMarks( + value, + result, + blockIndex, + result.selectionRange.start, + text.length, + typingMarks, + ); +} + +export function replaceInlineMarkCoverage( + marks: Readonly>, + start: number, + end: number, + types: ReadonlySet, +): Record { + if (start >= end) { + return cloneMarks(marks); + } + + const next: Record = {}; + for (const [id, mark] of Object.entries(marks)) { + if (mark.end <= start || mark.start >= end) { + next[id] = { ...mark }; + continue; + } + if (mark.start < start) { + next[id] = { ...mark, end: start }; + } + if (mark.end > end) { + next[uniqueMarkId(`${id}-right`, next)] = { ...mark, start: end }; + } + } + for (const type of EDITABLE_INLINE_MARK_TYPES) { + if (types.has(type)) { + next[uniqueMarkId(`mark-${type}`, next)] = { type, start, end }; + } + } + return normalizeInlineMarks(next); +} + +export function setInlineMarkCoverage( + marks: Readonly>, + type: EditableInlineMarkType, + start: number, + end: number, + active: boolean, +): Record { + if (start >= end) { + return cloneMarks(marks); + } + if (active) { + const next = cloneMarks(marks); + next[uniqueMarkId(`mark-${type}`, next)] = { type, start, end }; + return normalizeInlineMarks(next); + } + const next: Record = {}; + for (const [id, mark] of Object.entries(marks)) { + if (mark.type !== type || mark.end <= start || mark.start >= end) { + next[id] = { ...mark }; + continue; + } + if (mark.start < start) { + next[id] = { ...mark, end: start }; + } + if (mark.end > end) { + next[uniqueMarkId(`${id}-right`, next)] = { ...mark, start: end }; + } + } + return normalizeInlineMarks(next); +} + +export function mergeInlineMarks( + ...parts: ReadonlyArray<{ + marks: Readonly>; + offset: number; + }> +): Record { + const merged: Record = {}; + for (const part of parts) { + for (const [id, mark] of Object.entries(part.marks)) { + merged[uniqueMarkId(id, merged)] = { + ...mark, + start: mark.start + part.offset, + end: mark.end + part.offset, + }; + } + } + return normalizeInlineMarks(merged); +} + +export function inlineMarkTypesAtPoint( + value: EditableDocumentValue, + point: Pick, + affinity: SelectionAffinity = "backward", +): EditableInlineMarkType[] { + const block = value.blocks[point.blockIndex]; + if (block === undefined) { + return []; + } + const offset = Math.min( + Math.max(Math.trunc(point.offset), 0), + block.text.length, + ); + return EDITABLE_INLINE_MARK_TYPES.filter((type) => + Object.values(block.marks).some( + (mark) => mark.type === type && markContainsPoint(mark, offset, affinity), + ), + ); +} + +export function inlineMarkStateForSelection( + value: EditableDocumentValue, + selection: SelectionSnap | null, + type: EditableInlineMarkType, +): InlineMarkState { + const range = primaryRange(selection); + const ordered = orderedEditableSelection( + value, + range === null ? null : { primaryRange: range }, + ); + if (ordered === null) { + return "off"; + } + if ( + ordered.start.blockIndex === ordered.end.blockIndex && + ordered.start.offset === ordered.end.offset + ) { + const focus = range?.focus; + const affinity = + typeof focus === "object" && focus.affinity !== undefined + ? focus.affinity + : "backward"; + return inlineMarkTypesAtPoint(value, ordered.start, affinity).includes(type) + ? "on" + : "off"; + } + + let selectedLength = 0; + let coveredLength = 0; + for ( + let blockIndex = ordered.start.blockIndex; + blockIndex <= ordered.end.blockIndex; + blockIndex += 1 + ) { + const block = value.blocks[blockIndex]; + if (block === undefined) { + continue; + } + const start = + blockIndex === ordered.start.blockIndex ? ordered.start.offset : 0; + const end = + blockIndex === ordered.end.blockIndex + ? ordered.end.offset + : block.text.length; + selectedLength += Math.max(0, end - start); + coveredLength += coveredRangeLength(block.marks, type, start, end); + } + if (coveredLength === 0 || selectedLength === 0) { + return "off"; + } + return coveredLength === selectedLength ? "on" : "mixed"; +} + +export function normalizeInlineMarks( + marks: Readonly>, +): Record { + const normalized: Record = {}; + for (const type of EDITABLE_INLINE_MARK_TYPES) { + const entries = Object.entries(marks) + .filter(([, mark]) => mark.type === type && mark.start < mark.end) + .sort( + ([leftId, left], [rightId, right]) => + left.start - right.start || + left.end - right.end || + leftId.localeCompare(rightId), + ); + let previousId: string | null = null; + for (const [id, mark] of entries) { + const previous = previousId === null ? undefined : normalized[previousId]; + if (previous !== undefined && previous.end >= mark.start) { + previous.end = Math.max(previous.end, mark.end); + } else { + previousId = uniqueMarkId(id, normalized); + normalized[previousId] = { ...mark }; + } + } + } + return normalized; +} + +function withAuthoritativeInsertionMarks( + value: EditableDocumentValue, + result: Extract, + blockIndex: number, + start: number, + insertedLength: number, + typingMarks: ReadonlySet, +): T { + const applied = applyPatch(EditableDocumentSchema, value, result.patch); + if (!applied.result.ok) { + return textSurfaceFailure( + applied.result.reason ?? applied.result.code, + ) as T; + } + const marks = applied.state.blocks[blockIndex]?.marks; + if (marks === undefined) { + return textSurfaceFailure("The editable block no longer exists.") as T; + } + const authoritative = replaceInlineMarkCoverage( + marks, + start, + start + insertedLength, + typingMarks, + ); + return { + ...result, + patch: [ + ...result.patch, + { + op: "replace", + path: editableMarksPath(blockIndex), + value: authoritative, + } satisfies JSONPatchOperation, + ], + } as T; +} + +function selectionBlockIndex(selection: SelectionSnap): number | null { + const range = primaryRange(selection); + if ( + range === null || + typeof range.anchor === "string" || + typeof range.focus === "string" + ) { + return null; + } + const anchor = editableBlockIndexFromTextPath(range.anchor.path); + const focus = editableBlockIndexFromTextPath(range.focus.path); + return anchor !== null && anchor === focus ? anchor : null; +} + +function primaryRange(selection: SelectionSnap | null) { + return selection?.selectionRanges[selection.primaryIndex] ?? null; +} + +function textSurfaceFailure(reason: string): TextSurfaceReplaceResult { + return { + ok: false, + code: "multi_pointer_range", + reason, + pointer: null, + }; +} + +function markContainsPoint( + mark: EditableInlineMark, + offset: number, + affinity: SelectionAffinity, +): boolean { + if (affinity === "forward") { + return mark.start <= offset && offset < mark.end; + } + return ( + (mark.start < offset && offset <= mark.end) || + (offset === 0 && mark.start === 0) + ); +} + +function coveredRangeLength( + marks: Readonly>, + type: EditableInlineMarkType, + start: number, + end: number, +): number { + const ranges = Object.values(marks) + .filter( + (mark) => mark.type === type && mark.start < end && mark.end > start, + ) + .map((mark) => ({ + start: Math.max(start, mark.start), + end: Math.min(end, mark.end), + })) + .sort((left, right) => left.start - right.start || left.end - right.end); + let covered = 0; + let cursor = start; + for (const range of ranges) { + if (range.end <= cursor) { + continue; + } + const nextStart = Math.max(cursor, range.start); + covered += range.end - nextStart; + cursor = range.end; + } + return covered; +} + +function uniqueMarkId( + preferred: string, + marks: Readonly>, +): string { + if (!(preferred in marks)) { + return preferred; + } + let suffix = 2; + while (`${preferred}-${suffix}` in marks) { + suffix += 1; + } + return `${preferred}-${suffix}`; +} + +function cloneMarks( + marks: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(marks).map(([id, mark]) => [id, { ...mark }]), + ); +} diff --git a/packages/editable/core/model.test.ts b/packages/editable/core/model.test.ts index 885890e..fb8263a 100644 --- a/packages/editable/core/model.test.ts +++ b/packages/editable/core/model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createEditableDocument, EditableDocumentSchema, + type EditableDocumentValue, editableBlockIndexFromTextPath, editableTextPath, findEditableBlockIndex, @@ -9,15 +10,37 @@ import { primaryEditablePoint, } from "./model"; -function createTestEditableValue() { +function createTestEditableValue(): EditableDocumentValue { return { - schema: "interactive-os.editable-document@2" as const, + schema: "interactive-os.editable-document@3" as const, id: "model-test", blocks: [ - { id: "alpha", type: "heading" as const, text: "Alpha heading" }, - { id: "bravo", type: "paragraph" as const, text: "Bravo paragraph" }, - { id: "charlie", type: "quote" as const, text: "Charlie quote" }, - { id: "delta", type: "code" as const, text: "Delta code" }, + { + id: "alpha", + type: "heading" as const, + text: "Alpha heading", + marks: { + strong: { type: "bold" as const, start: 0, end: 5 }, + }, + }, + { + id: "bravo", + type: "paragraph" as const, + text: "Bravo paragraph", + marks: {}, + }, + { + id: "charlie", + type: "quote" as const, + text: "Charlie quote", + marks: {}, + }, + { + id: "delta", + type: "code" as const, + text: "Delta code", + marks: {}, + }, ], }; } @@ -37,6 +60,28 @@ describe("EditableDocumentSchema", () => { }).success, ).toBe(false); }); + + it("rejects empty and out-of-bounds inline marks", () => { + const value = createTestEditableValue(); + + for (const mark of [ + { type: "bold", start: 2, end: 2 }, + { type: "italic", start: -1, end: 2 }, + { type: "code", start: 0, end: value.blocks[0].text.length + 1 }, + ]) { + expect( + EditableDocumentSchema.safeParse({ + ...value, + blocks: [ + { + ...value.blocks[0], + marks: { invalid: mark }, + }, + ], + }).success, + ).toBe(false); + } + }); }); describe("editable text paths", () => { @@ -142,8 +187,8 @@ describe("createEditableDocument", () => { createEditableDocument({ ...initial, blocks: [ - { id: "duplicate", type: "paragraph", text: "one" }, - { id: "duplicate", type: "paragraph", text: "two" }, + { id: "duplicate", type: "paragraph", text: "one", marks: {} }, + { id: "duplicate", type: "paragraph", text: "two", marks: {} }, ], }), ).toThrow(); diff --git a/packages/editable/core/model.ts b/packages/editable/core/model.ts index ab2a1e3..b03f203 100644 --- a/packages/editable/core/model.ts +++ b/packages/editable/core/model.ts @@ -9,14 +9,28 @@ import { z } from "zod"; export type EditableBlockType = "paragraph" | "heading" | "quote" | "code"; +export type EditableInlineMarkType = + | "bold" + | "italic" + | "underline" + | "strike" + | "code"; + +export type EditableInlineMark = { + type: EditableInlineMarkType; + start: number; + end: number; +}; + export type EditableBlock = { id: string; type: EditableBlockType; text: string; + marks: Record; }; export type EditableDocumentValue = { - schema: "interactive-os.editable-document@2"; + schema: "interactive-os.editable-document@3"; id: string; blocks: EditableBlock[]; }; @@ -32,15 +46,34 @@ export type OrderedEditableSelection = { end: EditablePoint; }; -const EditableBlockSchema: z.ZodType = z.object({ - id: z.string().min(1), - type: z.enum(["paragraph", "heading", "quote", "code"]), - text: z.string(), +const EditableInlineMarkSchema: z.ZodType = z.object({ + type: z.enum(["bold", "italic", "underline", "strike", "code"]), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), }); +const EditableBlockSchema: z.ZodType = z + .object({ + id: z.string().min(1), + type: z.enum(["paragraph", "heading", "quote", "code"]), + text: z.string(), + marks: z.record(z.string().min(1), EditableInlineMarkSchema), + }) + .superRefine((block, context) => { + for (const [id, mark] of Object.entries(block.marks)) { + if (mark.start >= mark.end || mark.end > block.text.length) { + context.addIssue({ + code: "custom", + message: "Inline marks must be non-empty and stay inside block text.", + path: ["marks", id], + }); + } + } + }); + export const EditableDocumentSchema: z.ZodType = z .object({ - schema: z.literal("interactive-os.editable-document@2"), + schema: z.literal("interactive-os.editable-document@3"), id: z.string().min(1), blocks: z.array(EditableBlockSchema), }) diff --git a/packages/editable/core/textChange.test.ts b/packages/editable/core/textChange.test.ts index 01ad90d..c4f8705 100644 --- a/packages/editable/core/textChange.test.ts +++ b/packages/editable/core/textChange.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { accumulateNativeCompositionRange, diffText, + diffTextAtRange, diffTextNearRange, } from "./textChange"; @@ -38,4 +39,15 @@ describe("text change mapping", () => { insert: "a", }); }); + + it("preserves an authoritative replacement range in repeated text", () => { + expect(diffTextAtRange("aaaa", "aaa", { from: 1, to: 3 })).toEqual({ + from: 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..5401a1c 100644 --- a/packages/editable/core/textChange.ts +++ b/packages/editable/core/textChange.ts @@ -78,6 +78,32 @@ export function diffTextNearRange( }; } +export function diffTextAtRange( + before: string, + after: string, + preferred: TextRange, +): TextChange | null { + if (before === after) { + return null; + } + 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) + ) { + return null; + } + return { + from: range.from, + to: range.to, + insert: after.slice(range.from, insertEnd), + }; +} + export function applyTextChange(value: string, change: TextChange): string { return value.slice(0, change.from) + change.insert + value.slice(change.to); } diff --git a/packages/editable/crossRealm.test.ts b/packages/editable/crossRealm.test.ts index aa06bac..928ef4f 100644 --- a/packages/editable/crossRealm.test.ts +++ b/packages/editable/crossRealm.test.ts @@ -18,11 +18,12 @@ describe("owner-document DOM realm", () => { const editor = mountJsonEditable({ root, document: createEditableDocument({ - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "cross-realm-test", blocks: [ { id: "block-1", + marks: {}, type: "paragraph", text: "Cross-realm text", }, diff --git a/packages/editable/editor.test.ts b/packages/editable/editor.test.ts index d23f460..dd812c8 100644 --- a/packages/editable/editor.test.ts +++ b/packages/editable/editor.test.ts @@ -22,11 +22,11 @@ afterEach(() => { function setupEditor() { const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "editor-test", blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, + { id: "alpha", marks: {}, type: "paragraph", text: "abcdef" }, + { id: "beta", marks: {}, type: "paragraph", text: "second" }, ], }); const root = window.document.createElement("div"); @@ -71,6 +71,47 @@ function setDOMCaret(node: Text, offset: number): void { selection.addRange(range); } +function setDOMTextSelection( + fixture: EditorFixture, + blockId: string, + from: number, + to = from, +): void { + const surface = textSurface(fixture, blockId); + const start = domPointAtTextOffset(surface, from); + const end = domPointAtTextOffset(surface, to); + const selection = window.getSelection(); + if (selection === null) { + throw new Error("The test DOM does not expose a Selection."); + } + selection.setBaseAndExtent(start.node, start.offset, end.node, end.offset); + window.document.dispatchEvent(new Event("selectionchange")); +} + +function domPointAtTextOffset( + surface: HTMLElement, + requestedOffset: number, +): { node: Text; offset: number } { + const walker = window.document.createTreeWalker( + surface, + window.NodeFilter.SHOW_TEXT, + ); + let remaining = Math.max(0, requestedOffset); + let last: Text | null = null; + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + const text = node as Text; + last = text; + if (remaining <= text.data.length) { + return { node: text, offset: remaining }; + } + remaining -= text.data.length; + } + if (last === null) { + throw new Error("The editable surface has no Text node."); + } + return { node: last, offset: last.data.length }; +} + function modelFocusOffset(fixture: EditorFixture): number | undefined { const focus = fixture.document.selection?.primaryRange?.focus; return typeof focus === "string" ? undefined : focus?.offset; @@ -79,11 +120,15 @@ function modelFocusOffset(fixture: EditorFixture): number | undefined { function inputEvent( type: "beforeinput" | "input", inputType: string, - options: { data?: string | null; isComposing?: boolean } = {}, + options: { + cancelable?: boolean; + data?: string | null; + isComposing?: boolean; + } = {}, ): InputEvent { return new InputEvent(type, { bubbles: true, - cancelable: type === "beforeinput", + cancelable: options.cancelable ?? type === "beforeinput", data: options.data, inputType, isComposing: options.isComposing ?? false, @@ -1632,7 +1677,7 @@ describe("JSON editable coordinator", () => { expect(accepted).toBe(false); expect(fixture.document.value.blocks).toEqual([ - { id: "alpha", type: "paragraph", text: "X" }, + { id: "alpha", marks: {}, type: "paragraph", text: "X" }, ]); }); @@ -1801,3 +1846,409 @@ describe("JSON editable coordinator", () => { expect(fixture.faults).toEqual([]); }); }); + +describe("rich-text formatting coordinator", () => { + it("toggles a selected inline mark and exposes active and history state", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 1, 4); + + const result = fixture.editor.dispatch({ + type: "toggleInlineMark", + mark: "bold", + }); + + expect(result).toMatchObject({ ok: true, change: "document" }); + expect(fixture.document.value.blocks[0]?.marks).toEqual({ + "mark-bold": { type: "bold", start: 1, end: 4 }, + }); + expect( + textSurface(fixture, "alpha").querySelector("strong")?.textContent, + ).toBe("bcd"); + expect(fixture.editor.getSnapshot()).toMatchObject({ + formatting: { + blockType: "paragraph", + marks: { bold: "on" }, + }, + history: { canUndo: true, canRedo: false }, + }); + + expect(fixture.editor.dispatch({ type: "undo" }).ok).toBe(true); + expect(fixture.document.value.blocks[0]?.marks).toEqual({}); + expect(textSurface(fixture, "alpha").querySelector("strong")).toBeNull(); + expect(fixture.editor.getSnapshot().history.canRedo).toBe(true); + }); + + it("uses the same command for formatBeforeInput and keyboard shortcuts", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 1, 4); + + const formatted = fixture.root.dispatchEvent( + inputEvent("beforeinput", "formatBold"), + ); + expect(formatted).toBe(false); + expect(fixture.document.value.blocks[0]?.marks).toMatchObject({ + "mark-bold": { type: "bold", start: 1, end: 4 }, + }); + + const shortcut = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "i", + }); + expect(fixture.root.dispatchEvent(shortcut)).toBe(false); + expect(fixture.document.value.blocks[0]?.marks).toMatchObject({ + "mark-italic": { type: "italic", start: 1, end: 4 }, + }); + expect( + textSurface(fixture, "alpha").querySelector("strong em")?.textContent, + ).toBe("bcd"); + + expect( + fixture.root.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "b", + shiftKey: true, + }), + ), + ).toBe(false); + expect(fixture.document.value.blocks[0]?.type).toBe("quote"); + + expect( + fixture.root.dispatchEvent( + new KeyboardEvent("keydown", { + altKey: true, + bubbles: true, + cancelable: true, + code: "KeyC", + ctrlKey: true, + key: "ç", + }), + ), + ).toBe(false); + expect(fixture.document.value.blocks[0]?.type).toBe("code"); + }); + + it("stores a collapsed mark without history and applies it to typed text", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 2); + + expect( + fixture.root.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "b", + }), + ), + ).toBe(false); + expect(fixture.document.value.blocks[0]?.marks).toEqual({}); + expect(fixture.editor.getSnapshot()).toMatchObject({ + formatting: { marks: { bold: "on" } }, + history: { canUndo: false }, + }); + + expect( + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertText", { data: "X" }), + ), + ).toBe(false); + expect(fixture.document.value.blocks[0]).toMatchObject({ + text: "abXcdef", + marks: { "mark-bold": { type: "bold", start: 2, end: 3 } }, + }); + expect(fixture.editor.getSnapshot().formatting.marks.bold).toBe("on"); + }); + + it("can type plain text inside a marked range after toggling the stored mark off", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 1, 5); + fixture.editor.dispatch({ type: "toggleInlineMark", mark: "bold" }); + setDOMTextSelection(fixture, "alpha", 3); + + fixture.root.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "b", + }), + ); + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertText", { data: "X" }), + ); + + expect(fixture.document.value.blocks[0]).toMatchObject({ + text: "abcXdef", + marks: { + "mark-bold": { type: "bold", start: 1, end: 3 }, + "mark-bold-right": { type: "bold", start: 4, end: 6 }, + }, + }); + expect(textSurface(fixture, "alpha").innerHTML).toContain("X"); + expect( + textSurface(fixture, "alpha").querySelectorAll("strong"), + ).toHaveLength(2); + }); + + it("inherits a mark when the caret is on the marked side of its boundary", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 1, 4); + fixture.editor.dispatch({ type: "toggleInlineMark", mark: "bold" }); + const surface = textSurface(fixture, "alpha"); + const markedText = surface.querySelector("strong")?.firstChild; + const selection = window.getSelection(); + if (!(markedText instanceof Text) || selection === null) { + throw new Error("Expected a marked text boundary."); + } + selection.setBaseAndExtent(markedText, 0, markedText, 0); + window.document.dispatchEvent(new Event("selectionchange")); + + expect(fixture.editor.getSnapshot().formatting.marks.bold).toBe("on"); + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertText", { data: "X" }), + ); + + expect(fixture.document.value.blocks[0]).toMatchObject({ + text: "aXbcdef", + marks: { "mark-bold": { type: "bold", start: 1, end: 5 } }, + }); + }); + + it("uses a persisted beforeinput range for repeated native text", async () => { + const fixture = setupEditor(); + fixture.editor.dispatch({ + type: "replaceText", + blockId: "alpha", + from: 0, + to: 6, + text: "aaaa", + }); + setDOMTextSelection(fixture, "alpha", 2, 4); + fixture.editor.dispatch({ type: "toggleInlineMark", mark: "bold" }); + const plain = textSurface(fixture, "alpha").firstChild; + if (!(plain instanceof Text)) { + throw new Error("Expected leading plain text."); + } + setDOMCaret(plain, 1); + window.document.dispatchEvent(new Event("selectionchange")); + const beforeInput = inputEvent("beforeinput", "insertText", { + cancelable: false, + data: "a", + }); + Object.defineProperty(beforeInput, "getTargetRanges", { + value: () => [ + { + startContainer: plain, + startOffset: 1, + endContainer: plain, + endOffset: 1, + }, + ], + }); + + fixture.root.dispatchEvent(beforeInput); + await Promise.resolve(); + plain.insertData(1, "a"); + setDOMCaret(plain, 2); + fixture.root.dispatchEvent( + inputEvent("input", "insertText", { data: "a" }), + ); + + expect(fixture.document.value.blocks[0]).toMatchObject({ + text: "aaaaa", + marks: { "mark-bold": { type: "bold", start: 3, end: 5 } }, + }); + expect(fixture.faults).toEqual([]); + }); + + it("restores the native caret after formatting changes DOM topology", async () => { + const fixture = setupEditor(); + fixture.editor.dispatch({ + type: "replaceText", + blockId: "alpha", + from: 0, + to: 6, + text: "ab", + }); + setDOMTextSelection(fixture, "alpha", 0, 1); + fixture.editor.dispatch({ type: "toggleInlineMark", mark: "bold" }); + const plain = textSurface(fixture, "alpha").lastChild; + if (!(plain instanceof Text)) { + throw new Error("Expected trailing plain text."); + } + setDOMCaret(plain, 1); + window.document.dispatchEvent(new Event("selectionchange")); + fixture.root.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "b", + }), + ); + const beforeInput = inputEvent("beforeinput", "insertText", { + cancelable: false, + data: "X", + }); + Object.defineProperty(beforeInput, "getTargetRanges", { + value: () => [ + { + startContainer: plain, + startOffset: 1, + endContainer: plain, + endOffset: 1, + }, + ], + }); + + fixture.root.dispatchEvent(beforeInput); + await Promise.resolve(); + plain.insertData(1, "X"); + setDOMCaret(plain, 2); + fixture.root.dispatchEvent( + inputEvent("input", "insertText", { data: "X" }), + ); + + const selection = window.getSelection(); + expect(fixture.document.value.blocks[0]).toMatchObject({ + text: "abX", + marks: { + "mark-bold": { type: "bold", start: 0, end: 1 }, + "mark-bold-2": { type: "bold", start: 2, end: 3 }, + }, + }); + expect(modelFocusOffset(fixture)).toBe(3); + expect(selection?.focusNode?.isConnected).toBe(true); + expect( + textSurface(fixture, "alpha").contains(selection?.focusNode ?? null), + ).toBe(true); + expect(fixture.faults).toEqual([]); + }); + + it("keeps a marked composition Text and wrapper pinned while rebasing its mark", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 1, 5); + fixture.editor.dispatch({ type: "toggleInlineMark", mark: "bold" }); + setDOMTextSelection(fixture, "alpha", 3); + const point = domPointAtTextOffset(textSurface(fixture, "alpha"), 3); + const wrapper = point.node.parentElement; + if (wrapper?.tagName !== "STRONG") { + throw new Error("Expected a marked composition leaf."); + } + + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + point.node.insertData(point.offset, "한"); + setDOMCaret(point.node, point.offset + 1); + fixture.root.dispatchEvent( + inputEvent("input", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + + expect(wrapper.contains(point.node)).toBe(true); + expect(fixture.document.value.blocks[0]).toMatchObject({ + text: "abc한def", + marks: { "mark-bold": { type: "bold", start: 1, end: 6 } }, + }); + expect(fixture.editor.getSnapshot()).toMatchObject({ + phase: "composing", + composition: { blockId: "alpha", from: 3, to: 4 }, + }); + + const ignored = fixture.root.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + isComposing: true, + key: "i", + }), + ); + expect(ignored).toBe(true); + expect( + Object.values(fixture.document.value.blocks[0]?.marks ?? {}), + ).not.toContainEqual(expect.objectContaining({ type: "italic" })); + expect(fixture.faults).toEqual([]); + }); + + it("keeps an IME insertion plain when the stored mark is toggled off", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 1, 5); + fixture.editor.dispatch({ type: "toggleInlineMark", mark: "bold" }); + setDOMTextSelection(fixture, "alpha", 3); + fixture.root.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "b", + }), + ); + const point = domPointAtTextOffset(textSurface(fixture, "alpha"), 3); + + fixture.root.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + fixture.root.dispatchEvent( + inputEvent("beforeinput", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + point.node.insertData(point.offset, "한"); + setDOMCaret(point.node, point.offset + 1); + fixture.root.dispatchEvent( + inputEvent("input", "insertCompositionText", { + data: "한", + isComposing: true, + }), + ); + + expect(fixture.document.value.blocks[0]).toMatchObject({ + text: "abc한def", + marks: { + "mark-bold": { type: "bold", start: 1, end: 3 }, + "mark-bold-right": { type: "bold", start: 4, end: 6 }, + }, + }); + expect(fixture.faults).toEqual([]); + }); + + it("splits inline marks when Enter splits a block", () => { + const fixture = setupEditor(); + setDOMTextSelection(fixture, "alpha", 1, 5); + fixture.editor.dispatch({ type: "toggleInlineMark", mark: "bold" }); + setDOMTextSelection(fixture, "alpha", 3); + + expect( + fixture.root.dispatchEvent(inputEvent("beforeinput", "insertParagraph")), + ).toBe(false); + + expect(fixture.document.value.blocks.slice(0, 2)).toMatchObject([ + { + id: "alpha", + text: "abc", + marks: { "mark-bold": { type: "bold", start: 1, end: 3 } }, + }, + { + text: "def", + marks: { "mark-bold": { type: "bold", start: 0, end: 2 } }, + }, + ]); + expect(fixture.faults).toEqual([]); + }); +}); diff --git a/packages/editable/index.ts b/packages/editable/index.ts index 532294b..8f4486b 100644 --- a/packages/editable/index.ts +++ b/packages/editable/index.ts @@ -2,10 +2,15 @@ export type { EditableBlock, EditableBlockType, EditableDocumentValue, + EditableInlineMark, + EditableInlineMarkType, EditablePoint, EditorAction, EditorChangeOrigin, EditorFault, + EditorFormattingState, + EditorHistoryState, + EditorInlineMarkState, EditorPhase, EditorResult, EditorSnapshot, diff --git a/packages/editable/publicApi.test.ts b/packages/editable/publicApi.test.ts index 30be0cb..e7e2fa9 100644 --- a/packages/editable/publicApi.test.ts +++ b/packages/editable/publicApi.test.ts @@ -12,10 +12,15 @@ import type { EditableBlock, EditableBlockType, EditableDocumentValue, + EditableInlineMark, + EditableInlineMarkType, EditablePoint, EditorAction, EditorChangeOrigin, EditorFault, + EditorFormattingState, + EditorHistoryState, + EditorInlineMarkState, EditorPhase, EditorResult, EditorSnapshot, @@ -28,13 +33,25 @@ import * as PublicAPI from "./index"; import { getJsonEditableDocumentHost } from "./index"; type ExpectedBlockType = "paragraph" | "heading" | "quote" | "code"; +type ExpectedInlineMarkType = + | "bold" + | "italic" + | "underline" + | "strike" + | "code"; +type ExpectedInlineMark = { + type: ExpectedInlineMarkType; + start: number; + end: number; +}; type ExpectedBlock = { id: string; type: ExpectedBlockType; text: string; + marks: Record; }; type ExpectedDocument = { - schema: "interactive-os.editable-document@2"; + schema: "interactive-os.editable-document@3"; id: string; blocks: ExpectedBlock[]; }; @@ -45,12 +62,21 @@ type ExpectedOrderedSelection = { }; type ExpectedPhase = "idle" | "native-input" | "composing" | "settling"; type ExpectedChangeOrigin = "app" | "remote"; +type ExpectedInlineMarkState = "off" | "on" | "mixed"; +type ExpectedFormattingState = { + canFormat: boolean; + blockType: ExpectedBlockType | "mixed" | null; + marks: Record; +}; +type ExpectedHistoryState = { canUndo: boolean; canRedo: boolean }; type ExpectedSnapshot = { phase: ExpectedPhase; revision: number; queuedChanges: number; selection: SelectionSnap | null; composition: { blockId: string; from: number; to: number } | null; + formatting: ExpectedFormattingState; + history: ExpectedHistoryState; }; type ExpectedFault = { code: @@ -93,6 +119,7 @@ type ExpectedAction = blockType: ExpectedBlockType; blockId?: string; } + | { type: "toggleInlineMark"; mark: ExpectedInlineMarkType } | { type: "insertParagraph" } | { type: "deleteBackward" | "deleteForward" } | { type: "joinBackward" } @@ -157,12 +184,17 @@ describe("editable public API", () => { it("preserves the established public type contracts", () => { expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/editable/tests/smoke/consumer.ts b/packages/editable/tests/smoke/consumer.ts index 3249cd5..9db2a83 100644 --- a/packages/editable/tests/smoke/consumer.ts +++ b/packages/editable/tests/smoke/consumer.ts @@ -6,11 +6,12 @@ import { } from "@interactive-os/editable"; const initial: EditableDocumentValue = { - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "consumer-document", blocks: [ { id: "first-block", + marks: {}, type: "paragraph", text: "Consumer-owned content", }, diff --git a/src/editable-lab/model/demoDocument.test.ts b/src/editable-lab/model/demoDocument.test.ts index 13f67ae..7f9aa36 100644 --- a/src/editable-lab/model/demoDocument.test.ts +++ b/src/editable-lab/model/demoDocument.test.ts @@ -7,26 +7,30 @@ describe("createDemoEditableValue", () => { const second = createDemoEditableValue(); expect(first).toEqual({ - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "composition-island-demo", blocks: [ { id: "welcome", + marks: {}, type: "heading", text: "Composition island editor", }, { id: "korean-ime", + marks: {}, type: "paragraph", text: "한글 IME로 입력하는 동안 조합 중인 DOM 노드를 그대로 유지합니다.", }, { id: "japanese-ime", + marks: {}, type: "quote", text: "日本語 IME の変換中も、編集中の DOM ノードを置き換えません。", }, { id: "render-rule", + marks: {}, type: "code", text: "renderOutside(compositionIsland)", }, diff --git a/src/editable-lab/model/demoDocument.ts b/src/editable-lab/model/demoDocument.ts index 45e2854..908593d 100644 --- a/src/editable-lab/model/demoDocument.ts +++ b/src/editable-lab/model/demoDocument.ts @@ -2,26 +2,30 @@ import type { EditableDocumentValue } from "@interactive-os/editable"; export function createDemoEditableValue(): EditableDocumentValue { return { - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "composition-island-demo", blocks: [ { id: "welcome", + marks: {}, type: "heading", text: "Composition island editor", }, { id: "korean-ime", + marks: {}, type: "paragraph", text: "한글 IME로 입력하는 동안 조합 중인 DOM 노드를 그대로 유지합니다.", }, { id: "japanese-ime", + marks: {}, type: "quote", text: "日本語 IME の変換中も、編集中の DOM ノードを置き換えません。", }, { id: "render-rule", + marks: {}, type: "code", text: "renderOutside(compositionIsland)", }, diff --git a/src/editable-lab/runtime/causalDocumentInbox.test.ts b/src/editable-lab/runtime/causalDocumentInbox.test.ts index 1565008..77f6b84 100644 --- a/src/editable-lab/runtime/causalDocumentInbox.test.ts +++ b/src/editable-lab/runtime/causalDocumentInbox.test.ts @@ -21,11 +21,11 @@ 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", + schema: "interactive-os.editable-document@3", id: "causal-lab-test", blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, + { id: "alpha", marks: {}, type: "paragraph", text: "abcdef" }, + { id: "beta", marks: {}, type: "paragraph", text: "second" }, ], }); const root = window.document.createElement("div"); @@ -75,11 +75,11 @@ describe("editable lab causal inbox", () => { it("does not retry a recovered pin until the browser composition ends", async () => { vi.useFakeTimers(); const document = createEditableDocument({ - schema: "interactive-os.editable-document@2", + schema: "interactive-os.editable-document@3", id: "causal-recovery-test", blocks: [ - { id: "alpha", type: "paragraph", text: "abcdef" }, - { id: "beta", type: "paragraph", text: "second" }, + { id: "alpha", marks: {}, type: "paragraph", text: "abcdef" }, + { id: "beta", marks: {}, type: "paragraph", text: "second" }, ], }); const root = window.document.createElement("div"); diff --git a/src/editable-lab/ui/JsonEditableDemo.tsx b/src/editable-lab/ui/JsonEditableDemo.tsx index d4ef8d5..7029741 100644 --- a/src/editable-lab/ui/JsonEditableDemo.tsx +++ b/src/editable-lab/ui/JsonEditableDemo.tsx @@ -7,13 +7,18 @@ import { mountJsonEditable, } from "@interactive-os/editable"; import { + Bold, + Code, Code2, Heading1, + Italic, Pilcrow, Quote, Redo2, RefreshCw, RotateCcw, + Strikethrough, + Underline, Undo2, } from "lucide-react"; import { @@ -29,6 +34,7 @@ import { createEditableCausalInbox } from "../runtime/causalDocumentInbox"; import { exposeEditableLab } from "../runtime/testHarness"; type EditableCausalInbox = ReturnType; +type InlineMark = keyof EditorSnapshot["formatting"]["marks"]; export function JsonEditableDemo() { const document = useMemo( @@ -41,6 +47,8 @@ export function JsonEditableDemo() { const causalTracerSequenceRef = useRef(0); const [snapshot, setSnapshot] = useState(null); const [lastFault, setLastFault] = useState(null); + const formatting = snapshot?.formatting; + const history = snapshot?.history; useEffect(() => { const root = rootRef.current; @@ -78,6 +86,10 @@ export function JsonEditableDemo() { editorRef.current?.dispatch({ type: "setBlockType", blockType }); }, []); + const toggleInlineMark = useCallback((mark: InlineMark) => { + editorRef.current?.dispatch({ type: "toggleInlineMark", mark }); + }, []); + const updateOtherBlock = useCallback(() => { const editor = editorRef.current; const currentSnapshot = editor?.getSnapshot(); @@ -155,6 +167,7 @@ export function JsonEditableDemo() { path: "/blocks/0", value: { id: `causal-local-${sequence}`, + marks: {}, type: "paragraph", text: `로컬 선행 변경 ${sequence}`, }, @@ -197,67 +210,120 @@ export function JsonEditableDemo() { >
event.preventDefault()} + role="toolbar" > setBlockType("paragraph")} + pressed={formatting?.blockType === "paragraph"} + shortcutLabel="Mod+Alt+0" > - + - setBlockType("heading")}> - + setBlockType("heading")} + pressed={formatting?.blockType === "heading"} + shortcutLabel="Mod+Alt+1" + > + - setBlockType("quote")}> - + setBlockType("quote")} + pressed={formatting?.blockType === "quote"} + shortcutLabel="Mod+Shift+B" + > + - setBlockType("code")}> - + setBlockType("code")} + pressed={formatting?.blockType === "code"} + shortcutLabel="Mod+Alt+C" + > +