diff --git a/docs/api-reference/collaboration.md b/docs/api-reference/collaboration.md index d38b4bb9c..f11909fe3 100644 --- a/docs/api-reference/collaboration.md +++ b/docs/api-reference/collaboration.md @@ -425,3 +425,11 @@ interface TextSpliceOperation { readonly inserted: string; } ``` +## `@interactive-os/json-document-collaboration/editing` + +아래 API는 package root가 아닌 이 subpath에서 import합니다. +### `createCollaborationEditingHistory` + +```ts +createCollaborationEditingHistory(runtime: HistoryRuntime): EditingHistory +``` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index b5a6754ab..d3d7fb457 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -551,52 +551,57 @@ calendarVisibleHourBand(startMinutes: number, endMinutes: number, hourStart: num ## `createAnnotationEditor` ```ts -createAnnotationEditor(source: EditingDocumentSource): AnnotationEditor +createAnnotationEditor(source: EditingDocumentSource, options?: EditingHistoryOptions): AnnotationEditor ``` ## `createCalendarEditor` ```ts -createCalendarEditor(source: EditingDocumentSource, options?: { readonly createId?: () => string; readonly initialEventIds?: ReadonlyArray; }): CalendarEditor +createCalendarEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; readonly initialEventIds?: ReadonlyArray; }): CalendarEditor ``` ## `createDatabaseEditor` ```ts -createDatabaseEditor(source: EditingDocumentSource): DatabaseEditor +createDatabaseEditor(source: EditingDocumentSource, options?: EditingHistoryOptions): DatabaseEditor ``` ## `createDocumentEditor` ```ts -createDocumentEditor(source: EditingDocumentSource, options?: { readonly createId?: () => string; }): DocumentEditor +createDocumentEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; }): DocumentEditor +``` +## `createEditingId` + +```ts +createEditingId(prefix: string): string ``` ## `createEditingSession` ```ts -createEditingSession(options: { readonly document: JSONDocument; readonly selection: Selection; readonly reconcileSelection?: (selection: Selection, value: JSONValue) => Selection; }): EditingSession +createEditingSession(options: EditingSessionOptions): EditingSession ``` ## `createKanbanEditor` ```ts -createKanbanEditor(source: EditingDocumentSource): KanbanEditor +createKanbanEditor(source: EditingDocumentSource, options?: EditingHistoryOptions): KanbanEditor ``` ## `createObjectEditor` ```ts -createObjectEditor(source: EditingDocumentSource, options?: { readonly createId?: () => string; }): ObjectEditor +createObjectEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; }): ObjectEditor ``` ## `createOrderEditor` ```ts -createOrderEditor(source: EditingDocumentSource, options?: { readonly createId?: () => string; }): OrderEditor +createOrderEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; }): OrderEditor ``` ## `createSheetEditor` ```ts -createSheetEditor(source: EditingDocumentSource): SheetEditor +createSheetEditor(source: EditingDocumentSource, options?: EditingHistoryOptions): SheetEditor ``` ## `createTreeEditor` ```ts -createTreeEditor(source: EditingDocumentSource, options?: { readonly createId?: () => string; }): TreeEditor +createTreeEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; }): TreeEditor ``` ## `cutEditingClipboard` @@ -902,6 +907,53 @@ interface EditingDispatch; } ``` +## `EditingDocumentChange` + +```ts +interface EditingDocumentChange { + readonly before: JSONValue; + readonly after: JSONValue; + /** Null when catching up without an observed, matching applied change. */ + readonly change: JSONAppliedChange | null; +} +``` +## `EditingHistory` + +```ts +interface EditingHistory { + status(): EditingHistoryStatus; + undo(): EditingHistoryResult; + redo(): EditingHistoryResult; + /** Includes history-only changes, even when the document value stays equal. */ + subscribe(listener: () => void): () => void; +} +``` +## `EditingHistoryOptions` + +```ts +interface EditingHistoryOptions { + /** Use the history belonging to the same document. Omit for local history. */ + readonly history?: EditingHistory; +} +``` +## `EditingHistoryResult` + +```ts +type EditingHistoryResult = + | { readonly ok: true; readonly target: string } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `EditingHistoryStatus` + +```ts +interface EditingHistoryStatus { + readonly undoTarget: string | null; + readonly redoTarget: string | null; + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly revision: number; +} +``` ## `EditingIntent` ```ts @@ -917,6 +969,7 @@ interface EditingPlan { readonly selectionAfter: Selection; readonly origin: string; readonly history?: "record" | "ignore"; + /** Groups local inverse history. An external history owner defines its own steps. */ readonly historyGroup?: string; } ``` @@ -940,6 +993,16 @@ interface EditingSession { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } ``` +## `EditingSessionOptions` + +```ts +interface EditingSessionOptions extends EditingHistoryOptions { + readonly document: JSONDocument; + readonly selection: Selection; + readonly mapSelection?: (selection: Selection, change: EditingDocumentChange) => Selection; + readonly reconcileSelection?: (selection: Selection, value: JSONValue) => Selection; +} +``` ## `EditingSnapshot` ```ts diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index 8b5acd3d0..303096a8e 100644 --- a/docs/api-reference/packages.mjs +++ b/docs/api-reference/packages.mjs @@ -28,4 +28,10 @@ export const apiReferencePackages = [ ["rich-text-react", "@interactive-os/json-document-rich-text-react", "packages/json-document-rich-text-react/src/index.tsx", "Connector", "Rich Text React connector"], ["collaboration", "@interactive-os/json-document-collaboration", "packages/json-document-collaboration/src/index.ts", "Collaboration", "replica, history, text collaboration runtime"], ["contenteditable-collaboration", "@interactive-os/json-document-contenteditable-collaboration", "packages/contenteditable-collaboration/src/index.ts", "Collaboration", "collaborative contenteditable lease"], -].map(([slug, packageName, entrypoint, owner, responsibility]) => ({ slug, packageName, entrypoint, owner, responsibility })); +].map(([slug, packageName, entrypoint, owner, responsibility]) => ({ + slug, packageName, entrypoint, owner, responsibility, + subpaths: slug === "collaboration" ? [{ + packageName: "@interactive-os/json-document-collaboration/editing", + entrypoint: "packages/json-document-collaboration/src/editing-index.ts", + }] : [], +})); diff --git a/docs/api-reference/rich-text.md b/docs/api-reference/rich-text.md index 447623437..48247c549 100644 --- a/docs/api-reference/rich-text.md +++ b/docs/api-reference/rich-text.md @@ -188,7 +188,7 @@ type RichTextEditorCreationResult = ## `RichTextEditorOptions` ```ts -interface RichTextEditorOptions { +interface RichTextEditorOptions extends EditingHistoryOptions { readonly document: JSONDocument; readonly pointer?: Pointer; readonly selection?: RichTextSelection; diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index ecffa12a2..d77815441 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -146,6 +146,7 @@ const activeCompanionPackages = new Set([ "@interactive-os/json-document-rich-text-suggestion-react", "@interactive-os/json-document-rich-text-mention", "@interactive-os/json-document-rich-text-mention-react", + "@interactive-os/json-document-rich-text", "@interactive-os/json-document-selection", "@interactive-os/json-document-react", "@interactive-os/json-document-react-hook-form", diff --git a/docs/public/collaboration-history.md b/docs/public/collaboration-history.md index 188f84f3b..c49625743 100644 --- a/docs/public/collaboration-history.md +++ b/docs/public/collaboration-history.md @@ -6,4 +6,57 @@ Collaborative History는 지금 참여자가 만든 인과 기여를 끄거나 Editing의 로컬 History와 다릅니다. 로컬 undo는 한 editor의 값과 Selection을 같이 되돌립니다. 여기서의 undo는 문서 시간 여행이 아닙니다. -자세한 호출 예와 상태 읽기는 이어서 채웁니다. +## Editor에 연결하기 + +`createCollaborationEditingHistory(runtime)`는 +`@interactive-os/json-document-collaboration/editing`의 공개 API입니다. +같은 runtime의 document와 history를 editor에 함께 전달합니다. + +```ts +import { createTextRuntime } from "@interactive-os/json-document-collaboration/text"; +import { createCollaborationEditingHistory } from "@interactive-os/json-document-collaboration/editing"; +import { createRichTextEditor } from "@interactive-os/json-document-rich-text"; + +const runtime = createTextRuntime(initialRichText, { + actorId: "browser-a", + epochId: "draft-42/v1", + ruleset: { id: "rich-text/v1", digest: "my-schema/v1" }, +}); +const editor = createRichTextEditor({ + document: runtime.document, + history: createCollaborationEditingHistory(runtime), +}); + +editor.dispatch({ type: "text.insert", text: "안녕하세요" }); +editor.undo(); +editor.redo(); +editor.snapshot.canUndo; +editor.snapshot.canRedo; +``` + +Document·Order·Object·Sheet·Tree·Database·Kanban·Calendar·Annotation editor도 +두 번째 인자로 `{ history }`를 받습니다. 연결 없이 협업 document만 넣으면 +기존 local inverse history가 유지되며 외부 변경 시 비워집니다. + +## 하나의 history owner + +Toolbar, Cmd/Ctrl+Z, native history input은 모두 editor의 undo/redo를 호출합니다. +별도 Host stack을 만들지 않습니다. availability와 값이 바뀌지 않는 인과 history +통지도 Collaboration이 소유합니다. + +Editor는 자신이 기록한 target의 Selection을 복원하되 현재 문서로 mapping합니다. +다른 참여자의 text 삽입은 위치 계산에 반영하고, 삭제된 domain ID는 정리합니다. +Editor가 생성되기 전에 작성된 target은 알 수 없는 과거 Selection을 만들지 않고 +현재 Selection을 reconcile합니다. Selection은 collaboration wire에 들어가지 않습니다. + +협업 undo 단위는 인과 commit 하나입니다. Local `historyGroup`은 이 단위를 합치지 +않으며, external history에서 `history: "ignore"` plan은 변경 전에 거절됩니다. +기본 local 사용에서는 기존 grouping을 유지합니다. + +## Usage + +[Rich Text 협업 history 실행 예](/editing/rich-text?history=collaboration)에서 +직접 입력 → 원격 변경 수신 → Undo/Redo를 확인할 수 있습니다. +Usage와 Source 탭은 공개 editor와 connection의 정본 구현으로 연결됩니다. +API 타입은 [Collaboration API](/docs/api/collaboration)와 +[Editing API](/docs/api/editing)에 있습니다. diff --git a/docs/public/history.md b/docs/public/history.md index 789a1a2ab..2d8c677f7 100644 --- a/docs/public/history.md +++ b/docs/public/history.md @@ -29,6 +29,10 @@ History 항목은 JSON 값이 실제로 바뀐 편집에서 생깁니다. Select 현재 편집 대상만 바꾸므로 기록을 추가하지 않습니다. 검사를 통과하지 못한 요청과 문서 값이 그대로인 요청도 되돌릴 값이 없어 기록되지 않습니다. +기본 local history는 외부 문서 변경을 받으면 비워집니다. 다른 참여자의 변경을 +보존하며 내 기여만 취소하려면 [Collaborative History](collaboration-history.md)의 +공식 연결 API를 사용합니다. document만 바꾸는 것으로 history 의미까지 바뀌지는 않습니다. + 여기까지 `editor.dispatch`로 시작한 요청이 Selection과 Topology를 읽고, Clipboard를 거쳐 문서와 History를 바꾸는 흐름을 살펴봤습니다. editor가 받는 전체 요청은 [Intent 레퍼런스](intent.md)에서 확인할 수 있습니다. diff --git a/package-lock.json b/package-lock.json index 309a604c8..0f02d8174 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6537,12 +6537,19 @@ "license": "MIT", "devDependencies": { "@interactive-os/json-document": "*", + "@interactive-os/json-document-editing": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" }, "peerDependencies": { - "@interactive-os/json-document": "^3.0.0" + "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-editing": "^0.1.0-rc.0" + }, + "peerDependenciesMeta": { + "@interactive-os/json-document-editing": { + "optional": true + } } }, "packages/json-document-composer": { @@ -6902,6 +6909,7 @@ "version": "0.1.0-rc.0", "license": "MIT", "devDependencies": { + "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", diff --git a/packages/json-document-collaboration/README.md b/packages/json-document-collaboration/README.md index 71d26cf7c..4728857cd 100644 --- a/packages/json-document-collaboration/README.md +++ b/packages/json-document-collaboration/README.md @@ -1,5 +1,39 @@ # @interactive-os/json-document-collaboration +## Editing history integration + +`@interactive-os/json-document-collaboration/editing` exports +`createCollaborationEditingHistory(runtime: HistoryRuntime): EditingHistory`. +This optional subpath connects existing selective history to all Editing domain +editors, including Rich Text. It does not extend JSONDocument or the wire. + +```ts +import { createHistoryRuntime } from "@interactive-os/json-document-collaboration/history"; +import { createCollaborationEditingHistory } from "@interactive-os/json-document-collaboration/editing"; +import { createDocumentEditor } from "@interactive-os/json-document-editing"; + +const runtime = createHistoryRuntime({ blocks: [{ id: "a", text: "Draft" }] }, { + actorId: "browser-a", epochId: "document-42/v1", + ruleset: { id: "blocks", digest: "blocks/v1" }, +}); +const editor = createDocumentEditor(runtime.document, { + history: createCollaborationEditingHistory(runtime), +}); +editor.dispatch({ type: "text.replace", blockId: "a", text: "Edited" }); +editor.undo(); // Same selective owner as runtime.history, with editor selection restoration. +``` + +Use `createTextRuntime` for concurrent text splices. Pass the history and document +from the **same runtime**. Toolbar and DOM integrations call `editor.undo/redo`; +they must not maintain separate stacks. Status includes causal-only changes and +subscriptions are released with the last editor observer. + +One causal commit is one undo step. Editing's local `historyGroup` does not group +causal changes, and an external-history Editing plan cannot opt out of recording. +History remains local unless this connection is explicitly configured. +See [Collaborative History](../../docs/public/collaboration-history.md) and the +owner [API reference](../../docs/api-reference/collaboration.md). + Remote `document.subscribe` notifications compile visible tree identities into ordered JSON Patch moves, insertions, and removals. Consumers can use `trackPointer(pointer, change.applied, before)` with the previous snapshot to diff --git a/packages/json-document-collaboration/package.json b/packages/json-document-collaboration/package.json index bb9850e2a..c79bf9c87 100644 --- a/packages/json-document-collaboration/package.json +++ b/packages/json-document-collaboration/package.json @@ -35,6 +35,10 @@ "./text": { "types": "./dist/text-index.d.ts", "import": "./dist/text-index.js" + }, + "./editing": { + "types": "./dist/editing-index.d.ts", + "import": "./dist/editing-index.js" } }, "scripts": { @@ -48,10 +52,15 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { - "@interactive-os/json-document": "^3.0.0" + "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-editing": "^0.1.0-rc.0" + }, + "peerDependenciesMeta": { + "@interactive-os/json-document-editing": { "optional": true } }, "devDependencies": { "@interactive-os/json-document": "*", + "@interactive-os/json-document-editing": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" diff --git a/packages/json-document-collaboration/src/editing-index.ts b/packages/json-document-collaboration/src/editing-index.ts new file mode 100644 index 000000000..e3305bd30 --- /dev/null +++ b/packages/json-document-collaboration/src/editing-index.ts @@ -0,0 +1,28 @@ +import type { EditingHistory } from "@interactive-os/json-document-editing"; +import { changeIdKey } from "./change.js"; +import type { HistoryRuntime } from "./types.js"; + +/** Bind Editing to this runtime's selective history, one causal commit per step. */ +export function createCollaborationEditingHistory(runtime: HistoryRuntime): EditingHistory { + return { + status() { + const status = runtime.history.status(); + return { + undoTarget: status.undoTarget === null ? null : changeIdKey(status.undoTarget), + redoTarget: status.redoTarget === null ? null : changeIdKey(status.redoTarget), + canUndo: runtime.history.canUndo().ok, + canRedo: runtime.history.canRedo().ok, + revision: status.revision, + }; + }, + undo() { + const result = runtime.history.undo(); + return result.ok ? { ok: true, target: changeIdKey(result.target) } : result; + }, + redo() { + const result = runtime.history.redo(); + return result.ok ? { ok: true, target: changeIdKey(result.target) } : result; + }, + subscribe: (listener) => runtime.replica.subscribe(listener), + }; +} diff --git a/packages/json-document-collaboration/tests/editor-composition.test.ts b/packages/json-document-collaboration/tests/editor-composition.test.ts new file mode 100644 index 000000000..bc4a6694e --- /dev/null +++ b/packages/json-document-collaboration/tests/editor-composition.test.ts @@ -0,0 +1,143 @@ +import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; +import { + createDocumentEditor, createEditingSession, createOrderEditor, createObjectEditor, createTreeEditor, createCalendarEditor, +} from "@interactive-os/json-document-editing"; +import { describe, expect, test } from "vitest"; +import { createIndependentJSONDocument } from "../../../standards/json-document-v3/implementations/independent/json-document.js"; +import { createHistoryRuntime } from "../src/create.js"; +import { createCollaborationEditingHistory } from "../src/editing-index.js"; + +const ruleset = { id: "editing-composition", digest: "1" }; +const initial = { blocks: [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }], other: 0 }; +function runtime(value: JSONValue = initial, actorId = "local") { + return createHistoryRuntime(value, { actorId, epochId: "editing-composition", ruleset }); +} + +describe.each([ + { name: "reference", create: () => createJSONDocument(initial) }, + { name: "independent", create: () => createIndependentJSONDocument("json", initial) }, + { name: "collaboration (local history)", create: () => runtime().document }, +])("real Document consumer: $name", ({ create }) => { + test("moves, edits by identity, restores history and reconciles external deletion", () => { + const document = create(); + const editor = createDocumentEditor(document); + const release = editor.subscribe(() => {}); + expect(editor.dispatch({ type: "selection.move", direction: 1 }).ok).toBe(true); + expect(editor.dispatch({ type: "text.replace", blockId: "a", text: "Edited" }).ok).toBe(true); + expect(document.at("/blocks/1")).toMatchObject({ ok: true, value: { id: "a", text: "Edited" } }); + expect(editor.undo().ok).toBe(true); + expect(editor.undo().ok).toBe(true); + expect(document.value).toEqual(initial); + expect(editor.redo().ok).toBe(true); + expect(document.commit([{ op: "remove", path: "/blocks/1" }]).ok).toBe(true); + expect(editor.snapshot.selection).toEqual({ kind: "range", ranges: [], primaryIndex: null }); + expect(editor.snapshot.canUndo).toBe(false); + release(); + }); + + test("does not absorb reentrant writes into the editor transaction", () => { + const document = create(); + let once = false; + document.subscribe(() => { + if (once) return; + once = true; + document.commit([{ op: "replace", path: "/other", value: 99 }]); + }); + const editor = createDocumentEditor(document); + const result = editor.dispatch({ type: "selection.move", direction: 1 }); + expect(result).toMatchObject({ ok: true, snapshot: { value: { other: 0 } } }); + expect(editor.snapshot).toMatchObject({ value: { other: 99 }, canUndo: false }); + expect(editor.undo().ok).toBe(false); + expect(document.at("/other")).toMatchObject({ ok: true, value: 99 }); + }); +}); + +describe.each([true, false])("official selective history (observed: %s)", (observed) => { + test("retains remote fields, restores selection and shares runtime history availability", () => { + const local = runtime(); + const remote = runtime(initial, "remote"); + const editor = createDocumentEditor(local.document, { history: createCollaborationEditingHistory(local) }); + const release = observed ? editor.subscribe(() => {}) : () => {}; + expect(editor.dispatch({ type: "text.replace", blockId: "a", text: "Local", offset: 5 }).ok).toBe(true); + expect(remote.document.commit([{ op: "replace", path: "/blocks/1/text", value: "Remote" }]).ok).toBe(true); + expect(local.replica.ingest(remote.replica.exportBundle()).ok).toBe(true); + expect(editor.snapshot.canUndo).toBe(local.history.canUndo().ok); + expect(editor.undo().ok).toBe(true); + expect(local.document.at("/blocks")).toMatchObject({ ok: true, value: [ + { id: "a", text: "Alpha" }, { id: "b", text: "Remote" }, + ] }); + expect(editor.snapshot.selection.ranges[0]!.focus).toEqual({ blockId: "a", offset: 0 }); + expect(editor.snapshot.canRedo).toBe(local.history.canRedo().ok); + expect(editor.redo().ok).toBe(true); + expect(editor.snapshot.selection.ranges[0]!.focus).toEqual({ blockId: "a", offset: 5 }); + expect(local.document.at("/blocks/1/text")).toMatchObject({ ok: true, value: "Remote" }); + release(); + }); +}); + +test("publishes causal-only history changes and releases both subscriptions", () => { + const local = runtime(); + const editor = createDocumentEditor(local.document, { history: createCollaborationEditingHistory(local) }); + const seen: boolean[] = []; + const release = editor.subscribe((snapshot) => seen.push(snapshot.canUndo)); + expect(editor.dispatch({ type: "text.replace", blockId: "a", text: "Local" }).ok).toBe(true); + const remote = runtime(initial, "remote"); + remote.replica.ingest(local.replica.exportBundle()); + remote.document.commit([{ op: "replace", path: "/blocks/0/text", value: "Remote" }]); + local.replica.ingest(remote.replica.exportBundle()); + const before = local.document.value; + const undo = local.history.undo(); + expect(undo).toMatchObject({ ok: true, didChangeDocument: false }); + expect(local.document.value).toEqual(before); + expect(seen.at(-1)).toBe(false); + release(); + const count = seen.length; + local.history.redo(); + expect(seen).toHaveLength(count); +}); + +test("external history uses causal commit steps and rejects unsupported ignore before mutation", () => { + const local = runtime(); + const session = createEditingSession({ document: local.document, selection: null, history: createCollaborationEditingHistory(local) }); + const ignored = session.apply({ operations: [{ op: "replace", path: "/other", value: 1 }], selectionAfter: null, origin: "ignored", history: "ignore" }); + expect(ignored).toMatchObject({ ok: false, code: "history.ignore-unsupported" }); + expect(local.document.value).toEqual(initial); + for (const value of [1, 2]) expect(session.apply({ + operations: [{ op: "replace", path: "/other", value }], selectionAfter: null, origin: "typing", historyGroup: "typing", + }).ok).toBe(true); + expect(session.undo().ok).toBe(true); + expect(local.document.at("/other")).toMatchObject({ ok: true, value: 1 }); +}); + +test.each(["Document", "Order", "Object", "Tree", "Calendar"])("%s default IDs remain unique after concurrent merge", (domain) => { + const value = domain === "Document" ? { blocks: [] } + : domain === "Order" ? { items: [] } + : domain === "Object" ? { objects: [] } + : domain === "Tree" ? { nodes: [] } + : { calendars: [{ id: "home", title: "Home", color: "subtle", hidden: false }], events: [] }; + const left = runtime(value, "left"); + const right = runtime(value, "right"); + for (const { document } of [left, right]) { + const result = domain === "Document" ? createDocumentEditor(document).dispatch({ type: "block.insert", text: "copy" }) + : domain === "Order" ? createOrderEditor(document).dispatch({ type: "clipboard.paste", clipboard: { type: "application/vnd.interactive-os.order+json", items: [{ id: "source", label: "copy" }], text: "copy" } }) + : domain === "Object" ? createObjectEditor(document).dispatch({ type: "clipboard.paste", clipboard: { type: "application/vnd.interactive-os.objects+json", objects: [{ id: "source", label: "copy", x: 0, y: 0, width: 1, height: 1, color: "subtle" }], text: "copy" } }) + : domain === "Tree" ? createTreeEditor(document).dispatch({ type: "clipboard.paste", topology: { visibleIds: [] }, clipboard: { type: "application/vnd.interactive-os.tree+json", nodes: [{ id: "source", label: "copy", parentId: null }], text: "copy" } }) + : createCalendarEditor(document).dispatch({ type: "event.create", start: "2026-08-03T09:00", end: "2026-08-03T10:00", calendarId: "home" }); + expect(result.ok).toBe(true); + } + expect(left.replica.ingest(right.replica.exportBundle()).ok).toBe(true); + expect(right.replica.ingest(left.replica.exportBundle()).ok).toBe(true); + expect(left.document.value).toEqual(right.document.value); + const pointer = domain === "Document" ? "/blocks" : domain === "Order" ? "/items" : domain === "Object" ? "/objects" : domain === "Tree" ? "/nodes" : "/events"; + const entries = left.document.at(pointer); + expect(entries.ok).toBe(true); + if (!entries.ok) return; + const ids = (entries.value as ReadonlyArray<{ readonly id: string }>).map((entry) => entry.id); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + if (domain === "Document") { + expect(createDocumentEditor(left.document).dispatch({ type: "text.replace", blockId: ids[1]!, text: "targeted" }).ok).toBe(true); + expect(left.document.at("/blocks/0/text")).toMatchObject({ ok: true, value: "copy" }); + expect(left.document.at("/blocks/1/text")).toMatchObject({ ok: true, value: "targeted" }); + } +}); diff --git a/packages/json-document-collaboration/tests/unit/public-surface.test.ts b/packages/json-document-collaboration/tests/unit/public-surface.test.ts index ae635d52e..03ca8f6c5 100644 --- a/packages/json-document-collaboration/tests/unit/public-surface.test.ts +++ b/packages/json-document-collaboration/tests/unit/public-surface.test.ts @@ -42,14 +42,16 @@ describe("public collaboration surface", () => { expect(text.document.value).toEqual(initial); }); - test("package export paths stay the three public entrypoints", () => { + test("package export paths include the optional Editing history integration", () => { const pkg = JSON.parse(readSrc("package.json")) as { exports: Record; }; - expect(Object.keys(pkg.exports).sort()).toEqual([".", "./history", "./text"]); + expect(Object.keys(pkg.exports).sort()).toEqual([".", "./editing", "./history", "./text"]); expect(pkg.exports["."]?.import).toBe("./dist/index.js"); expect(pkg.exports["./history"]?.import).toBe("./dist/history-index.js"); expect(pkg.exports["./text"]?.import).toBe("./dist/text-index.js"); + expect(pkg.exports["./editing"]?.import).toBe("./dist/editing-index.js"); + expect(pkg.exports["./editing"]?.types).toBe("./dist/editing-index.d.ts"); }); test("each independent change reason has one owner module", () => { diff --git a/packages/json-document-collaboration/tsconfig.json b/packages/json-document-collaboration/tsconfig.json index bf2f76146..0010f7dcf 100644 --- a/packages/json-document-collaboration/tsconfig.json +++ b/packages/json-document-collaboration/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, - "references": [{ "path": "../json-document" }], + "references": [{ "path": "../json-document" }, { "path": "../json-document-editing" }], "include": [ "src/**/*.ts" ] diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index b1e83b610..30515ab07 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -11,12 +11,39 @@ do not reject completed edits; reentrant notifications are delivered in revision order. A returned result describes its own transition, even if a subscriber has already performed another transition. -Domains can provide `reconcileSelection(selection, value)` to -`createEditingSession`. This pure callback runs once for an actual external -value change, before the new snapshot is observed or delivered. It repairs -selection validity; it is not an applied-change mapping or collaborative history -rebase. External changes still clear local undo/redo. Without the callback, -selection is preserved as before. +Domains can provide `mapSelection(selection, { before, after, change })` and +`reconcileSelection(selection, value)` to `createEditingSession`. Both are pure +callbacks and run before publication of an external value change, mapping first. +`change` is the matching applied change, or `null` when a lazy read or reentrant +write must catch up from snapshots alone. Mapping must support that case. +Reconciliation repairs validity without claiming to preserve logical positions. +All nine built-in editors reconcile external deletion using their own selection +families; Calendar retains valid off-screen occurrences. Rich Text maps stable +text IDs through external text replacement, including affinity and scalar boundaries. + +External changes clear **local inverse history**, not an optional external +`EditingHistory` owner. Every domain editor accepts `{ history }`; Rich Text +accepts it in `RichTextEditorOptions`. The official Collaboration connection is +`createCollaborationEditingHistory(runtime)` from +`@interactive-os/json-document-collaboration/editing`. +Use it with the same runtime's document. Simply injecting a collaboration +document does not enable selective history. + +The external owner defines undo steps. Collaboration uses one causal commit +per step; local `historyGroup` does not merge those steps. An explicit +`history: "ignore"` plan is rejected before mutation with +`history.ignore-unsupported` when an external history owner is configured. +Availability and history-only notifications come from that owner. Selection +before/after each editor-authored target is retained locally and mapped to the +current document on undo/redo. Selections are not added to the collaboration wire. +Unknown targets (for example, changes made before this editor existed) reconcile +the current selection instead of inventing historical selection. + +`createEditingId(prefix)` supplies opaque UUID-based identities for Document, +Order, Object, Tree, Calendar and Rich Text. IDs do not restart per editor or +replica. Custom `createId` injection remains supported; its provider must ensure +uniqueness across all writers. Environments without `crypto.randomUUID` fail +explicitly with `editing.id-provider-unavailable`; no weak random fallback is used. The session subscribes to its document only while it has observers. The last unsubscribe releases that connection; later reads catch up with external state. @@ -33,7 +60,7 @@ domain slice is a small block document used by the official site demo. Every domain editor accepts either an initial JSON value or an existing `JSONDocument`. Passing an existing instance lets multiple Connectors observe and commit the same canonical state while each editor keeps its own structural -selection and local history. +selection and, by default, local history. ```ts const document = createJSONDocument(initialSheet); diff --git a/packages/json-document-editing/src/annotation.ts b/packages/json-document-editing/src/annotation.ts index 8c8cc7809..212ab8dec 100644 --- a/packages/json-document-editing/src/annotation.ts +++ b/packages/json-document-editing/src/annotation.ts @@ -1,5 +1,6 @@ import { buildPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import type { EditingHistoryOptions } from "./history.js"; import { createEditingSession, type EditingResult, type EditingSnapshot } from "./session.js"; import { assertAnnotation, assertAnnotationDocument } from "./annotation-validation.js"; @@ -29,10 +30,19 @@ export type AnnotationIntent = | { readonly type: "annotation.delete"; readonly annotationId: string }; export interface AnnotationEditor { readonly snapshot: EditingSnapshot; dispatch(intent: AnnotationIntent): EditingResult; undo(): EditingResult; redo(): EditingResult; subscribe(listener: () => void): () => void } -export function createAnnotationEditor(source: EditingDocumentSource): AnnotationEditor { +export function createAnnotationEditor(source: EditingDocumentSource, options: EditingHistoryOptions = {}): AnnotationEditor { const document = resolveDocumentSource(source); assertAnnotationDocument(document.value as AnnotationDocument); - const session = createEditingSession({ document, selection: selectionFor([]) }); + const session = createEditingSession({ + ...options, + document, + selection: selectionFor([]), + reconcileSelection(selection, value) { + const available = new Set((value as AnnotationDocument).annotations.map((annotation) => annotation.id)); + const ids = selection.ids.filter((id) => available.has(id)); + return selectionFor(ids, selection.primaryId !== null && available.has(selection.primaryId) ? selection.primaryId : ids.at(-1) ?? null); + }, + }); const value = () => session.snapshot.value as AnnotationDocument; function dispatch(intent: AnnotationIntent): EditingResult { const annotations = value().annotations; diff --git a/packages/json-document-editing/src/calendar.ts b/packages/json-document-editing/src/calendar.ts index bffe7682a..5e6bb9203 100644 --- a/packages/json-document-editing/src/calendar.ts +++ b/packages/json-document-editing/src/calendar.ts @@ -18,6 +18,8 @@ import { } from "./session.js"; import { cutEditingClipboard, type EditingClipboardCut } from "./clipboard.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import { createEditingId } from "./identity.js"; +import type { EditingHistoryOptions } from "./history.js"; import { addCalendarDate, assertCalendarDocument, @@ -213,7 +215,7 @@ export interface CalendarEditor { export function createCalendarEditor( source: EditingDocumentSource, - options: { + options: EditingHistoryOptions & { readonly createId?: () => string; readonly initialEventIds?: ReadonlyArray; } = {}, @@ -221,8 +223,7 @@ export function createCalendarEditor( const document = resolveDocumentSource(source); const initial = document.value as CalendarDocument; assertCalendarDocument(initial); - let sequence = 0; - const createId = options.createId ?? (() => `event-${++sequence}`); + const createId = options.createId ?? (() => createEditingId("event")); const selectionFamily = createMaterializedRangeSelectionFamily(); const first = initial.events[0]; const availableIds = new Set(initial.events.map((event) => event.id)); @@ -230,8 +231,15 @@ export function createCalendarEditor( ? (first ? [first.id] : []) : options.initialEventIds.filter((id) => availableIds.has(id)); const session = createEditingSession({ + ...options, document, selection: selectionForEvents(initial.events, initialEventIds), + reconcileSelection: (selection, value) => asCalendarSelection(selectionFamily.reconcile(selection, { + topology: calendarOccurrenceOrderedTopology( + (value as CalendarDocument).events, + selection.ranges.flatMap((range) => range.points), + ), + }).state), }); function value(): CalendarDocument { diff --git a/packages/json-document-editing/src/database.ts b/packages/json-document-editing/src/database.ts index 9dabc4ca8..666da66f9 100644 --- a/packages/json-document-editing/src/database.ts +++ b/packages/json-document-editing/src/database.ts @@ -11,6 +11,8 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import type { EditingHistoryOptions } from "./history.js"; +import { reconcileRangeSelection } from "./range-selection.js"; import { isClipboardJSONValue, isClipboardRecord } from "./clipboard.js"; import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds } from "./topology.js"; import { acceptsDatabaseValue, defaultDatabaseValue } from "./database-property-value.js"; @@ -175,17 +177,23 @@ export interface DatabaseEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } -export function createDatabaseEditor(source: EditingDocumentSource): DatabaseEditor { +export function createDatabaseEditor(source: EditingDocumentSource, options: EditingHistoryOptions = {}): DatabaseEditor { const document = resolveDocumentSource(source); const initial = document.value as DatabaseDocument; assertDatabaseDocument(initial); const firstRecord = initial.records[0]; const firstProperty = initial.schema.properties[0]; const session = createEditingSession({ + ...options, document, selection: firstRecord && firstProperty ? collapsed(firstRecord.id, firstProperty.id) : emptySelection(), + reconcileSelection: (selection, value) => withPrimaryAliases(reconcileRangeSelection(selection, (point) => { + const database = value as DatabaseDocument; + return database.records.some((record) => record.id === point.recordId) + && database.schema.properties.some((property) => property.id === point.propertyId) ? point : null; + })), }); let indexedDocument: DatabaseDocument | undefined = initial; let indexedDatabase: DatabaseIndex | undefined = createDatabaseIndex(initial); diff --git a/packages/json-document-editing/src/document.ts b/packages/json-document-editing/src/document.ts index 0794ce7fe..adb3f7611 100644 --- a/packages/json-document-editing/src/document.ts +++ b/packages/json-document-editing/src/document.ts @@ -1,10 +1,13 @@ import { type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import { createEditingId } from "./identity.js"; +import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { createEditingSession, type EditingResult, type EditingSession, type EditingSnapshot } from "./session.js"; import { collapsedRangeSelection, emptyRangeSelection, + reconcileRangeSelection, selectRangePoint, } from "./range-selection.js"; import { lineInterval, lineTopology } from "./topology.js"; @@ -78,14 +81,21 @@ export interface DocumentEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } -export function createDocumentEditor(source: EditingDocumentSource, options: { readonly createId?: () => string } = {}): DocumentEditor { +export function createDocumentEditor(source: EditingDocumentSource, options: EditingHistoryOptions & { readonly createId?: () => string } = {}): DocumentEditor { const document = resolveDocumentSource(source); const initial = document.value as BlockDocument; - let sequence = 0; - const createId = options.createId ?? (() => `block-${++sequence}`); + const createId = options.createId ?? (() => createEditingId("block")); const first = initial.blocks[0]; const initialSelection = first ? collapsed(first.id, 0) : emptySelection(); - const session = createEditingSession({ document, selection: initialSelection }); + const session = createEditingSession({ + ...options, + document, + selection: initialSelection, + reconcileSelection: (selection, value) => asDocumentSelection(reconcileRangeSelection(selection, (point) => { + const block = (value as BlockDocument).blocks.find((block) => block.id === point.blockId); + return block ? pointAt(block, point.offset) : null; + })), + }); function value(): BlockDocument { return session.snapshot.value as BlockDocument; diff --git a/packages/json-document-editing/src/history.ts b/packages/json-document-editing/src/history.ts new file mode 100644 index 000000000..c5db9aceb --- /dev/null +++ b/packages/json-document-editing/src/history.ts @@ -0,0 +1,25 @@ +/** Optional history owner. Its steps replace local inverse-patch history. */ +export interface EditingHistory { + status(): EditingHistoryStatus; + undo(): EditingHistoryResult; + redo(): EditingHistoryResult; + /** Includes history-only changes, even when the document value stays equal. */ + subscribe(listener: () => void): () => void; +} + +export interface EditingHistoryStatus { + readonly undoTarget: string | null; + readonly redoTarget: string | null; + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly revision: number; +} + +export type EditingHistoryResult = + | { readonly ok: true; readonly target: string } + | { readonly ok: false; readonly code: string; readonly reason?: string }; + +export interface EditingHistoryOptions { + /** Use the history belonging to the same document. Omit for local history. */ + readonly history?: EditingHistory; +} diff --git a/packages/json-document-editing/src/identity.ts b/packages/json-document-editing/src/identity.ts new file mode 100644 index 000000000..a4bbb09ea --- /dev/null +++ b/packages/json-document-editing/src/identity.ts @@ -0,0 +1,6 @@ +/** Create an opaque domain identity that is independent of editor and replica lifetimes. */ +export function createEditingId(prefix: string): string { + const provider = (globalThis as { readonly crypto?: { readonly randomUUID?: () => string } }).crypto; + if (typeof provider?.randomUUID !== "function") throw new TypeError("editing.id-provider-unavailable"); + return `${prefix}-${provider.randomUUID()}`; +} diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index f382dffb0..470f7fcdd 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -18,6 +18,8 @@ export { acceptsDatabaseValue, databaseValueFromText, defaultDatabaseValue } fro export { createObjectEditor, objectClipboardFormat } from "./object.js"; export { createOrderEditor, orderClipboardFormat } from "./order.js"; export { createEditingSession } from "./session.js"; +export { createEditingId } from "./identity.js"; +export type { EditingHistory, EditingHistoryOptions, EditingHistoryResult, EditingHistoryStatus } from "./history.js"; export { createSheetEditor, sheetClipboardFormat } from "./sheet.js"; export { createTreeEditor, treeClipboardFormat } from "./tree.js"; export { projectTreeVisibility, treeVisibilityNeighbor } from "./tree-visibility.js"; @@ -131,6 +133,8 @@ export type { export type { EditingDispatch, EditingIntent } from "./intent.js"; export type { EditingPlan, + EditingDocumentChange, + EditingSessionOptions, EditingResult, EditingSession, EditingSnapshot, diff --git a/packages/json-document-editing/src/invert-patch.ts b/packages/json-document-editing/src/invert-patch.ts new file mode 100644 index 000000000..a077bd7f1 --- /dev/null +++ b/packages/json-document-editing/src/invert-patch.ts @@ -0,0 +1,82 @@ +import { + appendSegment, + createJSONDocument, + parentPointer, + parsePointer, + trackPointer, + tryParsePointer, + type JSONDocument, + type JSONPatchOperation, +} from "@interactive-os/json-document"; + +/** Invert against each sequential pre-state, retaining moves when reversible. */ +export function invertEditingPatch(document: JSONDocument, operations: ReadonlyArray): ReadonlyArray | null { + const isolated = operations.length > 1 || operations.some((op) => op.op === "move" || op.op === "copy"); + const working = isolated ? createJSONDocument(document.value) : document; + let inverse: JSONPatchOperation[] = []; + for (const operation of operations) { + if (tryParsePointer(operation.path) === null) return null; + let step: JSONPatchOperation[] = []; + if (operation.op === "move") { + const source = working.at(operation.from); + if (!source.ok) return null; + if (operation.from === operation.path) continue; + const from = tryParsePointer(operation.from); + const to = tryParsePointer(operation.path); + if (from === null || to === null) return null; + if (to.every((part, index) => from[index] === part)) { + const destinationParent = parentPointer(operation.path); + const destinationContainer = destinationParent === null ? null : working.at(destinationParent); + const previous = working.at(operation.path); + if (!previous.ok || !working.commit([operation]).ok) return null; + // Array ancestors insert, so remove the insertion before restoring the + // source. A reverse move would target its own descendant and be invalid. + // Root/object ancestors replace the entire destination container. + step = destinationContainer?.ok && Array.isArray(destinationContainer.value) + ? [{ op: "remove", path: operation.path }, { op: "add", path: operation.from, value: source.value }] + : [{ op: "replace", path: operation.path, value: previous.value }]; + } else { + if (!working.commit([{ op: "remove", path: operation.from }]).ok) return null; + const path = insertionPath(working, operation.path); + if (path === null) return null; + const parent = parentPointer(path); + const container = parent === null ? null : working.at(parent); + const previous = working.at(path); + if (!working.commit([{ op: "add", path, value: source.value }]).ok) return null; + const move: JSONPatchOperation = { op: "move", from: path, path: operation.from }; + step = [move]; + if (previous.ok && container?.ok && !Array.isArray(container.value)) { + const restoredParent = trackPointer(parent!, [move], working.value); + if (restoredParent === null) return null; + step.push({ op: "add", path: appendSegment(restoredParent, parsePointer(path).at(-1)!), value: previous.value }); + } + } + } else if (operation.op === "add" || operation.op === "copy") { + const path = insertionPath(working, operation.path); + if (path === null) return null; + const parent = parentPointer(path); + const container = parent === null ? null : working.at(parent); + const previous = working.at(path); + step = [previous.ok && (parent === null || (container?.ok && !Array.isArray(container.value))) + ? { op: "replace", path, value: previous.value } + : { op: "remove", path }]; + if (isolated && !working.commit([operation]).ok) return null; + } else if (operation.op === "replace" || operation.op === "remove") { + const previous = working.at(operation.path); + if (!previous.ok) return null; + step = [{ op: operation.op === "replace" ? "replace" : "add", path: operation.path, value: previous.value }]; + if (isolated && !working.commit([operation]).ok) return null; + } else if (isolated && !working.commit([operation]).ok) return null; + inverse = [...step, ...inverse]; + } + return inverse; +} + +function insertionPath(document: JSONDocument, path: string): string | null { + if (!path.endsWith("/-")) return path; + const parent = path.slice(0, -2); + const container = document.at(parent); + if (!container.ok) return null; + // '-' is also a valid object property. It means append only on arrays. + return Array.isArray(container.value) ? appendSegment(parent, container.value.length) : path; +} diff --git a/packages/json-document-editing/src/kanban.ts b/packages/json-document-editing/src/kanban.ts index 2934eb453..bf9a11c83 100644 --- a/packages/json-document-editing/src/kanban.ts +++ b/packages/json-document-editing/src/kanban.ts @@ -14,6 +14,7 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import type { EditingHistoryOptions } from "./history.js"; import { assertKanbanDocument } from "./kanban-validation.js"; export interface KanbanCard extends Record { @@ -64,15 +65,25 @@ export interface KanbanEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } -export function createKanbanEditor(source: EditingDocumentSource): KanbanEditor { +export function createKanbanEditor(source: EditingDocumentSource, options: EditingHistoryOptions = {}): KanbanEditor { const document = resolveDocumentSource(source); const initial = document.value as KanbanDocument; assertKanbanDocument(initial); const selectionFamily = createKeySelectionFamily(); const first = initial.cards[0]; const session = createEditingSession({ + ...options, document, selection: first ? selectionFor([first.id]) : selectionFor([]), + reconcileSelection(selection, value) { + const context: KeySelectionContext = { + keys: (value as KanbanDocument).cards.map((card) => card.id), + universe: "cards", + universeMismatch: "clear", + }; + const next = selectionFamily.reconcile(selection, context).state; + return selectionFor(selectionFamily.targets(next, context), next.primaryKey); + }, }); function value(): KanbanDocument { diff --git a/packages/json-document-editing/src/object.ts b/packages/json-document-editing/src/object.ts index fdf27a02f..a764eb65f 100644 --- a/packages/json-document-editing/src/object.ts +++ b/packages/json-document-editing/src/object.ts @@ -14,6 +14,8 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import { createEditingId } from "./identity.js"; +import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { assertObjectDocument } from "./object-validation.js"; @@ -104,18 +106,27 @@ export interface ObjectEditor { export function createObjectEditor( source: EditingDocumentSource, - options: { readonly createId?: () => string } = {}, + options: EditingHistoryOptions & { readonly createId?: () => string } = {}, ): ObjectEditor { const document = resolveDocumentSource(source); const initial = document.value as ObjectDocument; assertObjectDocument(initial); - let sequence = 0; - const createId = options.createId ?? (() => `object-${++sequence}`); + const createId = options.createId ?? (() => createEditingId("object")); const selectionFamily = createKeySelectionFamily(); const first = initial.objects[0]; const session = createEditingSession({ + ...options, document, selection: first ? selectionFor([first.id]) : selectionFor([]), + reconcileSelection(selection, value) { + const context: KeySelectionContext = { + keys: (value as ObjectDocument).objects.map((object) => object.id), + universe: "objects", + universeMismatch: "clear", + }; + const next = selectionFamily.reconcile(selection, context).state; + return selectionFor(selectionFamily.targets(next, context), next.primaryKey); + }, }); function value(): ObjectDocument { diff --git a/packages/json-document-editing/src/order.ts b/packages/json-document-editing/src/order.ts index e972d2bb4..d02695352 100644 --- a/packages/json-document-editing/src/order.ts +++ b/packages/json-document-editing/src/order.ts @@ -3,10 +3,13 @@ import { type JSONValue, } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import { createEditingId } from "./identity.js"; +import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { collapsedRangeSelection, emptyRangeSelection, + reconcileRangeSelection, selectRangePoint, type RangeSelectionState, } from "./range-selection.js"; @@ -83,17 +86,19 @@ export interface OrderEditor { export function createOrderEditor( source: EditingDocumentSource, - options: { readonly createId?: () => string } = {}, + options: EditingHistoryOptions & { readonly createId?: () => string } = {}, ): OrderEditor { const document = resolveDocumentSource(source); const initial = document.value as OrderDocument; assertOrderDocument(initial); - let sequence = 0; - const createId = options.createId ?? (() => `item-${++sequence}`); + const createId = options.createId ?? (() => createEditingId("item")); const first = initial.items[0]; const session = createEditingSession({ + ...options, document, selection: first ? collapsed(first.id) : emptySelection(), + reconcileSelection: (selection, value) => asOrderSelection(reconcileRangeSelection(selection, + (point) => (value as OrderDocument).items.some((item) => item.id === point.itemId) ? point : null)), }); function value(): OrderDocument { diff --git a/packages/json-document-editing/src/range-selection.ts b/packages/json-document-editing/src/range-selection.ts index 55d81514d..33b9b73fa 100644 --- a/packages/json-document-editing/src/range-selection.ts +++ b/packages/json-document-editing/src/range-selection.ts @@ -40,4 +40,18 @@ export function emptyRangeSelection(): RangeSelectionState { return empty(); } +/** Reconcile domain points while retaining the canonical range/primary rules. */ +export function reconcileRangeSelection( + selection: RangeSelectionState, + reconcilePoint: (point: Point) => Point | null, +): RangeSelectionState { + return createRangeSelectionFamily().reconcile(selection, { + topology: { + equals: (left, right) => left === right, + interval: (anchor, focus) => [anchor, focus], + reconcilePoint, + }, + }).state; +} + export { primaryRange }; diff --git a/packages/json-document-editing/src/session.ts b/packages/json-document-editing/src/session.ts index 6781e546c..90f227edc 100644 --- a/packages/json-document-editing/src/session.ts +++ b/packages/json-document-editing/src/session.ts @@ -1,19 +1,36 @@ import { + applyPatch, createJSONDocument, jsonEqual, - parentPointer, type JSONAppliedChange, type JSONDocument, type JSONPatchOperation, type JSONValue, } from "@interactive-os/json-document"; import type { SelectionHistoryEntry } from "@interactive-os/json-document-selection"; +import { invertEditingPatch } from "./invert-patch.js"; +import type { EditingHistoryOptions, EditingHistoryResult } from "./history.js"; + +export interface EditingDocumentChange { + readonly before: JSONValue; + readonly after: JSONValue; + /** Null when catching up without an observed, matching applied change. */ + readonly change: JSONAppliedChange | null; +} + +export interface EditingSessionOptions extends EditingHistoryOptions { + readonly document: JSONDocument; + readonly selection: Selection; + readonly mapSelection?: (selection: Selection, change: EditingDocumentChange) => Selection; + readonly reconcileSelection?: (selection: Selection, value: JSONValue) => Selection; +} export interface EditingPlan { readonly operations: ReadonlyArray; readonly selectionAfter: Selection; readonly origin: string; readonly history?: "record" | "ignore"; + /** Groups local inverse history. An external history owner defines its own steps. */ readonly historyGroup?: string; } @@ -44,12 +61,7 @@ interface HistoryEntry readonly group?: string; } -export function createEditingSession(options: { - readonly document: JSONDocument; - readonly selection: Selection; - /** Reconcile domain selection when an external value invalidates local history. */ - readonly reconcileSelection?: (selection: Selection, value: JSONValue) => Selection; -}): EditingSession { +export function createEditingSession(options: EditingSessionOptions): EditingSession { const document = options.document; let selection = ownSelection(options.selection); let revision = 0; @@ -59,6 +71,12 @@ export function createEditingSession(options: { let isCommitting = false; let observedValue = document.value; let unsubscribeDocument: (() => void) | null = null; + let unsubscribeHistory: (() => void) | null = null; + let historyRevision = options.history?.status().revision; + const historySelections = new Map(); const listeners = new Set<(snapshot: EditingSnapshot) => void>(); const notifications: Array<{ snapshot: EditingSnapshot; listeners: Array<(snapshot: EditingSnapshot) => void> }> = []; let isNotifying = false; @@ -70,11 +88,11 @@ export function createEditingSession(options: { function currentSnapshot(): EditingSnapshot { return Object.freeze({ - value: document.value, + value: observedValue, selection, revision, - canUndo: undoStack.length > 0, - canRedo: redoStack.length > 0, + canUndo: options.history?.status().canUndo ?? undoStack.length > 0, + canRedo: options.history?.status().canRedo ?? redoStack.length > 0, }); } @@ -98,10 +116,26 @@ export function createEditingSession(options: { return current; } - function synchronizeExternalChange(): boolean { + function synchronizeExternalChange(change?: JSONAppliedChange): boolean { + if (isCommitting) return false; + const nextHistoryRevision = options.history?.status().revision; + const historyChanged = nextHistoryRevision !== historyRevision; + historyRevision = nextHistoryRevision; const latest = document.value; - if (jsonEqual(observedValue, latest)) return false; + if (jsonEqual(observedValue, latest)) { + if (historyChanged) revision += 1; + return historyChanged; + } + const before = observedValue; observedValue = latest; + if (options.mapSelection) { + const replay = change === undefined ? null : applyPatch(before, change.applied); + selection = ownSelection(options.mapSelection(selection, { + before, + after: latest, + change: replay?.ok && jsonEqual(replay.value, latest) ? change! : null, + })); + } if (options.reconcileSelection) selection = ownSelection(options.reconcileSelection(selection, latest)); undoStack = []; redoStack = []; @@ -114,22 +148,42 @@ export function createEditingSession(options: { operations: ReadonlyArray, metadata: Readonly>, ) { + const before = observedValue; + let notifications = 0; + const release = document.subscribe(() => { notifications++; }); isCommitting = true; try { - return document.commit(operations, { metadata }); + const result = document.commit(operations, { metadata }); + if (!result.ok) return result; + // Normally the current document is exactly this commit's result. Only a + // reentrant document write needs a replay to recover the earlier value. + const replay = notifications > 1 ? applyPatch(before, result.change.applied) : null; + observedValue = replay?.ok ? replay.value : document.value; + return result; } finally { + release(); isCommitting = false; - observedValue = document.value; } } - function observeDocument(): void { + function observeDocument(change: JSONAppliedChange): void { if (isCommitting) return; + if (synchronizeExternalChange(change)) publish(); + } + + function publishCommit(): EditingSnapshot { + const own = publish(); if (synchronizeExternalChange()) publish(); + return own; } function apply(plan: EditingPlan): EditingResult { + if (isCommitting) return { ok: false, code: "editing.reentrancy" }; synchronizeExternalChange(); + if (options.history && plan.history === "ignore" && plan.operations.length > 0) { + return { ok: false, code: "history.ignore-unsupported", reason: "The external history owner records document commits." }; + } + const beforeValue = observedValue; const beforeSelection = selection; const selectionAfter = ownSelection(plan.selectionAfter); if (plan.operations.length === 0) { @@ -138,8 +192,11 @@ export function createEditingSession(options: { return { ok: true, snapshot: publish() }; } - const inverse = invertOperations(document, plan.operations); - const beforeValue = inverse === null ? clone(document.value) : null; + const inverse = options.history ? [] : invertEditingPatch(document, plan.operations); + if (inverse === null) { + const validation = document.validatePatch(plan.operations); + return validation.ok ? { ok: false, code: "history.inverse-unavailable" } : validation; + } const result = commit(plan.operations, { editing: { origin: plan.origin, @@ -151,10 +208,18 @@ export function createEditingSession(options: { selection = selectionAfter; revision += 1; - if (plan.history !== "ignore" && result.change.applied.length > 0) { + const historyStatus = options.history?.status(); + historyRevision = historyStatus?.revision; + if (historyStatus?.undoTarget && result.change.applied.length > 0 && jsonEqual(observedValue, document.value)) { + historySelections.set(historyStatus.undoTarget, { + before: { value: beforeValue, selection: beforeSelection }, + after: { value: observedValue, selection }, + }); + } + if (!options.history && plan.history !== "ignore" && result.change.applied.length > 0) { const entry: HistoryEntry = { - forward: clonePatchOperations(plan.operations), - inverse: inverse ?? [{ op: "replace", path: "", value: beforeValue! }], + forward: result.change.applied, + inverse, selectionBefore: beforeSelection, selectionAfter: selection, ...(plan.historyGroup === undefined ? {} : { group: plan.historyGroup }), @@ -173,7 +238,7 @@ export function createEditingSession(options: { activeHistoryGroup = plan.historyGroup; redoStack = []; } - return { ok: true, snapshot: publish(), change: result.change }; + return { ok: true, snapshot: publishCommit(), change: result.change }; } function restore(entry: HistoryEntry, direction: "undo" | "redo"): EditingResult { @@ -189,6 +254,36 @@ export function createEditingSession(options: { return { ok: true, snapshot: currentSnapshot(), change: result.change }; } + function restoreExternal(direction: "undo" | "redo"): EditingResult { + const history = options.history!; + const target = direction === "undo" ? history.status().undoTarget : history.status().redoTarget; + const retained = target === null ? undefined : historySelections.get(target); + const reference = retained?.[direction === "undo" ? "before" : "after"] ?? { value: observedValue, selection }; + const before = observedValue; + const changes: JSONAppliedChange[] = []; + const release = document.subscribe((change) => { changes.push(change); }); + let result: EditingHistoryResult; + isCommitting = true; + try { + result = history[direction](); + } finally { + release(); + isCommitting = false; + } + if (!result.ok) return result; + const change = changes[0]; + const replay = changes.length > 1 && change ? applyPatch(before, change.applied) : null; + observedValue = replay?.ok ? replay.value : document.value; + historyRevision = history.status().revision; + selection = reference.selection; + if (options.mapSelection) selection = ownSelection(options.mapSelection(selection, { + before: reference.value, after: observedValue, change: null, + })); + if (options.reconcileSelection) selection = ownSelection(options.reconcileSelection(selection, observedValue)); + revision += 1; + return { ok: true, snapshot: publishCommit(), ...(change === undefined ? {} : { change }) }; + } + return { get snapshot() { synchronizeExternalChange(); @@ -196,6 +291,7 @@ export function createEditingSession(options: { }, apply, select(nextSelection) { + if (isCommitting) return currentSnapshot(); synchronizeExternalChange(); selection = ownSelection(nextSelection); revision += 1; @@ -203,6 +299,7 @@ export function createEditingSession(options: { return publish(); }, reconcile(reconciler) { + if (isCommitting) return currentSnapshot(); synchronizeExternalChange(); const nextSelection = reconciler(clone(selection), document.value); if (jsonEqual(selection, nextSelection)) return currentSnapshot(); @@ -212,26 +309,30 @@ export function createEditingSession(options: { return publish(); }, undo() { + if (isCommitting) return { ok: false, code: "editing.reentrancy" }; synchronizeExternalChange(); + if (options.history) return restoreExternal("undo"); const entry = undoStack.at(-1); if (!entry) return { ok: false, code: "history.empty" }; const result = restore(entry, "undo"); if (result.ok) { undoStack = undoStack.slice(0, -1); redoStack = [...redoStack, entry]; - return { ...result, snapshot: publish() }; + return { ...result, snapshot: publishCommit() }; } return result; }, redo() { + if (isCommitting) return { ok: false, code: "editing.reentrancy" }; synchronizeExternalChange(); + if (options.history) return restoreExternal("redo"); const entry = redoStack.at(-1); if (!entry) return { ok: false, code: "history.empty" }; const result = restore(entry, "redo"); if (result.ok) { redoStack = redoStack.slice(0, -1); undoStack = [...undoStack, entry]; - return { ...result, snapshot: publish() }; + return { ...result, snapshot: publishCommit() }; } return result; }, @@ -239,67 +340,21 @@ export function createEditingSession(options: { synchronizeExternalChange(); listeners.add(listener); unsubscribeDocument ??= document.subscribe(observeDocument); + unsubscribeHistory ??= options.history?.subscribe(() => { + if (synchronizeExternalChange()) publish(); + }) ?? null; return () => { listeners.delete(listener); if (listeners.size > 0) return; unsubscribeDocument?.(); unsubscribeDocument = null; + unsubscribeHistory?.(); + unsubscribeHistory = null; }; }, }; } -function invertOperations( - document: JSONDocument, - operations: ReadonlyArray, -): ReadonlyArray | null { - // Every inverse reads the state immediately before its forward operation. - // Keep single-operation edits on the original read port; a batch needs an - // isolated working document to resolve shifted indexes and overwritten values. - const working = operations.length > 1 ? createJSONDocument(document.value) : document; - const inverse: JSONPatchOperation[] = []; - for (const operation of operations) { - if (operation.op === "replace") { - const located = working.at(operation.path); - if (!located.ok) return null; - inverse.push({ op: "replace", path: operation.path, value: clone(located.value) }); - } else if (operation.op === "remove") { - const located = working.at(operation.path); - if (!located.ok) return null; - inverse.push({ op: "add", path: operation.path, value: clone(located.value) }); - } else if (operation.op === "add") { - const path = appendedIndexPath(working, operation.path); - if (path === null) return null; - const parent = parentPointer(path); - const container = parent === null ? null : working.at(parent); - const previous = working.at(path); - inverse.push(previous.ok && (parent === null || (container?.ok && !Array.isArray(container.value))) - ? { op: "replace", path, value: clone(previous.value) } - : { op: "remove", path }); - } else if (operation.op === "test") { - // A successful precondition changes no value and needs no inverse. - } else { - return null; - } - if (working !== document && !working.commit([operation]).ok) return null; - } - return inverse.reverse(); -} - -function appendedIndexPath(document: JSONDocument, path: string): string | null { - if (!path.endsWith("/-")) return path; - const parent = path.slice(0, -2); - const located = document.at(parent); - if (!located.ok || !Array.isArray(located.value)) return null; - return `${parent}/${located.value.length}`; -} - -function clonePatchOperations( - operations: ReadonlyArray, -): ReadonlyArray { - return JSON.parse(JSON.stringify(operations)) as ReadonlyArray; -} - function clone(value: Value): Value { return JSON.parse(JSON.stringify(value)) as Value; } diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index 8bf569717..e531ea0fa 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -10,6 +10,8 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import type { EditingHistoryOptions } from "./history.js"; +import { reconcileRangeSelection } from "./range-selection.js"; import { cutEditingClipboard, isClipboardJSONValue, isClipboardRecord } from "./clipboard.js"; import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds, type GridTopology } from "./topology.js"; import { assertSheetDocument, assertUniqueSheetIds } from "./sheet-validation.js"; @@ -116,7 +118,7 @@ export interface SheetEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } -export function createSheetEditor(source: EditingDocumentSource): SheetEditor { +export function createSheetEditor(source: EditingDocumentSource, options: EditingHistoryOptions = {}): SheetEditor { const document = resolveDocumentSource(source); const initial = document.value as SheetDocument; assertSheetDocument(initial); @@ -126,8 +128,14 @@ export function createSheetEditor(source: EditingDocumentSource): ? collapsed(firstRow.id, firstColumn.id) : emptySelection(); const session = createEditingSession({ + ...options, document, selection: initialSelection, + reconcileSelection: (selection, value) => withPrimaryAliases(reconcileRangeSelection(selection, (point) => { + const sheet = value as SheetDocument; + return sheet.rows.some((row) => row.id === point.rowId) + && sheet.columns.some((column) => column.id === point.columnId) ? point : null; + })), }); let indexedDocument: SheetDocument | undefined = initial; let indexedSheet: SheetIndex | undefined = createSheetIndex(initial); diff --git a/packages/json-document-editing/src/tree.ts b/packages/json-document-editing/src/tree.ts index d3ba89044..e9e9565e2 100644 --- a/packages/json-document-editing/src/tree.ts +++ b/packages/json-document-editing/src/tree.ts @@ -3,6 +3,9 @@ import { type JSONValue, } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; +import { createEditingId } from "./identity.js"; +import type { EditingHistoryOptions } from "./history.js"; +import { reconcileRangeSelection } from "./range-selection.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { createRangeSelectionFamily, @@ -100,18 +103,20 @@ export interface TreeEditor { export function createTreeEditor( source: EditingDocumentSource, - options: { readonly createId?: () => string } = {}, + options: EditingHistoryOptions & { readonly createId?: () => string } = {}, ): TreeEditor { const document = resolveDocumentSource(source); const initial = document.value as TreeDocument; assertTreeDocument(initial); - let sequence = 0; - const createId = options.createId ?? (() => `node-${++sequence}`); + const createId = options.createId ?? (() => createEditingId("node")); const selectionFamily = createRangeSelectionFamily(); const first = initial.nodes[0]; const session = createEditingSession({ + ...options, document, selection: first ? collapsed(first.id) : emptySelection(), + reconcileSelection: (selection, value) => asTreeSelection(reconcileRangeSelection(selection, + (point) => (value as TreeDocument).nodes.some((node) => node.id === point.nodeId) ? point : null)), }); let indexedDocument: TreeDocument | undefined = initial; let indexedNodes: TreeNodeIndex | undefined = createTreeNodeIndex(initial.nodes); diff --git a/packages/json-document-editing/tests/external-selection.test.ts b/packages/json-document-editing/tests/external-selection.test.ts new file mode 100644 index 000000000..2adfc3b4b --- /dev/null +++ b/packages/json-document-editing/tests/external-selection.test.ts @@ -0,0 +1,93 @@ +import { createJSONDocument, type JSONDocument, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; +import { describe, expect, test } from "vitest"; +import { + ANNOTATION_PROFILE_V1, createAnnotationEditor, createCalendarEditor, createDatabaseEditor, + createDocumentEditor, createKanbanEditor, createObjectEditor, createOrderEditor, createSheetEditor, + createTreeEditor, type EditingSnapshot, +} from "../src/index.js"; + +interface Case { + readonly name: string; + readonly initial: JSONValue; + readonly create: (document: JSONDocument) => { + readonly snapshot: EditingSnapshot; + subscribe(listener: () => void): () => void; + }; + readonly remove: ReadonlyArray; + readonly empty: JSONValue; +} + +const emptyRange = { kind: "range", ranges: [], primaryIndex: null }; +const emptyGrid = { ...emptyRange, anchor: null, focus: null }; +const emptyKeys = { kind: "explicit", keys: [], primaryKey: null }; +const calendar = { + calendars: [{ id: "home", title: "Home", hidden: false, color: "subtle" }], + events: [{ id: "a", title: "Draft", start: "2026-08-03T09:00", end: "2026-08-03T10:00", + allDay: false, calendarId: "home", recurrence: null, excludeDates: [] }], +}; +const cases: Case[] = [ + { name: "Document", initial: { blocks: [{ id: "a", text: "Draft" }] }, create: createDocumentEditor, + remove: [{ op: "remove", path: "/blocks/0" }], empty: emptyRange }, + { name: "Order", initial: { items: [{ id: "a", label: "Draft" }] }, create: createOrderEditor, + remove: [{ op: "remove", path: "/items/0" }], empty: emptyRange }, + { name: "Object", initial: { objects: [{ id: "a", label: "Draft", x: 0, y: 0, width: 1, height: 1 }] }, create: createObjectEditor, + remove: [{ op: "remove", path: "/objects/0" }], empty: emptyKeys }, + { name: "Tree", initial: { nodes: [{ id: "a", label: "Draft", parentId: null }] }, create: createTreeEditor, + remove: [{ op: "remove", path: "/nodes/0" }], empty: emptyRange }, + { name: "Sheet row", initial: { columns: [{ id: "title", label: "Title" }], rows: [{ id: "a", cells: { title: "Draft" } }] }, create: createSheetEditor, + remove: [{ op: "remove", path: "/rows/0" }], empty: emptyGrid }, + { name: "Sheet column", initial: { columns: [{ id: "title", label: "Title" }], rows: [{ id: "a", cells: { title: "Draft" } }] }, create: createSheetEditor, + remove: [{ op: "remove", path: "/columns/0" }, { op: "remove", path: "/rows/0/cells/title" }], empty: emptyGrid }, + { name: "Database", initial: { + schema: { properties: [{ id: "title", name: "Title", type: "title", options: [] }] }, + records: [{ id: "a", values: { title: "Draft" } }], + views: [{ id: "all", name: "All", type: "table", propertyOrder: ["title"], propertyVisibility: { title: true }, propertyWidths: {}, sort: null, filter: null }], + }, create: createDatabaseEditor, remove: [{ op: "remove", path: "/records/0" }], empty: emptyGrid }, + { name: "Kanban", initial: { columns: [{ id: "todo", title: "Todo", cardIds: ["a"] }], cards: [{ id: "a", title: "Draft" }] }, create: createKanbanEditor, + remove: [{ op: "remove", path: "/cards/0" }, { op: "remove", path: "/columns/0/cardIds/0" }], empty: emptyKeys }, + { name: "Calendar", initial: calendar, create: createCalendarEditor, + remove: [{ op: "remove", path: "/events/0" }], empty: emptyRange }, + { name: "Annotation", initial: { + profile: ANNOTATION_PROFILE_V1, id: "doc", + sources: [{ id: "source", src: "/sample.png", width: 100, height: 100 }], + annotations: [{ id: "a", body: { instruction: "Draft" }, target: { sourceId: "source", selector: { type: "point", x: 10, y: 10 } }, presentation: { type: "marker" } }], + }, create(document) { + const editor = createAnnotationEditor(document); + expect(editor.dispatch({ type: "selection.set", annotationId: "a", mode: "replace" }).ok).toBe(true); + return editor; + }, remove: [{ op: "remove", path: "/annotations/0" }], empty: { kind: "annotation", ids: [], primaryId: null } }, +]; + +describe.each([true, false])("external selection reconciliation (observed: %s)", (observed) => { + test.each(cases)("$name publishes a valid selection after external deletion", ({ initial, create, remove, empty }) => { + const document = createJSONDocument(initial); + const editor = create(document); + const published: JSONValue[] = []; + const release = observed ? editor.subscribe(() => published.push(editor.snapshot.selection)) : () => {}; + expect(document.commit(remove).ok).toBe(true); + expect(editor.snapshot.selection).toEqual(empty); + if (observed) expect(published).toEqual([empty]); + release(); + }); +}); + +test("Calendar retains occurrence selection when its calendar becomes hidden", () => { + const document = createJSONDocument(calendar); + const editor = createCalendarEditor(document); + const selection = editor.snapshot.selection; + const release = editor.subscribe(() => {}); + expect(document.commit([{ op: "replace", path: "/calendars/0/hidden", value: true }]).ok).toBe(true); + expect(editor.snapshot.selection).toEqual(selection); + release(); +}); + +test("Document clamps offsets on retained blocks and preserves a surviving primary range", () => { + const document = createJSONDocument({ blocks: [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }] }); + const editor = createDocumentEditor(document); + editor.dispatch({ type: "selection.set", blockId: "b", offset: 4, mode: "toggle" }); + document.commit([{ op: "remove", path: "/blocks/0" }, { op: "replace", path: "/blocks/0/text", value: "B" }]); + expect(editor.snapshot.selection).toEqual({ + kind: "range", primaryIndex: 0, + ranges: [{ anchor: { blockId: "b", offset: 1 }, focus: { blockId: "b", offset: 1 } }], + }); +}); diff --git a/packages/json-document-editing/tests/identity.test.ts b/packages/json-document-editing/tests/identity.test.ts new file mode 100644 index 000000000..5b6c88da0 --- /dev/null +++ b/packages/json-document-editing/tests/identity.test.ts @@ -0,0 +1,58 @@ +import { createJSONDocument, type JSONDocument, type JSONValue } from "@interactive-os/json-document"; +import { describe, expect, test, vi } from "vitest"; +import { createDocumentEditor, createOrderEditor, createObjectEditor, createTreeEditor, createCalendarEditor, createEditingId } from "../src/index.js"; + +interface Case { + readonly name: string; + readonly initial: JSONValue; + readonly pointer: string; + readonly insert: (document: JSONDocument) => boolean; +} +const cases: Case[] = [ + { name: "Document", initial: { blocks: [] }, pointer: "/blocks", insert: (document) => + createDocumentEditor(document).dispatch({ type: "block.insert", text: "copy" }).ok }, + { name: "Order", initial: { items: [] }, pointer: "/items", insert: (document) => + createOrderEditor(document).dispatch({ type: "clipboard.paste", clipboard: { + type: "application/vnd.interactive-os.order+json", items: [{ id: "original", label: "copy" }], text: "copy", + } }).ok }, + { name: "Object", initial: { objects: [] }, pointer: "/objects", insert: (document) => + createObjectEditor(document).dispatch({ type: "clipboard.paste", clipboard: { + type: "application/vnd.interactive-os.objects+json", + objects: [{ id: "original", label: "copy", x: 0, y: 0, width: 1, height: 1, color: "subtle" }], text: "copy", + } }).ok }, + { name: "Tree", initial: { nodes: [] }, pointer: "/nodes", insert: (document) => + createTreeEditor(document).dispatch({ type: "clipboard.paste", topology: { visibleIds: [] }, clipboard: { + type: "application/vnd.interactive-os.tree+json", nodes: [{ id: "original", label: "copy", parentId: null }], text: "copy", + } }).ok }, + { name: "Calendar", initial: { calendars: [{ id: "home", title: "Home", hidden: false, color: "subtle" }], events: [] }, + pointer: "/events", insert: (document) => createCalendarEditor(document).dispatch({ + type: "event.create", title: "copy", start: "2026-08-03T09:00", end: "2026-08-03T10:00", calendarId: "home", + }).ok }, +]; + +describe("default domain identities", () => { + test.each(cases)("$name does not reuse IDs across independent or recreated editors", ({ initial, pointer, insert }) => { + const ids: JSONValue[] = []; + for (let instance = 0; instance < 3; instance++) { + const document = createJSONDocument(initial); + for (let recreation = 0; recreation < 3; recreation++) expect(insert(document)).toBe(true); + const items = document.at(pointer); + expect(items.ok).toBe(true); + if (items.ok) ids.push(...(items.value as ReadonlyArray<{ readonly id: string }>).map((item) => item.id)); + } + expect(new Set(ids).size).toBe(9); + }); + + test("preserves the injected ID provider", () => { + const editor = createDocumentEditor({ blocks: [] }, { createId: () => "host-id" }); + expect(editor.dispatch({ type: "block.insert" }).ok).toBe(true); + expect(editor.snapshot.value).toEqual({ blocks: [{ id: "host-id", text: "" }] }); + }); + + test("fails explicitly without a collision-resistant platform provider", () => { + vi.stubGlobal("crypto", undefined); + try { + expect(() => createEditingId("block")).toThrow("editing.id-provider-unavailable"); + } finally { vi.unstubAllGlobals(); } + }); +}); diff --git a/packages/json-document-editing/tests/session-composition.test.ts b/packages/json-document-editing/tests/session-composition.test.ts new file mode 100644 index 000000000..076af7deb --- /dev/null +++ b/packages/json-document-editing/tests/session-composition.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import { createJSONDocument, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; +import { createEditingSession, type EditingDocumentChange } from "../src/session.js"; + +describe("editing transaction composition", () => { + test.each([true, false])("provides before/change/after mapping before reconciliation (observed: %s)", (observed) => { + const document = createJSONDocument({ text: "abc" }); + const contexts: EditingDocumentChange[] = []; + const session = createEditingSession({ + document, selection: { offset: 1 }, + mapSelection(selection, context) { + contexts.push(context); + return { offset: selection.offset + 1 }; + }, + reconcileSelection(selection, value) { + expect(selection.offset).toBe(2); + expect(value).toEqual({ text: "Xabc" }); + return selection; + }, + }); + const published: number[] = []; + const release = observed ? session.subscribe((snapshot) => published.push(snapshot.selection.offset)) : () => {}; + const result = document.commit([{ op: "replace", path: "/text", value: "Xabc" }]); + expect(result.ok).toBe(true); + expect(session.snapshot.selection.offset).toBe(2); + expect(contexts).toHaveLength(1); + expect(contexts[0]).toEqual({ before: { text: "abc" }, after: { text: "Xabc" }, change: observed && result.ok ? result.change : null }); + if (observed) expect(published).toEqual([2]); + release(); + }); + + test("returns malformed pointer failure without throwing from inverse planning", () => { + const session = createEditingSession({ document: createJSONDocument({ a: 1 }), selection: null }); + expect(session.apply({ operations: [{ op: "add", path: "/~2", value: 2 }], selectionAfter: null, origin: "invalid" })).toMatchObject({ + ok: false, code: "invalid_pointer", + }); + }); + test.each(["before", "after", "unobserved"])("separates a subscriber's write from its own commit (%s)", (order) => { + const document = createJSONDocument({ items: ["a", "b"], other: 0 }); + const session = createEditingSession({ document, selection: null }); + const seen: JSONValue[] = []; + if (order === "before") session.subscribe((snapshot) => seen.push(snapshot.value)); + let written = false; + document.subscribe(() => { + if (written) return; + written = true; + document.commit([{ op: "replace", path: "/other", value: 99 }]); + }); + if (order === "after") session.subscribe((snapshot) => seen.push(snapshot.value)); + const result = session.apply({ operations: [{ op: "move", from: "/items/0", path: "/items/1" }], selectionAfter: null, origin: "reorder" }); + expect(result).toMatchObject({ ok: true, snapshot: { value: { items: ["b", "a"], other: 0 }, revision: 1 } }); + expect(session.snapshot).toMatchObject({ value: { items: ["b", "a"], other: 99 }, revision: 2, canUndo: false }); + expect(session.undo()).toMatchObject({ ok: false, code: "history.empty" }); + expect(document.value).toEqual({ items: ["b", "a"], other: 99 }); + if (order !== "unobserved") expect(seen).toEqual([{ items: ["b", "a"], other: 0 }, { items: ["b", "a"], other: 99 }]); + }); + + test.each([ + { name: "array move", initial: { list: ["a", "b", "c"] }, operations: [{ op: "move", from: "/list/0", path: "/list/2" }] }, + { name: "object move overwrites destination", initial: { source: { n: 1 }, destination: { n: 2 } }, operations: [{ op: "move", from: "/source", path: "/destination" }] }, + { name: "array move to append", initial: { list: ["a", "b", "c"] }, operations: [{ op: "move", from: "/list/0", path: "/list/-" }] }, + { name: "copy overwrites destination", initial: { source: { n: 1 }, destination: { n: 2 } }, operations: [{ op: "copy", from: "/source", path: "/destination" }] }, + { name: "copy into array", initial: { list: ["a", "b"] }, operations: [{ op: "copy", from: "/list/0", path: "/list/-" }] }, + { name: "move across shifted array parents", initial: { list: ["a", {}, { children: [] }] }, operations: [{ op: "move", from: "/list/0", path: "/list/1/children/-" }] }, + { name: "move overwrites within a shifted parent", initial: { list: ["a", {}, { child: "old" }] }, operations: [{ op: "move", from: "/list/0", path: "/list/1/child" }] }, + { name: "move replaces an ancestor", initial: { parent: { child: { text: "a" }, sibling: true } }, operations: [{ op: "move", from: "/parent/child", path: "/parent" }] }, + { name: "move inserts at an array ancestor", initial: { list: [{ child: 1 }, { sibling: true }] }, operations: [{ op: "move", from: "/list/0/child", path: "/list/0" }] }, + { name: "move inserts a nested array at its array ancestor", initial: { list: [{ child: [1, 2] }] }, operations: [{ op: "move", from: "/list/0/child", path: "/list/0" }] }, + { name: "move inserts a deep descendant at its array ancestor", initial: { list: [{ child: [1, 2] }] }, operations: [{ op: "move", from: "/list/0/child/1", path: "/list/0" }] }, + { name: "move inserts at a root array element ancestor", initial: [{ child: 1 }, "sibling"], operations: [{ op: "move", from: "/0/child", path: "/0" }] }, + ])("undo $name without replacing the document", ({ initial, operations }) => { + const document = createJSONDocument(initial); + const session = createEditingSession({ document, selection: null }); + expect(session.apply({ operations: operations as JSONPatchOperation[], selectionAfter: null, origin: "edit" }).ok).toBe(true); + const after = document.value; + const undone = session.undo(); + expect(undone.ok).toBe(true); + expect(document.value).toEqual(initial); + if (undone.ok) expect(undone.change?.applied.some((op) => op.path === "")).toBe(false); + expect(session.redo().ok).toBe(true); + expect(document.value).toEqual(after); + }); +}); diff --git a/packages/json-document-rich-text-web/package.json b/packages/json-document-rich-text-web/package.json index 1808cbc39..9993b12c2 100644 --- a/packages/json-document-rich-text-web/package.json +++ b/packages/json-document-rich-text-web/package.json @@ -32,6 +32,7 @@ "@interactive-os/json-document-web": "^0.1.0-rc.0" }, "devDependencies": { + "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", diff --git a/packages/json-document-rich-text-web/tests/selective-history.test.ts b/packages/json-document-rich-text-web/tests/selective-history.test.ts new file mode 100644 index 000000000..be254336f --- /dev/null +++ b/packages/json-document-rich-text-web/tests/selective-history.test.ts @@ -0,0 +1,59 @@ +import { createTextRuntime } from "@interactive-os/json-document-collaboration/text"; +import { createCollaborationEditingHistory } from "@interactive-os/json-document-collaboration/editing"; +import { createRichTextEditor, type RichTextDocument } from "@interactive-os/json-document-rich-text"; +import { describe, expect, test } from "vitest"; +import { createRichTextContentEditableBinding } from "../src/index.js"; + +describe("synthetic DOM history input uses the official selective history", () => { + test.each(["toolbar", "Meta+Z", "Ctrl+Z", "beforeinput"])("%s preserves concurrent text and supports redo", (input) => { + const initial: RichTextDocument = { + profile: "urn:interactive-os:json-document:rich-text:1", id: "doc", type: "doc", + content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "abcd", marks: [] }] }], + }; + const shared = { epochId: "web-history", ruleset: { id: "web-history", digest: "1" } }; + const local = createTextRuntime(initial, { ...shared, actorId: "local" }); + const remote = createTextRuntime(initial, { ...shared, actorId: "remote" }); + const point = { kind: "text" as const, nodeId: "t", offset: 2, affinity: "forward" as const }; + const editor = createRichTextEditor({ document: local.document, history: createCollaborationEditingHistory(local), selection: { + kind: "range", ranges: [{ anchor: point, focus: point }], primaryIndex: 0, + } }); + const root = document.createElement("div"); + root.setAttribute("contenteditable", "true"); + root.innerHTML = '

abcd

'; + document.body.append(root); + const text = root.querySelector("span")!.firstChild!; + const render = editor.subscribe(() => { + const located = local.document.at("/content/0/content/0/text"); + if (located.ok) text.textContent = String(located.value); + }); + const binding = createRichTextContentEditableBinding({ root, editor }); + editor.dispatch({ type: "text.insert", text: "!" }); + remote.document.commit([{ op: "replace", path: "/content/0/content/0/text", value: "Xabcd" }]); + local.replica.ingest(remote.replica.exportBundle()); + expect(text.textContent).toBe("Xab!cd"); + binding.restoreSelection(); + historyInput(false); + expect(text.textContent).toBe("Xabcd"); + expect(editor.snapshot.canRedo).toBe(true); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(3); + historyInput(true); + expect(text.textContent).toBe("Xab!cd"); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(4); + binding.destroy(); + render(); + root.remove(); + + function historyInput(redo: boolean) { + if (input === "toolbar") { + expect((redo ? editor.redo() : editor.undo()).ok).toBe(true); + return; + } + const event = input === "beforeinput" + ? new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: redo ? "historyRedo" : "historyUndo" }) + : new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "z", + metaKey: input === "Meta+Z", ctrlKey: input === "Ctrl+Z", shiftKey: redo }); + root.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + }); +}); diff --git a/packages/json-document-rich-text/README.md b/packages/json-document-rich-text/README.md index 1b1e7b97b..4c06f1e7a 100644 --- a/packages/json-document-rich-text/README.md +++ b/packages/json-document-rich-text/README.md @@ -1,5 +1,19 @@ # @interactive-os/json-document-rich-text +External text changes map selection offsets through the changed span of the +same stable text ID. Common prefix/suffix delimit the replacement; forward +affinity follows inserted/replacement text, backward affinity stays before it. +Offsets remain on Unicode scalar boundaries. Deleted identities still reconcile +through the domain topology. JSON snapshots do not encode an arbitrary author's +edit intent: an ambiguous whole-string replacement uses these explicit span rules. + +`createRichTextEditor({ document, history })` can use the official +`createCollaborationEditingHistory(runtime)` connection. With `createTextRuntime`, +undo removes local text contributions while preserving concurrent remote text. +Selection restoration maps the recorded position through the current document. +The default remains local inverse history; a collaborative document alone does +not select a different history owner. + Experimental reference implementation of the Draft JSONDocument Rich Text v1 profile. It owns canonical Rich Text model types, logical topology, editing transforms, and target-neutral rendering while JSONDocument, Selection, and diff --git a/packages/json-document-rich-text/src/editor-validation.ts b/packages/json-document-rich-text/src/editor-validation.ts index 64fc6b808..0ba2ad3b7 100644 --- a/packages/json-document-rich-text/src/editor-validation.ts +++ b/packages/json-document-rich-text/src/editor-validation.ts @@ -1,4 +1,4 @@ -import type { JSONDocument, Pointer } from "@interactive-os/json-document"; +import { createJSONDocument, type JSONDocument, type JSONValue, type Pointer } from "@interactive-os/json-document"; import { getActiveRichTextInstrument } from "./instrument.js"; import { hasRichTextContent, isRichTextDocument, type RichTextDocument, type RichTextNode } from "./model.js"; import type { RichTextSchema } from "./schema.js"; @@ -12,6 +12,11 @@ export function readRichTextDocument(document: JSONDocument, pointer: Pointer): return result.value; } +export function readRichTextSnapshot(value: JSONValue, pointer: Pointer): RichTextDocument { + if (pointer === "" && isRichTextDocument(value)) return value; + return readRichTextDocument(createJSONDocument(value), pointer); +} + export function validateLocalOrFallback(next: RichTextDocument, path: ReadonlyArray, schema: RichTextSchema): ReturnType { const incremental = validateRichTextPath(next, path, { schema }); if (incremental.ok) return incremental; diff --git a/packages/json-document-rich-text/src/editor.ts b/packages/json-document-rich-text/src/editor.ts index 51f67ccc9..f7cebe8ab 100644 --- a/packages/json-document-rich-text/src/editor.ts +++ b/packages/json-document-rich-text/src/editor.ts @@ -12,6 +12,7 @@ import { cutEditingClipboard, type EditingResult, type EditingSnapshot, + type EditingHistoryOptions, } from "@interactive-os/json-document-editing"; import { collapsedRangeSelection, @@ -50,8 +51,8 @@ import { rememberAppliedOperations } from "./applied-change.js"; import { indexValidatedRichText, richTextTopology, seedRichTextTopology, type RichTextTopology } from "./topology.js"; import { validateRichText, validateRichTextNodeAt } from "./validation.js"; import type { RichTextValidationFailure } from "./validation.js"; -import { readRichTextDocument, validateLocalOrFallback, validateReplacementNodes, validateContentSize } from "./editor-validation.js"; -import { allTextNodes, collapsedAtPoint, firstSelection, mapSelectionByExistingIds, mapSelectionByTextOrder, reconcileOrFirst } from "./selection-mapping.js"; +import { readRichTextDocument, readRichTextSnapshot, validateLocalOrFallback, validateReplacementNodes, validateContentSize } from "./editor-validation.js"; +import { allTextNodes, collapsedAtPoint, firstSelection, mapExternalRichTextSelection, mapSelectionByExistingIds, mapSelectionByTextOrder, reconcileOrFirst } from "./selection-mapping.js"; import { nextScalarOffset, previousScalarOffset, validTextOffset } from "./text-offset.js"; export type RichTextIntent = @@ -83,7 +84,7 @@ export interface RichTextEditor { subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; } -export interface RichTextEditorOptions { +export interface RichTextEditorOptions extends EditingHistoryOptions { readonly document: JSONDocument; readonly pointer?: Pointer; readonly selection?: RichTextSelection; @@ -138,11 +139,17 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd }; const selectionFamily = createRangeSelectionFamily(); const session = createEditingSession({ + ...(options.history === undefined ? {} : { history: options.history }), document, selection: options.selection === undefined ? firstSelection(initial) : asRichTextSelection(selectionFamily.reconcile(options.selection, { topology: initialTopology }).state), - reconcileSelection: (selection) => asRichTextSelection(selectionFamily.reconcile(selection, { topology: richTextTopology(value()) }).state), + mapSelection: (selection, change) => mapExternalRichTextSelection( + readRichTextSnapshot(change.before, pointer), readRichTextSnapshot(change.after, pointer), selection, + ), + reconcileSelection: (selection, value) => asRichTextSelection(selectionFamily.reconcile(selection, { + topology: richTextTopology(readRichTextSnapshot(value, pointer)), + }).state), }); const createId = options.createId ?? createRichTextNodeId; diff --git a/packages/json-document-rich-text/src/identity.ts b/packages/json-document-rich-text/src/identity.ts index 6c187462d..7825f0313 100644 --- a/packages/json-document-rich-text/src/identity.ts +++ b/packages/json-document-rich-text/src/identity.ts @@ -1,8 +1,10 @@ import type { RichTextNodeId } from "./model.js"; +import { createEditingId } from "@interactive-os/json-document-editing"; export function createRichTextNodeId(): RichTextNodeId { - const cryptoProvider = (globalThis as { readonly crypto?: { readonly randomUUID?: () => string } }).crypto; - const randomUUID = cryptoProvider?.randomUUID; - if (typeof randomUUID !== "function") throw new TypeError("rich-text.id-provider-unavailable"); - return `rt-${randomUUID.call(cryptoProvider)}`; + try { + return createEditingId("rt"); + } catch (cause) { + throw new TypeError("rich-text.id-provider-unavailable", { cause }); + } } diff --git a/packages/json-document-rich-text/src/selection-mapping.ts b/packages/json-document-rich-text/src/selection-mapping.ts index 102068213..4da34df0f 100644 --- a/packages/json-document-rich-text/src/selection-mapping.ts +++ b/packages/json-document-rich-text/src/selection-mapping.ts @@ -1,6 +1,37 @@ import { collapsedRangeSelection, createRangeSelectionFamily } from "@interactive-os/json-document-selection"; import { hasRichTextContent, isRichTextText, type RichTextDocument, type RichTextNode, type RichTextPoint, type RichTextSelection, type RichTextTarget } from "./model.js"; import { richTextTopology } from "./topology.js"; +import { validTextOffset } from "./text-offset.js"; + +/** Map stable text identities through the changed span; affinity owns its boundaries. */ +export function mapExternalRichTextSelection(before: RichTextDocument, after: RichTextDocument, selection: RichTextSelection): RichTextSelection { + const previous = richTextTopology(before); + const topology = richTextTopology(after); + return asRichTextSelection(createRangeSelectionFamily().map(selection, { + mapPoint(point) { + const oldNode = previous.locate(point.nodeId)?.node; + const newNode = topology.locate(point.nodeId)?.node; + if (point.kind !== "text" || !oldNode || !newNode || !isRichTextText(oldNode) || !isRichTextText(newNode)) { + return topology.reconcilePoint(point); + } + return { ...point, offset: mapTextOffset(oldNode.text, newNode.text, point.offset, point.affinity) }; + }, + }, { topology }).state); +} + +function mapTextOffset(before: string, after: string, offset: number, affinity: RichTextPoint["affinity"]): number { + if (before === after) return offset; + let start = 0; + while (start < before.length && start < after.length && before[start] === after[start]) start++; + while (!validTextOffset(before, start) || !validTextOffset(after, start)) start--; + let oldEnd = before.length; + let newEnd = after.length; + while (oldEnd > start && newEnd > start && before[oldEnd - 1] === after[newEnd - 1]) { oldEnd--; newEnd--; } + while (!validTextOffset(before, oldEnd) || !validTextOffset(after, newEnd)) { oldEnd++; newEnd++; } + if (offset < start) return offset; + if (offset > oldEnd) return offset + newEnd - oldEnd; + return affinity === "backward" ? start : newEnd; +} export function firstSelection(document: RichTextDocument): RichTextSelection { const text = findFirstText(document); diff --git a/packages/json-document-rich-text/tests/editor-protocol.test.ts b/packages/json-document-rich-text/tests/editor-protocol.test.ts index 59cdd88eb..aa077444a 100644 --- a/packages/json-document-rich-text/tests/editor-protocol.test.ts +++ b/packages/json-document-rich-text/tests/editor-protocol.test.ts @@ -16,6 +16,17 @@ function selection(offset = 4) { } describe("Rich Text extension protocol", () => { + it("repeats structural edit and undo after an optimized large-array snapshot", () => { + const document = createJSONDocument(createRichTextBlockFixture(100)); + const point = { kind: "text" as const, nodeId: "block-text-50", offset: 1, affinity: "forward" as const }; + const editor = createRichTextEditor({ document, selection: { kind: "range", ranges: [{ anchor: point, focus: point }], primaryIndex: 0 } }); + const initial = document.value; + for (let index = 0; index < 3; index++) { + expect(editor.dispatch({ type: "block.split" }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + expect(document.value).toEqual(initial); + } + }); it("rejects descendant schema errors, invalid marks, ID provider collisions and custom cardinality", () => { const intents: RichTextIntent[] = [ { type: "node.insert", point: { kind: "child", nodeId: "doc", offset: 1, affinity: "forward" }, diff --git a/packages/json-document-rich-text/tests/external-selection.test.ts b/packages/json-document-rich-text/tests/external-selection.test.ts new file mode 100644 index 000000000..b15293b0d --- /dev/null +++ b/packages/json-document-rich-text/tests/external-selection.test.ts @@ -0,0 +1,49 @@ +import { buildPointer, createJSONDocument } from "@interactive-os/json-document"; +import { describe, expect, test } from "vitest"; +import { createRichTextEditor, type RichTextDocument, type RichTextPoint } from "../src/index.js"; + +function rich(text: string): RichTextDocument { + return { profile: "urn:interactive-os:json-document:rich-text:1", id: "doc", type: "doc", content: [ + { id: "p", type: "paragraph", content: [{ id: "t", type: "text", text, marks: [] }] }, + ] }; +} + +describe.each([true, false])("external Rich Text positions (observed: %s)", (observed) => { + test.each([ + { before: "abcd", after: "Xabcd", offset: 2, expected: 3, affinity: "forward" }, + { before: "abcd", after: "acd", offset: 3, expected: 2, affinity: "forward" }, + { before: "abcd", after: "abXcd", offset: 2, expected: 3, affinity: "forward" }, + { before: "abcd", after: "abXcd", offset: 2, expected: 2, affinity: "backward" }, + { before: "abcd", after: "aXd", offset: 2, expected: 2, affinity: "forward" }, + { before: "abcd", after: "aXd", offset: 2, expected: 1, affinity: "backward" }, + { before: "abcd", after: "😀abcd", offset: 2, expected: 4, affinity: "forward" }, + { before: "a😀b", after: "a😁b", offset: 3, expected: 3, affinity: "forward" }, + ] as const)("$before → $after preserves offset $offset ($affinity)", ({ before, after, offset, expected, affinity }) => { + const document = createJSONDocument(rich(before)); + const point: RichTextPoint = { kind: "text", nodeId: "t", offset, affinity }; + const editor = createRichTextEditor({ document, selection: { + kind: "range", ranges: [{ anchor: point, focus: point }], primaryIndex: 0, + } }); + const published: number[] = []; + const release = observed ? editor.subscribe((snapshot) => published.push(snapshot.selection.ranges[0]!.focus.offset)) : () => {}; + expect(document.commit([{ op: "replace", path: "/content/0/content/0/text", value: after }]).ok).toBe(true); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(expected); + if (observed) expect(published).toEqual([expected]); + expect(editor.dispatch({ type: "text.insert", text: "!" }).ok).toBe(true); + expect(document.at("/content/0/content/0/text")).toMatchObject({ ok: true, value: after.slice(0, expected) + "!" + after.slice(expected) }); + release(); + }); +}); + +test("maps only the bound subtree through an escaped pointer", () => { + const document = createJSONDocument({ "a/b~c": rich("abcd"), adjacent: rich("other") }); + const pointer = buildPointer(["a/b~c"]); + const point: RichTextPoint = { kind: "text", nodeId: "t", offset: 2, affinity: "forward" }; + const editor = createRichTextEditor({ document, pointer, selection: { + kind: "range", ranges: [{ anchor: point, focus: point }], primaryIndex: 0, + } }); + document.commit([{ op: "replace", path: pointer + "/content/0/content/0/text", value: "Xabcd" }]); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(3); + document.commit([{ op: "replace", path: "/adjacent/content/0/content/0/text", value: "unrelated" }]); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(3); +}); diff --git a/packages/json-document-rich-text/tests/selective-history.test.ts b/packages/json-document-rich-text/tests/selective-history.test.ts new file mode 100644 index 000000000..b80fb961d --- /dev/null +++ b/packages/json-document-rich-text/tests/selective-history.test.ts @@ -0,0 +1,35 @@ +import { createTextRuntime } from "@interactive-os/json-document-collaboration/text"; +import { createCollaborationEditingHistory } from "@interactive-os/json-document-collaboration/editing"; +import { describe, expect, test } from "vitest"; +import { createRichTextEditor, type RichTextDocument, type RichTextPoint } from "../src/index.js"; + +const initial: RichTextDocument = { + profile: "urn:interactive-os:json-document:rich-text:1", id: "doc", type: "doc", + content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "abcd", marks: [] }] }], +}; + +describe.each([true, false])("Rich Text selective history (observed: %s)", (observed) => { + test("preserves concurrent text and restores positions through remote insertion", () => { + const shared = { epochId: "rich-history", ruleset: { id: "rich-history", digest: "1" } }; + const local = createTextRuntime(initial, { ...shared, actorId: "local" }); + const remote = createTextRuntime(initial, { ...shared, actorId: "remote" }); + const point: RichTextPoint = { kind: "text", nodeId: "t", offset: 2, affinity: "forward" }; + const editor = createRichTextEditor({ document: local.document, history: createCollaborationEditingHistory(local), selection: { + kind: "range", ranges: [{ anchor: point, focus: point }], primaryIndex: 0, + } }); + const release = observed ? editor.subscribe(() => {}) : () => {}; + expect(editor.dispatch({ type: "text.insert", text: "!" }).ok).toBe(true); + expect(remote.document.commit([{ op: "replace", path: "/content/0/content/0/text", value: "Xabcd" }]).ok).toBe(true); + expect(local.replica.ingest(remote.replica.exportBundle()).ok).toBe(true); + expect(local.document.at("/content/0/content/0/text")).toMatchObject({ ok: true, value: "Xab!cd" }); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(4); + expect(editor.snapshot.canUndo).toBe(true); + expect(editor.undo().ok).toBe(true); + expect(local.document.at("/content/0/content/0/text")).toMatchObject({ ok: true, value: "Xabcd" }); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(3); + expect(editor.redo().ok).toBe(true); + expect(local.document.at("/content/0/content/0/text")).toMatchObject({ ok: true, value: "Xab!cd" }); + expect(editor.snapshot.selection.ranges[0]!.focus.offset).toBe(4); + release(); + }); +}); diff --git a/packages/json-document/src/domain/json-document/create.ts b/packages/json-document/src/domain/json-document/create.ts index a95a4c70b..d3dabc355 100644 --- a/packages/json-document/src/domain/json-document/create.ts +++ b/packages/json-document/src/domain/json-document/create.ts @@ -216,6 +216,9 @@ function localCommitEffect( } if (operation.op === "add" || operation.op === "remove") { if (typeof operation.path !== "string" || operation.path === "") return "unknown"; + // A later operation can cancel a structural mutation or overwrite an + // object add. Only the final value establishes a multi-operation effect. + if (operations.length > 1) return "unknown"; changed = true; continue; } diff --git a/packages/json-document/src/foundation/json/shared-array.ts b/packages/json-document/src/foundation/json/shared-array.ts index 1dc5cece6..1adf3f9b9 100644 --- a/packages/json-document/src/foundation/json/shared-array.ts +++ b/packages/json-document/src/foundation/json/shared-array.ts @@ -17,6 +17,11 @@ export function denseArrayCopies(): number { return denseCopies; } +/** Internal ownership check; public reflection may materialize a dense snapshot. */ +export function isSharedArray(value: object): boolean { + return overlays.has(value); +} + export function replaceArrayIndex( array: readonly unknown[], index: number, @@ -52,7 +57,16 @@ function createSharedArray( ): unknown[] { const target: unknown[] = []; target.length = base.length; - Object.freeze(target); + let materialized = false; + function materialize(): void { + if (materialized) return; + denseCopies += 1; + for (let index = 0; index < base.length; index++) { + target[index] = replacements.has(index) ? replacements.get(index) : base[index]; + } + Object.freeze(target); + materialized = true; + } const handler: ProxyHandler = { get(_target, property) { if (property === "length") return base.length; @@ -70,17 +84,23 @@ function createSharedArray( }; }, getOwnPropertyDescriptor(_target, property) { - if (property === "length") { - return { value: base.length, writable: false, enumerable: false, configurable: false }; - } - const index = propertyIndex(property); - if (index === null || index >= base.length) return undefined; - return { - value: replacements.has(index) ? replacements.get(index) : base[index], - writable: false, - enumerable: true, - configurable: true, - }; + materialize(); + return Reflect.getOwnPropertyDescriptor(target, property); + }, + ownKeys() { + materialize(); + return Reflect.ownKeys(target); + }, + isExtensible() { + materialize(); + return false; + }, + preventExtensions() { + materialize(); + return true; + }, + setPrototypeOf() { + return false; }, has(_target, property) { if (property === "length") return true; @@ -91,8 +111,9 @@ function createSharedArray( set() { return false; }, - defineProperty() { - return false; + defineProperty(_target, property, attributes) { + materialize(); + return Reflect.defineProperty(target, property, attributes); }, deleteProperty() { return false; diff --git a/packages/json-document/src/foundation/protocol/apply.ts b/packages/json-document/src/foundation/protocol/apply.ts index 550ce36ae..b8f9c8dc6 100644 --- a/packages/json-document/src/foundation/protocol/apply.ts +++ b/packages/json-document/src/foundation/protocol/apply.ts @@ -9,6 +9,7 @@ import { } from "../patch/trusted.js"; import { parseArrayIndex } from "../pointer/array-index.js"; import { parsePointer } from "../pointer/core.js"; +import { isSharedArray } from "../json/shared-array.js"; import type { JSONAppliedChange, JSONPatchFailure, @@ -183,7 +184,7 @@ function freezeAlongOperations( for (const segments of paths) { if (!freezeAlongPath(value, segments)) return false; } - if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + if (value !== null && typeof value === "object" && !isSharedArray(value) && !Object.isFrozen(value)) { freezeInspections += 1; Object.freeze(value); } @@ -212,7 +213,7 @@ function freezeAlongPath(root: JSONValue, segments: ReadonlyArray): bool freezeJSON(current); for (let index = stack.length - 1; index >= 0; index -= 1) { const container = stack[index]!; - if (!Object.isFrozen(container)) Object.freeze(container); + if (!isSharedArray(container) && !Object.isFrozen(container)) Object.freeze(container); } return true; } @@ -220,7 +221,7 @@ function freezeAlongPath(root: JSONValue, segments: ReadonlyArray): bool function freezeJSON(value: T): T { if (value === null || typeof value !== "object") return value; freezeInspections += 1; - if (Object.isFrozen(value)) return value; + if (isSharedArray(value) || Object.isFrozen(value)) return value; for (const child of Object.values(value)) freezeJSON(child as JSONValue); Object.freeze(value); return value; diff --git a/packages/json-document/tests/foundation/owned-freeze.test.ts b/packages/json-document/tests/foundation/owned-freeze.test.ts index 6948082b1..c5752d062 100644 --- a/packages/json-document/tests/foundation/owned-freeze.test.ts +++ b/packages/json-document/tests/foundation/owned-freeze.test.ts @@ -1,4 +1,4 @@ -import { createJSONDocument } from "@interactive-os/json-document"; +import { applyPatch, createJSONDocument } from "@interactive-os/json-document"; import { expect, test } from "vitest"; import { @@ -23,6 +23,29 @@ test("createJSONDocument freezes a clone and leaves the caller tree mutable", () expect((document.value as { items: Array<{ title: string }> }).items[0]?.title).toBe("Draft"); }); +test.each([64, 512, 4096])("owned snapshots remain valid protocol inputs after a leaf replacement (%s items)", (size) => { + const document = createJSONDocument({ items: Array.from({ length: size }, (_, id) => ({ id, text: "before" })) }); + expect(document.commit([{ op: "replace", path: "/items/1/text", value: "after" }]).ok).toBe(true); + const snapshot = document.value; + expect(createJSONDocument(snapshot).value).toEqual(snapshot); + expect(applyPatch(snapshot, [{ op: "replace", path: "/items/2/text", value: "next" }])).toMatchObject({ ok: true }); + expect(document.at("/items/2/text")).toMatchObject({ ok: true, value: "before" }); +}); + +test("large-array reflection exposes a frozen dense JSON snapshot without changing indexed reads", () => { + const document = createJSONDocument(Array.from({ length: 64 }, (_, id) => ({ id }))); + document.commit([{ op: "replace", path: "/1/id", value: 99 }]); + const snapshot = document.value as ReadonlyArray<{ readonly id: number }>; + expect(Reflect.setPrototypeOf(snapshot, null)).toBe(false); + expect(Object.getOwnPropertyDescriptor(snapshot, "1")?.value).toEqual({ id: 99 }); + expect(Object.keys(snapshot)).toHaveLength(64); + expect(Object.values(snapshot)).toHaveLength(64); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(() => Object.freeze(snapshot)).not.toThrow(); + expect(Reflect.set(snapshot, "1", { id: 0 })).toBe(false); + expect(snapshot[1]?.id).toBe(99); +}); + test("a leaf replace freeze inspects the changed path, not every sibling", () => { const small = applyOwnedAndCount(256, 80); const large = applyOwnedAndCount(10_000, 80); diff --git a/scripts/generate-api-reference.mjs b/scripts/generate-api-reference.mjs index e17f4afa8..0486673bb 100644 --- a/scripts/generate-api-reference.mjs +++ b/scripts/generate-api-reference.mjs @@ -8,7 +8,7 @@ const root = dirname(dirname(fileURLToPath(import.meta.url))); const check = process.argv.includes("--check"); const configPath = join(root, "tsconfig.build.json"); const parsed = ts.parseJsonConfigFileContent(ts.readConfigFile(configPath, ts.sys.readFile).config, ts.sys, root); -const entrypoints = apiReferencePackages.map(({ entrypoint }) => join(root, entrypoint)); +const entrypoints = apiReferencePackages.flatMap(({ entrypoint, subpaths }) => [entrypoint, ...subpaths.map((subpath) => subpath.entrypoint)]).map((entrypoint) => join(root, entrypoint)); const sourcePaths = Object.fromEntries(apiReferencePackages.map(({ packageName, entrypoint }) => [packageName, [entrypoint]])); const program = ts.createProgram([...new Set([...parsed.fileNames, ...entrypoints])], { ...parsed.options, @@ -74,6 +74,16 @@ for (const descriptor of apiReferencePackages) { display(symbol, entry), "```", ].join("\n")); + for (const subpath of descriptor.subpaths) { + const subpathEntry = program.getSourceFile(join(root, subpath.entrypoint)); + if (!subpathEntry) throw new Error(`public entrypoint를 찾을 수 없습니다: ${subpath.entrypoint}`); + const subpathExports = checker.getExportsOfModule(checker.getSymbolAtLocation(subpathEntry)).sort((a, b) => a.name.localeCompare(b.name)); + exportCount += subpathExports.length; + sections.push(`## \`${subpath.packageName}\`\n\n아래 API는 package root가 아닌 이 subpath에서 import합니다.`); + sections.push(...subpathExports.map((symbol) => [ + `### \`${symbol.name}\``, "", "```ts", display(symbol, subpathEntry), "```", + ].join("\n"))); + } const output = [ `# ${descriptor.packageName} API`, "", diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index 0d9126a18..b69276c4b 100644 --- a/site/config/json-document-source-aliases.ts +++ b/site/config/json-document-source-aliases.ts @@ -95,6 +95,10 @@ export function jsonDocumentSourceAliases(): SourceAlias[] { find: "@interactive-os/json-document-collaboration/text", replacement: sourceFile("packages/json-document-collaboration/src/text-index.ts"), }, + { + find: "@interactive-os/json-document-collaboration/editing", + replacement: sourceFile("packages/json-document-collaboration/src/editing-index.ts"), + }, { find: "@interactive-os/json-document-collaboration", replacement: sourceFile("packages/json-document-collaboration/src/index.ts"), diff --git a/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx b/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx index 42d69730f..1e865dea5 100644 --- a/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx +++ b/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx @@ -2,6 +2,9 @@ import { useState } from "react"; import { CornerDownLeft, Redo2, Undo2 } from "lucide-react"; import { DemoPage } from "../../shared/demo-workbench/DemoPage"; import { createJSONDocument } from "@interactive-os/json-document"; +import { createEditingId } from "@interactive-os/json-document-editing"; +import { createTextRuntime } from "@interactive-os/json-document-collaboration/text"; +import { createCollaborationEditingHistory } from "@interactive-os/json-document-collaboration/editing"; import { useEditing } from "@interactive-os/json-document-react"; import { createRichTextEditor, @@ -107,7 +110,18 @@ const initialDocument: RichTextDocument = { }; export function RichTextDemoRoute() { - const [editor] = useState(() => createRichTextEditor({ document: createJSONDocument(initialDocument) })); + const [{ editor, collaboration }] = useState(() => { + if (new URLSearchParams(window.location.search).get("history") !== "collaboration") { + return { editor: createRichTextEditor({ document: createJSONDocument(initialDocument) }), collaboration: null }; + } + const shared = { epochId: "rich-text-history-demo", ruleset: { id: "rich-text/v1", digest: "demo/v1" } }; + const local = createTextRuntime(initialDocument, { ...shared, actorId: createEditingId("local") }); + const remote = createTextRuntime(initialDocument, { ...shared, actorId: createEditingId("remote") }); + return { + editor: createRichTextEditor({ document: local.document, history: createCollaborationEditingHistory(local) }), + collaboration: { local, remote, remoteEditor: createRichTextEditor({ document: remote.document }) }, + }; + }); const document = editor.snapshot.value as RichTextDocument; const primary = editor.snapshot.selection.primaryIndex === null ? null @@ -161,6 +175,11 @@ export function RichTextDemoRoute() { Apply sample intent runHistory("undo")} disabled={commands.undo.disabled}> runHistory("redo")} disabled={commands.redo.disabled}> + {collaboration && { + collaboration.remote.replica.ingest(collaboration.local.replica.exportBundle()); + collaboration.remoteEditor.dispatch({ type: "text.insert", text: "remote · " }); + collaboration.local.replica.ingest(collaboration.remote.replica.exportBundle()); + }}>원격 변경 수신} last: {lastAction} diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 545817973..e76ad76f8 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -35,6 +35,9 @@ import documentTextControlSource from "../../../../packages/json-document-react/ import documentEditingSource from "../../../../packages/json-document-editing/src/document.ts?raw"; import editingClipboardSource from "../../../../packages/json-document-editing/src/clipboard.ts?raw"; import editingSessionSource from "../../../../packages/json-document-editing/src/session.ts?raw"; +import editingIdentitySource from "../../../../packages/json-document-editing/src/identity.ts?raw"; +import editingHistorySource from "../../../../packages/json-document-editing/src/history.ts?raw"; +import editingInverseSource from "../../../../packages/json-document-editing/src/invert-patch.ts?raw"; import objectEditingSource from "../../../../packages/json-document-editing/src/object.ts?raw"; import kanbanEditingSource from "../../../../packages/json-document-editing/src/kanban.ts?raw"; import editingTopologySource from "../../../../packages/json-document-editing/src/topology.ts?raw"; @@ -102,11 +105,13 @@ import selectionRangeSource from "../../../../packages/json-document-selection/s import selectionMaterializedRangeSource from "../../../../packages/json-document-selection/src/range/materialized.ts?raw"; import contentEditableReactSource from "../../../../packages/json-document-contenteditable/src/content-editable.tsx?raw"; import collaborationCreateSource from "../../../../packages/json-document-collaboration/src/create.ts?raw"; +import collaborationEditingSource from "../../../../packages/json-document-collaboration/src/editing-index.ts?raw"; import collaborationContentEditableSource from "../../../../packages/contenteditable-collaboration/src/lease.ts?raw"; import ajvSource from "../../../../packages/json-document-ajv/src/index.ts?raw"; import a2uiSource from "../../../../packages/json-document-a2ui/src/index.ts?raw"; import reactHookFormSource from "../../../../packages/json-document-react-hook-form/src/index.ts?raw"; import richTextSource from "../../../../packages/json-document-rich-text/src/editor.ts?raw"; +import richTextSelectionMappingSource from "../../../../packages/json-document-rich-text/src/selection-mapping.ts?raw"; import richTextAppliedChangeSource from "../../../../packages/json-document-rich-text/src/applied-change.ts?raw"; import richTextPlainTextSource from "../../../../packages/json-document-rich-text/src/plain-text.ts?raw"; import richTextWebSource from "../../../../packages/json-document-rich-text-web/src/contenteditable.ts?raw"; @@ -204,6 +209,9 @@ const registeredUsageSources = new Map([ ["packages/json-document-editing/src/document.ts", documentEditingSource], ["packages/json-document-editing/src/clipboard.ts", editingClipboardSource], ["packages/json-document-editing/src/session.ts", editingSessionSource], + ["packages/json-document-editing/src/identity.ts", editingIdentitySource], + ["packages/json-document-editing/src/history.ts", editingHistorySource], + ["packages/json-document-editing/src/invert-patch.ts", editingInverseSource], ["packages/json-document-editing/src/object.ts", objectEditingSource], ["packages/json-document-editing/src/kanban.ts", kanbanEditingSource], ["packages/json-document-editing/src/topology.ts", editingTopologySource], @@ -271,11 +279,13 @@ const registeredUsageSources = new Map([ ["packages/json-document-selection/src/range/materialized.ts", selectionMaterializedRangeSource], ["packages/json-document-contenteditable/src/content-editable.tsx", contentEditableReactSource], ["packages/json-document-collaboration/src/create.ts", collaborationCreateSource], + ["packages/json-document-collaboration/src/editing-index.ts", collaborationEditingSource], ["packages/contenteditable-collaboration/src/lease.ts", collaborationContentEditableSource], ["packages/json-document-ajv/src/index.ts", ajvSource], ["packages/json-document-a2ui/src/index.ts", a2uiSource], ["packages/json-document-react-hook-form/src/index.ts", reactHookFormSource], ["packages/json-document-rich-text/src/editor.ts", richTextSource], + ["packages/json-document-rich-text/src/selection-mapping.ts", richTextSelectionMappingSource], ["packages/json-document-rich-text/src/applied-change.ts", richTextAppliedChangeSource], ["packages/json-document-rich-text/src/plain-text.ts", richTextPlainTextSource], ["packages/json-document-rich-text-web/src/contenteditable.ts", richTextWebSource], @@ -664,6 +674,16 @@ const registeredPublicUsages = [ symbol: "ContentEditable", sourcePath: "packages/json-document-contenteditable/src/content-editable.tsx", }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createEditingId", + sourcePath: "packages/json-document-editing/src/identity.ts", + }, + { + packageName: "@interactive-os/json-document-collaboration/editing", + symbol: "createCollaborationEditingHistory", + sourcePath: "packages/json-document-collaboration/src/editing-index.ts", + }, { packageName: "@interactive-os/json-document-collaboration/text", symbol: "createTextRuntime", diff --git a/site/tests/browser/document-demo.spec.ts b/site/tests/browser/document-demo.spec.ts index 1e460ce8e..c874f8d2e 100644 --- a/site/tests/browser/document-demo.spec.ts +++ b/site/tests/browser/document-demo.spec.ts @@ -19,6 +19,8 @@ test("minimal document demo completes selection, clipboard, edit, move, undo, an await page.getByRole("button", { name: "Paste", exact: true }).click(); await expect(page.locator("article[data-selected=true]")).toHaveCount(2); await expect(page.getByRole("textbox", { name: "Block 6 text" })).toHaveValue(/Shift-click/); + const pastedIds = (await canonicalDocument(page)).blocks.slice(4).map((block) => block.id); + expect(new Set(pastedIds).size).toBe(2); await page.getByRole("textbox", { name: "Block 5 text" }).fill("한글 편집도 같은 transaction을 사용합니다."); await page.getByRole("button", { name: "Select block 5" }).click(); @@ -26,7 +28,7 @@ test("minimal document demo completes selection, clipboard, edit, move, undo, an await page.getByRole("button", { name: "Move up" }).click(); const moved = await canonicalDocument(page); - expect(moved.blocks.map((block) => block.id)).toEqual(["welcome", "select", "clipboard", "block-1", "block-2", "json"]); + expect(moved.blocks.map((block) => block.id)).toEqual(["welcome", "select", "clipboard", ...pastedIds, "json"]); await page.getByRole("button", { name: "Undo", exact: true }).click(); await page.getByRole("button", { name: "Undo", exact: true }).click(); @@ -37,7 +39,7 @@ test("minimal document demo completes selection, clipboard, edit, move, undo, an await page.getByRole("button", { name: "Redo", exact: true }).click(); await page.getByRole("button", { name: "Redo", exact: true }).click(); const redone = await canonicalDocument(page); - expect(redone.blocks.find((block) => block.id === "block-1")?.text).toBe("한글 편집도 같은 transaction을 사용합니다."); + expect(redone.blocks.find((block) => block.id === pastedIds[0])?.text).toBe("한글 편집도 같은 transaction을 사용합니다."); await expect(page.locator("article[data-selected=true]")).toHaveCount(2); }); diff --git a/site/tests/browser/rich-text-demo.spec.ts b/site/tests/browser/rich-text-demo.spec.ts index ce5557926..4bdcf95d5 100644 --- a/site/tests/browser/rich-text-demo.spec.ts +++ b/site/tests/browser/rich-text-demo.spec.ts @@ -1,5 +1,23 @@ import { expect, test, type Page } from "@playwright/test"; +test("Rich Text collaborative history routes DOM undo through the selective owner", async ({ page }) => { + await page.goto("/editing/rich-text?history=collaboration"); + await setSelection(page, "text-heading", 2, 2); + await page.keyboard.type("!"); + await page.getByRole("button", { name: "원격 변경 수신" }).click(); + await expect.poll(async () => textNode(await json(page, "rich-text-document-json"), "text-heading").text).toBe("remote · Ca!nonical Rich Text"); + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect.poll(async () => textNode(await json(page, "rich-text-document-json"), "text-heading").text).toBe("remote · Canonical Rich Text"); + expect((await json(page, "rich-text-selection-json")).selection.ranges[0].focus.offset).toBe(11); + const prevented = await page.getByTestId("rich-text-editor").evaluate(root => { + const event = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "historyRedo" }); + root.dispatchEvent(event); + return event.defaultPrevented; + }); + expect(prevented).toBe(true); + await expect.poll(async () => textNode(await json(page, "rich-text-document-json"), "text-heading").text).toBe("remote · Ca!nonical Rich Text"); +}); + test("Rich Text Lab leaves nested native input to its own host", async ({ page }) => { await page.goto("/editing/rich-text"); await setSelection(page, "text-editable", 3, 3); diff --git a/site/tsconfig.json b/site/tsconfig.json index 3bd9c081e..f68e99868 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -35,6 +35,7 @@ "@interactive-os/json-document-database": ["../packages/json-document-database/src/index.ts"], "@interactive-os/json-document-collaboration": ["../packages/json-document-collaboration/src/index.ts"], "@interactive-os/json-document-collaboration/text": ["../packages/json-document-collaboration/src/text-index.ts"], + "@interactive-os/json-document-collaboration/editing": ["../packages/json-document-collaboration/src/editing-index.ts"], "@interactive-os/json-document-contenteditable-collaboration": ["../packages/contenteditable-collaboration/src/index.ts"] }, "jsx": "react-jsx", diff --git a/standards/json-document-v3/conformance/vectors/json-document.json b/standards/json-document-v3/conformance/vectors/json-document.json index 3cefe45c4..81cf9afb1 100644 --- a/standards/json-document-v3/conformance/vectors/json-document.json +++ b/standards/json-document-v3/conformance/vectors/json-document.json @@ -408,6 +408,29 @@ "notifications": [] } }, + { + "id": "canceling-structural-batch-does-not-notify", + "kind": "commit", + "requirements": ["JD3-PATCH-001", "JD3-COMMIT-002", "JD3-NOTIFICATION-001"], + "operations": [ + { "op": "add", "path": "/meta/temporary", "value": true }, + { "op": "remove", "path": "/meta/temporary" }, + { "op": "remove", "path": "/items/0" }, + { "op": "add", "path": "/items/0", "value": { "id": "a", "done": false } }, + { "op": "add", "path": "/title", "value": "intermediate" }, + { "op": "replace", "path": "/title", "value": "Draft" } + ], + "expect": { + "probe": { "ok": true }, + "commit": { "ok": true, "change": { "applied": [] } }, + "value": { + "title": "Draft", + "items": [{ "id": "a", "done": false }, { "id": "b", "done": false }], + "meta": { "owner": "core" } + }, + "notifications": [] + } + }, { "id": "validation-cannot-transform-the-candidate", "kind": "commit",