diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index 90b6f26a..a0d067d6 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -210,19 +210,20 @@ export const repoCatalog = { "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", + "ContentEditableCommand", + "ContentEditableCore", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableUpdate", + "ContentEditableObservationReader", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", @@ -1114,19 +1115,20 @@ export const repoCatalog = { "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", + "ContentEditableCommand", + "ContentEditableCore", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableUpdate", + "ContentEditableObservationReader", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index a93afe5d..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` | 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` | 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 980822d8..49609f84 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -209,19 +209,20 @@ "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", + "ContentEditableCommand", + "ContentEditableCore", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableUpdate", + "ContentEditableObservationReader", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", @@ -1113,19 +1114,20 @@ "publicExports": [ "ContentEditableAdapter", "ContentEditableAdapterOptions", - "ContentEditableClipboardResult", + "ContentEditableCommand", + "ContentEditableCore", "ContentEditableError", - "ContentEditableErrorCode", - "ContentEditableFlushOptions", - "ContentEditableUpdate", + "ContentEditableObservationReader", + "ContentEditableResult", "JSON_ATOM_ATTRIBUTE", "JSON_ATOM_REPLACEMENT", "JSON_DOCUMENT_CONTENTEDITABLE_MIME", "JSON_TEXT_ATTRIBUTE", "TextSurfaceResolver", - "createContentEditableAdapter" + "createContentEditableAdapter", + "createContentEditableCore" ], - "publicExportCount": 13, + "publicExportCount": 14, "keywords": [ "@interactive-os/json-document", "contenteditable", diff --git a/packages/contenteditable-web/README.md b/packages/contenteditable-web/README.md index 12d51cde..f072320d 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 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 import { createContentEditableAdapter } from "@interactive-os/json-document-contenteditable-web"; diff --git a/packages/contenteditable-web/src/core.ts b/packages/contenteditable-web/src/core.ts new file mode 100644 index 00000000..b8724de5 --- /dev/null +++ b/packages/contenteditable-web/src/core.ts @@ -0,0 +1,450 @@ +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 ContentEditableObservationReader { + point?(): { path: Pointer; offset: number } | null; + text(path: Pointer): string | null; + selection(): SelectionSnap | null; +} + +interface FlushOptions { + label?: string; + mergeKey?: string; +} + +export type ContentEditableCommand = + | { + type: "begin-native-input"; + point: { path: Pointer; offset: number } | null; + } + | { + type: "commit-native-input"; + point: { path: Pointer; offset: number } | null; + } + | { + type: "begin-composition"; + point: { path: Pointer; offset: number } | null; + } + | { + type: "commit-composition"; + point: { path: Pointer; offset: number } | null; + } + | { + type: "sync-selection"; + selection: SelectionSnap | null; + } + | { + type: "flush"; + } + | { + type: "copy"; + } + | { + type: "cut"; + } + | { + type: "paste"; + payload: TextSurfaceFragment | string | null; + selection?: SelectionSnap | null; + } + | { + type: "history"; + command: "undo" | "redo"; + }; + +export type ContentEditableResult = + | { + 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 interface ContentEditableError { + ok: false; + code: + | "clipboard_unavailable" + | "commit_failed" + | "empty_selection" + | "invalid_payload" + | "missing_text_path"; + reason: string; +} + +export interface ContentEditableCore { + handle( + command: ContentEditableCommand, + reader: ContentEditableObservationReader, + ): ContentEditableResult; + reset(): void; +} + +type NativeInputLease = { + path: Pointer; + phase: "native" | "composing" | "pending-commit"; +}; + +export function createContentEditableCore({ + document, + surface, +}: { + document: JSONDocument; + surface: TextSurfaceResolver; +}): ContentEditableCore { + let lease: NativeInputLease | null = null; + + const beginLease = ( + point: { path: Pointer; offset: number } | 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 }; + return lease; + }; + + const flush = ( + reader: ContentEditableObservationReader, + options: FlushOptions = {}, + ): ContentEditableResult => { + 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): ContentEditableResult => { + 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): ContentEditableResult => { + 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): ContentEditableResult => { + 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, + ): ContentEditableResult => + replaceSelection(fragment, reader, selection, "paste text"); + + const pasteText = ( + text: string, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableResult => + replaceSelection(text, reader, selection, "paste text"); + + const paste = ( + payload: TextSurfaceFragment | string | null, + reader: ContentEditableObservationReader, + selection?: SelectionSnap | null, + ): ContentEditableResult => { + 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 = ( + command: ContentEditableCommand, + reader: ContentEditableObservationReader, + ): ContentEditableResult => { + if (command.type === "begin-native-input") { + beginLease(command.point, "native"); + return noChange(document); + } + 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 (command.type === "commit-composition") { + if (lease !== null) lease = { ...lease, phase: "pending-commit" }; + return flush(reader, { label: "composition commit" }); + } + if (command.type === "sync-selection") { + return syncSelection(command.selection); + } + if (command.type === "flush") { + return flush(reader); + } + if (command.type === "copy") { + return copy(reader); + } + if (command.type === "cut") { + return cut(reader); + } + if (command.type === "paste") { + return paste(command.payload, reader, command.selection); + } + if (command.type === "history") { + const result = command.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, + ): ContentEditableResult { + 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, + 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): ContentEditableResult { + 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 deleted file mode 100644 index 0d9021a0..00000000 --- a/packages/contenteditable-web/src/create.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { - replaceTextSurfaceSelection, - syncTextSurfaceMutation, - type JSONDocument, - type Pointer, - type SelectionSnap, - type TextSurface, - type TextSurfaceFragment, -} from "@interactive-os/json-document"; -import { - JSON_ATOM_ATTRIBUTE, - JSON_DOCUMENT_CONTENTEDITABLE_MIME, - JSON_TEXT_ATTRIBUTE, -} from "./constants.js"; -import { - isTextSurfaceFragment, - readClipboardFragment, - readClipboardPlainText, - selectedTextSurfaceFragment, - writeClipboardFragment, -} from "./clipboard.js"; -import { editableTextContent, findElementByAttribute } from "./domText.js"; -import { - restoreDOMSelection, - selectionFromDOM, - textPathFromSelection, - textPointFromDOMSelection, -} from "./selection.js"; -import type { - ContentEditableAdapter, - ContentEditableAdapterOptions, - 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, - document, - root, - surface, - textAttribute = JSON_TEXT_ATTRIBUTE, -}: ContentEditableAdapterOptions): ContentEditableAdapter { - let lease: BrowserLease | null = null; - - 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 syncSelectionFromDOM = (): SelectionSnap | null => { - const selection = selectionFromDOM(root, textAttribute, atomAttribute); - if (selection !== null) document.selection?.restore(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 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."); - } - - writeClipboardFragment(event, fragment, clipboardMime); - document.clipboard.write(fragment, { trustedPayload: true }); - return { ok: true, value: document.value }; - }; - - 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 pasteFragment = ( - fragment: TextSurfaceFragment, - selection = document.selection?.snapshot() ?? null, - ): ContentEditableClipboardResult => - replaceSelection(fragment, selection, "paste text"); - - const pasteText = ( - text: string, - selection = document.selection?.snapshot() ?? null, - ): ContentEditableClipboardResult => - replaceSelection(text, selection, "paste text"); - - 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 handle = (event: Event): ContentEditableUpdate => { - if (event.type === "beforeinput") { - beginLeaseFromDOM("native"); - return noChange(document); - } - if (event.type === "compositionstart") { - beginLeaseFromDOM("composing"); - return noChange(document); - } - if (event.type === "compositionend") { - if (lease !== null) lease = { ...lease, phase: "pending-commit" }; - return flush({ label: "composition commit" }); - } - if (event.type === "input") { - beginLeaseFromDOM(lease?.phase === "pending-commit" ? "pending-commit" : "native"); - return flush({ - label: "native input", - ...(lease === null ? {} : { mergeKey: `native:${lease.path}` }), - }); - } - if (event.type === "selectionchange" || event.type === "select") { - const selection = syncSelectionFromDOM(); - return { - ok: true, - kind: selection === null ? "no-change" : "selection", - patch: [], - selection, - }; - } - if (event.type === "copy" && isClipboardEventLike(event)) { - event.preventDefault(); - const result = copy(event); - return clipboardResultToUpdate(result, document); - } - if (event.type === "cut" && isClipboardEventLike(event)) { - event.preventDefault(); - const result = cut(event); - return clipboardResultToUpdate(result, document); - } - if (event.type === "paste" && isClipboardEventLike(event)) { - event.preventDefault(); - const result = paste(event); - return clipboardResultToUpdate(result, document); - } - 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 }; - } - } - return noChange(document); - }; - - const bind = (): (() => void) => { - const rootEvents = [ - "beforeinput", - "compositionstart", - "compositionend", - "input", - "copy", - "cut", - "paste", - "keydown", - "select", - ] as const; - for (const type of rootEvents) root.addEventListener(type, handle); - const selectionHandler = (event: Event) => { - const selection = root.ownerDocument.getSelection(); - if ( - selection?.anchorNode !== null && - selection?.anchorNode !== undefined && - root.contains(selection.anchorNode) - ) { - handle(event); - } - }; - root.ownerDocument.addEventListener("selectionchange", selectionHandler); - return () => { - for (const type of rootEvents) root.removeEventListener(type, handle); - root.ownerDocument.removeEventListener("selectionchange", selectionHandler); - }; - }; - - 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."); - } - const planned = replaceTextSurfaceSelection( - selection, - 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, - }); - return commit.ok - ? { ok: true, value: document.value } - : { ok: false, code: "commit_failed", reason: commit.reason ?? commit.code }; - } - - return { - bind, - handle, - flush, - syncSelectionFromDOM, - restoreSelectionToDOM(selection = document.selection?.snapshot()) { - return restoreDOMSelection(root, selection, textAttribute, atomAttribute); - }, - 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 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): ContentEditableUpdate { - return { - ok: true, - kind: "no-change", - patch: [], - selection: document.selection?.snapshot() ?? null, - }; -} - -function emptySelectionError(reason: string): ContentEditableClipboardResult { - return { ok: false, code: "empty_selection", reason }; -} - -function clipboardResultToUpdate( - result: ContentEditableClipboardResult, - document: JSONDocument, -): ContentEditableUpdate { - return result.ok - ? { - ok: true, - kind: "text", - patch: document.lastPatch, - selection: document.selection?.snapshot() ?? null, - } - : result; -} - -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"; - if (key === "y" || (key === "z" && event.shiftKey)) return "redo"; - return null; -} - -function isClipboardEventLike(event: Event): event is ClipboardEvent { - return "clipboardData" in event; -} - -function isKeyboardEventLike(event: Event): event is KeyboardEvent { - return "key" in event; -} diff --git a/packages/contenteditable-web/src/dom/clipboardEvent.ts b/packages/contenteditable-web/src/dom/clipboardEvent.ts new file mode 100644 index 00000000..0462bd28 --- /dev/null +++ b/packages/contenteditable-web/src/dom/clipboardEvent.ts @@ -0,0 +1,30 @@ +import type { TextSurfaceFragment } from "@interactive-os/json-document"; +import { JSON_DOCUMENT_CONTENTEDITABLE_MIME } from "../constants.js"; +import { isTextSurfaceFragment, plainTextFromFragment } from "../fragment.js"; + +export function writeClipboardFragment( + event: ClipboardEvent | undefined, + fragment: TextSurfaceFragment, + mime = JSON_DOCUMENT_CONTENTEDITABLE_MIME, +): void { + event?.clipboardData?.setData("text/plain", plainTextFromFragment(fragment)); + event?.clipboardData?.setData(mime, JSON.stringify(fragment)); +} + +export function readClipboardFragment( + event: ClipboardEvent | undefined, + mime = JSON_DOCUMENT_CONTENTEDITABLE_MIME, +): TextSurfaceFragment | null { + const raw = event?.clipboardData?.getData(mime) ?? ""; + if (raw.length === 0) return null; + try { + const value = JSON.parse(raw) as unknown; + return isTextSurfaceFragment(value) ? value : null; + } catch { + return null; + } +} + +export function readClipboardPlainText(event: ClipboardEvent | undefined): string { + return event?.clipboardData?.getData("text/plain") ?? ""; +} diff --git a/packages/contenteditable-web/src/dom/createAdapter.ts b/packages/contenteditable-web/src/dom/createAdapter.ts new file mode 100644 index 00000000..73dd3fa0 --- /dev/null +++ b/packages/contenteditable-web/src/dom/createAdapter.ts @@ -0,0 +1,252 @@ +import type { + JSONDocument, + Pointer, + SelectionSnap, + TextSurfaceFragment, +} from "@interactive-os/json-document"; +import { + JSON_ATOM_ATTRIBUTE, + JSON_DOCUMENT_CONTENTEDITABLE_MIME, + JSON_TEXT_ATTRIBUTE, +} from "../constants.js"; +import { + readClipboardFragment, + readClipboardPlainText, + writeClipboardFragment, +} from "./clipboardEvent.js"; +import { + createContentEditableCore, + type ContentEditableObservationReader, + type ContentEditableResult, +} from "../core.js"; +import { editableTextContent, findElementByAttribute } from "./textProjection.js"; +import { + restoreDOMSelection, + selectionFromDOM, + textPointFromDOMSelection, +} from "./selection.js"; +import type { + ContentEditableAdapter, + ContentEditableAdapterOptions, +} from "../types.js"; + +export function createContentEditableAdapter({ + atomAttribute = JSON_ATOM_ATTRIBUTE, + clipboardMime = JSON_DOCUMENT_CONTENTEDITABLE_MIME, + document, + root, + surface, + textAttribute = JSON_TEXT_ATTRIBUTE, +}: ContentEditableAdapterOptions): ContentEditableAdapter { + const core = createContentEditableCore({ document, surface }); + + const textElementForPath = (path: Pointer): HTMLElement | null => + findElementByAttribute(root, textAttribute, path); + + 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 = domSelection(); + core.handle({ type: "sync-selection", selection }, reader); + return selection; + }; + + const flush = (): ContentEditableResult => + core.handle({ type: "flush" }, reader); + + const copy = (event?: ClipboardEvent): ContentEditableResult => { + const result = core.handle({ type: "copy" }, reader); + if (result.ok && result.kind === "copy") { + writeClipboardFragment(event, result.payload, clipboardMime); + } + return result; + }; + + const cut = (event?: ClipboardEvent): ContentEditableResult => { + const result = core.handle({ type: "cut" }, reader); + if (result.ok && result.kind === "cut") { + writeClipboardFragment(event, result.payload, clipboardMime); + } + return result; + }; + + const pasteFragment = ( + fragment: TextSurfaceFragment, + selection = document.selection?.snapshot() ?? null, + ): ContentEditableResult => + core.handle({ type: "paste", payload: fragment, selection }, reader); + + const pasteText = ( + text: string, + selection = document.selection?.snapshot() ?? null, + ): ContentEditableResult => + core.handle({ type: "paste", payload: text, selection }, reader); + + const paste = (event?: ClipboardEvent): ContentEditableResult => + core.handle({ + type: "paste", + payload: readClipboardPayload(event, clipboardMime), + }, reader); + + const handle = (event: Event): ContentEditableResult => { + if (event.type === "beforeinput") { + return core.handle({ type: "begin-native-input", point: point() }, reader); + } + if (event.type === "compositionstart") { + return core.handle({ type: "begin-composition", point: point() }, reader); + } + if (event.type === "compositionend") { + return core.handle({ type: "commit-composition", point: point() }, reader); + } + if (event.type === "input") { + return core.handle({ type: "commit-native-input", point: point() }, reader); + } + if (event.type === "selectionchange" || event.type === "select") { + return core.handle({ type: "sync-selection", selection: domSelection() }, reader); + } + if (event.type === "copy" && isClipboardEventLike(event)) { + event.preventDefault(); + return copyCoreAndWrite(event); + } + if (event.type === "cut" && isClipboardEventLike(event)) { + event.preventDefault(); + return cutCoreAndWrite(event); + } + if (event.type === "paste" && isClipboardEventLike(event)) { + event.preventDefault(); + return 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 = core.handle({ type: "history", command }, reader); + if (result.ok) { + restoreDOMSelection( + root, + document.selection?.snapshot(), + textAttribute, + atomAttribute, + ); + } + return result; + } + } + return noChange(document); + }; + + const bind = (): (() => void) => { + const rootEvents = [ + "beforeinput", + "compositionstart", + "compositionend", + "input", + "copy", + "cut", + "paste", + "keydown", + "select", + ] as const; + for (const type of rootEvents) root.addEventListener(type, handle); + const selectionHandler = (event: Event) => { + const selection = root.ownerDocument.getSelection(); + if ( + selection?.anchorNode !== null && + selection?.anchorNode !== undefined && + root.contains(selection.anchorNode) + ) { + handle(event); + } + }; + root.ownerDocument.addEventListener("selectionchange", selectionHandler); + return () => { + for (const type of rootEvents) root.removeEventListener(type, handle); + root.ownerDocument.removeEventListener("selectionchange", selectionHandler); + }; + }; + + function copyCoreAndWrite(event?: ClipboardEvent): ContentEditableResult { + const result = core.handle({ type: "copy" }, reader); + if (result.ok && result.kind === "copy") { + writeClipboardFragment(event, result.payload, clipboardMime); + } + return result; + } + + function cutCoreAndWrite(event?: ClipboardEvent): ContentEditableResult { + const result = core.handle({ type: "cut" }, reader); + if (result.ok && result.kind === "cut") { + writeClipboardFragment(event, result.payload, clipboardMime); + } + return result; + } + + return { + bind, + handle, + flush, + syncSelectionFromDOM, + restoreSelectionToDOM(selection = document.selection?.snapshot()) { + return restoreDOMSelection(root, selection, textAttribute, atomAttribute); + }, + copy, + cut, + paste, + pasteFragment, + pasteText, + reset() { + core.reset(); + }, + }; +} + +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 noChange(document: JSONDocument): ContentEditableResult { + return { + ok: true, + kind: "no-change", + patch: [], + selection: document.selection?.snapshot() ?? null, + value: document.value, + }; +} + +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"; + if (key === "y" || (key === "z" && event.shiftKey)) return "redo"; + return null; +} + +function isClipboardEventLike(event: Event): event is ClipboardEvent { + return "clipboardData" in event; +} + +function isKeyboardEventLike(event: Event): event is KeyboardEvent { + return "key" in event; +} 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/clipboard.ts b/packages/contenteditable-web/src/fragment.ts similarity index 61% rename from packages/contenteditable-web/src/clipboard.ts rename to packages/contenteditable-web/src/fragment.ts index ebfcb17d..680a66ae 100644 --- a/packages/contenteditable-web/src/clipboard.ts +++ b/packages/contenteditable-web/src/fragment.ts @@ -1,11 +1,12 @@ import { + type JSONDocument, + type SelectionSnap, + type TextSurface, type TextSurfaceAtom, type TextSurfaceFragment, type TextSurfaceRange, textSurfaceFragment, } from "@interactive-os/json-document"; -import type { JSONDocument, SelectionSnap, TextSurface } from "@interactive-os/json-document"; -import { JSON_DOCUMENT_CONTENTEDITABLE_MIME } from "./constants.js"; export function selectedTextSurfaceFragment( document: JSONDocument, @@ -20,33 +21,6 @@ export function plainTextFromFragment(fragment: TextSurfaceFragment): string { return fragment.text; } -export function writeClipboardFragment( - event: ClipboardEvent | undefined, - fragment: TextSurfaceFragment, - mime = JSON_DOCUMENT_CONTENTEDITABLE_MIME, -): void { - event?.clipboardData?.setData("text/plain", plainTextFromFragment(fragment)); - event?.clipboardData?.setData(mime, JSON.stringify(fragment)); -} - -export function readClipboardFragment( - event: ClipboardEvent | undefined, - mime = JSON_DOCUMENT_CONTENTEDITABLE_MIME, -): TextSurfaceFragment | null { - const raw = event?.clipboardData?.getData(mime) ?? ""; - if (raw.length === 0) return null; - try { - const value = JSON.parse(raw) as unknown; - return isTextSurfaceFragment(value) ? value : null; - } catch { - return null; - } -} - -export function readClipboardPlainText(event: ClipboardEvent | undefined): string { - return event?.clipboardData?.getData("text/plain") ?? ""; -} - export function isTextSurfaceFragment(value: unknown): value is TextSurfaceFragment { return ( isRecord(value) && diff --git a/packages/contenteditable-web/src/index.ts b/packages/contenteditable-web/src/index.ts index ef9f8f8f..169af7f3 100644 --- a/packages/contenteditable-web/src/index.ts +++ b/packages/contenteditable-web/src/index.ts @@ -4,14 +4,17 @@ export { JSON_DOCUMENT_CONTENTEDITABLE_MIME, JSON_TEXT_ATTRIBUTE, } from "./constants.js"; -export { createContentEditableAdapter } from "./create.js"; +export { createContentEditableCore } from "./core.js"; +export { createContentEditableAdapter } from "./dom/createAdapter.js"; export type { ContentEditableAdapter, ContentEditableAdapterOptions, - ContentEditableClipboardResult, ContentEditableError, - ContentEditableErrorCode, - ContentEditableFlushOptions, - ContentEditableUpdate, + ContentEditableResult, TextSurfaceResolver, } from "./types.js"; +export type { + ContentEditableCommand, + ContentEditableCore, + ContentEditableObservationReader, +} from "./core.js"; diff --git a/packages/contenteditable-web/src/types.ts b/packages/contenteditable-web/src/types.ts index 5f946b81..317af237 100644 --- a/packages/contenteditable-web/src/types.ts +++ b/packages/contenteditable-web/src/types.ts @@ -1,15 +1,18 @@ import type { JSONDocument, - JSONPatchOperation, - Pointer, SelectionSnap, - TextSurface, TextSurfaceFragment, } from "@interactive-os/json-document"; +import type { + ContentEditableResult, + TextSurfaceResolver, +} from "./core.js"; -export type TextSurfaceResolver = - | TextSurface - | ((textPath: Pointer) => TextSurface | null); +export type { + ContentEditableError, + ContentEditableResult, + TextSurfaceResolver, +} from "./core.js"; export interface ContentEditableAdapterOptions { document: JSONDocument; @@ -20,53 +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 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; - 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 new file mode 100644 index 00000000..46575af8 --- /dev/null +++ b/packages/contenteditable-web/tests/contenteditable-core.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment node + +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, +} from "../src/index.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 createTestAdapter(initialText: string, initialSelection: SelectionSnap | null = null) { + let text = initialText; + let currentSelection = initialSelection; + const currentPoint = { 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; + }, + point() { + return currentPoint; + }, + }; +} + +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"); + + 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 adapter = createTestAdapter(doc.value.body, selection(5, 5)); + const core = createContentEditableCore({ document: doc, surface }); + + 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: "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}`); + 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 adapter = createTestAdapter("", selection(0, 0)); + const core = createContentEditableCore({ document: doc, surface }); + + 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: "commit-composition", point: adapter.point() }, adapter.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 adapter = createTestAdapter(doc.value.body, selected); + const core = createContentEditableCore({ document: doc, surface }); + + const copied = core.handle({ type: "copy" }, adapter.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.handle({ type: "cut" }, adapter.reader); + + expect(cut).toMatchObject({ ok: true, kind: "cut" }); + expect(doc.value).toEqual({ body: "", atoms: {}, marks: {} }); + + adapter.setText(doc.value.body); + adapter.setSelection(selection(0, 0)); + + const pasted = core.handle({ type: "paste", payload: copied.payload }, adapter.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 adapter = createTestAdapter(doc.value.body, selection(0, 5)); + const core = createContentEditableCore({ document: doc, surface }); + + const pasted = core.handle({ type: "paste", payload: "Rich" }, adapter.reader); + + expect(pasted).toMatchObject({ ok: true, kind: "text" }); + expect(doc.value.body).toBe("Rich text"); + 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(); +}