From 8c94ccd2361f41bbfb5d626fd8dd5b449a01a8b7 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 14:56:38 +0900 Subject: [PATCH 1/2] Add contenteditable adapter packages --- config/json-document-source-aliases.ts | 2 + package-lock.json | 43 ++ packages/contenteditable-react/README.md | 26 ++ packages/contenteditable-react/package.json | 61 +++ packages/contenteditable-react/src/index.ts | 6 + .../src/useContentEditable.ts | 123 ++++++ .../tests/contenteditable-react.test.tsx | 187 ++++++++ packages/contenteditable-react/tsconfig.json | 24 + .../contenteditable-react/tsconfig.test.json | 18 + .../contenteditable-react/vitest.config.ts | 14 + packages/contenteditable-web/README.md | 30 ++ packages/contenteditable-web/package.json | 52 +++ packages/contenteditable-web/src/clipboard.ts | 82 ++++ packages/contenteditable-web/src/constants.ts | 5 + packages/contenteditable-web/src/create.ts | 416 ++++++++++++++++++ packages/contenteditable-web/src/domText.ts | 163 +++++++ packages/contenteditable-web/src/index.ts | 17 + packages/contenteditable-web/src/selection.ts | 135 ++++++ packages/contenteditable-web/src/types.ts | 72 +++ .../tests/contenteditable-web.test.ts | 295 +++++++++++++ packages/contenteditable-web/tsconfig.json | 22 + .../contenteditable-web/tsconfig.test.json | 16 + packages/contenteditable-web/vitest.config.ts | 13 + scripts/evaluate-extensions.mjs | 29 ++ 24 files changed, 1851 insertions(+) create mode 100644 packages/contenteditable-react/README.md create mode 100644 packages/contenteditable-react/package.json create mode 100644 packages/contenteditable-react/src/index.ts create mode 100644 packages/contenteditable-react/src/useContentEditable.ts create mode 100644 packages/contenteditable-react/tests/contenteditable-react.test.tsx create mode 100644 packages/contenteditable-react/tsconfig.json create mode 100644 packages/contenteditable-react/tsconfig.test.json create mode 100644 packages/contenteditable-react/vitest.config.ts create mode 100644 packages/contenteditable-web/README.md create mode 100644 packages/contenteditable-web/package.json create mode 100644 packages/contenteditable-web/src/clipboard.ts create mode 100644 packages/contenteditable-web/src/constants.ts create mode 100644 packages/contenteditable-web/src/create.ts create mode 100644 packages/contenteditable-web/src/domText.ts create mode 100644 packages/contenteditable-web/src/index.ts create mode 100644 packages/contenteditable-web/src/selection.ts create mode 100644 packages/contenteditable-web/src/types.ts create mode 100644 packages/contenteditable-web/tests/contenteditable-web.test.ts create mode 100644 packages/contenteditable-web/tsconfig.json create mode 100644 packages/contenteditable-web/tsconfig.test.json create mode 100644 packages/contenteditable-web/vitest.config.ts diff --git a/config/json-document-source-aliases.ts b/config/json-document-source-aliases.ts index 74b31d08..af9da058 100644 --- a/config/json-document-source-aliases.ts +++ b/config/json-document-source-aliases.ts @@ -7,6 +7,8 @@ export interface SourceAlias { export const officialExtensionPackages = [ "clipboard-web", + "contenteditable-web", + "contenteditable-react", "collection", "outline", "schema-form", diff --git a/package-lock.json b/package-lock.json index 7cfa87b4..2b37feb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1859,6 +1859,14 @@ "resolved": "packages/comments", "link": true }, + "node_modules/@interactive-os/json-document-contenteditable-react": { + "resolved": "packages/contenteditable-react", + "link": true + }, + "node_modules/@interactive-os/json-document-contenteditable-web": { + "resolved": "packages/contenteditable-web", + "link": true + }, "node_modules/@interactive-os/json-document-copy-review-lab": { "resolved": "apps/copy-review-lab", "link": true @@ -6456,6 +6464,41 @@ "@interactive-os/json-document": "^1.0.0" } }, + "packages/contenteditable-react": { + "name": "@interactive-os/json-document-contenteditable-react", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-contenteditable-web": "*", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0", + "@interactive-os/json-document-contenteditable-web": "^0.1.0", + "react": ">=18" + } + }, + "packages/contenteditable-web": { + "name": "@interactive-os/json-document-contenteditable-web", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document": "*", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0" + } + }, "packages/dirty-state": { "name": "@interactive-os/json-document-dirty-state", "version": "0.1.0", diff --git a/packages/contenteditable-react/README.md b/packages/contenteditable-react/README.md new file mode 100644 index 00000000..2194709f --- /dev/null +++ b/packages/contenteditable-react/README.md @@ -0,0 +1,26 @@ +# json-document-contenteditable-react + +Thin React hook for `@interactive-os/json-document-contenteditable-web`. + +The hook owns React timing concerns: + +- creating and disposing the web adapter +- rendering document value into the contenteditable root +- restoring selection after document-driven rerenders +- preserving command-start selection for toolbar interactions + +```tsx +import { useContentEditable } from "@interactive-os/json-document-contenteditable-react"; + +const editor = useContentEditable({ + document: doc, + rootRef, + surface, + renderContent, +}); +``` + +It does not define editor block semantics or rendering policy. + +It does not call `doc.use(...)`; hosts compose the hook around a +json-document instance. diff --git a/packages/contenteditable-react/package.json b/packages/contenteditable-react/package.json new file mode 100644 index 00000000..daa7c069 --- /dev/null +++ b/packages/contenteditable-react/package.json @@ -0,0 +1,61 @@ +{ + "name": "@interactive-os/json-document-contenteditable-react", + "version": "0.1.0", + "description": "React hook for the json-document contenteditable web adapter.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/contenteditable-react" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "react", + "json", + "headless" + ], + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "prebuild": "npm run build -w @interactive-os/json-document-contenteditable-web", + "build": "npm run clean && tsc -p tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "pretypecheck": "npm run build -w @interactive-os/json-document-contenteditable-web", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0", + "@interactive-os/json-document-contenteditable-web": "^0.1.0", + "react": ">=18" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-contenteditable-web": "*", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0" + } +} diff --git a/packages/contenteditable-react/src/index.ts b/packages/contenteditable-react/src/index.ts new file mode 100644 index 00000000..b2a66d58 --- /dev/null +++ b/packages/contenteditable-react/src/index.ts @@ -0,0 +1,6 @@ +export { useContentEditable } from "./useContentEditable.js"; +export type { + ContentEditableCommandPointerEvent, + UseContentEditableOptions, + UseContentEditableResult, +} from "./useContentEditable.js"; diff --git a/packages/contenteditable-react/src/useContentEditable.ts b/packages/contenteditable-react/src/useContentEditable.ts new file mode 100644 index 00000000..056666e9 --- /dev/null +++ b/packages/contenteditable-react/src/useContentEditable.ts @@ -0,0 +1,123 @@ +import { + createContentEditableAdapter, + type ContentEditableAdapter, + type TextSurfaceResolver, +} from "@interactive-os/json-document-contenteditable-web"; +import type { JSONDocument, SelectionSnap } from "@interactive-os/json-document"; +import { + useCallback, + useLayoutEffect, + useRef, + type RefObject, +} from "react"; + +export interface ContentEditableCommandPointerEvent { + preventDefault(): void; +} + +export interface UseContentEditableOptions { + document: JSONDocument; + rootRef: RefObject; + surface: TextSurfaceResolver; + renderContent(root: HTMLElement, value: T): void; + atomAttribute?: string; + textAttribute?: string; + clipboardMime?: string; +} + +export interface UseContentEditableResult { + adapterRef: RefObject | null>; + commandSelectionRef: RefObject; + renderNow(): void; + restoreSelectionToDOM(selection?: SelectionSnap): boolean; + syncCommandSelection(event?: ContentEditableCommandPointerEvent): SelectionSnap | null; + getCommandSelection(): SelectionSnap | null; +} + +export function useContentEditable({ + atomAttribute, + clipboardMime, + document, + renderContent, + rootRef, + surface, + textAttribute, +}: UseContentEditableOptions): UseContentEditableResult { + const adapterRef = useRef | null>(null); + const commandSelectionRef = useRef(null); + + const renderNow = useCallback(() => { + const root = rootRef.current; + if (root === null) return; + renderContent(root, document.value); + adapterRef.current?.restoreSelectionToDOM(); + }, [document, renderContent, rootRef]); + + const restoreSelectionToDOM = useCallback((selection?: SelectionSnap): boolean => { + return adapterRef.current?.restoreSelectionToDOM(selection) ?? false; + }, []); + + const syncCommandSelection = useCallback( + (event?: ContentEditableCommandPointerEvent): SelectionSnap | null => { + event?.preventDefault(); + const selection = + adapterRef.current?.syncSelectionFromDOM() ?? + document.selection?.snapshot() ?? + null; + commandSelectionRef.current = selection; + return selection; + }, + [document], + ); + + const getCommandSelection = useCallback( + (): SelectionSnap | null => commandSelectionRef.current, + [], + ); + + useLayoutEffect(() => { + const root = rootRef.current; + if (root === null) return undefined; + + renderContent(root, document.value); + const adapter = createContentEditableAdapter({ + document, + root, + surface, + ...(atomAttribute === undefined ? {} : { atomAttribute }), + ...(clipboardMime === undefined ? {} : { clipboardMime }), + ...(textAttribute === undefined ? {} : { textAttribute }), + }); + adapterRef.current = adapter; + const unbind = adapter.bind(); + adapter.restoreSelectionToDOM(); + + const unsubscribe = document.subscribe(() => { + renderContent(root, document.value); + adapter.restoreSelectionToDOM(); + }); + + return () => { + unsubscribe(); + unbind(); + if (adapterRef.current === adapter) adapterRef.current = null; + }; + }, [ + atomAttribute, + clipboardMime, + document, + renderContent, + rootRef, + surface, + textAttribute, + ]); + + return { + adapterRef, + commandSelectionRef, + renderNow, + restoreSelectionToDOM, + syncCommandSelection, + getCommandSelection, + }; +} diff --git a/packages/contenteditable-react/tests/contenteditable-react.test.tsx b/packages/contenteditable-react/tests/contenteditable-react.test.tsx new file mode 100644 index 00000000..ad495325 --- /dev/null +++ b/packages/contenteditable-react/tests/contenteditable-react.test.tsx @@ -0,0 +1,187 @@ +import { describe, expect, test, vi } from "vitest"; +import * as z from "zod"; +import { + useLayoutEffect, + useRef, + type RefObject, +} from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; + +import { createJSONDocument, type JSONDocument, type TextSurface } from "@interactive-os/json-document"; +import { + JSON_TEXT_ATTRIBUTE, +} from "@interactive-os/json-document-contenteditable-web"; +import { + useContentEditable, + type UseContentEditableResult, +} from "../src/index.js"; + +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(), z.never()), + marks: z.record(z.string(), MarkSchema), +}); + +type Value = z.output; + +const surface: TextSurface = { + textPath: "/body", + atomsPath: "/atoms", + rangesPath: "/marks", +}; + +function createDoc() { + return createJSONDocument( + Schema, + { body: "Plain text", atoms: {}, marks: {} }, + { history: 20, selection: true, trustedInitial: true }, + ); +} + +function renderContent(root: HTMLElement, value: Value) { + root.replaceChildren(); + const host = document.createElement("div"); + host.setAttribute(JSON_TEXT_ATTRIBUTE, "/body"); + const bold = Object.values(value.marks).find((mark) => mark.type === "bold"); + if (bold === undefined) { + host.textContent = value.body; + } else { + host.append(document.createTextNode(value.body.slice(0, bold.start))); + const strong = document.createElement("strong"); + strong.textContent = value.body.slice(bold.start, bold.end); + host.append(strong, document.createTextNode(value.body.slice(bold.end))); + } + root.append(host); +} + +function Editor({ + doc, + onReady, +}: { + doc: JSONDocument; + onReady(api: UseContentEditableResult, rootRef: RefObject): void; +}) { + const rootRef = useRef(null); + const api = useContentEditable({ + document: doc, + rootRef, + surface, + renderContent, + }); + + useLayoutEffect(() => { + onReady(api, rootRef); + }, [api, onReady]); + + return ( +
+ ); +} + +function selectText(root: HTMLElement, start: number, end: number) { + const host = root.querySelector(`[${JSON_TEXT_ATTRIBUTE}]`); + if (!(host instanceof HTMLElement)) throw new Error("missing text host"); + const text = host.textContent ?? ""; + const textNode = firstTextNode(host); + if (textNode === null) throw new Error("missing text node"); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.collapse(textNode, Math.min(start, text.length)); + selection?.extend(textNode, Math.min(end, text.length)); +} + +function firstTextNode(node: Node): Text | null { + if (node.nodeType === Node.TEXT_NODE) return node as Text; + for (const child of Array.from(node.childNodes)) { + const found = firstTextNode(child); + if (found !== null) return found; + } + return null; +} + +describe("@interactive-os/json-document-contenteditable-react", () => { + test("restores selection after document-driven rerender", () => { + const doc = createDoc(); + const container = document.createElement("div"); + document.body.replaceChildren(container); + const root = createRoot(container); + const ready: { + api?: UseContentEditableResult; + editorRef?: RefObject; + } = {}; + + act(() => { + root.render( { + ready.api = nextApi; + ready.editorRef = nextRef; + }} />); + }); + + const api = ready.api; + const editor = ready.editorRef?.current; + if (!(editor instanceof HTMLElement) || api === undefined) throw new Error("editor not ready"); + + selectText(editor, 5, 0); + api.adapterRef.current?.syncSelectionFromDOM(); + const selection = doc.selection?.snapshot(); + if (selection === undefined) throw new Error("selection not synced"); + + act(() => { + doc.commit([ + { op: "add", path: "/marks/bold", value: { type: "bold", start: 0, end: 5 } }, + ], { + selectionAfter: selection, + }); + }); + + expect(editor.querySelector("strong")?.textContent).toBe("Plain"); + expect(document.getSelection()?.toString()).toBe("Plain"); + expect(document.getSelection()?.isCollapsed).toBe(false); + }); + + test("preserves command-start selection for toolbar commands", () => { + const doc = createDoc(); + const container = document.createElement("div"); + document.body.replaceChildren(container); + const root = createRoot(container); + const ready: { + api?: UseContentEditableResult; + editorRef?: RefObject; + } = {}; + + act(() => { + root.render( { + ready.api = nextApi; + ready.editorRef = nextRef; + }} />); + }); + + const api = ready.api; + const editor = ready.editorRef?.current; + if (!(editor instanceof HTMLElement) || api === undefined) throw new Error("editor not ready"); + + selectText(editor, 0, 5); + const preventDefault = vi.fn(); + api.syncCommandSelection({ preventDefault }); + selectText(editor, doc.value.body.length, doc.value.body.length); + + const result = api.adapterRef.current?.pasteText("Hi", api.getCommandSelection()); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ ok: true }); + expect(doc.value.body).toBe("Hi text"); + expect(doc.selection?.focus).toMatchObject({ path: "/body", offset: 2 }); + }); +}); diff --git a/packages/contenteditable-react/tsconfig.json b/packages/contenteditable-react/tsconfig.json new file mode 100644 index 00000000..9bccfa71 --- /dev/null +++ b/packages/contenteditable-react/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "rootDir": "src", + "outDir": "dist", + "lib": [ + "ES2022", + "DOM" + ] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/packages/contenteditable-react/tsconfig.test.json b/packages/contenteditable-react/tsconfig.test.json new file mode 100644 index 00000000..f9adb2f6 --- /dev/null +++ b/packages/contenteditable-react/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "node" + ], + "noEmit": true, + "rootDir": ".", + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.ts", + "tests/**/*.tsx", + "vitest.config.ts" + ] +} diff --git a/packages/contenteditable-react/vitest.config.ts b/packages/contenteditable-react/vitest.config.ts new file mode 100644 index 00000000..8d4cf55a --- /dev/null +++ b/packages/contenteditable-react/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@interactive-os/json-document": new URL("../json-document/src/index.ts", import.meta.url).pathname, + "@interactive-os/json-document-contenteditable-web": new URL("../contenteditable-web/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "jsdom", + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], + }, +}); diff --git a/packages/contenteditable-web/README.md b/packages/contenteditable-web/README.md new file mode 100644 index 00000000..12d51cde --- /dev/null +++ b/packages/contenteditable-web/README.md @@ -0,0 +1,30 @@ +# json-document-contenteditable-web + +Official DOM adapter for using `@interactive-os/json-document` text surfaces with +browser `contenteditable`. + +This package owns browser-specific behavior: + +- DOM Selection `<->` `SelectionSnap` +- contenteditable native text flush +- composition/native input leases +- atom elements counted as one model character +- structured text-surface clipboard fragments + +```ts +import { createContentEditableAdapter } from "@interactive-os/json-document-contenteditable-web"; + +const adapter = createContentEditableAdapter({ + document: doc, + root, + surface, +}); + +const unbind = adapter.bind(); +``` + +It intentionally does not define editor block semantics, toolbar policy, React +rendering, or rich text commands. + +It does not call `doc.use(...)`; hosts compose the adapter around a +json-document instance. diff --git a/packages/contenteditable-web/package.json b/packages/contenteditable-web/package.json new file mode 100644 index 00000000..492dcd6e --- /dev/null +++ b/packages/contenteditable-web/package.json @@ -0,0 +1,52 @@ +{ + "name": "@interactive-os/json-document-contenteditable-web", + "version": "0.1.0", + "description": "Official contenteditable DOM adapter for json-document text surfaces.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/contenteditable-web" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "web", + "json", + "headless" + ], + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -p tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0" + } +} diff --git a/packages/contenteditable-web/src/clipboard.ts b/packages/contenteditable-web/src/clipboard.ts new file mode 100644 index 00000000..ebfcb17d --- /dev/null +++ b/packages/contenteditable-web/src/clipboard.ts @@ -0,0 +1,82 @@ +import { + 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, + 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 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) && + 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/constants.ts b/packages/contenteditable-web/src/constants.ts new file mode 100644 index 00000000..919b0eb2 --- /dev/null +++ b/packages/contenteditable-web/src/constants.ts @@ -0,0 +1,5 @@ +export const JSON_TEXT_ATTRIBUTE = "data-json-text"; +export const JSON_ATOM_ATTRIBUTE = "data-json-atom"; +export const JSON_ATOM_REPLACEMENT = "\uFFFC"; +export const JSON_DOCUMENT_CONTENTEDITABLE_MIME = + "application/vnd.interactive-os.json-document.text-surface+json"; diff --git a/packages/contenteditable-web/src/create.ts b/packages/contenteditable-web/src/create.ts new file mode 100644 index 00000000..0d9021a0 --- /dev/null +++ b/packages/contenteditable-web/src/create.ts @@ -0,0 +1,416 @@ +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/domText.ts b/packages/contenteditable-web/src/domText.ts new file mode 100644 index 00000000..ef8cfe7b --- /dev/null +++ b/packages/contenteditable-web/src/domText.ts @@ -0,0 +1,163 @@ +import { JSON_ATOM_REPLACEMENT } from "./constants.js"; + +export function editableTextContent(node: Node, atomAttribute: string): string { + if (isAtomElement(node, atomAttribute)) return JSON_ATOM_REPLACEMENT; + if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; + + let text = ""; + for (const child of Array.from(node.childNodes)) { + text += editableTextContent(child, atomAttribute); + } + return text; +} + +export function textOffsetInElement( + element: Element, + node: Node, + offset: number, + atomAttribute: string, +): number { + const atom = closestAttributeElement(element as HTMLElement, node, atomAttribute); + if (atom !== null && element.contains(atom)) { + return textOffsetForNode(element, atom, atomAttribute) + (offset > 0 ? 1 : 0); + } + + if (node === element) return offsetInElementChildren(element, offset, atomAttribute); + return textOffsetForNode(element, node, atomAttribute) + offset; +} + +export function textDOMPositionForOffset( + element: Element, + offset: number, + atomAttribute: string, +): { node: Node; offset: number } { + const position = textDOMPositionInChildren(element, Math.max(0, offset), atomAttribute); + if (position !== null) return position; + + const text = element.ownerDocument.createTextNode(""); + element.append(text); + return { node: text, offset: 0 }; +} + +export function closestAttributeElement( + root: HTMLElement, + node: Node, + attribute: string, +): HTMLElement | null { + const start = node instanceof HTMLElement ? node : node.parentElement; + const element = start?.closest(`[${attribute}]`) ?? null; + return element instanceof HTMLElement && root.contains(element) ? element : null; +} + +export function findElementByAttribute( + root: HTMLElement, + attribute: string, + value: string, +): HTMLElement | null { + if (root.getAttribute(attribute) === value) return root; + for (const element of Array.from(root.querySelectorAll(`[${attribute}]`))) { + if (element instanceof HTMLElement && element.getAttribute(attribute) === value) { + return element; + } + } + return null; +} + +function textOffsetForNode( + element: Element, + target: Node, + atomAttribute: string, +): number { + let total = 0; + let found = false; + + const visit = (node: Node): boolean => { + if (node === target) { + found = true; + return false; + } + if (isAtomElement(node, atomAttribute)) { + total += 1; + return true; + } + if (node.nodeType === Node.TEXT_NODE) { + total += node.textContent?.length ?? 0; + return true; + } + for (const child of Array.from(node.childNodes)) { + if (!visit(child)) return false; + } + return true; + }; + + for (const child of Array.from(element.childNodes)) { + if (!visit(child)) break; + } + return found ? total : editableTextLength(element, atomAttribute); +} + +function offsetInElementChildren( + element: Element, + offset: number, + atomAttribute: string, +): number { + let total = 0; + for (const child of Array.from(element.childNodes).slice(0, offset)) { + total += editableTextLength(child, atomAttribute); + } + return total; +} + +function textDOMPositionInChildren( + element: Element, + offset: number, + atomAttribute: string, +): { node: Node; offset: number } | null { + let remaining = offset; + const children = Array.from(element.childNodes); + for (const child of children) { + if (isAtomElement(child, atomAttribute)) { + const index = indexInParent(child); + if (remaining <= 0) return { node: element, offset: index }; + if (remaining <= 1) return { node: element, offset: index + 1 }; + remaining -= 1; + continue; + } + + if (child.nodeType === Node.TEXT_NODE) { + const length = child.textContent?.length ?? 0; + if (remaining <= length) return { node: child, offset: remaining }; + remaining -= length; + continue; + } + + if (child instanceof Element) { + const length = editableTextLength(child, atomAttribute); + if (remaining <= length) { + return textDOMPositionInChildren(child, remaining, atomAttribute); + } + remaining -= length; + } + } + return { node: element, offset: children.length }; +} + +function editableTextLength(node: Node, atomAttribute: string): number { + if (isAtomElement(node, atomAttribute)) return 1; + if (node.nodeType === Node.TEXT_NODE) return node.textContent?.length ?? 0; + let length = 0; + for (const child of Array.from(node.childNodes)) { + length += editableTextLength(child, atomAttribute); + } + return length; +} + +function isAtomElement(node: Node, atomAttribute: string): boolean { + return node instanceof HTMLElement && node.hasAttribute(atomAttribute); +} + +function indexInParent(node: Node): number { + return node.parentNode === null + ? 0 + : Array.from(node.parentNode.childNodes).indexOf(node); +} diff --git a/packages/contenteditable-web/src/index.ts b/packages/contenteditable-web/src/index.ts new file mode 100644 index 00000000..ef9f8f8f --- /dev/null +++ b/packages/contenteditable-web/src/index.ts @@ -0,0 +1,17 @@ +export { + JSON_ATOM_ATTRIBUTE, + JSON_ATOM_REPLACEMENT, + JSON_DOCUMENT_CONTENTEDITABLE_MIME, + JSON_TEXT_ATTRIBUTE, +} from "./constants.js"; +export { createContentEditableAdapter } from "./create.js"; +export type { + ContentEditableAdapter, + ContentEditableAdapterOptions, + ContentEditableClipboardResult, + ContentEditableError, + ContentEditableErrorCode, + ContentEditableFlushOptions, + ContentEditableUpdate, + TextSurfaceResolver, +} from "./types.js"; diff --git a/packages/contenteditable-web/src/selection.ts b/packages/contenteditable-web/src/selection.ts new file mode 100644 index 00000000..c9cbad7d --- /dev/null +++ b/packages/contenteditable-web/src/selection.ts @@ -0,0 +1,135 @@ +import type { Pointer, SelectionPoint, SelectionSnap } from "@interactive-os/json-document"; +import { + closestAttributeElement, + findElementByAttribute, + textDOMPositionForOffset, + textOffsetInElement, +} from "./domText.js"; + +export function selectionFromDOM( + root: HTMLElement, + textAttribute: string, + atomAttribute: string, +): SelectionSnap | null { + const selection = root.ownerDocument.getSelection(); + if ( + selection === null || + selection.anchorNode === null || + selection.focusNode === null || + !root.contains(selection.anchorNode) || + !root.contains(selection.focusNode) + ) { + return null; + } + + const anchor = textPointFromDOMPosition( + root, + textAttribute, + atomAttribute, + selection.anchorNode, + selection.anchorOffset, + ); + const focus = textPointFromDOMPosition( + root, + textAttribute, + atomAttribute, + selection.focusNode, + selection.focusOffset, + ); + if (anchor === null || focus === null) return null; + return selectionFromPoints(anchor, focus); +} + +export function textPointFromDOMSelection( + root: HTMLElement, + textAttribute: string, + atomAttribute: string, +): { path: Pointer; offset: number } | null { + const selection = root.ownerDocument.getSelection(); + if (selection === null || selection.focusNode === null || !root.contains(selection.focusNode)) { + return null; + } + return textPointFromDOMPosition( + root, + textAttribute, + atomAttribute, + selection.focusNode, + selection.focusOffset, + ); +} + +export function restoreDOMSelection( + root: HTMLElement, + selection: SelectionSnap | undefined, + textAttribute: string, + atomAttribute: string, +): boolean { + const range = selection?.selectionRanges[selection.primaryIndex]; + if (range === undefined) return false; + + const anchor = domPositionFromSelectionPoint(root, range.anchor, textAttribute, atomAttribute); + const focus = domPositionFromSelectionPoint(root, range.focus, textAttribute, atomAttribute); + if (anchor === null || focus === null) return false; + + const domSelection = root.ownerDocument.getSelection(); + if (domSelection === null) return false; + + domSelection.removeAllRanges(); + domSelection.collapse(anchor.node, anchor.offset); + if (anchor.node !== focus.node || anchor.offset !== focus.offset) { + domSelection.extend(focus.node, focus.offset); + } + return true; +} + +export 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 textPointFromDOMPosition( + root: HTMLElement, + textAttribute: string, + atomAttribute: string, + node: Node, + offset: number, +): { path: Pointer; offset: number } | null { + const element = closestAttributeElement(root, node, textAttribute); + const path = element?.getAttribute(textAttribute) ?? null; + if (element === null || path === null) return null; + return { + path, + offset: textOffsetInElement(element, node, offset, atomAttribute), + }; +} + +function domPositionFromSelectionPoint( + root: HTMLElement, + point: SelectionPoint, + textAttribute: string, + atomAttribute: string, +): { node: Node; offset: number } | null { + if (typeof point === "string") return null; + const element = findElementByAttribute(root, textAttribute, point.path); + return element === null + ? null + : textDOMPositionForOffset(element, point.offset ?? 0, atomAttribute); +} + +function selectionFromPoints(anchor: SelectionPoint, focus: SelectionPoint): SelectionSnap { + return { + selectedPointers: [], + selectionRanges: [{ anchor, focus }], + primaryIndex: 0, + anchor, + focus, + }; +} diff --git a/packages/contenteditable-web/src/types.ts b/packages/contenteditable-web/src/types.ts new file mode 100644 index 00000000..5f946b81 --- /dev/null +++ b/packages/contenteditable-web/src/types.ts @@ -0,0 +1,72 @@ +import type { + JSONDocument, + JSONPatchOperation, + Pointer, + SelectionSnap, + TextSurface, + TextSurfaceFragment, +} from "@interactive-os/json-document"; + +export type TextSurfaceResolver = + | TextSurface + | ((textPath: Pointer) => TextSurface | null); + +export interface ContentEditableAdapterOptions { + document: JSONDocument; + root: HTMLElement; + surface: TextSurfaceResolver; + atomAttribute?: string; + textAttribute?: string; + 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; + 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; + pasteFragment( + fragment: TextSurfaceFragment, + selection?: SelectionSnap | null, + ): ContentEditableClipboardResult; + reset(): void; +} diff --git a/packages/contenteditable-web/tests/contenteditable-web.test.ts b/packages/contenteditable-web/tests/contenteditable-web.test.ts new file mode 100644 index 00000000..b2bb84fc --- /dev/null +++ b/packages/contenteditable-web/tests/contenteditable-web.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, test } from "vitest"; +import * as z from "zod"; + +import { createJSONDocument, type TextSurface } from "@interactive-os/json-document"; +import { + JSON_ATOM_ATTRIBUTE, + JSON_ATOM_REPLACEMENT, + JSON_DOCUMENT_CONTENTEDITABLE_MIME, + JSON_TEXT_ATTRIBUTE, + createContentEditableAdapter, +} 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 createRoot(): HTMLElement { + document.body.replaceChildren(); + const root = document.createElement("div"); + document.body.append(root); + return root; +} + +function render(root: HTMLElement, value: z.output) { + root.replaceChildren(); + const host = document.createElement("div"); + host.setAttribute(JSON_TEXT_ATTRIBUTE, "/body"); + for (let offset = 0; offset < value.body.length; offset += 1) { + const atom = Object.entries(value.atoms).find((entry) => entry[1].offset === offset); + if (value.body[offset] === JSON_ATOM_REPLACEMENT && atom !== undefined) { + const [id, record] = atom; + const element = document.createElement("span"); + element.setAttribute(JSON_ATOM_ATTRIBUTE, id); + element.contentEditable = "false"; + element.textContent = record.label; + host.append(element); + } else { + host.append(document.createTextNode(value.body[offset] ?? "")); + } + } + root.append(host); +} + +function selectText(root: HTMLElement, start: number, end: number) { + const host = root.querySelector(`[${JSON_TEXT_ATTRIBUTE}]`); + if (!(host instanceof HTMLElement)) throw new Error("missing text host"); + const anchor = locateTextPosition(host, start); + const focus = locateTextPosition(host, end); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.collapse(anchor.node, anchor.offset); + selection?.extend(focus.node, focus.offset); +} + +function locateTextPosition(element: HTMLElement, target: number): { node: Node; offset: number } { + let remaining = target; + const visit = (node: Node): { node: Node; offset: number } | null => { + if (node instanceof HTMLElement && node.hasAttribute(JSON_ATOM_ATTRIBUTE)) { + const parent = node.parentNode; + if (parent === null) return null; + const index = Array.from(parent.childNodes).indexOf(node); + if (remaining <= 0) return { node: parent, offset: index }; + if (remaining <= 1) return { node: parent, offset: index + 1 }; + remaining -= 1; + return null; + } + if (node.nodeType === Node.TEXT_NODE) { + const length = node.textContent?.length ?? 0; + if (remaining <= length) return { node, offset: remaining }; + remaining -= length; + return null; + } + for (const child of Array.from(node.childNodes)) { + const found = visit(child); + if (found !== null) return found; + } + return null; + }; + return visit(element) ?? { node: element, offset: element.childNodes.length }; +} + +function createClipboardEvent(type: string): ClipboardEvent { + const store = new Map(); + const event = new Event(type, { bubbles: true, cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, "clipboardData", { + value: { + getData: (name: string) => store.get(name) ?? "", + setData: (name: string, value: string) => { + store.set(name, value); + }, + }, + }); + return event; +} + +describe("@interactive-os/json-document-contenteditable-web", () => { + test("flushes native text input into a json-document transaction", () => { + const doc = createDoc(); + const root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + + selectText(root, 5, 5); + adapter.handle(new InputEvent("beforeinput", { inputType: "insertText", bubbles: true })); + const host = root.querySelector(`[${JSON_TEXT_ATTRIBUTE}]`); + if (!(host instanceof HTMLElement)) throw new Error("missing text host"); + host.textContent = `Plain! text ${JSON_ATOM_REPLACEMENT}`; + adapter.handle(new InputEvent("input", { inputType: "insertText", bubbles: true })); + + expect(doc.value.body).toBe(`Plain! text ${JSON_ATOM_REPLACEMENT}`); + expect(doc.value.atoms.ada?.offset).toBe(12); + expect(doc.canUndo()).toEqual({ ok: true }); + }); + + test("commits IME-style composition without intermediate model writes", () => { + const doc = createDoc({ body: "", atoms: {}, marks: {} }); + const root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + + selectText(root, 0, 0); + adapter.handle(new CompositionEvent("compositionstart", { bubbles: true })); + const host = root.querySelector(`[${JSON_TEXT_ATTRIBUTE}]`); + if (!(host instanceof HTMLElement)) throw new Error("missing text host"); + host.textContent = "한"; + expect(doc.value.body).toBe(""); + + adapter.handle(new CompositionEvent("compositionend", { bubbles: true })); + + expect(doc.value.body).toBe("한"); + }); + + test("restores DOM selection direction after rerendered wrappers", () => { + const doc = createDoc({ body: "Plain text", atoms: {}, marks: {} }); + const root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + + selectText(root, 5, 0); + const selection = adapter.syncSelectionFromDOM(); + const host = root.querySelector(`[${JSON_TEXT_ATTRIBUTE}]`); + if (!(host instanceof HTMLElement)) throw new Error("missing text host"); + host.innerHTML = "Plain text"; + + expect(adapter.restoreSelectionToDOM(selection ?? undefined)).toBe(true); + expect(document.getSelection()?.toString()).toBe("Plain"); + expect(document.getSelection()?.isCollapsed).toBe(false); + }); + + test("maps atom elements as one model character", () => { + const doc = createDoc(); + const root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + const atom = root.querySelector(`[${JSON_ATOM_ATTRIBUTE}]`); + if (!(atom instanceof HTMLElement)) throw new Error("missing atom"); + + const range = document.createRange(); + range.setStartBefore(atom); + range.setEndAfter(atom); + const domSelection = document.getSelection(); + domSelection?.removeAllRanges(); + domSelection?.addRange(range); + + const selection = adapter.syncSelectionFromDOM(); + + expect(selection?.selectionRanges[0]).toMatchObject({ + anchor: { path: "/body", offset: 11 }, + focus: { path: "/body", offset: 12 }, + }); + }); + + test("copies and pastes structured text surface fragments", () => { + 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 root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + + selectText(root, 0, 4); + const copyEvent = createClipboardEvent("copy"); + expect(adapter.copy(copyEvent)).toMatchObject({ ok: true }); + expect(JSON.parse(copyEvent.clipboardData?.getData(JSON_DOCUMENT_CONTENTEDITABLE_MIME) ?? "{}")) + .toMatchObject({ + text: `Hi ${JSON_ATOM_REPLACEMENT}`, + atoms: { ada: { offset: 3 } }, + ranges: { bold: { start: 0, end: 2 } }, + }); + + selectText(root, 0, 0); + adapter.syncSelectionFromDOM(); + const pasteEvent = createClipboardEvent("paste"); + pasteEvent.clipboardData?.setData( + JSON_DOCUMENT_CONTENTEDITABLE_MIME, + copyEvent.clipboardData?.getData(JSON_DOCUMENT_CONTENTEDITABLE_MIME) ?? "", + ); + + expect(adapter.paste(pasteEvent)).toMatchObject({ ok: true }); + expect(doc.value.body).toBe(`Hi ${JSON_ATOM_REPLACEMENT}Hi ${JSON_ATOM_REPLACEMENT}`); + expect(Object.values(doc.value.atoms).map((atom) => atom.offset).sort((a, b) => a - b)) + .toEqual([3, 7]); + }); + + test("pastes plain text at the current document selection", () => { + const doc = createDoc({ body: "Plain text", atoms: {}, marks: {} }); + const root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + + selectText(root, 0, 5); + adapter.syncSelectionFromDOM(); + const pasteEvent = createClipboardEvent("paste"); + pasteEvent.clipboardData?.setData("text/plain", "Rich"); + + expect(adapter.paste(pasteEvent)).toMatchObject({ ok: true }); + expect(doc.value.body).toBe("Rich text"); + expect(doc.selection?.focus).toMatchObject({ path: "/body", offset: 4 }); + }); + + test("dispatches clipboard paste events through handle", () => { + const doc = createDoc({ body: "Plain text", atoms: {}, marks: {} }); + const root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + + selectText(root, 6, 10); + adapter.syncSelectionFromDOM(); + const pasteEvent = createClipboardEvent("paste"); + pasteEvent.clipboardData?.setData("text/plain", "body"); + + expect(adapter.handle(pasteEvent)).toMatchObject({ ok: true, kind: "text" }); + expect(pasteEvent.defaultPrevented).toBe(true); + expect(doc.value.body).toBe("Plain body"); + }); + + test("falls back to plain text when structured clipboard data is malformed", () => { + const doc = createDoc({ body: "Plain text", atoms: {}, marks: {} }); + const root = createRoot(); + render(root, doc.value); + const adapter = createContentEditableAdapter({ document: doc, root, surface }); + + selectText(root, 0, 5); + adapter.syncSelectionFromDOM(); + const pasteEvent = createClipboardEvent("paste"); + pasteEvent.clipboardData?.setData( + JSON_DOCUMENT_CONTENTEDITABLE_MIME, + JSON.stringify({ text: "Bad", atoms: [] }), + ); + pasteEvent.clipboardData?.setData("text/plain", "Safe"); + + expect(adapter.paste(pasteEvent)).toMatchObject({ ok: true }); + expect(doc.value.body).toBe("Safe text"); + }); +}); diff --git a/packages/contenteditable-web/tsconfig.json b/packages/contenteditable-web/tsconfig.json new file mode 100644 index 00000000..615be15f --- /dev/null +++ b/packages/contenteditable-web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "rootDir": "src", + "outDir": "dist", + "lib": [ + "ES2022", + "DOM" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/contenteditable-web/tsconfig.test.json b/packages/contenteditable-web/tsconfig.test.json new file mode 100644 index 00000000..2720ba72 --- /dev/null +++ b/packages/contenteditable-web/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [ + "node" + ], + "noEmit": true, + "rootDir": ".", + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "vitest.config.ts" + ] +} diff --git a/packages/contenteditable-web/vitest.config.ts b/packages/contenteditable-web/vitest.config.ts new file mode 100644 index 00000000..973d282a --- /dev/null +++ b/packages/contenteditable-web/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@interactive-os/json-document": new URL("../json-document/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "jsdom", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/scripts/evaluate-extensions.mjs b/scripts/evaluate-extensions.mjs index 7b4316a8..086f674f 100644 --- a/scripts/evaluate-extensions.mjs +++ b/scripts/evaluate-extensions.mjs @@ -13,6 +13,34 @@ const officialExtensions = [ /\{ readText, writeText \}/, ], }, + { + root: "packages/contenteditable-web", + name: "@interactive-os/json-document-contenteditable-web", + description: /contenteditable DOM adapter/, + readme: [ + /createContentEditableAdapter\(/, + /DOM Selection.*SelectionSnap/s, + /composition\/native input leases/, + /does not call\s*`doc\.use\(\.\.\.\)`/, + /does not define editor block semantics/, + ], + }, + { + root: "packages/contenteditable-react", + name: "@interactive-os/json-document-contenteditable-react", + description: /React hook/, + allowedImports: [ + "@interactive-os/json-document-contenteditable-web", + "react", + ], + readme: [ + /useContentEditable\(/, + /rendering document value into the contenteditable root/, + /preserving command-start selection/, + /does not call\s*`doc\.use\(\.\.\.\)`/, + /does not define editor block semantics/, + ], + }, { root: "packages/collection", name: "@interactive-os/json-document-collection", @@ -290,6 +318,7 @@ for (const extension of officialExtensions) { for (const match of source.matchAll(/\bfrom\s+["']([^"']+)["']/g)) { const specifier = match[1]; if (specifier === "@interactive-os/json-document") continue; + if (extension.allowedImports?.includes(specifier)) continue; if (specifier.startsWith(".") && !specifier.includes("../json-document")) continue; fail(`${sourcePath}: extension source must import json-document only through the package entrypoint (${specifier}).`); } From 0407849ebc7de1dc84026f5c2dac16ecb0de3dd1 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:06:20 +0900 Subject: [PATCH 2/2] Update catalog for contenteditable adapters --- README.md | 2 + apps/site/src/generated/repo-catalog.ts | 138 +++++++++++++++++++++++- docs/generated/extensions-catalog.md | 4 +- docs/generated/repo-catalog.json | 138 +++++++++++++++++++++++- llms.txt | 2 + packages/json-document/README.md | 2 + scripts/generate-docs.mjs | 8 ++ 7 files changed, 289 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 44fe74a4..3ea49e38 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ schema -> document -> pointer/query -> can* -> change -> result | [packages/json-document](packages/json-document) | core package. `createJSONDocument`, JSON Patch/Pointer/Path, selection, clipboard, history | | [packages/collection](packages/collection) | ordered JSON array item 이동/복제/삭제 | | [packages/clipboard-web](packages/clipboard-web) | browser clipboard bridge | +| [packages/contenteditable-web](packages/contenteditable-web) | `@interactive-os/json-document-contenteditable-web` DOM contenteditable text-surface adapter | +| [packages/contenteditable-react](packages/contenteditable-react) | `@interactive-os/json-document-contenteditable-react` React wrapper for contenteditable timing | | [packages/schema-form](packages/schema-form) | schema-backed field descriptor | | [packages/form-draft](packages/form-draft) | valid JSON commit 전 temporary invalid form input | | [packages/protected-ranges](packages/protected-ranges) | protected JSON Pointer range edit guard | diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index 56dc4144..90b6f26a 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -164,6 +164,73 @@ export const repoCatalog = { "review" ] }, + { + "path": "packages/contenteditable-react", + "name": "@interactive-os/json-document-contenteditable-react", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "React hook for the json-document contenteditable web adapter.", + "license": "MIT", + "summary": "Thin React hook for `@interactive-os/json-document-contenteditable-web`.", + "guidance": { + "useFor": "wrap the contenteditable web adapter with React render and selection restore timing", + "notFor": "rendering policy, editor commands, or non-React hosts" + }, + "publicExports": [ + "ContentEditableCommandPointerEvent", + "UseContentEditableOptions", + "UseContentEditableResult", + "useContentEditable" + ], + "publicExportCount": 4, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "react" + ] + }, + { + "path": "packages/contenteditable-web", + "name": "@interactive-os/json-document-contenteditable-web", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "Official contenteditable DOM adapter for json-document text surfaces.", + "license": "MIT", + "summary": "Official DOM adapter for using `@interactive-os/json-document` text surfaces with\nbrowser `contenteditable`.", + "guidance": { + "useFor": "bind text surfaces to DOM contenteditable selection, IME input, and clipboard events", + "notFor": "editor block semantics, toolbar commands, React rendering, or rich text schema policy" + }, + "publicExports": [ + "ContentEditableAdapter", + "ContentEditableAdapterOptions", + "ContentEditableClipboardResult", + "ContentEditableError", + "ContentEditableErrorCode", + "ContentEditableFlushOptions", + "ContentEditableUpdate", + "JSON_ATOM_ATTRIBUTE", + "JSON_ATOM_REPLACEMENT", + "JSON_DOCUMENT_CONTENTEDITABLE_MIME", + "JSON_TEXT_ATTRIBUTE", + "TextSurfaceResolver", + "createContentEditableAdapter" + ], + "publicExportCount": 13, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "web" + ] + }, { "path": "packages/dirty-state", "name": "@interactive-os/json-document-dirty-state", @@ -1001,6 +1068,73 @@ export const repoCatalog = { "review" ] }, + { + "path": "packages/contenteditable-react", + "name": "@interactive-os/json-document-contenteditable-react", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "React hook for the json-document contenteditable web adapter.", + "license": "MIT", + "summary": "Thin React hook for `@interactive-os/json-document-contenteditable-web`.", + "guidance": { + "useFor": "wrap the contenteditable web adapter with React render and selection restore timing", + "notFor": "rendering policy, editor commands, or non-React hosts" + }, + "publicExports": [ + "ContentEditableCommandPointerEvent", + "UseContentEditableOptions", + "UseContentEditableResult", + "useContentEditable" + ], + "publicExportCount": 4, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "react" + ] + }, + { + "path": "packages/contenteditable-web", + "name": "@interactive-os/json-document-contenteditable-web", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "Official contenteditable DOM adapter for json-document text surfaces.", + "license": "MIT", + "summary": "Official DOM adapter for using `@interactive-os/json-document` text surfaces with\nbrowser `contenteditable`.", + "guidance": { + "useFor": "bind text surfaces to DOM contenteditable selection, IME input, and clipboard events", + "notFor": "editor block semantics, toolbar commands, React rendering, or rich text schema policy" + }, + "publicExports": [ + "ContentEditableAdapter", + "ContentEditableAdapterOptions", + "ContentEditableClipboardResult", + "ContentEditableError", + "ContentEditableErrorCode", + "ContentEditableFlushOptions", + "ContentEditableUpdate", + "JSON_ATOM_ATTRIBUTE", + "JSON_ATOM_REPLACEMENT", + "JSON_DOCUMENT_CONTENTEDITABLE_MIME", + "JSON_TEXT_ATTRIBUTE", + "TextSurfaceResolver", + "createContentEditableAdapter" + ], + "publicExportCount": 13, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "web" + ] + }, { "path": "packages/dirty-state", "name": "@interactive-os/json-document-dirty-state", @@ -3110,8 +3244,8 @@ export const repoCatalog = { } ], "totals": { - "packages": 18, - "officialExtensions": 17, + "packages": 20, + "officialExtensions": 19, "labExtensions": 38, "apps": 12 } diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index 651265cb..a93afe5d 100644 --- a/docs/generated/extensions-catalog.md +++ b/docs/generated/extensions-catalog.md @@ -4,7 +4,7 @@ This section is generated from `packages/*` and `labs/extensions/*`. -Official extensions: 17 +Official extensions: 19 | Package | Exports | Use for | Not for | Summary | | --- | ---: | --- | --- | --- | @@ -12,6 +12,8 @@ Official extensions: 17 | `@interactive-os/json-document-clipboard-web` | 20 | bridge json-document clipboard payloads to the browser clipboard | TSV/CSV spreadsheet paste engines | Web clipboard extension functions for `@interactive-os/json-document`. | | `@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-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 1899b1dd..980822d8 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -163,6 +163,73 @@ "review" ] }, + { + "path": "packages/contenteditable-react", + "name": "@interactive-os/json-document-contenteditable-react", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "React hook for the json-document contenteditable web adapter.", + "license": "MIT", + "summary": "Thin React hook for `@interactive-os/json-document-contenteditable-web`.", + "guidance": { + "useFor": "wrap the contenteditable web adapter with React render and selection restore timing", + "notFor": "rendering policy, editor commands, or non-React hosts" + }, + "publicExports": [ + "ContentEditableCommandPointerEvent", + "UseContentEditableOptions", + "UseContentEditableResult", + "useContentEditable" + ], + "publicExportCount": 4, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "react" + ] + }, + { + "path": "packages/contenteditable-web", + "name": "@interactive-os/json-document-contenteditable-web", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "Official contenteditable DOM adapter for json-document text surfaces.", + "license": "MIT", + "summary": "Official DOM adapter for using `@interactive-os/json-document` text surfaces with\nbrowser `contenteditable`.", + "guidance": { + "useFor": "bind text surfaces to DOM contenteditable selection, IME input, and clipboard events", + "notFor": "editor block semantics, toolbar commands, React rendering, or rich text schema policy" + }, + "publicExports": [ + "ContentEditableAdapter", + "ContentEditableAdapterOptions", + "ContentEditableClipboardResult", + "ContentEditableError", + "ContentEditableErrorCode", + "ContentEditableFlushOptions", + "ContentEditableUpdate", + "JSON_ATOM_ATTRIBUTE", + "JSON_ATOM_REPLACEMENT", + "JSON_DOCUMENT_CONTENTEDITABLE_MIME", + "JSON_TEXT_ATTRIBUTE", + "TextSurfaceResolver", + "createContentEditableAdapter" + ], + "publicExportCount": 13, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "web" + ] + }, { "path": "packages/dirty-state", "name": "@interactive-os/json-document-dirty-state", @@ -1000,6 +1067,73 @@ "review" ] }, + { + "path": "packages/contenteditable-react", + "name": "@interactive-os/json-document-contenteditable-react", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "React hook for the json-document contenteditable web adapter.", + "license": "MIT", + "summary": "Thin React hook for `@interactive-os/json-document-contenteditable-web`.", + "guidance": { + "useFor": "wrap the contenteditable web adapter with React render and selection restore timing", + "notFor": "rendering policy, editor commands, or non-React hosts" + }, + "publicExports": [ + "ContentEditableCommandPointerEvent", + "UseContentEditableOptions", + "UseContentEditableResult", + "useContentEditable" + ], + "publicExportCount": 4, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "react" + ] + }, + { + "path": "packages/contenteditable-web", + "name": "@interactive-os/json-document-contenteditable-web", + "status": "official-extension", + "private": false, + "publishable": true, + "version": "0.1.0", + "description": "Official contenteditable DOM adapter for json-document text surfaces.", + "license": "MIT", + "summary": "Official DOM adapter for using `@interactive-os/json-document` text surfaces with\nbrowser `contenteditable`.", + "guidance": { + "useFor": "bind text surfaces to DOM contenteditable selection, IME input, and clipboard events", + "notFor": "editor block semantics, toolbar commands, React rendering, or rich text schema policy" + }, + "publicExports": [ + "ContentEditableAdapter", + "ContentEditableAdapterOptions", + "ContentEditableClipboardResult", + "ContentEditableError", + "ContentEditableErrorCode", + "ContentEditableFlushOptions", + "ContentEditableUpdate", + "JSON_ATOM_ATTRIBUTE", + "JSON_ATOM_REPLACEMENT", + "JSON_DOCUMENT_CONTENTEDITABLE_MIME", + "JSON_TEXT_ATTRIBUTE", + "TextSurfaceResolver", + "createContentEditableAdapter" + ], + "publicExportCount": 13, + "keywords": [ + "@interactive-os/json-document", + "contenteditable", + "headless", + "json", + "web" + ] + }, { "path": "packages/dirty-state", "name": "@interactive-os/json-document-dirty-state", @@ -3109,8 +3243,8 @@ } ], "totals": { - "packages": 18, - "officialExtensions": 17, + "packages": 20, + "officialExtensions": 19, "labExtensions": 38, "apps": 12 } diff --git a/llms.txt b/llms.txt index e49651a1..c0be55c8 100644 --- a/llms.txt +++ b/llms.txt @@ -35,6 +35,8 @@ import { createGrouping } from "@interactive-os/json-document-grouping"; import { createProposedChanges } from "@interactive-os/json-document-proposed-changes"; import { createComments } from "@interactive-os/json-document-comments"; import { createWebClipboard } from "@interactive-os/json-document-clipboard-web"; +import { createContentEditableAdapter } from "@interactive-os/json-document-contenteditable-web"; +import { useContentEditable } from "@interactive-os/json-document-contenteditable-react"; ``` Official extension은 현재 shipped `packages/*`만이다. `labs/extensions/*`는 후보이고 공식 package로 안내하지 않는다. `calculated-fields`, `paste-special`, `autosave`는 1.0에서 lab 유지이며 official 승격을 보류한다. diff --git a/packages/json-document/README.md b/packages/json-document/README.md index 6a409d43..13d297c2 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -267,6 +267,8 @@ import { createGrouping } from "@interactive-os/json-document-grouping"; import { createProposedChanges } from "@interactive-os/json-document-proposed-changes"; import { createComments } from "@interactive-os/json-document-comments"; import { createWebClipboard } from "@interactive-os/json-document-clipboard-web"; +import { createContentEditableAdapter } from "@interactive-os/json-document-contenteditable-web"; +import { useContentEditable } from "@interactive-os/json-document-contenteditable-react"; ``` 공식 package는 현재 `packages/*`에 있는 extension만 뜻합니다. diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs index 682297a7..3d808a5d 100644 --- a/scripts/generate-docs.mjs +++ b/scripts/generate-docs.mjs @@ -46,6 +46,14 @@ const extensionGuidance = { useFor: "bridge json-document clipboard payloads to the browser clipboard", notFor: "TSV/CSV spreadsheet paste engines", }, + "@interactive-os/json-document-contenteditable-web": { + useFor: "bind text surfaces to DOM contenteditable selection, IME input, and clipboard events", + notFor: "editor block semantics, toolbar commands, React rendering, or rich text schema policy", + }, + "@interactive-os/json-document-contenteditable-react": { + useFor: "wrap the contenteditable web adapter with React render and selection restore timing", + notFor: "rendering policy, editor commands, or non-React hosts", + }, "@interactive-os/json-document-convert-type": { useFor: "convert a field type (string/number/integer/boolean) where the schema permits it", notFor: "locale/format-aware parsing of currency or dates, or input masks",