From c08d4896acb4b93bcf40906a2aa25ad8971fb98f 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: Wed, 24 Jun 2026 15:52:59 +0900 Subject: [PATCH 1/4] Add headless contenteditable core --- apps/site/src/generated/repo-catalog.ts | 24 +- docs/generated/extensions-catalog.md | 2 +- docs/generated/repo-catalog.json | 24 +- packages/contenteditable-web/README.md | 39 +- packages/contenteditable-web/src/clipboard.ts | 60 +-- packages/contenteditable-web/src/core.ts | 480 ++++++++++++++++++ packages/contenteditable-web/src/create.ts | 340 ++++--------- packages/contenteditable-web/src/fragment.ts | 56 ++ packages/contenteditable-web/src/index.ts | 10 + packages/contenteditable-web/src/types.ts | 37 +- .../tests/contenteditable-core.test.ts | 195 +++++++ 11 files changed, 934 insertions(+), 333 deletions(-) create mode 100644 packages/contenteditable-web/src/core.ts create mode 100644 packages/contenteditable-web/src/fragment.ts create mode 100644 packages/contenteditable-web/tests/contenteditable-core.test.ts diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index 90b6f26a..dc5ce2b0 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -211,18 +211,26 @@ export const repoCatalog = { "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCore", + "ContentEditableCoreEvent", + "ContentEditableCoreOptions", + "ContentEditableCoreResult", "ContentEditableError", "ContentEditableErrorCode", "ContentEditableFlushOptions", + "ContentEditableHistoryCommand", + "ContentEditableObservationReader", + "ContentEditableTextPoint", "ContentEditableUpdate", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 21, "keywords": [ "@interactive-os/json-document", "contenteditable", @@ -1115,18 +1123,26 @@ export const repoCatalog = { "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCore", + "ContentEditableCoreEvent", + "ContentEditableCoreOptions", + "ContentEditableCoreResult", "ContentEditableError", "ContentEditableErrorCode", "ContentEditableFlushOptions", + "ContentEditableHistoryCommand", + "ContentEditableObservationReader", + "ContentEditableTextPoint", "ContentEditableUpdate", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 21, "keywords": [ "@interactive-os/json-document", "contenteditable", diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index a93afe5d..1a52bb66 100644 --- a/docs/generated/extensions-catalog.md +++ b/docs/generated/extensions-catalog.md @@ -13,7 +13,7 @@ Official extensions: 19 | `@interactive-os/json-document-collection` | 9 | edit ordered JSON arrays with item-level commands | database collections or rendered list UI | Official headless collection editing extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-comments` | 14 | anchor review comments to document structure | comment UI, moderation, or author storage | Official headless comments extension for review notes anchored to `@interactive-os/json-document` documents. | | `@interactive-os/json-document-contenteditable-react` | 4 | wrap the contenteditable web adapter with React render and selection restore timing | rendering policy, editor commands, or non-React hosts | Thin React hook for `@interactive-os/json-document-contenteditable-web`. | -| `@interactive-os/json-document-contenteditable-web` | 13 | bind text surfaces to DOM contenteditable selection, IME input, and clipboard events | editor block semantics, toolbar commands, React rendering, or rich text schema policy | Official DOM adapter for using `@interactive-os/json-document` text surfaces with browser `contenteditable`. | +| `@interactive-os/json-document-contenteditable-web` | 21 | bind text surfaces to DOM contenteditable selection, IME input, and clipboard events | editor block semantics, toolbar commands, React rendering, or rich text schema policy | Official DOM adapter for using `@interactive-os/json-document` text surfaces with browser `contenteditable`. | | `@interactive-os/json-document-dirty-state` | 7 | compare a document to a clean baseline | persistence or server save status | Official headless dirty state tracking extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-form-draft` | 15 | hold temporary invalid form input before committing valid JSON | rendered form components | Official headless form draft extension for temporary input that is not ready to enter a schema-valid `@interactive-os/json-document` document. | | `@interactive-os/json-document-grouping` | 15 | group and ungroup selected sibling JSON items | Airtable group-by views | Official extension for structural `group` and `ungroup`. | diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 980822d8..51343a02 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -210,18 +210,26 @@ "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCore", + "ContentEditableCoreEvent", + "ContentEditableCoreOptions", + "ContentEditableCoreResult", "ContentEditableError", "ContentEditableErrorCode", "ContentEditableFlushOptions", + "ContentEditableHistoryCommand", + "ContentEditableObservationReader", + "ContentEditableTextPoint", "ContentEditableUpdate", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 21, "keywords": [ "@interactive-os/json-document", "contenteditable", @@ -1114,18 +1122,26 @@ "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCore", + "ContentEditableCoreEvent", + "ContentEditableCoreOptions", + "ContentEditableCoreResult", "ContentEditableError", "ContentEditableErrorCode", "ContentEditableFlushOptions", + "ContentEditableHistoryCommand", + "ContentEditableObservationReader", + "ContentEditableTextPoint", "ContentEditableUpdate", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 21, "keywords": [ "@interactive-os/json-document", "contenteditable", diff --git a/packages/contenteditable-web/README.md b/packages/contenteditable-web/README.md index 12d51cde..2c58717f 100644 --- a/packages/contenteditable-web/README.md +++ b/packages/contenteditable-web/README.md @@ -3,13 +3,42 @@ Official DOM adapter for using `@interactive-os/json-document` text surfaces with browser `contenteditable`. -This package owns browser-specific behavior: +This package splits contenteditable into a DOM-free core and a very small DOM +adapter. + +The headless core owns browser-independent editing decisions: -- DOM Selection `<->` `SelectionSnap` -- contenteditable native text flush - composition/native input leases -- atom elements counted as one model character -- structured text-surface clipboard fragments +- text-surface mutation planning +- copy, cut, paste, and structured fragments +- atom/range preservation +- undo/redo command dispatch + +The DOM adapter owns only browser I/O: + +- DOM Selection `<->` `SelectionSnap` +- contenteditable text observation +- atom elements counted as one observed model character +- ClipboardEvent read/write +- native event binding + +```ts +import { createContentEditableCore } from "@interactive-os/json-document-contenteditable-web"; + +const core = createContentEditableCore({ + document: doc, + surface, +}); + +const result = core.handle({ + type: "input", + point: { path: "/body", offset: 4 }, +}, { + point: () => ({ path: "/body", offset: 4 }), + text: (path) => path === "/body" ? "Next text" : null, + selection: () => doc.selection?.snapshot() ?? null, +}); +``` ```ts import { createContentEditableAdapter } from "@interactive-os/json-document-contenteditable-web"; diff --git a/packages/contenteditable-web/src/clipboard.ts b/packages/contenteditable-web/src/clipboard.ts index ebfcb17d..7968b14e 100644 --- a/packages/contenteditable-web/src/clipboard.ts +++ b/packages/contenteditable-web/src/clipboard.ts @@ -1,24 +1,12 @@ -import { - type TextSurfaceAtom, - type TextSurfaceFragment, - type TextSurfaceRange, - textSurfaceFragment, -} from "@interactive-os/json-document"; -import type { JSONDocument, SelectionSnap, TextSurface } from "@interactive-os/json-document"; +import type { TextSurfaceFragment } from "@interactive-os/json-document"; import { JSON_DOCUMENT_CONTENTEDITABLE_MIME } from "./constants.js"; +import { isTextSurfaceFragment, plainTextFromFragment } from "./fragment.js"; -export function selectedTextSurfaceFragment( - document: JSONDocument, - selection: SelectionSnap, - surface: TextSurface, -): TextSurfaceFragment | null { - const result = textSurfaceFragment(selection, document.value, surface); - return result.ok && result.fragment.text.length > 0 ? result.fragment : null; -} - -export function plainTextFromFragment(fragment: TextSurfaceFragment): string { - return fragment.text; -} +export { + isTextSurfaceFragment, + plainTextFromFragment, + selectedTextSurfaceFragment, +} from "./fragment.js"; export function writeClipboardFragment( event: ClipboardEvent | undefined, @@ -46,37 +34,3 @@ export function readClipboardFragment( export function readClipboardPlainText(event: ClipboardEvent | undefined): string { return event?.clipboardData?.getData("text/plain") ?? ""; } - -export function isTextSurfaceFragment(value: unknown): value is TextSurfaceFragment { - return ( - isRecord(value) && - typeof value.text === "string" && - isOptionalSidecarRecord(value.atoms, isTextSurfaceAtom) && - isOptionalSidecarRecord(value.ranges, isTextSurfaceRange) - ); -} - -function isOptionalSidecarRecord( - value: unknown, - isEntry: (entry: unknown) => entry is T, -): value is Record | undefined { - if (value === undefined) return true; - if (!isRecord(value)) return false; - return Object.values(value).every(isEntry); -} - -function isTextSurfaceAtom(value: unknown): value is TextSurfaceAtom { - return isRecord(value) && Number.isFinite(value.offset); -} - -function isTextSurfaceRange(value: unknown): value is TextSurfaceRange { - return ( - isRecord(value) && - Number.isFinite(value.start) && - Number.isFinite(value.end) - ); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} diff --git a/packages/contenteditable-web/src/core.ts b/packages/contenteditable-web/src/core.ts new file mode 100644 index 00000000..069d9a6d --- /dev/null +++ b/packages/contenteditable-web/src/core.ts @@ -0,0 +1,480 @@ +import { + replaceTextSurfaceSelection, + syncTextSurfaceMutation, + type JSONDocument, + type JSONPatchOperation, + type Pointer, + type SelectionSnap, + type TextSurface, + type TextSurfaceFragment, +} from "@interactive-os/json-document"; +import { + isTextSurfaceFragment, + selectedTextSurfaceFragment, +} from "./fragment.js"; + +export type TextSurfaceResolver = + | TextSurface + | ((textPath: Pointer) => TextSurface | null); + +export interface ContentEditableTextPoint { + path: Pointer; + offset: number; +} + +export interface ContentEditableObservationReader { + point?(): ContentEditableTextPoint | null; + text(path: Pointer): string | null; + selection(): SelectionSnap | null; +} + +export interface ContentEditableFlushOptions { + label?: string; + mergeKey?: string; +} + +export type ContentEditableHistoryCommand = "undo" | "redo"; + +export type ContentEditableCoreEvent = + | { + type: "beforeinput"; + point: ContentEditableTextPoint | null; + } + | { + type: "compositionstart"; + point: ContentEditableTextPoint | null; + } + | { + type: "compositionend"; + point: ContentEditableTextPoint | null; + } + | { + type: "input"; + point: ContentEditableTextPoint | null; + } + | { + type: "selection"; + selection: SelectionSnap | null; + } + | { + type: "copy"; + } + | { + type: "cut"; + } + | { + type: "paste"; + payload: TextSurfaceFragment | string | null; + } + | { + type: "history"; + command: ContentEditableHistoryCommand; + }; + +export type ContentEditableCoreResult = + | { + ok: true; + kind: "no-change" | "selection" | "text" | "history"; + patch: ReadonlyArray; + selection: SelectionSnap | null; + value: T; + } + | { + ok: true; + kind: "copy" | "cut"; + patch: ReadonlyArray; + selection: SelectionSnap | null; + payload: TextSurfaceFragment; + value: T; + } + | ContentEditableError; + +export type ContentEditableErrorCode = + | "clipboard_unavailable" + | "commit_failed" + | "empty_selection" + | "invalid_payload" + | "missing_text_path"; + +export interface ContentEditableError { + ok: false; + code: ContentEditableErrorCode; + reason: string; +} + +export interface ContentEditableCoreOptions { + document: JSONDocument; + surface: TextSurfaceResolver; +} + +export interface ContentEditableCore { + handle( + event: ContentEditableCoreEvent, + reader: ContentEditableObservationReader, + ): ContentEditableCoreResult; + flush( + reader: ContentEditableObservationReader, + options?: ContentEditableFlushOptions, + ): ContentEditableCoreResult; + syncSelection(selection: SelectionSnap | null): ContentEditableCoreResult; + copy(reader: ContentEditableObservationReader): ContentEditableCoreResult; + cut(reader: ContentEditableObservationReader): ContentEditableCoreResult; + paste( + payload: TextSurfaceFragment | string | null, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableCoreResult; + pasteText( + text: string, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableCoreResult; + pasteFragment( + fragment: TextSurfaceFragment, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableCoreResult; + reset(): void; +} + +type BrowserLease = { + path: Pointer; + phase: "native" | "composing" | "pending-commit"; +}; + +export function createContentEditableCore({ + document, + surface, +}: ContentEditableCoreOptions): ContentEditableCore { + let lease: BrowserLease | null = null; + + const beginLease = ( + point: ContentEditableTextPoint | null, + phase: BrowserLease["phase"] = "native", + ): BrowserLease | null => { + if (point === null) return lease; + if (readDocumentString(document, point.path) === null) return lease; + lease = { path: point.path, phase }; + return lease; + }; + + const flush = ( + reader: ContentEditableObservationReader, + options: ContentEditableFlushOptions = {}, + ): ContentEditableCoreResult => { + const path = lease?.path ?? reader.point?.()?.path ?? null; + if (path === null) { + return syncSelection(reader.selection()); + } + + const previousText = readDocumentString(document, path); + const textSurface = resolveSurface(surface, path); + const nextText = reader.text(path); + if (previousText === null || textSurface === null || nextText === null) { + return { + ok: false, + code: "missing_text_path", + reason: `No text surface found for ${path}.`, + }; + } + + const selectionAfter = + reader.selection() ?? + document.selection?.snapshot() ?? + null; + + const planned = syncTextSurfaceMutation( + document.value, + textSurface, + previousText, + nextText, + ); + if (!planned.ok) { + return { + ok: false, + code: "invalid_payload", + reason: planned.reason, + }; + } + + if (planned.patch.length === 0) { + if (selectionAfter !== null) document.selection?.restore(selectionAfter); + lease = null; + return { + ok: true, + kind: "selection", + patch: [], + selection: selectionAfter, + value: document.value, + }; + } + + const commit = document.commit(planned.patch, { + label: options.label ?? "contenteditable text", + origin: "contenteditable", + ...(options.mergeKey === undefined ? {} : { mergeKey: options.mergeKey }), + ...(selectionAfter === null ? {} : { selectionAfter }), + }); + if (!commit.ok) { + return { + ok: false, + code: "commit_failed", + reason: commit.reason ?? commit.code, + }; + } + lease = null; + return { + ok: true, + kind: "text", + patch: planned.patch, + selection: selectionAfter, + value: document.value, + }; + }; + + const syncSelection = (selection: SelectionSnap | null): ContentEditableCoreResult => { + if (selection !== null) document.selection?.restore(selection); + return { + ok: true, + kind: selection === null ? "no-change" : "selection", + patch: [], + selection, + value: document.value, + }; + }; + + const copy = (reader: ContentEditableObservationReader): ContentEditableCoreResult => { + const flushed = flush(reader, { label: "copy selection" }); + if (!flushed.ok) return flushed; + const selection = document.selection?.snapshot() ?? null; + const textSurface = surfaceFromSelection(surface, selection); + if (selection === null || textSurface === null) { + return emptySelectionError("No text surface selection was copied."); + } + + const fragment = selectedTextSurfaceFragment(document, selection, textSurface); + if (fragment === null) { + return emptySelectionError("No text or atom range is selected."); + } + + document.clipboard.write(fragment, { trustedPayload: true }); + return { + ok: true, + kind: "copy", + patch: [], + selection, + payload: fragment, + value: document.value, + }; + }; + + const cut = (reader: ContentEditableObservationReader): ContentEditableCoreResult => { + const copyResult = copy(reader); + if (!copyResult.ok) return copyResult; + if (copyResult.kind !== "copy") return noChange(document); + return replaceSelection("", reader, undefined, "cut text", "cut", copyResult.payload); + }; + + const pasteFragment = ( + fragment: TextSurfaceFragment, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableCoreResult => + replaceSelection(fragment, reader, selection, "paste text"); + + const pasteText = ( + text: string, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableCoreResult => + replaceSelection(text, reader, selection, "paste text"); + + const paste = ( + payload: TextSurfaceFragment | string | null, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableCoreResult => { + if (typeof payload === "string") return pasteText(payload, reader, selection); + if (payload !== null) return pasteFragment(payload, reader, selection); + + const fragment = readDocumentClipboardFragment(document); + if (fragment !== null) return pasteFragment(fragment, reader, selection); + return { + ok: false, + code: "clipboard_unavailable", + reason: "No paste payload was available.", + }; + }; + + const handle = ( + event: ContentEditableCoreEvent, + reader: ContentEditableObservationReader, + ): ContentEditableCoreResult => { + if (event.type === "beforeinput") { + beginLease(event.point, "native"); + return noChange(document); + } + if (event.type === "compositionstart") { + beginLease(event.point, "composing"); + return noChange(document); + } + if (event.type === "compositionend") { + if (lease !== null) lease = { ...lease, phase: "pending-commit" }; + return flush(reader, { label: "composition commit" }); + } + if (event.type === "input") { + beginLease(event.point, lease?.phase === "pending-commit" ? "pending-commit" : "native"); + return flush(reader, { + label: "native input", + ...(lease === null ? {} : { mergeKey: `native:${lease.path}` }), + }); + } + if (event.type === "selection") { + return syncSelection(event.selection); + } + if (event.type === "copy") { + return copy(reader); + } + if (event.type === "cut") { + return cut(reader); + } + if (event.type === "paste") { + return paste(event.payload, reader); + } + if (event.type === "history") { + const result = event.command === "undo" ? document.undo() : document.redo(); + return result.ok + ? { + ok: true, + kind: "history", + patch: [], + selection: document.selection?.snapshot() ?? null, + value: document.value, + } + : { ok: false, code: "commit_failed", reason: result.reason ?? result.code }; + } + return noChange(document); + }; + + function replaceSelection( + replacement: string | TextSurfaceFragment, + reader: ContentEditableObservationReader, + selection: SelectionSnap | null | undefined, + label: string, + kind: "cut" | "text" = "text", + payload?: TextSurfaceFragment, + ): ContentEditableCoreResult { + const flushed = flush(reader, { label: "flush before text surface replace" }); + if (!flushed.ok) return flushed; + const targetSelection = + selection === undefined + ? document.selection?.snapshot() ?? null + : selection; + if (targetSelection !== null) document.selection?.restore(targetSelection); + const textSurface = surfaceFromSelection(surface, targetSelection); + if (targetSelection === null || textSurface === null) { + return emptySelectionError("No text surface selection is available."); + } + const planned = replaceTextSurfaceSelection( + targetSelection, + document.value, + textSurface, + replacement, + ); + if (!planned.ok) { + return { ok: false, code: "invalid_payload", reason: planned.reason }; + } + const commit = document.commit(planned.patch, { + label, + origin: "contenteditable", + selectionAfter: planned.selectionAfter, + }); + if (!commit.ok) { + return { ok: false, code: "commit_failed", reason: commit.reason ?? commit.code }; + } + return kind === "cut" && payload !== undefined + ? { + ok: true, + kind: "cut", + patch: planned.patch, + selection: document.selection?.snapshot() ?? null, + payload, + value: document.value, + } + : { + ok: true, + kind: "text", + patch: planned.patch, + selection: document.selection?.snapshot() ?? null, + value: document.value, + }; + } + + return { + handle, + flush, + syncSelection, + copy, + cut, + paste, + pasteFragment, + pasteText, + reset() { + lease = null; + }, + }; +} + +function resolveSurface( + resolver: TextSurfaceResolver, + textPath: Pointer, +): TextSurface | null { + return typeof resolver === "function" ? resolver(textPath) : resolver; +} + +function surfaceFromSelection( + resolver: TextSurfaceResolver, + selection: SelectionSnap | null, +): TextSurface | null { + const path = textPathFromSelection(selection); + return path === null ? null : resolveSurface(resolver, path); +} + +function textPathFromSelection(selection: SelectionSnap | null): Pointer | null { + const range = selection?.selectionRanges[selection.primaryIndex]; + if ( + range === undefined || + typeof range.anchor === "string" || + typeof range.focus === "string" || + range.anchor.path !== range.focus.path + ) { + return null; + } + return range.anchor.path; +} + +function readDocumentString(document: JSONDocument, path: Pointer): string | null { + const result = document.at(path); + return result.ok && typeof result.value === "string" ? result.value : null; +} + +function readDocumentClipboardFragment( + document: JSONDocument, +): TextSurfaceFragment | null { + const result = document.clipboard.read(); + return result.ok && isTextSurfaceFragment(result.payload) ? result.payload : null; +} + +function noChange(document: JSONDocument): ContentEditableCoreResult { + return { + ok: true, + kind: "no-change", + patch: [], + selection: document.selection?.snapshot() ?? null, + value: document.value, + }; +} + +function emptySelectionError(reason: string): ContentEditableError { + return { ok: false, code: "empty_selection", reason }; +} diff --git a/packages/contenteditable-web/src/create.ts b/packages/contenteditable-web/src/create.ts index 0d9021a0..614b0011 100644 --- a/packages/contenteditable-web/src/create.ts +++ b/packages/contenteditable-web/src/create.ts @@ -1,11 +1,8 @@ -import { - replaceTextSurfaceSelection, - syncTextSurfaceMutation, - type JSONDocument, - type Pointer, - type SelectionSnap, - type TextSurface, - type TextSurfaceFragment, +import type { + JSONDocument, + Pointer, + SelectionSnap, + TextSurfaceFragment, } from "@interactive-os/json-document"; import { JSON_ATOM_ATTRIBUTE, @@ -13,17 +10,20 @@ import { JSON_TEXT_ATTRIBUTE, } from "./constants.js"; import { - isTextSurfaceFragment, readClipboardFragment, readClipboardPlainText, - selectedTextSurfaceFragment, writeClipboardFragment, } from "./clipboard.js"; +import { + createContentEditableCore, + type ContentEditableCoreResult, + type ContentEditableHistoryCommand, + type ContentEditableObservationReader, +} from "./core.js"; import { editableTextContent, findElementByAttribute } from "./domText.js"; import { restoreDOMSelection, selectionFromDOM, - textPathFromSelection, textPointFromDOMSelection, } from "./selection.js"; import type { @@ -32,14 +32,8 @@ import type { ContentEditableClipboardResult, ContentEditableFlushOptions, ContentEditableUpdate, - TextSurfaceResolver, } from "./types.js"; -type BrowserLease = { - path: Pointer; - phase: "native" | "composing" | "pending-commit"; -}; - export function createContentEditableAdapter({ atomAttribute = JSON_ATOM_ATTRIBUTE, clipboardMime = JSON_DOCUMENT_CONTENTEDITABLE_MIME, @@ -48,219 +42,110 @@ export function createContentEditableAdapter({ surface, textAttribute = JSON_TEXT_ATTRIBUTE, }: ContentEditableAdapterOptions): ContentEditableAdapter { - let lease: BrowserLease | null = null; + const core = createContentEditableCore({ document, surface }); const textElementForPath = (path: Pointer): HTMLElement | null => findElementByAttribute(root, textAttribute, path); - const beginLeaseFromDOM = ( - phase: BrowserLease["phase"] = "native", - ): BrowserLease | null => { - const point = textPointFromDOMSelection(root, textAttribute, atomAttribute); - if (point === null) return lease; - if (readDocumentString(document, point.path) === null) return lease; - lease = { path: point.path, phase }; - return lease; + const domSelection = (): SelectionSnap | null => + selectionFromDOM(root, textAttribute, atomAttribute); + + const point = () => textPointFromDOMSelection(root, textAttribute, atomAttribute); + + const reader: ContentEditableObservationReader = { + point, + text(path) { + const textElement = textElementForPath(path); + return textElement === null ? null : editableTextContent(textElement, atomAttribute); + }, + selection: domSelection, }; const syncSelectionFromDOM = (): SelectionSnap | null => { - const selection = selectionFromDOM(root, textAttribute, atomAttribute); - if (selection !== null) document.selection?.restore(selection); + const selection = domSelection(); + core.syncSelection(selection); return selection; }; - const flush = (options: ContentEditableFlushOptions = {}): ContentEditableUpdate => { - const path = - lease?.path ?? - textPointFromDOMSelection(root, textAttribute, atomAttribute)?.path ?? - null; - if (path === null) { - const selection = syncSelectionFromDOM(); - return { - ok: true, - kind: selection === null ? "no-change" : "selection", - patch: [], - selection, - }; - } - - const textElement = textElementForPath(path); - if (textElement === null) { - return { - ok: false, - code: "missing_text_path", - reason: `No text element found for ${path}.`, - }; - } - - const previousText = readDocumentString(document, path); - const textSurface = resolveSurface(surface, path); - if (previousText === null || textSurface === null) { - return { - ok: false, - code: "missing_text_path", - reason: `No text surface found for ${path}.`, - }; - } - - const nextText = editableTextContent(textElement, atomAttribute); - const selectionAfter = - selectionFromDOM(root, textAttribute, atomAttribute) ?? - document.selection?.snapshot() ?? - null; - - const planned = syncTextSurfaceMutation( - document.value, - textSurface, - previousText, - nextText, - ); - if (!planned.ok) { - return { - ok: false, - code: "invalid_payload", - reason: planned.reason, - }; - } - - if (planned.patch.length === 0) { - if (selectionAfter !== null) document.selection?.restore(selectionAfter); - lease = null; - return { ok: true, kind: "selection", patch: [], selection: selectionAfter }; - } - - const commit = document.commit(planned.patch, { - label: options.label ?? "contenteditable text", - origin: "contenteditable", - ...(options.mergeKey === undefined ? {} : { mergeKey: options.mergeKey }), - ...(selectionAfter === null ? {} : { selectionAfter }), - }); - if (!commit.ok) { - return { - ok: false, - code: "commit_failed", - reason: commit.reason ?? commit.code, - }; - } - lease = null; - return { - ok: true, - kind: "text", - patch: planned.patch, - selection: selectionAfter, - }; - }; + const flush = (options: ContentEditableFlushOptions = {}): ContentEditableUpdate => + coreResultToUpdate(core.flush(reader, options)); const copy = (event?: ClipboardEvent): ContentEditableClipboardResult => { - flush({ label: "copy selection" }); - const selection = document.selection?.snapshot() ?? null; - const textSurface = surfaceFromSelection(surface, selection); - if (selection === null || textSurface === null) { - return emptySelectionError("No text surface selection was copied."); - } - - const fragment = selectedTextSurfaceFragment(document, selection, textSurface); - if (fragment === null) { - return emptySelectionError("No text or atom range is selected."); + const result = core.copy(reader); + if (result.ok && result.kind === "copy") { + writeClipboardFragment(event, result.payload, clipboardMime); } - - writeClipboardFragment(event, fragment, clipboardMime); - document.clipboard.write(fragment, { trustedPayload: true }); - return { ok: true, value: document.value }; + return coreResultToClipboardResult(result); }; const cut = (event?: ClipboardEvent): ContentEditableClipboardResult => { - const copyResult = copy(event); - if (!copyResult.ok) return copyResult; - const selection = document.selection?.snapshot() ?? null; - return replaceSelection("", selection, "cut text"); + const result = core.cut(reader); + if (result.ok && result.kind === "cut") { + writeClipboardFragment(event, result.payload, clipboardMime); + } + return coreResultToClipboardResult(result); }; const pasteFragment = ( fragment: TextSurfaceFragment, selection = document.selection?.snapshot() ?? null, ): ContentEditableClipboardResult => - replaceSelection(fragment, selection, "paste text"); + coreResultToClipboardResult(core.pasteFragment(fragment, reader, selection)); const pasteText = ( text: string, selection = document.selection?.snapshot() ?? null, ): ContentEditableClipboardResult => - replaceSelection(text, selection, "paste text"); + coreResultToClipboardResult(core.pasteText(text, reader, selection)); - const paste = (event?: ClipboardEvent): ContentEditableClipboardResult => { - const fragment = - readClipboardFragment(event, clipboardMime) ?? - readDocumentClipboardFragment(document); - if (fragment !== null) return pasteFragment(fragment); - - const text = readClipboardPlainText(event); - if (text.length > 0) return pasteText(text); - return { - ok: false, - code: "clipboard_unavailable", - reason: "No paste payload was available.", - }; - }; + const paste = (event?: ClipboardEvent): ContentEditableClipboardResult => + coreResultToClipboardResult(core.paste(readClipboardPayload(event, clipboardMime), reader)); const handle = (event: Event): ContentEditableUpdate => { if (event.type === "beforeinput") { - beginLeaseFromDOM("native"); - return noChange(document); + return coreResultToUpdate(core.handle({ type: "beforeinput", point: point() }, reader)); } if (event.type === "compositionstart") { - beginLeaseFromDOM("composing"); - return noChange(document); + return coreResultToUpdate(core.handle({ type: "compositionstart", point: point() }, reader)); } if (event.type === "compositionend") { - if (lease !== null) lease = { ...lease, phase: "pending-commit" }; - return flush({ label: "composition commit" }); + return coreResultToUpdate(core.handle({ type: "compositionend", point: point() }, reader)); } if (event.type === "input") { - beginLeaseFromDOM(lease?.phase === "pending-commit" ? "pending-commit" : "native"); - return flush({ - label: "native input", - ...(lease === null ? {} : { mergeKey: `native:${lease.path}` }), - }); + return coreResultToUpdate(core.handle({ type: "input", point: point() }, reader)); } if (event.type === "selectionchange" || event.type === "select") { - const selection = syncSelectionFromDOM(); - return { - ok: true, - kind: selection === null ? "no-change" : "selection", - patch: [], - selection, - }; + return coreResultToUpdate(core.handle({ type: "selection", selection: domSelection() }, reader)); } if (event.type === "copy" && isClipboardEventLike(event)) { event.preventDefault(); - const result = copy(event); - return clipboardResultToUpdate(result, document); + return coreResultToUpdate(copyCoreAndWrite(event)); } if (event.type === "cut" && isClipboardEventLike(event)) { event.preventDefault(); - const result = cut(event); - return clipboardResultToUpdate(result, document); + return coreResultToUpdate(cutCoreAndWrite(event)); } if (event.type === "paste" && isClipboardEventLike(event)) { event.preventDefault(); - const result = paste(event); - return clipboardResultToUpdate(result, document); + return coreResultToUpdate(core.handle({ + type: "paste", + payload: readClipboardPayload(event, clipboardMime), + }, reader)); } if (event.type === "keydown" && isKeyboardEventLike(event)) { const command = historyCommandFromKey(event); if (command !== null) { event.preventDefault(); - const result = command === "undo" ? document.undo() : document.redo(); - restoreDOMSelection( - root, - document.selection?.snapshot(), - textAttribute, - atomAttribute, - ); - return result.ok - ? { ok: true, kind: "text", patch: [], selection: document.selection?.snapshot() ?? null } - : { ok: false, code: "commit_failed", reason: result.reason ?? result.code }; + const result = core.handle({ type: "history", command }, reader); + if (result.ok) { + restoreDOMSelection( + root, + document.selection?.snapshot(), + textAttribute, + atomAttribute, + ); + } + return coreResultToUpdate(result); } } return noChange(document); @@ -296,34 +181,20 @@ export function createContentEditableAdapter({ }; }; - function replaceSelection( - replacement: string | TextSurfaceFragment, - selection: SelectionSnap | null, - label: string, - ): ContentEditableClipboardResult { - flush({ label: "flush before text surface replace" }); - if (selection !== null) document.selection?.restore(selection); - const textSurface = surfaceFromSelection(surface, selection); - if (selection === null || textSurface === null) { - return emptySelectionError("No text surface selection is available."); + function copyCoreAndWrite(event?: ClipboardEvent): ContentEditableCoreResult { + const result = core.handle({ type: "copy" }, reader); + if (result.ok && result.kind === "copy") { + writeClipboardFragment(event, result.payload, clipboardMime); } - const planned = replaceTextSurfaceSelection( - selection, - document.value, - textSurface, - replacement, - ); - if (!planned.ok) { - return { ok: false, code: "invalid_payload", reason: planned.reason }; + return result; + } + + function cutCoreAndWrite(event?: ClipboardEvent): ContentEditableCoreResult { + const result = core.handle({ type: "cut" }, reader); + if (result.ok && result.kind === "cut") { + writeClipboardFragment(event, result.payload, clipboardMime); } - const commit = document.commit(planned.patch, { - label, - origin: "contenteditable", - selectionAfter: planned.selectionAfter, - }); - return commit.ok - ? { ok: true, value: document.value } - : { ok: false, code: "commit_failed", reason: commit.reason ?? commit.code }; + return result; } return { @@ -340,36 +211,31 @@ export function createContentEditableAdapter({ pasteFragment, pasteText, reset() { - lease = null; + core.reset(); }, }; } -function resolveSurface( - resolver: TextSurfaceResolver, - textPath: Pointer, -): TextSurface | null { - return typeof resolver === "function" ? resolver(textPath) : resolver; +function readClipboardPayload( + event: ClipboardEvent | undefined, + clipboardMime: string, +): TextSurfaceFragment | string | null { + const fragment = readClipboardFragment(event, clipboardMime); + if (fragment !== null) return fragment; + const text = readClipboardPlainText(event); + return text.length === 0 ? null : text; } -function surfaceFromSelection( - resolver: TextSurfaceResolver, - selection: SelectionSnap | null, -): TextSurface | null { - const path = textPathFromSelection(selection); - return path === null ? null : resolveSurface(resolver, path); -} - -function readDocumentString(document: JSONDocument, path: Pointer): string | null { - const result = document.at(path); - return result.ok && typeof result.value === "string" ? result.value : null; -} - -function readDocumentClipboardFragment( - document: JSONDocument, -): TextSurfaceFragment | null { - const result = document.clipboard.read(); - return result.ok && isTextSurfaceFragment(result.payload) ? result.payload : null; +function coreResultToUpdate(result: ContentEditableCoreResult): ContentEditableUpdate { + if (!result.ok) return result; + return { + ok: true, + kind: result.kind === "history" || result.kind === "copy" || result.kind === "cut" + ? "text" + : result.kind, + patch: result.patch, + selection: result.selection, + }; } function noChange(document: JSONDocument): ContentEditableUpdate { @@ -381,25 +247,15 @@ function noChange(document: JSONDocument): ContentEditableUpdate { }; } -function emptySelectionError(reason: string): ContentEditableClipboardResult { - return { ok: false, code: "empty_selection", reason }; -} - -function clipboardResultToUpdate( - result: ContentEditableClipboardResult, - document: JSONDocument, -): ContentEditableUpdate { +function coreResultToClipboardResult( + result: ContentEditableCoreResult, +): ContentEditableClipboardResult { return result.ok - ? { - ok: true, - kind: "text", - patch: document.lastPatch, - selection: document.selection?.snapshot() ?? null, - } + ? { ok: true, value: result.value } : result; } -function historyCommandFromKey(event: KeyboardEvent): "undo" | "redo" | null { +function historyCommandFromKey(event: KeyboardEvent): ContentEditableHistoryCommand | null { const key = event.key.toLowerCase(); if (!(event.metaKey || event.ctrlKey) || event.altKey) return null; if (key === "z" && !event.shiftKey) return "undo"; diff --git a/packages/contenteditable-web/src/fragment.ts b/packages/contenteditable-web/src/fragment.ts new file mode 100644 index 00000000..680a66ae --- /dev/null +++ b/packages/contenteditable-web/src/fragment.ts @@ -0,0 +1,56 @@ +import { + type JSONDocument, + type SelectionSnap, + type TextSurface, + type TextSurfaceAtom, + type TextSurfaceFragment, + type TextSurfaceRange, + textSurfaceFragment, +} from "@interactive-os/json-document"; + +export function selectedTextSurfaceFragment( + document: JSONDocument, + selection: SelectionSnap, + surface: TextSurface, +): TextSurfaceFragment | null { + const result = textSurfaceFragment(selection, document.value, surface); + return result.ok && result.fragment.text.length > 0 ? result.fragment : null; +} + +export function plainTextFromFragment(fragment: TextSurfaceFragment): string { + return fragment.text; +} + +export function isTextSurfaceFragment(value: unknown): value is TextSurfaceFragment { + return ( + isRecord(value) && + typeof value.text === "string" && + isOptionalSidecarRecord(value.atoms, isTextSurfaceAtom) && + isOptionalSidecarRecord(value.ranges, isTextSurfaceRange) + ); +} + +function isOptionalSidecarRecord( + value: unknown, + isEntry: (entry: unknown) => entry is T, +): value is Record | undefined { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return Object.values(value).every(isEntry); +} + +function isTextSurfaceAtom(value: unknown): value is TextSurfaceAtom { + return isRecord(value) && Number.isFinite(value.offset); +} + +function isTextSurfaceRange(value: unknown): value is TextSurfaceRange { + return ( + isRecord(value) && + Number.isFinite(value.start) && + Number.isFinite(value.end) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/contenteditable-web/src/index.ts b/packages/contenteditable-web/src/index.ts index ef9f8f8f..50dc854c 100644 --- a/packages/contenteditable-web/src/index.ts +++ b/packages/contenteditable-web/src/index.ts @@ -4,6 +4,7 @@ export { JSON_DOCUMENT_CONTENTEDITABLE_MIME, JSON_TEXT_ATTRIBUTE, } from "./constants.js"; +export { createContentEditableCore } from "./core.js"; export { createContentEditableAdapter } from "./create.js"; export type { ContentEditableAdapter, @@ -15,3 +16,12 @@ export type { ContentEditableUpdate, TextSurfaceResolver, } from "./types.js"; +export type { + ContentEditableCore, + ContentEditableCoreEvent, + ContentEditableCoreOptions, + ContentEditableCoreResult, + ContentEditableHistoryCommand, + ContentEditableObservationReader, + ContentEditableTextPoint, +} from "./core.js"; diff --git a/packages/contenteditable-web/src/types.ts b/packages/contenteditable-web/src/types.ts index 5f946b81..37d5cdae 100644 --- a/packages/contenteditable-web/src/types.ts +++ b/packages/contenteditable-web/src/types.ts @@ -1,15 +1,22 @@ import type { JSONDocument, JSONPatchOperation, - Pointer, SelectionSnap, - TextSurface, TextSurfaceFragment, } from "@interactive-os/json-document"; - -export type TextSurfaceResolver = - | TextSurface - | ((textPath: Pointer) => TextSurface | null); +import type { + ContentEditableError, + ContentEditableErrorCode, + ContentEditableFlushOptions, + TextSurfaceResolver, +} from "./core.js"; + +export type { + ContentEditableError, + ContentEditableErrorCode, + ContentEditableFlushOptions, + TextSurfaceResolver, +} from "./core.js"; export interface ContentEditableAdapterOptions { document: JSONDocument; @@ -36,24 +43,6 @@ export type ContentEditableClipboardResult = } | ContentEditableError; -export type ContentEditableErrorCode = - | "clipboard_unavailable" - | "commit_failed" - | "empty_selection" - | "invalid_payload" - | "missing_text_path"; - -export interface ContentEditableError { - ok: false; - code: ContentEditableErrorCode; - reason: string; -} - -export interface ContentEditableFlushOptions { - label?: string; - mergeKey?: string; -} - export interface ContentEditableAdapter { bind(): () => void; handle(event: Event): ContentEditableUpdate; diff --git a/packages/contenteditable-web/tests/contenteditable-core.test.ts b/packages/contenteditable-web/tests/contenteditable-core.test.ts new file mode 100644 index 00000000..18a3faa0 --- /dev/null +++ b/packages/contenteditable-web/tests/contenteditable-core.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node + +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import * as z from "zod"; + +import { createJSONDocument, type SelectionSnap, type TextSurface } from "@interactive-os/json-document"; +import { + createContentEditableCore, + type ContentEditableObservationReader, + type ContentEditableTextPoint, +} from "../src/core.js"; +import { JSON_ATOM_REPLACEMENT } from "../src/constants.js"; + +const AtomSchema = z.object({ + type: z.literal("mention"), + label: z.string(), + offset: z.number().int().nonnegative(), +}); + +const MarkSchema = z.object({ + type: z.literal("bold"), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), +}); + +const Schema = z.object({ + body: z.string(), + atoms: z.record(z.string(), AtomSchema), + marks: z.record(z.string(), MarkSchema), +}); + +const surface: TextSurface = { + textPath: "/body", + atomsPath: "/atoms", + rangesPath: "/marks", +}; + +function createDoc(value: z.output = { + body: `Plain text ${JSON_ATOM_REPLACEMENT}`, + atoms: { + ada: { type: "mention" as const, label: "@Ada", offset: 11 }, + }, + marks: {}, +}) { + return createJSONDocument(Schema, value, { + history: 20, + selection: true, + trustedInitial: true, + }); +} + +function selection(anchor: number, focus: number): SelectionSnap { + const anchorPoint = { path: "/body", offset: anchor }; + const focusPoint = { path: "/body", offset: focus }; + return { + selectedPointers: ["/body"], + selectionRanges: [{ anchor: anchorPoint, focus: focusPoint }], + primaryIndex: 0, + anchor: anchorPoint, + focus: focusPoint, + }; +} + +function createReader(initialText: string, initialSelection: SelectionSnap | null = null) { + let text = initialText; + let currentSelection = initialSelection; + const currentPoint: ContentEditableTextPoint = { path: "/body", offset: 0 }; + const reader: ContentEditableObservationReader = { + point: () => currentPoint, + text: (path) => path === "/body" ? text : null, + selection: () => currentSelection, + }; + return { + reader, + setText(next: string) { + text = next; + }, + setSelection(next: SelectionSnap | null) { + currentSelection = next; + }, + setOffset(offset: number) { + currentPoint.offset = offset; + }, + }; +} + +describe("contenteditable headless core", () => { + test("keeps the core source free of DOM APIs", () => { + for (const path of ["../src/core.ts", "../src/fragment.ts"]) { + const source = readFileSync(new URL(path, import.meta.url), "utf8"); + + expect(source).not.toMatch( + /\b(?:HTMLElement|ClipboardEvent|KeyboardEvent|InputEvent|CompositionEvent|Range|Node|window|ownerDocument|querySelector)\b/, + ); + } + }); + + test("commits native input observations without DOM", () => { + const doc = createDoc(); + const observed = createReader(doc.value.body, selection(5, 5)); + const core = createContentEditableCore({ document: doc, surface }); + + observed.setOffset(5); + core.handle({ type: "beforeinput", point: observed.reader.point?.() ?? null }, observed.reader); + observed.setText(`Plain! text ${JSON_ATOM_REPLACEMENT}`); + observed.setSelection(selection(6, 6)); + + const result = core.handle({ type: "input", point: observed.reader.point?.() ?? null }, observed.reader); + + expect(result).toMatchObject({ ok: true, kind: "text" }); + expect(doc.value.body).toBe(`Plain! text ${JSON_ATOM_REPLACEMENT}`); + expect(doc.value.atoms.ada?.offset).toBe(12); + expect(doc.selection?.focus).toMatchObject({ path: "/body", offset: 6 }); + }); + + test("keeps IME composition out of the document until compositionend", () => { + const doc = createDoc({ body: "", atoms: {}, marks: {} }); + const observed = createReader("", selection(0, 0)); + const core = createContentEditableCore({ document: doc, surface }); + + core.handle({ type: "compositionstart", point: observed.reader.point?.() ?? null }, observed.reader); + observed.setText("한"); + observed.setSelection(selection(1, 1)); + + expect(doc.value.body).toBe(""); + + const result = core.handle({ type: "compositionend", point: observed.reader.point?.() ?? null }, observed.reader); + + expect(result).toMatchObject({ ok: true, kind: "text" }); + expect(doc.value.body).toBe("한"); + expect(doc.selection?.focus).toMatchObject({ path: "/body", offset: 1 }); + }); + + test("copies, cuts, and pastes structured fragments without DOM", () => { + const doc = createDoc({ + body: `Hi ${JSON_ATOM_REPLACEMENT}`, + atoms: { + ada: { type: "mention", label: "@Ada", offset: 3 }, + }, + marks: { + bold: { type: "bold", start: 0, end: 2 }, + }, + }); + const selected = selection(0, 4); + const observed = createReader(doc.value.body, selected); + const core = createContentEditableCore({ document: doc, surface }); + + const copied = core.copy(observed.reader); + + expect(copied).toMatchObject({ + ok: true, + kind: "copy", + payload: { + text: `Hi ${JSON_ATOM_REPLACEMENT}`, + atoms: { ada: { offset: 3 } }, + ranges: { bold: { start: 0, end: 2 } }, + }, + }); + if (!copied.ok || copied.kind !== "copy") throw new Error("copy failed"); + + const cut = core.cut(observed.reader); + + expect(cut).toMatchObject({ ok: true, kind: "cut" }); + expect(doc.value).toEqual({ body: "", atoms: {}, marks: {} }); + + observed.setText(doc.value.body); + observed.setSelection(selection(0, 0)); + + const pasted = core.paste(copied.payload, observed.reader); + + expect(pasted).toMatchObject({ ok: true, kind: "text" }); + expect(doc.value).toEqual({ + body: `Hi ${JSON_ATOM_REPLACEMENT}`, + atoms: { + ada: { type: "mention", label: "@Ada", offset: 3 }, + }, + marks: { + bold: { type: "bold", start: 0, end: 2 }, + }, + }); + }); + + test("uses current selection for plain text paste without DOM", () => { + const doc = createDoc({ body: "Plain text", atoms: {}, marks: {} }); + const observed = createReader(doc.value.body, selection(0, 5)); + const core = createContentEditableCore({ document: doc, surface }); + + const pasted = core.paste("Rich", observed.reader); + + expect(pasted).toMatchObject({ ok: true, kind: "text" }); + expect(doc.value.body).toBe("Rich text"); + expect(doc.selection?.focus).toMatchObject({ path: "/body", offset: 4 }); + }); +}); From a3dbe73551cb3af1ef2501ac8ed6a78b5ae96668 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: Wed, 24 Jun 2026 16:13:41 +0900 Subject: [PATCH 2/4] Reduce contenteditable core to commands --- apps/site/src/generated/repo-catalog.ts | 4 +- docs/generated/repo-catalog.json | 4 +- packages/contenteditable-web/README.md | 10 +- packages/contenteditable-web/src/core.ts | 102 ++++++++---------- packages/contenteditable-web/src/create.ts | 39 ++++--- packages/contenteditable-web/src/index.ts | 2 +- .../tests/contenteditable-core.test.ts | 43 ++++---- 7 files changed, 102 insertions(+), 102 deletions(-) diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index dc5ce2b0..b7ac9c3c 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -211,8 +211,8 @@ export const repoCatalog = { "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreEvent", "ContentEditableCoreOptions", "ContentEditableCoreResult", "ContentEditableError", @@ -1123,8 +1123,8 @@ export const repoCatalog = { "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreEvent", "ContentEditableCoreOptions", "ContentEditableCoreResult", "ContentEditableError", diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 51343a02..63270e76 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -210,8 +210,8 @@ "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreEvent", "ContentEditableCoreOptions", "ContentEditableCoreResult", "ContentEditableError", @@ -1122,8 +1122,8 @@ "ContentEditableAdapter", "ContentEditableAdapterOptions", "ContentEditableClipboardResult", + "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreEvent", "ContentEditableCoreOptions", "ContentEditableCoreResult", "ContentEditableError", diff --git a/packages/contenteditable-web/README.md b/packages/contenteditable-web/README.md index 2c58717f..f072320d 100644 --- a/packages/contenteditable-web/README.md +++ b/packages/contenteditable-web/README.md @@ -30,14 +30,14 @@ const core = createContentEditableCore({ surface, }); -const result = core.handle({ - type: "input", - point: { path: "/body", offset: 4 }, -}, { +const reader = { point: () => ({ path: "/body", offset: 4 }), text: (path) => path === "/body" ? "Next text" : null, selection: () => doc.selection?.snapshot() ?? null, -}); +}; + +core.handle({ type: "begin-native-input", point: reader.point() }, reader); +const result = core.handle({ type: "commit-native-input", point: reader.point() }, reader); ``` ```ts diff --git a/packages/contenteditable-web/src/core.ts b/packages/contenteditable-web/src/core.ts index 069d9a6d..d928a32f 100644 --- a/packages/contenteditable-web/src/core.ts +++ b/packages/contenteditable-web/src/core.ts @@ -35,27 +35,31 @@ export interface ContentEditableFlushOptions { export type ContentEditableHistoryCommand = "undo" | "redo"; -export type ContentEditableCoreEvent = +export type ContentEditableCommand = | { - type: "beforeinput"; + type: "begin-native-input"; point: ContentEditableTextPoint | null; } | { - type: "compositionstart"; + type: "commit-native-input"; point: ContentEditableTextPoint | null; } | { - type: "compositionend"; + type: "begin-composition"; point: ContentEditableTextPoint | null; } | { - type: "input"; + type: "commit-composition"; point: ContentEditableTextPoint | null; } | { - type: "selection"; + type: "sync-selection"; selection: SelectionSnap | null; } + | { + type: "flush"; + options?: ContentEditableFlushOptions; + } | { type: "copy"; } @@ -65,6 +69,7 @@ export type ContentEditableCoreEvent = | { type: "paste"; payload: TextSurfaceFragment | string | null; + selection?: SelectionSnap | null; } | { type: "history"; @@ -109,35 +114,13 @@ export interface ContentEditableCoreOptions { export interface ContentEditableCore { handle( - event: ContentEditableCoreEvent, - reader: ContentEditableObservationReader, - ): ContentEditableCoreResult; - flush( + command: ContentEditableCommand, reader: ContentEditableObservationReader, - options?: ContentEditableFlushOptions, - ): ContentEditableCoreResult; - syncSelection(selection: SelectionSnap | null): ContentEditableCoreResult; - copy(reader: ContentEditableObservationReader): ContentEditableCoreResult; - cut(reader: ContentEditableObservationReader): ContentEditableCoreResult; - paste( - payload: TextSurfaceFragment | string | null, - reader: ContentEditableObservationReader, - selection?: SelectionSnap | null, - ): ContentEditableCoreResult; - pasteText( - text: string, - reader: ContentEditableObservationReader, - selection?: SelectionSnap | null, - ): ContentEditableCoreResult; - pasteFragment( - fragment: TextSurfaceFragment, - reader: ContentEditableObservationReader, - selection?: SelectionSnap | null, ): ContentEditableCoreResult; reset(): void; } -type BrowserLease = { +type NativeInputLease = { path: Pointer; phase: "native" | "composing" | "pending-commit"; }; @@ -146,12 +129,12 @@ export function createContentEditableCore({ document, surface, }: ContentEditableCoreOptions): ContentEditableCore { - let lease: BrowserLease | null = null; + let lease: NativeInputLease | null = null; const beginLease = ( point: ContentEditableTextPoint | null, - phase: BrowserLease["phase"] = "native", - ): BrowserLease | null => { + phase: NativeInputLease["phase"] = "native", + ): NativeInputLease | null => { if (point === null) return lease; if (readDocumentString(document, point.path) === null) return lease; lease = { path: point.path, phase }; @@ -307,42 +290,48 @@ export function createContentEditableCore({ }; const handle = ( - event: ContentEditableCoreEvent, + command: ContentEditableCommand, reader: ContentEditableObservationReader, ): ContentEditableCoreResult => { - if (event.type === "beforeinput") { - beginLease(event.point, "native"); + if (command.type === "begin-native-input") { + beginLease(command.point, "native"); return noChange(document); } - if (event.type === "compositionstart") { - beginLease(event.point, "composing"); + if (command.type === "commit-native-input") { + beginLease( + command.point, + lease?.phase === "pending-commit" ? "pending-commit" : "native", + ); + return flush(reader, { + label: "native input", + ...(lease === null ? {} : { mergeKey: `native:${lease.path}` }), + }); + } + if (command.type === "begin-composition") { + beginLease(command.point, "composing"); return noChange(document); } - if (event.type === "compositionend") { + if (command.type === "commit-composition") { if (lease !== null) lease = { ...lease, phase: "pending-commit" }; return flush(reader, { label: "composition commit" }); } - if (event.type === "input") { - beginLease(event.point, lease?.phase === "pending-commit" ? "pending-commit" : "native"); - return flush(reader, { - label: "native input", - ...(lease === null ? {} : { mergeKey: `native:${lease.path}` }), - }); + if (command.type === "sync-selection") { + return syncSelection(command.selection); } - if (event.type === "selection") { - return syncSelection(event.selection); + if (command.type === "flush") { + return flush(reader, command.options); } - if (event.type === "copy") { + if (command.type === "copy") { return copy(reader); } - if (event.type === "cut") { + if (command.type === "cut") { return cut(reader); } - if (event.type === "paste") { - return paste(event.payload, reader); + if (command.type === "paste") { + return paste(command.payload, reader, command.selection); } - if (event.type === "history") { - const result = event.command === "undo" ? document.undo() : document.redo(); + if (command.type === "history") { + const result = command.command === "undo" ? document.undo() : document.redo(); return result.ok ? { ok: true, @@ -412,13 +401,6 @@ export function createContentEditableCore({ return { handle, - flush, - syncSelection, - copy, - cut, - paste, - pasteFragment, - pasteText, reset() { lease = null; }, diff --git a/packages/contenteditable-web/src/create.ts b/packages/contenteditable-web/src/create.ts index 614b0011..98bb50dd 100644 --- a/packages/contenteditable-web/src/create.ts +++ b/packages/contenteditable-web/src/create.ts @@ -63,15 +63,15 @@ export function createContentEditableAdapter({ const syncSelectionFromDOM = (): SelectionSnap | null => { const selection = domSelection(); - core.syncSelection(selection); + core.handle({ type: "sync-selection", selection }, reader); return selection; }; const flush = (options: ContentEditableFlushOptions = {}): ContentEditableUpdate => - coreResultToUpdate(core.flush(reader, options)); + coreResultToUpdate(core.handle({ type: "flush", options }, reader)); const copy = (event?: ClipboardEvent): ContentEditableClipboardResult => { - const result = core.copy(reader); + const result = core.handle({ type: "copy" }, reader); if (result.ok && result.kind === "copy") { writeClipboardFragment(event, result.payload, clipboardMime); } @@ -79,7 +79,7 @@ export function createContentEditableAdapter({ }; const cut = (event?: ClipboardEvent): ContentEditableClipboardResult => { - const result = core.cut(reader); + const result = core.handle({ type: "cut" }, reader); if (result.ok && result.kind === "cut") { writeClipboardFragment(event, result.payload, clipboardMime); } @@ -90,32 +90,47 @@ export function createContentEditableAdapter({ fragment: TextSurfaceFragment, selection = document.selection?.snapshot() ?? null, ): ContentEditableClipboardResult => - coreResultToClipboardResult(core.pasteFragment(fragment, reader, selection)); + coreResultToClipboardResult( + core.handle({ type: "paste", payload: fragment, selection }, reader), + ); const pasteText = ( text: string, selection = document.selection?.snapshot() ?? null, ): ContentEditableClipboardResult => - coreResultToClipboardResult(core.pasteText(text, reader, selection)); + coreResultToClipboardResult( + core.handle({ type: "paste", payload: text, selection }, reader), + ); const paste = (event?: ClipboardEvent): ContentEditableClipboardResult => - coreResultToClipboardResult(core.paste(readClipboardPayload(event, clipboardMime), reader)); + coreResultToClipboardResult(core.handle({ + type: "paste", + payload: readClipboardPayload(event, clipboardMime), + }, reader)); const handle = (event: Event): ContentEditableUpdate => { if (event.type === "beforeinput") { - return coreResultToUpdate(core.handle({ type: "beforeinput", point: point() }, reader)); + return coreResultToUpdate( + core.handle({ type: "begin-native-input", point: point() }, reader), + ); } if (event.type === "compositionstart") { - return coreResultToUpdate(core.handle({ type: "compositionstart", point: point() }, reader)); + return coreResultToUpdate(core.handle({ type: "begin-composition", point: point() }, reader)); } if (event.type === "compositionend") { - return coreResultToUpdate(core.handle({ type: "compositionend", point: point() }, reader)); + return coreResultToUpdate( + core.handle({ type: "commit-composition", point: point() }, reader), + ); } if (event.type === "input") { - return coreResultToUpdate(core.handle({ type: "input", point: point() }, reader)); + return coreResultToUpdate( + core.handle({ type: "commit-native-input", point: point() }, reader), + ); } if (event.type === "selectionchange" || event.type === "select") { - return coreResultToUpdate(core.handle({ type: "selection", selection: domSelection() }, reader)); + return coreResultToUpdate( + core.handle({ type: "sync-selection", selection: domSelection() }, reader), + ); } if (event.type === "copy" && isClipboardEventLike(event)) { event.preventDefault(); diff --git a/packages/contenteditable-web/src/index.ts b/packages/contenteditable-web/src/index.ts index 50dc854c..00fa671f 100644 --- a/packages/contenteditable-web/src/index.ts +++ b/packages/contenteditable-web/src/index.ts @@ -17,8 +17,8 @@ export type { TextSurfaceResolver, } from "./types.js"; export type { + ContentEditableCommand, ContentEditableCore, - ContentEditableCoreEvent, ContentEditableCoreOptions, ContentEditableCoreResult, ContentEditableHistoryCommand, diff --git a/packages/contenteditable-web/tests/contenteditable-core.test.ts b/packages/contenteditable-web/tests/contenteditable-core.test.ts index 18a3faa0..d9e85821 100644 --- a/packages/contenteditable-web/tests/contenteditable-core.test.ts +++ b/packages/contenteditable-web/tests/contenteditable-core.test.ts @@ -62,7 +62,7 @@ function selection(anchor: number, focus: number): SelectionSnap { }; } -function createReader(initialText: string, initialSelection: SelectionSnap | null = null) { +function createTestAdapter(initialText: string, initialSelection: SelectionSnap | null = null) { let text = initialText; let currentSelection = initialSelection; const currentPoint: ContentEditableTextPoint = { path: "/body", offset: 0 }; @@ -82,6 +82,9 @@ function createReader(initialText: string, initialSelection: SelectionSnap | nul setOffset(offset: number) { currentPoint.offset = offset; }, + point() { + return currentPoint; + }, }; } @@ -98,15 +101,15 @@ describe("contenteditable headless core", () => { test("commits native input observations without DOM", () => { const doc = createDoc(); - const observed = createReader(doc.value.body, selection(5, 5)); + const adapter = createTestAdapter(doc.value.body, selection(5, 5)); const core = createContentEditableCore({ document: doc, surface }); - observed.setOffset(5); - core.handle({ type: "beforeinput", point: observed.reader.point?.() ?? null }, observed.reader); - observed.setText(`Plain! text ${JSON_ATOM_REPLACEMENT}`); - observed.setSelection(selection(6, 6)); + adapter.setOffset(5); + core.handle({ type: "begin-native-input", point: adapter.point() }, adapter.reader); + adapter.setText(`Plain! text ${JSON_ATOM_REPLACEMENT}`); + adapter.setSelection(selection(6, 6)); - const result = core.handle({ type: "input", point: observed.reader.point?.() ?? null }, observed.reader); + const result = core.handle({ type: "commit-native-input", point: adapter.point() }, adapter.reader); expect(result).toMatchObject({ ok: true, kind: "text" }); expect(doc.value.body).toBe(`Plain! text ${JSON_ATOM_REPLACEMENT}`); @@ -116,16 +119,16 @@ describe("contenteditable headless core", () => { test("keeps IME composition out of the document until compositionend", () => { const doc = createDoc({ body: "", atoms: {}, marks: {} }); - const observed = createReader("", selection(0, 0)); + const adapter = createTestAdapter("", selection(0, 0)); const core = createContentEditableCore({ document: doc, surface }); - core.handle({ type: "compositionstart", point: observed.reader.point?.() ?? null }, observed.reader); - observed.setText("한"); - observed.setSelection(selection(1, 1)); + core.handle({ type: "begin-composition", point: adapter.point() }, adapter.reader); + adapter.setText("한"); + adapter.setSelection(selection(1, 1)); expect(doc.value.body).toBe(""); - const result = core.handle({ type: "compositionend", point: observed.reader.point?.() ?? null }, observed.reader); + const result = core.handle({ type: "commit-composition", point: adapter.point() }, adapter.reader); expect(result).toMatchObject({ ok: true, kind: "text" }); expect(doc.value.body).toBe("한"); @@ -143,10 +146,10 @@ describe("contenteditable headless core", () => { }, }); const selected = selection(0, 4); - const observed = createReader(doc.value.body, selected); + const adapter = createTestAdapter(doc.value.body, selected); const core = createContentEditableCore({ document: doc, surface }); - const copied = core.copy(observed.reader); + const copied = core.handle({ type: "copy" }, adapter.reader); expect(copied).toMatchObject({ ok: true, @@ -159,15 +162,15 @@ describe("contenteditable headless core", () => { }); if (!copied.ok || copied.kind !== "copy") throw new Error("copy failed"); - const cut = core.cut(observed.reader); + const cut = core.handle({ type: "cut" }, adapter.reader); expect(cut).toMatchObject({ ok: true, kind: "cut" }); expect(doc.value).toEqual({ body: "", atoms: {}, marks: {} }); - observed.setText(doc.value.body); - observed.setSelection(selection(0, 0)); + adapter.setText(doc.value.body); + adapter.setSelection(selection(0, 0)); - const pasted = core.paste(copied.payload, observed.reader); + const pasted = core.handle({ type: "paste", payload: copied.payload }, adapter.reader); expect(pasted).toMatchObject({ ok: true, kind: "text" }); expect(doc.value).toEqual({ @@ -183,10 +186,10 @@ describe("contenteditable headless core", () => { test("uses current selection for plain text paste without DOM", () => { const doc = createDoc({ body: "Plain text", atoms: {}, marks: {} }); - const observed = createReader(doc.value.body, selection(0, 5)); + const adapter = createTestAdapter(doc.value.body, selection(0, 5)); const core = createContentEditableCore({ document: doc, surface }); - const pasted = core.paste("Rich", observed.reader); + const pasted = core.handle({ type: "paste", payload: "Rich" }, adapter.reader); expect(pasted).toMatchObject({ ok: true, kind: "text" }); expect(doc.value.body).toBe("Rich text"); From 12c10f0600f08bc3ad2bad9926431fe4780d6afa 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: Wed, 24 Jun 2026 16:23:26 +0900 Subject: [PATCH 3/4] Freeze contenteditable public API --- apps/site/src/generated/repo-catalog.ts | 22 +---- docs/generated/extensions-catalog.md | 2 +- docs/generated/repo-catalog.json | 22 +---- packages/contenteditable-web/src/core.ts | 76 +++++++-------- packages/contenteditable-web/src/create.ts | 95 ++++++------------- packages/contenteditable-web/src/index.ts | 9 +- packages/contenteditable-web/src/types.ts | 38 ++------ .../tests/contenteditable-core.test.ts | 48 +++++++++- 8 files changed, 125 insertions(+), 187 deletions(-) diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index b7ac9c3c..a0d067d6 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -210,18 +210,11 @@ export const repoCatalog = { "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreOptions", - "ContentEditableCoreResult", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableHistoryCommand", "ContentEditableObservationReader", - "ContentEditableTextPoint", - "ContentEditableUpdate", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", @@ -230,7 +223,7 @@ export const repoCatalog = { "createContentEditableAdapter", "createContentEditableCore" ], - "publicExportCount": 21, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", @@ -1122,18 +1115,11 @@ export const repoCatalog = { "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreOptions", - "ContentEditableCoreResult", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableHistoryCommand", "ContentEditableObservationReader", - "ContentEditableTextPoint", - "ContentEditableUpdate", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", @@ -1142,7 +1128,7 @@ export const repoCatalog = { "createContentEditableAdapter", "createContentEditableCore" ], - "publicExportCount": 21, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index 1a52bb66..83e7303f 100644 --- a/docs/generated/extensions-catalog.md +++ b/docs/generated/extensions-catalog.md @@ -13,7 +13,7 @@ Official extensions: 19 | `@interactive-os/json-document-collection` | 9 | edit ordered JSON arrays with item-level commands | database collections or rendered list UI | Official headless collection editing extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-comments` | 14 | anchor review comments to document structure | comment UI, moderation, or author storage | Official headless comments extension for review notes anchored to `@interactive-os/json-document` documents. | | `@interactive-os/json-document-contenteditable-react` | 4 | wrap the contenteditable web adapter with React render and selection restore timing | rendering policy, editor commands, or non-React hosts | Thin React hook for `@interactive-os/json-document-contenteditable-web`. | -| `@interactive-os/json-document-contenteditable-web` | 21 | bind text surfaces to DOM contenteditable selection, IME input, and clipboard events | editor block semantics, toolbar commands, React rendering, or rich text schema policy | Official DOM adapter for using `@interactive-os/json-document` text surfaces with browser `contenteditable`. | +| `@interactive-os/json-document-contenteditable-web` | 14 | bind text surfaces to DOM contenteditable selection, IME input, and clipboard events | editor block semantics, toolbar commands, React rendering, or rich text schema policy | Official DOM adapter for using `@interactive-os/json-document` text surfaces with browser `contenteditable`. | | `@interactive-os/json-document-dirty-state` | 7 | compare a document to a clean baseline | persistence or server save status | Official headless dirty state tracking extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-form-draft` | 15 | hold temporary invalid form input before committing valid JSON | rendered form components | Official headless form draft extension for temporary input that is not ready to enter a schema-valid `@interactive-os/json-document` document. | | `@interactive-os/json-document-grouping` | 15 | group and ungroup selected sibling JSON items | Airtable group-by views | Official extension for structural `group` and `ungroup`. | diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 63270e76..49609f84 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -209,18 +209,11 @@ "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreOptions", - "ContentEditableCoreResult", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableHistoryCommand", "ContentEditableObservationReader", - "ContentEditableTextPoint", - "ContentEditableUpdate", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", @@ -229,7 +222,7 @@ "createContentEditableAdapter", "createContentEditableCore" ], - "publicExportCount": 21, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", @@ -1121,18 +1114,11 @@ "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", "ContentEditableCommand", "ContentEditableCore", - "ContentEditableCoreOptions", - "ContentEditableCoreResult", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableHistoryCommand", "ContentEditableObservationReader", - "ContentEditableTextPoint", - "ContentEditableUpdate", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", @@ -1141,7 +1127,7 @@ "createContentEditableAdapter", "createContentEditableCore" ], - "publicExportCount": 21, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", diff --git a/packages/contenteditable-web/src/core.ts b/packages/contenteditable-web/src/core.ts index d928a32f..b8724de5 100644 --- a/packages/contenteditable-web/src/core.ts +++ b/packages/contenteditable-web/src/core.ts @@ -17,40 +17,33 @@ export type TextSurfaceResolver = | TextSurface | ((textPath: Pointer) => TextSurface | null); -export interface ContentEditableTextPoint { - path: Pointer; - offset: number; -} - export interface ContentEditableObservationReader { - point?(): ContentEditableTextPoint | null; + point?(): { path: Pointer; offset: number } | null; text(path: Pointer): string | null; selection(): SelectionSnap | null; } -export interface ContentEditableFlushOptions { +interface FlushOptions { label?: string; mergeKey?: string; } -export type ContentEditableHistoryCommand = "undo" | "redo"; - export type ContentEditableCommand = | { type: "begin-native-input"; - point: ContentEditableTextPoint | null; + point: { path: Pointer; offset: number } | null; } | { type: "commit-native-input"; - point: ContentEditableTextPoint | null; + point: { path: Pointer; offset: number } | null; } | { type: "begin-composition"; - point: ContentEditableTextPoint | null; + point: { path: Pointer; offset: number } | null; } | { type: "commit-composition"; - point: ContentEditableTextPoint | null; + point: { path: Pointer; offset: number } | null; } | { type: "sync-selection"; @@ -58,7 +51,6 @@ export type ContentEditableCommand = } | { type: "flush"; - options?: ContentEditableFlushOptions; } | { type: "copy"; @@ -73,10 +65,10 @@ export type ContentEditableCommand = } | { type: "history"; - command: ContentEditableHistoryCommand; + command: "undo" | "redo"; }; -export type ContentEditableCoreResult = +export type ContentEditableResult = | { ok: true; kind: "no-change" | "selection" | "text" | "history"; @@ -94,29 +86,22 @@ export type ContentEditableCoreResult = } | ContentEditableError; -export type ContentEditableErrorCode = - | "clipboard_unavailable" - | "commit_failed" - | "empty_selection" - | "invalid_payload" - | "missing_text_path"; - export interface ContentEditableError { ok: false; - code: ContentEditableErrorCode; + code: + | "clipboard_unavailable" + | "commit_failed" + | "empty_selection" + | "invalid_payload" + | "missing_text_path"; reason: string; } -export interface ContentEditableCoreOptions { - document: JSONDocument; - surface: TextSurfaceResolver; -} - export interface ContentEditableCore { handle( command: ContentEditableCommand, reader: ContentEditableObservationReader, - ): ContentEditableCoreResult; + ): ContentEditableResult; reset(): void; } @@ -128,11 +113,14 @@ type NativeInputLease = { export function createContentEditableCore({ document, surface, -}: ContentEditableCoreOptions): ContentEditableCore { +}: { + document: JSONDocument; + surface: TextSurfaceResolver; +}): ContentEditableCore { let lease: NativeInputLease | null = null; const beginLease = ( - point: ContentEditableTextPoint | null, + point: { path: Pointer; offset: number } | null, phase: NativeInputLease["phase"] = "native", ): NativeInputLease | null => { if (point === null) return lease; @@ -143,8 +131,8 @@ export function createContentEditableCore({ const flush = ( reader: ContentEditableObservationReader, - options: ContentEditableFlushOptions = {}, - ): ContentEditableCoreResult => { + options: FlushOptions = {}, + ): ContentEditableResult => { const path = lease?.path ?? reader.point?.()?.path ?? null; if (path === null) { return syncSelection(reader.selection()); @@ -215,7 +203,7 @@ export function createContentEditableCore({ }; }; - const syncSelection = (selection: SelectionSnap | null): ContentEditableCoreResult => { + const syncSelection = (selection: SelectionSnap | null): ContentEditableResult => { if (selection !== null) document.selection?.restore(selection); return { ok: true, @@ -226,7 +214,7 @@ export function createContentEditableCore({ }; }; - const copy = (reader: ContentEditableObservationReader): ContentEditableCoreResult => { + const copy = (reader: ContentEditableObservationReader): ContentEditableResult => { const flushed = flush(reader, { label: "copy selection" }); if (!flushed.ok) return flushed; const selection = document.selection?.snapshot() ?? null; @@ -251,7 +239,7 @@ export function createContentEditableCore({ }; }; - const cut = (reader: ContentEditableObservationReader): ContentEditableCoreResult => { + const cut = (reader: ContentEditableObservationReader): ContentEditableResult => { const copyResult = copy(reader); if (!copyResult.ok) return copyResult; if (copyResult.kind !== "copy") return noChange(document); @@ -262,21 +250,21 @@ export function createContentEditableCore({ fragment: TextSurfaceFragment, reader: ContentEditableObservationReader, selection?: SelectionSnap | null, - ): ContentEditableCoreResult => + ): ContentEditableResult => replaceSelection(fragment, reader, selection, "paste text"); const pasteText = ( text: string, reader: ContentEditableObservationReader, selection?: SelectionSnap | null, - ): ContentEditableCoreResult => + ): ContentEditableResult => replaceSelection(text, reader, selection, "paste text"); const paste = ( payload: TextSurfaceFragment | string | null, reader: ContentEditableObservationReader, selection?: SelectionSnap | null, - ): ContentEditableCoreResult => { + ): ContentEditableResult => { if (typeof payload === "string") return pasteText(payload, reader, selection); if (payload !== null) return pasteFragment(payload, reader, selection); @@ -292,7 +280,7 @@ export function createContentEditableCore({ const handle = ( command: ContentEditableCommand, reader: ContentEditableObservationReader, - ): ContentEditableCoreResult => { + ): ContentEditableResult => { if (command.type === "begin-native-input") { beginLease(command.point, "native"); return noChange(document); @@ -319,7 +307,7 @@ export function createContentEditableCore({ return syncSelection(command.selection); } if (command.type === "flush") { - return flush(reader, command.options); + return flush(reader); } if (command.type === "copy") { return copy(reader); @@ -352,7 +340,7 @@ export function createContentEditableCore({ label: string, kind: "cut" | "text" = "text", payload?: TextSurfaceFragment, - ): ContentEditableCoreResult { + ): ContentEditableResult { const flushed = flush(reader, { label: "flush before text surface replace" }); if (!flushed.ok) return flushed; const targetSelection = @@ -447,7 +435,7 @@ function readDocumentClipboardFragment( return result.ok && isTextSurfaceFragment(result.payload) ? result.payload : null; } -function noChange(document: JSONDocument): ContentEditableCoreResult { +function noChange(document: JSONDocument): ContentEditableResult { return { ok: true, kind: "no-change", diff --git a/packages/contenteditable-web/src/create.ts b/packages/contenteditable-web/src/create.ts index 98bb50dd..d8c51bd2 100644 --- a/packages/contenteditable-web/src/create.ts +++ b/packages/contenteditable-web/src/create.ts @@ -16,9 +16,8 @@ import { } from "./clipboard.js"; import { createContentEditableCore, - type ContentEditableCoreResult, - type ContentEditableHistoryCommand, type ContentEditableObservationReader, + type ContentEditableResult, } from "./core.js"; import { editableTextContent, findElementByAttribute } from "./domText.js"; import { @@ -29,9 +28,6 @@ import { import type { ContentEditableAdapter, ContentEditableAdapterOptions, - ContentEditableClipboardResult, - ContentEditableFlushOptions, - ContentEditableUpdate, } from "./types.js"; export function createContentEditableAdapter({ @@ -67,85 +63,73 @@ export function createContentEditableAdapter({ return selection; }; - const flush = (options: ContentEditableFlushOptions = {}): ContentEditableUpdate => - coreResultToUpdate(core.handle({ type: "flush", options }, reader)); + const flush = (): ContentEditableResult => + core.handle({ type: "flush" }, reader); - const copy = (event?: ClipboardEvent): ContentEditableClipboardResult => { + const copy = (event?: ClipboardEvent): ContentEditableResult => { const result = core.handle({ type: "copy" }, reader); if (result.ok && result.kind === "copy") { writeClipboardFragment(event, result.payload, clipboardMime); } - return coreResultToClipboardResult(result); + return result; }; - const cut = (event?: ClipboardEvent): ContentEditableClipboardResult => { + const cut = (event?: ClipboardEvent): ContentEditableResult => { const result = core.handle({ type: "cut" }, reader); if (result.ok && result.kind === "cut") { writeClipboardFragment(event, result.payload, clipboardMime); } - return coreResultToClipboardResult(result); + return result; }; const pasteFragment = ( fragment: TextSurfaceFragment, selection = document.selection?.snapshot() ?? null, - ): ContentEditableClipboardResult => - coreResultToClipboardResult( - core.handle({ type: "paste", payload: fragment, selection }, reader), - ); + ): ContentEditableResult => + core.handle({ type: "paste", payload: fragment, selection }, reader); const pasteText = ( text: string, selection = document.selection?.snapshot() ?? null, - ): ContentEditableClipboardResult => - coreResultToClipboardResult( - core.handle({ type: "paste", payload: text, selection }, reader), - ); + ): ContentEditableResult => + core.handle({ type: "paste", payload: text, selection }, reader); - const paste = (event?: ClipboardEvent): ContentEditableClipboardResult => - coreResultToClipboardResult(core.handle({ + const paste = (event?: ClipboardEvent): ContentEditableResult => + core.handle({ type: "paste", payload: readClipboardPayload(event, clipboardMime), - }, reader)); + }, reader); - const handle = (event: Event): ContentEditableUpdate => { + const handle = (event: Event): ContentEditableResult => { if (event.type === "beforeinput") { - return coreResultToUpdate( - core.handle({ type: "begin-native-input", point: point() }, reader), - ); + return core.handle({ type: "begin-native-input", point: point() }, reader); } if (event.type === "compositionstart") { - return coreResultToUpdate(core.handle({ type: "begin-composition", point: point() }, reader)); + return core.handle({ type: "begin-composition", point: point() }, reader); } if (event.type === "compositionend") { - return coreResultToUpdate( - core.handle({ type: "commit-composition", point: point() }, reader), - ); + return core.handle({ type: "commit-composition", point: point() }, reader); } if (event.type === "input") { - return coreResultToUpdate( - core.handle({ type: "commit-native-input", point: point() }, reader), - ); + return core.handle({ type: "commit-native-input", point: point() }, reader); } if (event.type === "selectionchange" || event.type === "select") { - return coreResultToUpdate( - core.handle({ type: "sync-selection", selection: domSelection() }, reader), - ); + return core.handle({ type: "sync-selection", selection: domSelection() }, reader); } if (event.type === "copy" && isClipboardEventLike(event)) { event.preventDefault(); - return coreResultToUpdate(copyCoreAndWrite(event)); + return copyCoreAndWrite(event); } if (event.type === "cut" && isClipboardEventLike(event)) { event.preventDefault(); - return coreResultToUpdate(cutCoreAndWrite(event)); + return cutCoreAndWrite(event); } if (event.type === "paste" && isClipboardEventLike(event)) { event.preventDefault(); - return coreResultToUpdate(core.handle({ + return core.handle({ type: "paste", payload: readClipboardPayload(event, clipboardMime), - }, reader)); + }, reader); } if (event.type === "keydown" && isKeyboardEventLike(event)) { const command = historyCommandFromKey(event); @@ -160,7 +144,7 @@ export function createContentEditableAdapter({ atomAttribute, ); } - return coreResultToUpdate(result); + return result; } } return noChange(document); @@ -196,7 +180,7 @@ export function createContentEditableAdapter({ }; }; - function copyCoreAndWrite(event?: ClipboardEvent): ContentEditableCoreResult { + function copyCoreAndWrite(event?: ClipboardEvent): ContentEditableResult { const result = core.handle({ type: "copy" }, reader); if (result.ok && result.kind === "copy") { writeClipboardFragment(event, result.payload, clipboardMime); @@ -204,7 +188,7 @@ export function createContentEditableAdapter({ return result; } - function cutCoreAndWrite(event?: ClipboardEvent): ContentEditableCoreResult { + function cutCoreAndWrite(event?: ClipboardEvent): ContentEditableResult { const result = core.handle({ type: "cut" }, reader); if (result.ok && result.kind === "cut") { writeClipboardFragment(event, result.payload, clipboardMime); @@ -241,36 +225,17 @@ function readClipboardPayload( return text.length === 0 ? null : text; } -function coreResultToUpdate(result: ContentEditableCoreResult): ContentEditableUpdate { - if (!result.ok) return result; - return { - ok: true, - kind: result.kind === "history" || result.kind === "copy" || result.kind === "cut" - ? "text" - : result.kind, - patch: result.patch, - selection: result.selection, - }; -} - -function noChange(document: JSONDocument): ContentEditableUpdate { +function noChange(document: JSONDocument): ContentEditableResult { return { ok: true, kind: "no-change", patch: [], selection: document.selection?.snapshot() ?? null, + value: document.value, }; } -function coreResultToClipboardResult( - result: ContentEditableCoreResult, -): ContentEditableClipboardResult { - return result.ok - ? { ok: true, value: result.value } - : result; -} - -function historyCommandFromKey(event: KeyboardEvent): ContentEditableHistoryCommand | null { +function historyCommandFromKey(event: KeyboardEvent): "undo" | "redo" | null { const key = event.key.toLowerCase(); if (!(event.metaKey || event.ctrlKey) || event.altKey) return null; if (key === "z" && !event.shiftKey) return "undo"; diff --git a/packages/contenteditable-web/src/index.ts b/packages/contenteditable-web/src/index.ts index 00fa671f..1cc5b2d9 100644 --- a/packages/contenteditable-web/src/index.ts +++ b/packages/contenteditable-web/src/index.ts @@ -9,19 +9,12 @@ export { createContentEditableAdapter } from "./create.js"; export type { ContentEditableAdapter, ContentEditableAdapterOptions, - ContentEditableClipboardResult, ContentEditableError, - ContentEditableErrorCode, - ContentEditableFlushOptions, - ContentEditableUpdate, + ContentEditableResult, TextSurfaceResolver, } from "./types.js"; export type { ContentEditableCommand, ContentEditableCore, - ContentEditableCoreOptions, - ContentEditableCoreResult, - ContentEditableHistoryCommand, ContentEditableObservationReader, - ContentEditableTextPoint, } from "./core.js"; diff --git a/packages/contenteditable-web/src/types.ts b/packages/contenteditable-web/src/types.ts index 37d5cdae..317af237 100644 --- a/packages/contenteditable-web/src/types.ts +++ b/packages/contenteditable-web/src/types.ts @@ -1,20 +1,16 @@ import type { JSONDocument, - JSONPatchOperation, SelectionSnap, TextSurfaceFragment, } from "@interactive-os/json-document"; import type { - ContentEditableError, - ContentEditableErrorCode, - ContentEditableFlushOptions, + ContentEditableResult, TextSurfaceResolver, } from "./core.js"; export type { ContentEditableError, - ContentEditableErrorCode, - ContentEditableFlushOptions, + ContentEditableResult, TextSurfaceResolver, } from "./core.js"; @@ -27,35 +23,19 @@ export interface ContentEditableAdapterOptions { clipboardMime?: string; } -export type ContentEditableUpdate = - | { - ok: true; - kind: "no-change" | "selection" | "text"; - patch: ReadonlyArray; - selection: SelectionSnap | null; - } - | ContentEditableError; - -export type ContentEditableClipboardResult = - | { - ok: true; - value: T; - } - | ContentEditableError; - export interface ContentEditableAdapter { bind(): () => void; - handle(event: Event): ContentEditableUpdate; - flush(options?: ContentEditableFlushOptions): ContentEditableUpdate; + handle(event: Event): ContentEditableResult; + flush(): ContentEditableResult; syncSelectionFromDOM(): SelectionSnap | null; restoreSelectionToDOM(selection?: SelectionSnap): boolean; - copy(event?: ClipboardEvent): ContentEditableClipboardResult; - cut(event?: ClipboardEvent): ContentEditableClipboardResult; - paste(event?: ClipboardEvent): ContentEditableClipboardResult; - pasteText(text: string, selection?: SelectionSnap | null): ContentEditableClipboardResult; + copy(event?: ClipboardEvent): ContentEditableResult; + cut(event?: ClipboardEvent): ContentEditableResult; + paste(event?: ClipboardEvent): ContentEditableResult; + pasteText(text: string, selection?: SelectionSnap | null): ContentEditableResult; pasteFragment( fragment: TextSurfaceFragment, selection?: SelectionSnap | null, - ): ContentEditableClipboardResult; + ): ContentEditableResult; reset(): void; } diff --git a/packages/contenteditable-web/tests/contenteditable-core.test.ts b/packages/contenteditable-web/tests/contenteditable-core.test.ts index d9e85821..46575af8 100644 --- a/packages/contenteditable-web/tests/contenteditable-core.test.ts +++ b/packages/contenteditable-web/tests/contenteditable-core.test.ts @@ -2,15 +2,15 @@ import { readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; +import * as ts from "typescript"; import * as z from "zod"; import { createJSONDocument, type SelectionSnap, type TextSurface } from "@interactive-os/json-document"; import { + JSON_ATOM_REPLACEMENT, createContentEditableCore, type ContentEditableObservationReader, - type ContentEditableTextPoint, -} from "../src/core.js"; -import { JSON_ATOM_REPLACEMENT } from "../src/constants.js"; +} from "../src/index.js"; const AtomSchema = z.object({ type: z.literal("mention"), @@ -65,7 +65,7 @@ function selection(anchor: number, focus: number): SelectionSnap { function createTestAdapter(initialText: string, initialSelection: SelectionSnap | null = null) { let text = initialText; let currentSelection = initialSelection; - const currentPoint: ContentEditableTextPoint = { path: "/body", offset: 0 }; + const currentPoint = { path: "/body", offset: 0 }; const reader: ContentEditableObservationReader = { point: () => currentPoint, text: (path) => path === "/body" ? text : null, @@ -89,6 +89,27 @@ function createTestAdapter(initialText: string, initialSelection: SelectionSnap } describe("contenteditable headless core", () => { + test("keeps the root public API fixed", () => { + const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8"); + + expect(exportedNames(source)).toEqual([ + "ContentEditableAdapter", + "ContentEditableAdapterOptions", + "ContentEditableCommand", + "ContentEditableCore", + "ContentEditableError", + "ContentEditableObservationReader", + "ContentEditableResult", + "JSON_ATOM_ATTRIBUTE", + "JSON_ATOM_REPLACEMENT", + "JSON_DOCUMENT_CONTENTEDITABLE_MIME", + "JSON_TEXT_ATTRIBUTE", + "TextSurfaceResolver", + "createContentEditableAdapter", + "createContentEditableCore", + ]); + }); + test("keeps the core source free of DOM APIs", () => { for (const path of ["../src/core.ts", "../src/fragment.ts"]) { const source = readFileSync(new URL(path, import.meta.url), "utf8"); @@ -196,3 +217,22 @@ describe("contenteditable headless core", () => { expect(doc.selection?.focus).toMatchObject({ path: "/body", offset: 4 }); }); }); + +function exportedNames(source: string): string[] { + const names = new Set(); + const sourceFile = ts.createSourceFile("index.ts", source, ts.ScriptTarget.Latest, true); + + for (const statement of sourceFile.statements) { + if ( + ts.isExportDeclaration(statement) && + statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + names.add(element.name.text); + } + } + } + + return Array.from(names).sort(); +} From 465a372df7fc42e35f5ca455e38b0bf020225411 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: Wed, 24 Jun 2026 16:45:40 +0900 Subject: [PATCH 4/4] Group contenteditable DOM bridge --- .../src/{clipboard.ts => dom/clipboardEvent.ts} | 10 ++-------- .../src/{create.ts => dom/createAdapter.ts} | 10 +++++----- .../contenteditable-web/src/{ => dom}/selection.ts | 2 +- .../src/{domText.ts => dom/textProjection.ts} | 2 +- packages/contenteditable-web/src/index.ts | 2 +- 5 files changed, 10 insertions(+), 16 deletions(-) rename packages/contenteditable-web/src/{clipboard.ts => dom/clipboardEvent.ts} (77%) rename packages/contenteditable-web/src/{create.ts => dom/createAdapter.ts} (97%) rename packages/contenteditable-web/src/{ => dom}/selection.ts (99%) rename packages/contenteditable-web/src/{domText.ts => dom/textProjection.ts} (98%) diff --git a/packages/contenteditable-web/src/clipboard.ts b/packages/contenteditable-web/src/dom/clipboardEvent.ts similarity index 77% rename from packages/contenteditable-web/src/clipboard.ts rename to packages/contenteditable-web/src/dom/clipboardEvent.ts index 7968b14e..0462bd28 100644 --- a/packages/contenteditable-web/src/clipboard.ts +++ b/packages/contenteditable-web/src/dom/clipboardEvent.ts @@ -1,12 +1,6 @@ import type { TextSurfaceFragment } from "@interactive-os/json-document"; -import { JSON_DOCUMENT_CONTENTEDITABLE_MIME } from "./constants.js"; -import { isTextSurfaceFragment, plainTextFromFragment } from "./fragment.js"; - -export { - isTextSurfaceFragment, - plainTextFromFragment, - selectedTextSurfaceFragment, -} from "./fragment.js"; +import { JSON_DOCUMENT_CONTENTEDITABLE_MIME } from "../constants.js"; +import { isTextSurfaceFragment, plainTextFromFragment } from "../fragment.js"; export function writeClipboardFragment( event: ClipboardEvent | undefined, diff --git a/packages/contenteditable-web/src/create.ts b/packages/contenteditable-web/src/dom/createAdapter.ts similarity index 97% rename from packages/contenteditable-web/src/create.ts rename to packages/contenteditable-web/src/dom/createAdapter.ts index d8c51bd2..73dd3fa0 100644 --- a/packages/contenteditable-web/src/create.ts +++ b/packages/contenteditable-web/src/dom/createAdapter.ts @@ -8,18 +8,18 @@ import { JSON_ATOM_ATTRIBUTE, JSON_DOCUMENT_CONTENTEDITABLE_MIME, JSON_TEXT_ATTRIBUTE, -} from "./constants.js"; +} from "../constants.js"; import { readClipboardFragment, readClipboardPlainText, writeClipboardFragment, -} from "./clipboard.js"; +} from "./clipboardEvent.js"; import { createContentEditableCore, type ContentEditableObservationReader, type ContentEditableResult, -} from "./core.js"; -import { editableTextContent, findElementByAttribute } from "./domText.js"; +} from "../core.js"; +import { editableTextContent, findElementByAttribute } from "./textProjection.js"; import { restoreDOMSelection, selectionFromDOM, @@ -28,7 +28,7 @@ import { import type { ContentEditableAdapter, ContentEditableAdapterOptions, -} from "./types.js"; +} from "../types.js"; export function createContentEditableAdapter({ atomAttribute = JSON_ATOM_ATTRIBUTE, diff --git a/packages/contenteditable-web/src/selection.ts b/packages/contenteditable-web/src/dom/selection.ts similarity index 99% rename from packages/contenteditable-web/src/selection.ts rename to packages/contenteditable-web/src/dom/selection.ts index c9cbad7d..88e3c17b 100644 --- a/packages/contenteditable-web/src/selection.ts +++ b/packages/contenteditable-web/src/dom/selection.ts @@ -4,7 +4,7 @@ import { findElementByAttribute, textDOMPositionForOffset, textOffsetInElement, -} from "./domText.js"; +} from "./textProjection.js"; export function selectionFromDOM( root: HTMLElement, diff --git a/packages/contenteditable-web/src/domText.ts b/packages/contenteditable-web/src/dom/textProjection.ts similarity index 98% rename from packages/contenteditable-web/src/domText.ts rename to packages/contenteditable-web/src/dom/textProjection.ts index ef8cfe7b..0bd18d99 100644 --- a/packages/contenteditable-web/src/domText.ts +++ b/packages/contenteditable-web/src/dom/textProjection.ts @@ -1,4 +1,4 @@ -import { JSON_ATOM_REPLACEMENT } from "./constants.js"; +import { JSON_ATOM_REPLACEMENT } from "../constants.js"; export function editableTextContent(node: Node, atomAttribute: string): string { if (isAtomElement(node, atomAttribute)) return JSON_ATOM_REPLACEMENT; diff --git a/packages/contenteditable-web/src/index.ts b/packages/contenteditable-web/src/index.ts index 1cc5b2d9..169af7f3 100644 --- a/packages/contenteditable-web/src/index.ts +++ b/packages/contenteditable-web/src/index.ts @@ -5,7 +5,7 @@ export { JSON_TEXT_ATTRIBUTE, } from "./constants.js"; export { createContentEditableCore } from "./core.js"; -export { createContentEditableAdapter } from "./create.js"; +export { createContentEditableAdapter } from "./dom/createAdapter.js"; export type { ContentEditableAdapter, ContentEditableAdapterOptions,