From 10e79ae481ea0399f6f363a9c500ee6eef7b949f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Mon, 7 Sep 2026 23:00:55 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=ED=8E=B8=EC=A7=91=20=EB=AC=B8=EB=B2=95?= =?UTF-8?q?=EC=9D=98=20=EA=B3=B5=ED=86=B5=20=EA=B7=9C=EC=B9=99=EC=9D=84=20?= =?UTF-8?q?Hands=20profile=20=EC=A0=81=ED=95=A9=EC=84=B1=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 5 + docs/evaluate.mjs | 5 +- docs/public/official-hands.md | 58 ++-- packages/json-document-affordance/README.md | 12 + .../tests/affordance.test.ts | 12 - .../tests/conformance/editing-grammar.test.ts | 58 ++++ packages/json-document-editing/README.md | 32 ++ .../tests/conformance/editing-grammar.test.ts | 114 +++++++ .../tests/conformance/editing-grammar.ts | 201 +++++++++++ packages/json-document-rich-text/README.md | 25 ++ .../tests/conformance/editing-grammar.test.ts | 80 +++++ .../tests/editor.test.ts | 24 -- packages/json-document-selection/README.md | 11 + .../tests/conformance/select-all.test.ts | 26 ++ packages/json-document-web/README.md | 15 + .../tests/clipboard-rejection.test.ts | 84 +++++ .../src/shared/demo-workbench/demo-sources.ts | 7 + site/tests/unit/demo-workbench.test.tsx | 9 +- standards/editing-grammar.md | 318 ++++++++++++++++++ 19 files changed, 1034 insertions(+), 62 deletions(-) create mode 100644 packages/json-document-affordance/tests/conformance/editing-grammar.test.ts create mode 100644 packages/json-document-editing/tests/conformance/editing-grammar.test.ts create mode 100644 packages/json-document-editing/tests/conformance/editing-grammar.ts create mode 100644 packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts create mode 100644 packages/json-document-selection/tests/conformance/select-all.test.ts create mode 100644 standards/editing-grammar.md diff --git a/docs/README.md b/docs/README.md index 8251569b..bf5b9731 100644 --- a/docs/README.md +++ b/docs/README.md @@ -137,6 +137,11 @@ reconciliation까지의 DOM 편집 정본은 `standards/dom-editing-lifecycle.md identifier나 동작을 바꾸지 않으며, 과거 version 문서는 정본 public surface의 Root symbol·six-member 계약을 확장하지 않습니다. +편집 문법의 안정화 설계는 `standards/editing-grammar.md`에 있습니다. 공통 편집 +규칙, Hands profile의 선택, 입력 매핑의 소유자와 적합성 증거를 연결하는 Design +Draft이며 기존 Stable profile의 권위를 변경하지 않습니다. API reference와 Usage는 +각 owner에 유지하고, 설계 문서를 별도의 API catalog로 사용하지 않습니다. + 문서 원천, Pages 산출물, live 응답의 공개 계약 검사는 `public-contract-checks.mjs`가 소유합니다. Root symbol 수는 Core의 `public-contract.json`, 유효한 package 참조는 `api-reference/packages.mjs`에서 diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 1415c63a..1bac21b2 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -32,7 +32,7 @@ function filesUnder(path) { .flatMap((entry) => { if ( entry.isDirectory() - && [".git", ".npm-cache", "node_modules", "dist", "build", "coverage", "test-results"].includes(entry.name) + && [".git", ".worktrees", ".npm-cache", "node_modules", "dist", "build", "coverage", "test-results"].includes(entry.name) ) { return []; } @@ -220,10 +220,11 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ if (JSON.stringify(fileNames("standards")) !== JSON.stringify([ "dom-editing-lifecycle.md", + "editing-grammar.md", "repository-implementation-shape.md", "repository-naming.md", ])) { - fail("standards: repository naming and implementation shape must be the only repository-wide standard files."); + fail("standards: only repository naming, implementation shape, DOM editing lifecycle, and the editing grammar design may appear at the root."); } for (const token of [ diff --git a/docs/public/official-hands.md b/docs/public/official-hands.md index 3c35c2b4..2ca611d2 100644 --- a/docs/public/official-hands.md +++ b/docs/public/official-hands.md @@ -25,23 +25,17 @@ Get from Official Hands ## 왜 Official인가 -문서, 목록, 표, 나무와 캔버스 편집기는 오랫동안 비슷한 행동으로 -수렴했습니다. click은 선택하고, modifier-click은 선택을 토글하며, Shift는 -범위를 확장합니다. Escape는 진행 중인 행동을 취소하고 undo는 변경 전 -상태로 돌아갑니다. +문서, 목록, 표, 나무와 캔버스 편집기는 오랫동안 선택, 복사, 붙여넣기, +실행 취소 같은 행동을 공유해 왔습니다. 선택만 바꾸면 내용은 유지되고, +범위 확장은 기준점을 보존하며, 편집은 내용과 다음 작업 위치를 함께 정합니다. Official은 제품 취향을 임의로 정한다는 뜻이 아닙니다. 여러 편집기에서 반복해서 검증된 기대를 기본 동작으로 제공한다는 뜻입니다. -```text -자의적인 product opinion -└─ "Kanban card에는 반드시 dueDate가 있다" - -수렴한 editing opinion -├─ "Shift 선택은 범위를 확장한다" -├─ "Escape는 진행 중인 상호작용을 취소한다" -└─ "undo는 document와 Selection을 함께 복원한다" -``` +공통 규칙과 입력 관습은 구별합니다. 범위를 확장한다는 의미는 Selection에 +있고, Shift+click을 그 의미에 연결하는 일은 Adapter와 Affordance에 있습니다. +포커스가 이동할 때 선택도 바꿀지, 전체 선택 상태에서 Mod+A를 다시 누르면 +선택을 해제할지는 사용하는 편집 방식이 정합니다. 대부분의 사용자는 Official Hands만으로 편집기를 완성할 수 있어야 합니다. Custom Hands는 기본 경로가 아니라 제품에만 있는 차이를 위한 escape hatch입니다. @@ -70,6 +64,27 @@ row identity, column identity와 cell addressability를 먼저 정해야 합니 Sheet다운 편집 행동이 무엇을 대상으로 하는지 안정적으로 정하기 위해 필요합니다. +### 공통 규칙과 profile의 선택 + +공통 편집 규칙은 Selection과 Editing이 소유합니다. Profile은 그 규칙이 자신의 +문서에서 무엇을 대상으로 하며 어떤 결과를 만드는지 결정합니다. 다음 표는 현재 +구현을 이해하기 위한 예입니다. + +| 작업 | Document | Sheet | Rich Text | +| --- | --- | --- | --- | +| Copy 대상 | 선택된 블록 전체 | primary 직사각형 | 선택된 텍스트와 구조 | +| Paste 위치 | 기본적으로 마지막 선택 블록 뒤 | focus 셀부터 | 선택 구간에 적용 | +| Cut의 제거 | 선택 블록 제거 | primary 직사각형의 셀 값 비우기 | 선택 구간 제거 | + +같은 Copy라도 무엇을 복사하는지는 profile의 약속입니다. 사용자는 각 profile에서 +지원하는 작업, 여러 선택 범위의 처리, 붙여넣기 경계, 작업 후 선택 위치와 Undo +단위를 알 수 있어야 합니다. 작업 자체의 미지원과 현재 선택 때문에 실행할 수 없는 +상태도 구별합니다. + +이 구분은 새로운 공통 editor interface를 요구하지 않습니다. 기존 editor API와 +Selection family, EditingSession을 사용하면서 입력부터 편집 결과까지 같은 의미를 +유지하는 조합을 지향합니다. + ## 자유롭게 남겨 두는 것 Official Hands가 최소 profile을 제공해도 완성 제품을 대신 소유하지는 않습니다. @@ -161,14 +176,13 @@ Core는 어떤 Hands도 강제하지 않습니다. 사용자가 Official Hands Affordance와 Adapter가 사람이 작업을 끝낼 수 있는 하나의 kit로 조합되는 방식을 검토합니다. -## 열린 질문 +## 사용자가 기대할 계약 -- Official Hands로 인정할 최소 완료 증거는 무엇인가? -- 수렴한 기본 동작과 제품별 policy를 어떤 기준으로 가르는가? -- profile의 configuration과 extension은 어느 수준까지 compatibility를 약속하는가? -- minimum schema에 Host field를 연결하는 공통 방식은 무엇인가? -- 여러 Official Hands가 같은 document에서 조합될 때 identity와 History를 어떻게 공유하는가? -- Custom Hands로 내려가야 하는 명확한 신호는 무엇인가? +Official profile의 지향점은 구현이 바뀌어도 같은 지원 입력에서 같은 편집 의미를 +얻는 것입니다. 선택하고, 편집하고, 복사하고, 되돌리는 전체 흐름이 그 약속에 +포함됩니다. Keyboard나 pointer로 실행해도 해당 editor를 직접 호출해도 같은 +편집 의도는 같은 문서와 Selection의 결과로 이어져야 합니다. -이 질문이 닫히기 전에는 Official Hands 후보를 완성된 SDK contract로 -설명하지 않습니다. +구체적인 profile별 필수 작업, Host field 연결, 여러 Hand가 공유하는 History +단위는 아직 확정하지 않았습니다. 현재 후보 목록과 위 동작 예시는 완성된 SDK의 +호환성 보장이 아닙니다. diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 66899758..8a4d6313 100644 --- a/packages/json-document-affordance/README.md +++ b/packages/json-document-affordance/README.md @@ -78,3 +78,15 @@ the final placement and available size while Tooltip, Menu, Dialog, and product open/focus semantics remain outside this geometry contract. Usage: [Affordance](https://developer-1px.github.io/json-document/docs/affordance) + +`selectAllAffordance` implements an explicit Mod+A toggle input convention: +when everything is selected it emits `clear`; otherwise it emits `select-all`. +The semantic `select-all` command itself is idempotent. Hosts choosing this +input convention consume the existing Affordance API. + +[Editing grammar integration tests](tests/conformance/editing-grammar.test.ts) +connect that mapping to KeySelection and connect `createGestureSession` to +Document's `selection.move`. Structural preview and cancellation leave committed +value/history unchanged; commit dispatches the latest preview once. This proves +the tested composition, not every Host callback. IME composition has a separate +[DOM editing lifecycle](../../standards/dom-editing-lifecycle.md) contract. diff --git a/packages/json-document-affordance/tests/affordance.test.ts b/packages/json-document-affordance/tests/affordance.test.ts index c17e8fed..c87a454f 100644 --- a/packages/json-document-affordance/tests/affordance.test.ts +++ b/packages/json-document-affordance/tests/affordance.test.ts @@ -29,7 +29,6 @@ import { panAffordance, resizeAffordance, resolveAffordanceKey, - selectAllAffordance, snapAffordance, treeAffordance, createBoardDragSession, @@ -656,17 +655,6 @@ describe("dropAffordance", () => { }); }); -describe("selectAllAffordance", () => { - test("toggles Mod+A between select-all and clear", () => { - expect(selectAllAffordance({ key: "a", metaKey: true, ctrlKey: false }, { allSelected: false }).hand) - .toEqual({ type: "select-all" }); - expect(selectAllAffordance({ key: "a", metaKey: true, ctrlKey: false }, { allSelected: true }).hand) - .toEqual({ type: "clear" }); - expect(selectAllAffordance({ key: "a", metaKey: false, ctrlKey: false }, { allSelected: false }).hand) - .toBeNull(); - }); -}); - describe("drag constrain and copy", () => { test("Shift constrains to the dominant axis and Alt shows copy", () => { expect(dragAffordance({ x: 0, y: 0 }, { x: 12, y: 3 }, { shiftKey: true }).hand) diff --git a/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts new file mode 100644 index 00000000..6af1531c --- /dev/null +++ b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts @@ -0,0 +1,58 @@ +import { createDocumentEditor } from "@interactive-os/json-document-editing"; +import { createKeySelectionFamily, emptyKeySelection, type KeySelectionContext } from "@interactive-os/json-document-selection"; +import { describe, expect, test } from "vitest"; +import { createGestureSession, selectAllAffordance, type GestureCancelReason } from "../../src/index.js"; + +describe("editing grammar / input mapping", () => { + test.each(["metaKey", "ctrlKey"] as const)("EG-SELECT / %s+A toggle profile sends clear as a distinct intent", (modifier) => { + const context: KeySelectionContext = { keys: ["a", "b"], universe: "visible:v1", universeMismatch: "clear" }; + const family = createKeySelectionFamily(); + const stroke = { key: "a", metaKey: false, ctrlKey: false, [modifier]: true }; + expect(selectAllAffordance(stroke, { allSelected: false }).hand).toEqual({ type: "select-all" }); + const selected = family.transition(emptyKeySelection(), { type: "select-all", universe: context.universe }, context).state; + expect(family.targets(selected, context)).toEqual(["a", "b"]); + const second = selectAllAffordance(stroke, { allSelected: true }).hand; + expect(second).toEqual({ type: "clear" }); + if (second?.type !== "clear") throw new Error("Expected the toggle profile to emit clear"); + expect(family.targets(family.transition(selected, second, context).state, context)).toEqual([]); + expect(selectAllAffordance({ key: "a", metaKey: false, ctrlKey: false }, { allSelected: false }).hand).toBeNull(); + }); + + test.each(["cancel", "pointer-cancel", "lost-capture"] satisfies GestureCancelReason[])("EG-GESTURE / %s discards preview; commit moves once", (reason) => { + const initial = { blocks: [{ id: "a", text: "A" }, { id: "b", text: "B" }, { id: "c", text: "C" }] }; + const editor = createDocumentEditor(initial); + // Navigation changes the editing target without moving content. + expect(editor.dispatch({ type: "selection.set", blockId: "b" }).ok).toBe(true); + expect(editor.snapshot.value).toEqual(initial); + expect(editor.snapshot.canUndo).toBe(false); + const before = structuredClone(editor.snapshot); + const published: unknown[] = []; + const release = editor.subscribe((snapshot) => published.push(snapshot)); + const gesture = createGestureSession<{ type: "block-move"; direction: -1 | 1 }>({ + onCommit(preview) { expect(editor.dispatch({ type: "selection.move", direction: preview.direction }).ok).toBe(true); }, + }); + try { + gesture.begin({ type: "block-move", direction: -1 }); + gesture.preview({ type: "block-move", direction: 1 }); + gesture.preview({ type: "block-move", direction: -1 }); + expect(editor.snapshot).toMatchObject(before); + gesture.cancel(reason); + expect(gesture.getActive()).toBeNull(); + expect(gesture.commit()).toBeNull(); + expect(editor.snapshot).toMatchObject(before); + expect(published).toEqual([]); + + gesture.begin({ type: "block-move", direction: -1 }); + gesture.preview({ type: "block-move", direction: 1 }); + expect(editor.snapshot).toMatchObject(before); + expect(gesture.commit()).toEqual({ type: "block-move", direction: 1 }); + expect(gesture.getActive()).toBeNull(); + expect(gesture.commit()).toBeNull(); + expect(published).toHaveLength(1); + expect(editor.snapshot.value).toEqual({ blocks: [initial.blocks[0], initial.blocks[2], initial.blocks[1]] }); + expect(editor.selectedBlockIds).toEqual(["b"]); + expect(editor.undo().ok).toBe(true); + expect(editor.snapshot).toMatchObject({ value: before.value, selection: before.selection, canUndo: false, canRedo: true }); + } finally { release(); } + }); +}); diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 30515ab0..d80ec2c7 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -149,3 +149,35 @@ targets may use numbered `marker` presentations for instructions or a serializable `{ type: "reaction", reaction: "like" | "dislike" }` presentation for comment-free feedback. Both use the same create, move, delete, and history contracts exposed by `createAnnotationEditor`. + +## Editing grammar evidence (Draft) + +The [editing grammar design](../../standards/editing-grammar.md) separates shared +rules from each Hand's interpretation. The [test-only runner](tests/conformance/editing-grammar.ts) +executes the same selection, copy, edit, cut, paste, rejection, no-op, and local +undo/redo observations through public APIs. Its [Document and Sheet bindings](tests/conformance/editing-grammar.test.ts) +keep their own types and expected values; Rich Text binds it from its own package. +These are applicability checks across different profiles, not independent +implementations of one frozen profile. + +| Profile decision | Document binding | Sheet binding | +| --- | --- | --- | +| Target and identity | Stable block ID plus text offset; `selection.move` moves content | Stable row/column IDs; cell values | +| Selection | Directional block ranges; Copy includes whole blocks even with text offsets | Rectangular ranges; Copy/Cut use the primary rectangle | +| Topology | Document block order | Document axes by default; supplied visible axes for topology-aware operations | +| Supported operations | Select, insert, remove, move, duplicate, Copy/Cut/Paste, Undo/Redo | Select, commit/fill cells, Copy/Cut/Paste, Undo/Redo; Cut clears values to `null` | +| Paste and resulting selection | Insert after the last selected block by default (or `afterId`); fresh IDs; one collapsed range per inserted block, first primary | Start at focus without replacing the old rectangle; select the written rectangle; reject overflow with `paste.out-of-bounds` | +| Local history | Consecutive text changes in the same block can share a group; selection ends the active group | Consecutive commits to the same cell can share a group; selection ends the active group | +| Input | Headless calls; Web/Affordance choose physical bindings and native text arbitration | Headless calls; Web/Affordance choose physical bindings and native text arbitration | + +Both bindings clear local history on external value changes and reconcile missing +selection endpoints. An injected `EditingHistory` retains its own step and +restoration policy. A missing command (the current Database binding has no +`cut`) is distinct from a supported command that is unavailable for the current +selection. The existing [clipboard surface test](tests/clipboard-surface.test.ts) +records that distinction; it does not decide every future Database profile. + +Existing public API references and live Usage remain in +[Editing](https://developer-1px.github.io/json-document/docs/api/editing), +[Document](https://developer-1px.github.io/json-document/demo), and +[Sheet](https://developer-1px.github.io/json-document/demo/sheet). diff --git a/packages/json-document-editing/tests/conformance/editing-grammar.test.ts b/packages/json-document-editing/tests/conformance/editing-grammar.test.ts new file mode 100644 index 00000000..963a7886 --- /dev/null +++ b/packages/json-document-editing/tests/conformance/editing-grammar.test.ts @@ -0,0 +1,114 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { expect } from "vitest"; +import { createDocumentEditor, createSheetEditor, type DocumentSelection, type SheetSelection } from "../../src/index.js"; +import { editingGrammar } from "./editing-grammar.js"; + +const blocks = [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }, { id: "c", text: "Gamma" }]; +function blockRange(anchor: string, focus = anchor, offset = 1): DocumentSelection { + return { kind: "range", ranges: [{ anchor: { blockId: anchor, offset }, focus: { blockId: focus, offset } }], primaryIndex: 0 }; +} + +editingGrammar("Document / block range / local history", () => { + const document = createJSONDocument({ blocks }); + let id = 0; + const editor = createDocumentEditor(document, { createId: () => `new-${++id}` }); + const clipboard = { type: "application/vnd.interactive-os.blocks+json" as const, blocks, text: "Alpha\nBeta\nGamma" }; + return { + document, editor, clipboard, + selectStart: () => editor.dispatch({ type: "selection.set", blockId: "a", offset: 0 }), + extend: () => editor.dispatch({ type: "selection.set", blockId: "c", offset: 1, mode: "extend" }), + assertSelected(selection) { + expect(selection).toEqual({ kind: "range", primaryIndex: 0, ranges: [{ + anchor: { blockId: "a", offset: 0 }, focus: { blockId: "c", offset: 1 }, + }] }); + expect(editor.selectedBlockIds).toEqual(["a", "b", "c"]); + }, + edit: () => editor.dispatch({ type: "text.replace", blockId: "c", text: "Changed" }), + assertEdited(snapshot) { + expect(snapshot.value).toEqual({ blocks: [blocks[0], blocks[1], { id: "c", text: "Changed" }] }); + expect(snapshot.selection).toEqual(blockRange("c", "c", 7)); + }, + paste: () => editor.dispatch({ type: "clipboard.paste", clipboard }), + assertPasted(snapshot) { + // Fixed injected allocator: existing IDs survive, every inserted block is fresh. + expect(snapshot.value).toEqual({ blocks: [...blocks, + { id: "new-1", text: "Alpha" }, { id: "new-2", text: "Beta" }, { id: "new-3", text: "Gamma" }, + ] }); + expect(snapshot.selection).toEqual({ kind: "range", primaryIndex: 0, ranges: [ + { anchor: { blockId: "new-1", offset: 0 }, focus: { blockId: "new-1", offset: 0 } }, + { anchor: { blockId: "new-2", offset: 0 }, focus: { blockId: "new-2", offset: 0 } }, + { anchor: { blockId: "new-3", offset: 0 }, focus: { blockId: "new-3", offset: 0 } }, + ] }); + }, + assertCut(snapshot) { + expect(snapshot.value).toEqual({ blocks: [] }); + expect(snapshot.selection).toEqual({ kind: "range", ranges: [], primaryIndex: null }); + }, + reject: () => editor.dispatch({ type: "text.replace", blockId: "missing", text: "rejected" }), + rejectionCode: "text.block-not-found", + noop: () => editor.dispatch({ type: "text.replace", blockId: "a", text: "Alpha" }), + removeExternal() { expect(document.commit([{ op: "remove", path: "/blocks/0" }]).ok).toBe(true); }, + assertExternal(selection) { expect(selection).toEqual({ kind: "range", ranges: [], primaryIndex: null }); }, + }; +}); + +const sheet = { + columns: [{ id: "name", label: "Name" }, { id: "value", label: "Value" }], + rows: [ + { id: "a", cells: { name: "Alpha", value: 1 } }, + { id: "b", cells: { name: "Beta", value: 2 } }, + { id: "c", cells: { name: "Gamma", value: 3 } }, + ], +}; +function cellsRange(anchorRow: string, focusRow = anchorRow, columnId = "name"): SheetSelection { + const anchor = { rowId: anchorRow, columnId }; + const focus = { rowId: focusRow, columnId }; + return { kind: "range", anchor, focus, ranges: [{ anchor, focus }], primaryIndex: 0 }; +} + +editingGrammar("Sheet / primary rectangle / local history", () => { + const document = createJSONDocument(sheet); + const editor = createSheetEditor(document); + return { + document, editor, + clipboard: { type: "application/vnd.interactive-os.sheet+json" as const, cells: [["Alpha"], ["Beta"], ["Gamma"]], text: "Alpha\nBeta\nGamma" }, + selectStart: () => editor.dispatch({ type: "selection.set", rowId: "a", columnId: "name" }), + extend: () => editor.dispatch({ type: "selection.set", rowId: "c", columnId: "name", mode: "extend" }), + assertSelected(selection) { + expect(selection).toEqual(cellsRange("a", "c")); + expect(editor.selectedCells).toEqual([ + { rowId: "a", columnId: "name", value: "Alpha" }, + { rowId: "b", columnId: "name", value: "Beta" }, + { rowId: "c", columnId: "name", value: "Gamma" }, + ]); + }, + edit: () => editor.dispatch({ type: "cell.commit", rowId: "c", columnId: "value", value: 30 }), + assertEdited(snapshot) { + expect(snapshot.value).toEqual({ ...sheet, rows: [sheet.rows[0], sheet.rows[1], { id: "c", cells: { name: "Gamma", value: 30 } }] }); + expect(snapshot.selection).toEqual(cellsRange("c", "c", "value")); + }, + // The selected rectangle is a:c; paste starts at focus c and leaves a:b intact. + paste: () => editor.dispatch({ type: "clipboard.paste", clipboard: { + type: "application/vnd.interactive-os.sheet+json", cells: [["Pasted"]], text: "Pasted", + } }), + assertPasted(snapshot) { + expect(snapshot.value).toEqual({ ...sheet, rows: [sheet.rows[0], sheet.rows[1], { id: "c", cells: { name: "Pasted", value: 3 } }] }); + expect(snapshot.selection).toEqual(cellsRange("c")); + }, + assertCut(snapshot) { + expect(snapshot.value).toEqual({ ...sheet, rows: [ + { id: "a", cells: { name: null, value: 1 } }, + { id: "b", cells: { name: null, value: 2 } }, + { id: "c", cells: { name: null, value: 3 } }, + ] }); + expect(snapshot.selection).toEqual(cellsRange("a", "c")); + }, + reject: () => editor.dispatch({ type: "clipboard.paste", clipboard: { + type: "application/vnd.interactive-os.sheet+json", cells: [[1, 2, 3]], text: "1\t2\t3", + } }), + rejectionCode: "paste.out-of-bounds", + noop: () => editor.dispatch({ type: "cell.commit", rowId: "a", columnId: "name", value: "Alpha" }), + removeExternal() { expect(document.commit([{ op: "remove", path: "/rows/0" }]).ok).toBe(true); }, + assertExternal(selection) { expect(selection).toEqual({ kind: "range", anchor: null, focus: null, ranges: [], primaryIndex: null }); }, + }; +}); diff --git a/packages/json-document-editing/tests/conformance/editing-grammar.ts b/packages/json-document-editing/tests/conformance/editing-grammar.ts new file mode 100644 index 00000000..a276500e --- /dev/null +++ b/packages/json-document-editing/tests/conformance/editing-grammar.ts @@ -0,0 +1,201 @@ +import type { JSONDocument, JSONValue } from "@interactive-os/json-document"; +import { describe, expect, test } from "vitest"; +import type { EditingResult, EditingSnapshot } from "../../src/index.js"; + +/** Test-only calls and observations; profiles keep their own intent/selection types. */ +export interface EditingGrammarBinding { + readonly document: JSONDocument; + readonly editor: { + readonly snapshot: EditingSnapshot; + subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; + copy(): Clipboard | null; + cut(): { readonly clipboard: Clipboard; readonly result: EditingResult } | null; + undo(): EditingResult; + redo(): EditingResult; + }; + selectStart(): EditingResult; + extend(): EditingResult; + assertSelected(selection: Selection): void; + readonly clipboard: Clipboard; + edit(): EditingResult; + assertEdited(snapshot: EditingSnapshot): void; + paste(): EditingResult; + assertPasted(snapshot: EditingSnapshot): void; + assertCut(snapshot: EditingSnapshot): void; + reject(): EditingResult; + readonly rejectionCode: string; + noop(): EditingResult; + removeExternal(): void; + assertExternal(selection: Selection): void; +} + +function content(snapshot: EditingSnapshot) { + return { value: snapshot.value, selection: snapshot.selection }; +} + +/** One local-history step: coherent publication, exact restoration, retained result. */ +function roundTrip( + binding: EditingGrammarBinding, + action: () => EditingResult, + assertAfter: (snapshot: EditingSnapshot) => void, +) { + const { editor, document } = binding; + const before = structuredClone(content(editor.snapshot)); + const published: EditingSnapshot[] = []; + const release = editor.subscribe((snapshot) => published.push(snapshot)); + try { + const result = action(); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.code); + const after = structuredClone(content(result.snapshot)); + expect(after.value).not.toEqual(before.value); + assertAfter(result.snapshot); + expect(document.value).toEqual(after.value); + expect(result.snapshot).toMatchObject({ ...after, canUndo: true, canRedo: false }); + expect(published).toHaveLength(1); + expect(published[0]).toMatchObject(result.snapshot); + expect(editor.snapshot).toMatchObject(result.snapshot); + + const undone = editor.undo(); + expect(undone).toMatchObject({ ok: true, snapshot: { ...before, canUndo: false, canRedo: true } }); + expect(content(editor.snapshot)).toEqual(before); + expect(document.value).toEqual(before.value); + const redone = editor.redo(); + expect(redone).toMatchObject({ ok: true, snapshot: { ...after, canUndo: true, canRedo: false } }); + expect(content(editor.snapshot)).toEqual(after); + expect(document.value).toEqual(after.value); + expect(published.map(content)).toEqual([after, before, after]); + expect(content(result.snapshot)).toEqual(after); + expect(published[0]).toMatchObject({ ...after, canUndo: true, canRedo: false }); + } finally { + release(); + } +} + +export function editingGrammar( + profile: string, + create: () => EditingGrammarBinding, +) { + describe(`editing grammar / ${profile}`, () => { + test.each(["empty", "undo", "redo"] as const)("EG-SELECT / extend preserves targets and %s history", (history) => { + const binding = create(); + const { editor, document } = binding; + const initial = structuredClone(content(editor.snapshot)); + if (history !== "empty") expect(binding.edit().ok).toBe(true); + const edited = structuredClone(content(editor.snapshot)); + if (history === "redo") expect(editor.undo().ok).toBe(true); + const before = structuredClone(editor.snapshot); + expect(binding.selectStart().ok).toBe(true); + expect(binding.extend().ok).toBe(true); + binding.assertSelected(editor.snapshot.selection); + expect(document.value).toEqual(before.value); + expect(editor.snapshot).toMatchObject({ value: before.value, canUndo: before.canUndo, canRedo: before.canRedo }); + if (history === "undo") { + expect(editor.undo().ok).toBe(true); + expect(content(editor.snapshot)).toEqual(initial); + } else if (history === "redo") { + expect(editor.redo().ok).toBe(true); + expect(content(editor.snapshot)).toEqual(edited); + } + }); + + test("EG-COPY / repeated structured copy preserves state and redo", () => { + const binding = create(); + const { editor } = binding; + expect(binding.edit().ok).toBe(true); + const edited = structuredClone(content(editor.snapshot)); + expect(editor.undo().ok).toBe(true); + expect(binding.selectStart().ok).toBe(true); + expect(binding.extend().ok).toBe(true); + const before = structuredClone(editor.snapshot); + const published: EditingSnapshot[] = []; + const release = editor.subscribe((snapshot) => published.push(snapshot)); + try { + expect(editor.copy()).toEqual(binding.clipboard); + expect(editor.copy()).toEqual(binding.clipboard); + expect(editor.snapshot).toMatchObject(before); + expect(binding.document.value).toEqual(before.value); + expect(published).toEqual([]); + expect(editor.redo().ok).toBe(true); + expect(content(editor.snapshot)).toEqual(edited); + } finally { release(); } + }); + + test("EG-EDIT EG-HISTORY EG-RESULT / edit → undo → redo", () => { + const binding = create(); + roundTrip(binding, binding.edit, binding.assertEdited); + }); + + test("EG-PASTE EG-HISTORY EG-RESULT / profile paste → undo → redo", () => { + const binding = create(); + expect(binding.selectStart().ok).toBe(true); + expect(binding.extend().ok).toBe(true); + roundTrip(binding, binding.paste, binding.assertPasted); + }); + + test("EG-CUT EG-HISTORY / capture target → remove → undo → redo", () => { + const binding = create(); + expect(binding.selectStart().ok).toBe(true); + expect(binding.extend().ok).toBe(true); + roundTrip(binding, () => { + const cut = binding.editor.cut(); + expect(cut).not.toBeNull(); + expect(cut?.clipboard).toEqual(binding.clipboard); + return cut!.result; + }, binding.assertCut); + }); + + test.each(["undo", "redo"] as const)("EG-EDIT / rejection preserves %s and publishes nothing", (history) => { + const binding = create(); + const initial = structuredClone(content(binding.editor.snapshot)); + expect(binding.edit().ok).toBe(true); + const edited = structuredClone(content(binding.editor.snapshot)); + if (history === "redo") expect(binding.editor.undo().ok).toBe(true); + const before = structuredClone(binding.editor.snapshot); + const published: EditingSnapshot[] = []; + const release = binding.editor.subscribe((snapshot) => published.push(snapshot)); + try { + expect(binding.reject()).toMatchObject({ ok: false, code: binding.rejectionCode }); + expect(binding.editor.snapshot).toMatchObject(before); + expect(binding.document.value).toEqual(before.value); + expect(published).toEqual([]); + expect(binding.editor[history]().ok).toBe(true); + expect(content(binding.editor.snapshot)).toEqual(history === "undo" ? initial : edited); + } finally { release(); } + }); + + test("EG-HISTORY / no-op preserves redo without recording a step", () => { + const binding = create(); + expect(binding.edit().ok).toBe(true); + const edited = structuredClone(content(binding.editor.snapshot)); + expect(binding.editor.undo().ok).toBe(true); + const before = structuredClone(binding.editor.snapshot); + expect(binding.noop().ok).toBe(true); + expect(binding.editor.snapshot).toMatchObject({ value: before.value, canUndo: false, canRedo: true }); + expect(binding.editor.redo().ok).toBe(true); + expect(content(binding.editor.snapshot)).toEqual(edited); + }); + + test.each([false, true])("EG-TARGET EG-HISTORY / external deletion, observed=%s", (observed) => { + const binding = create(); + expect(binding.edit().ok).toBe(true); + expect(binding.selectStart().ok).toBe(true); + const published: EditingSnapshot[] = []; + const release = observed ? binding.editor.subscribe((snapshot) => published.push(snapshot)) : () => {}; + try { + binding.removeExternal(); + const after = binding.editor.snapshot; + binding.assertExternal(after.selection); + expect(after).toMatchObject({ value: binding.document.value, canUndo: false, canRedo: false }); + if (observed) { + expect(published).toHaveLength(1); + expect(published[0]).toMatchObject(after); + } + const saved = structuredClone(content(after)); + expect(binding.editor.undo().ok).toBe(false); + expect(binding.editor.redo().ok).toBe(false); + expect(content(binding.editor.snapshot)).toEqual(saved); + } finally { release(); } + }); + }); +} diff --git a/packages/json-document-rich-text/README.md b/packages/json-document-rich-text/README.md index 4c06f1e7..73570693 100644 --- a/packages/json-document-rich-text/README.md +++ b/packages/json-document-rich-text/README.md @@ -73,3 +73,28 @@ clone and reuses unchanged subtree identity. Mutating borrowed input after the call is outside the contract; use the default for external payloads. The public surface is not frozen while RFC #363 remains Draft. + +## Editing grammar evidence (Draft) + +The [inline-slice binding](tests/conformance/editing-grammar.test.ts) runs Editing's +shared grammar suite through `createRichTextEditor`, `dispatch`, `copy`, `cut`, +`undo`, `redo`, and `subscribe`. It preserves the existing +[Rich Text v1 corpus](../../standards/json-document-rich-text-v1) and +[conformance tests](tests/conformance.test.ts). + +| Profile decision | Rich Text v1 binding | +| --- | --- | +| Target and identity | Stable node IDs with text/child points and affinity | +| Selection and topology | Logical document order; multiple directional ranges and a primary index; Copy projects structured slices rather than rendered DOM | +| Supported operations | Selection, text/node/block/mark transforms, Copy/Cut/Paste, Undo/Redo through the existing typed intents | +| Paste and resulting selection | Replace selected content with a schema-compatible slice; allocate fresh inserted/split IDs, preserve surviving IDs, and publish the mapped post-insert point | +| Failure | Reject incompatible clipboard profiles and invalid schema/duplicate identities before committing | +| History | Local inverse history by default; `historyGroup` explicitly groups typing; external changes clear local history; injected history owns its own steps | +| Input | Rich Text Web owns DOM selection and native editing arbitration; composition follows the separate DOM editing lifecycle contract | + +The fixture checks `Alpha` with offsets 1–3: Copy reads `lp`, Cut leaves `Aha`, +and pasting `Z` leaves `AZha` with a caret after `Z`. It also checks the exact +surviving and injected IDs across document, selection, Undo, and Redo. This is +one inline profile path, not coverage of every structural slice or browser. +See the [editing grammar design](../../standards/editing-grammar.md) for the +common rules and the limits of this evidence. diff --git a/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts b/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts new file mode 100644 index 00000000..d3a85ed1 --- /dev/null +++ b/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts @@ -0,0 +1,80 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { createRangeSelectionFamily } from "@interactive-os/json-document-selection"; +import { expect } from "vitest"; +import { editingGrammar } from "../../../json-document-editing/tests/conformance/editing-grammar.js"; +import { + createRichTextEditor, type RichTextClipboard, type RichTextDocument, + type RichTextPoint, type RichTextSelection, type RichTextTarget, +} 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: "Alpha", marks: [] }] }, + { id: "q", type: "paragraph", content: [] }, + ], +}; +const point = (offset: number): RichTextPoint => ({ kind: "text", nodeId: "t", offset, affinity: "forward" }); +const range = (anchor: number, focus = anchor): RichTextSelection => ({ + kind: "range", ranges: [{ anchor: point(anchor), focus: point(focus) }], primaryIndex: 0, +}); + +editingGrammar("Rich Text v1 / inline slice / local history", () => { + const document = createJSONDocument(initial); + let id = 0; + const editor = createRichTextEditor({ document, createId: () => `new-${++id}` }); + const family = createRangeSelectionFamily(); + const clipboard: RichTextClipboard = { + type: "application/vnd.interactive-os.rich-text+json", text: "lp", html: "", + slice: { profile: initial.profile, openStart: 1, openEnd: 1, content: [ + { id: "t", type: "text", text: "lp", marks: [] }, + ] }, + }; + const withText = (text: string) => ({ ...initial, content: [ + { id: "p", type: "paragraph", content: [{ id: "t", type: "text", text, marks: [] }] }, + initial.content[1], + ] }); + return { + document, editor, clipboard, + selectStart: () => editor.dispatch({ type: "selection.set", selection: range(1) }), + extend() { + const selection = family.transition(editor.snapshot.selection, { type: "extend-primary", point: point(3) }, { topology: editor.topology }).state; + return editor.dispatch({ type: "selection.set", selection: selection as RichTextSelection }); + }, + assertSelected(selection) { + expect(selection).toEqual(range(1, 3)); + expect(family.targets(selection, { topology: editor.topology })).toEqual([{ kind: "text", nodeId: "t", from: 1, to: 3 }]); + }, + edit: () => editor.dispatch({ type: "text.insert", text: "!" }), + assertEdited(snapshot) { + expect(snapshot.value).toEqual(withText("!Alpha")); + expect(snapshot.selection).toEqual(range(1)); + }, + paste: () => editor.dispatch({ type: "clipboard.paste", clipboard: { + ...clipboard, text: "Z", slice: { ...clipboard.slice, content: [{ id: "source", type: "text", text: "Z", marks: [] }] }, + } }), + assertPasted(snapshot) { + // The surviving prefix keeps t; inserted text and split suffix get fresh IDs. + expect(snapshot.value).toEqual({ ...initial, content: [ + { id: "p", type: "paragraph", content: [ + { id: "t", type: "text", text: "A", marks: [] }, + { id: "new-1", type: "text", text: "Z", marks: [] }, + { id: "new-2", type: "text", text: "ha", marks: [] }, + ] }, initial.content[1], + ] }); + const after = { kind: "text", nodeId: "new-1", offset: 1, affinity: "forward" }; + expect(snapshot.selection).toEqual({ kind: "range", ranges: [{ anchor: after, focus: after }], primaryIndex: 0 }); + }, + assertCut(snapshot) { + expect(snapshot.value).toEqual(withText("Aha")); + expect(snapshot.selection).toEqual(range(1)); + }, + reject: () => editor.dispatch({ type: "node.insert", point: { kind: "child", nodeId: "doc", offset: 1, affinity: "forward" }, + node: { id: "duplicate-parent", type: "paragraph", content: [{ id: "t", type: "text", text: "duplicate", marks: [] }] }, + }), + rejectionCode: "rich-text.duplicate-id", + noop: () => editor.dispatch({ type: "text.insert", text: "" }), + removeExternal() { expect(document.commit([{ op: "remove", path: "/content/0" }]).ok).toBe(true); }, + assertExternal(selection) { expect(selection).toEqual({ kind: "range", ranges: [], primaryIndex: null }); }, + }; +}); diff --git a/packages/json-document-rich-text/tests/editor.test.ts b/packages/json-document-rich-text/tests/editor.test.ts index 4e34ecbc..561b4599 100644 --- a/packages/json-document-rich-text/tests/editor.test.ts +++ b/packages/json-document-rich-text/tests/editor.test.ts @@ -58,30 +58,6 @@ describe("Official Rich Text editor", () => { expect((document.value as RichTextDocument).content.at(-1)).toMatchObject({ type: "paragraph", content: [] }); expect(editor.snapshot.selection.ranges[0]?.anchor).toMatchObject({ kind: "child", offset: 0 }); }); - it("commits text through EditingSession and restores value with selection", () => { - const document = createJSONDocument(initial); - const selection = collapsed("text-2", 4); - const editor = createRichTextEditor({ document, selection }); - - const edited = editor.dispatch({ type: "text.insert", text: " works" }); - expect(edited.ok).toBe(true); - expect((document.value as RichTextDocument).content[1]).toMatchObject({ - content: [{ text: "Text works" }], - }); - expect(editor.snapshot.selection.ranges[0]?.focus).toMatchObject({ nodeId: "text-2", offset: 10 }); - expect(editor.snapshot.canUndo).toBe(true); - - expect(editor.undo().ok).toBe(true); - expect((document.value as RichTextDocument).content[1]).toMatchObject({ - content: [{ text: "Text" }], - }); - expect(editor.snapshot.selection).toEqual(selection); - - expect(editor.redo().ok).toBe(true); - expect((document.value as RichTextDocument).content[1]).toMatchObject({ - content: [{ text: "Text works" }], - }); - }); it("uses logical topology instead of a rendered DOM order", () => { const editor = createRichTextEditor({ diff --git a/packages/json-document-selection/README.md b/packages/json-document-selection/README.md index 9bc811af..935a6cea 100644 --- a/packages/json-document-selection/README.md +++ b/packages/json-document-selection/README.md @@ -42,3 +42,14 @@ Viewport-to-domain coordinate conversion, pointer capture, auto-scroll, and acce ## Editing and history Domain facades translate `{ selection, intent }` into forward/inverse patches plus `selectionAfter`. A document mutation history entry stores `selectionBefore` and `selectionAfter`; selection-only movement does not create a document history entry. Native text selection is represented only by an edit lease in `SelectionSession` and stays owned by the input/editor. + +## Select All semantics (Draft grammar) + +`KeySelectionCommand`'s `select-all` selects the specified universe. Repeating +that intent in the same universe preserves the selection; issuing it after +subtracting targets restores those targets. It does not toggle the universe off. +[EG-SELECT conformance](tests/conformance/select-all.test.ts) checks empty, +single-target, ordered multi-target, and excluded-target cases. +An input profile may translate a second Mod+A into a separate `clear` command, +as `selectAllAffordance` does. Navigation, selected targets, and a native text +caret remain separate responsibilities. diff --git a/packages/json-document-selection/tests/conformance/select-all.test.ts b/packages/json-document-selection/tests/conformance/select-all.test.ts new file mode 100644 index 00000000..15b4eeff --- /dev/null +++ b/packages/json-document-selection/tests/conformance/select-all.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; +import { createKeySelectionFamily, emptyKeySelection, type KeySelectionContext } from "../../src/index.js"; + +describe("EG-SELECT / KeySelection semantic select-all", () => { + test.each([{ keys: [] }, { keys: ["a"] }, { keys: ["c", "a", "b"] }])("repeated select-all is idempotent in universe $keys", ({ keys }) => { + const context: KeySelectionContext = { keys, universe: "visible:v1", universeMismatch: "clear" }; + const family = createKeySelectionFamily(); + const command = { type: "select-all" as const, universe: context.universe }; + const first = family.transition(emptyKeySelection(), command, context); + const repeated = family.transition(first.state, command, context); + expect(repeated.state).toEqual(first.state); + expect(repeated.changed).toBe(false); + expect(family.targets(repeated.state, context)).toEqual(keys); + }); + + test("select-all restores excluded targets; it does not mean toggle-all", () => { + const context: KeySelectionContext = { keys: ["a", "b"], universe: "visible:v1", universeMismatch: "clear" }; + const family = createKeySelectionFamily(); + const command = { type: "select-all" as const, universe: context.universe }; + const all = family.transition(emptyKeySelection(), command, context).state; + const partial = family.transition(all, { type: "subtract", keys: ["b"] }, context).state; + const restored = family.transition(partial, command, context).state; + expect(family.targets(restored, context)).toEqual(["a", "b"]); + expect(family.transition(restored, command, context).changed).toBe(false); + }); +}); diff --git a/packages/json-document-web/README.md b/packages/json-document-web/README.md index 74391a85..fb27bf63 100644 --- a/packages/json-document-web/README.md +++ b/packages/json-document-web/README.md @@ -202,3 +202,18 @@ so non-browser tooling can load it safely. | --- | --- | | `@interactive-os/json-document-editing` | `>=0.1.0-rc.0 <1` | | `@interactive-os/json-document-selection` | `>=0.1.0-rc.0 <1` | + +## Cut failure boundary (Draft grammar) + +`createWebClipboardBinding` captures the editor payload and writes its +representations before calling `cut`. A failed write leaves removal uncalled; +a rejected removal reports `editing.rejected` after native event cancellation. +Previously written clipboard data can remain in either failure case: the OS +clipboard and JSONDocument are not one transaction. Headless `editor.cut()` +returns its captured payload alongside the editing result. + +[EG-CUT integration cases](tests/clipboard-rejection.test.ts) use a real +Document editor to check each write failure, schema-rejected removal, successful +capture-before-removal, and selection-restoring Undo. Existing unsupported-format +cases retain their event ownership behavior. These tests exercise the Web event +port; they do not certify browser-specific clipboard permissions or transport. diff --git a/packages/json-document-web/tests/clipboard-rejection.test.ts b/packages/json-document-web/tests/clipboard-rejection.test.ts index c4c43441..754224be 100644 --- a/packages/json-document-web/tests/clipboard-rejection.test.ts +++ b/packages/json-document-web/tests/clipboard-rejection.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createDocumentEditor } from "@interactive-os/json-document-editing"; import { createWebClipboardBinding, documentClipboardCodec } from "../src/index.js"; describe("clipboard event ownership", () => { @@ -25,4 +27,86 @@ describe("clipboard event ownership", () => { expect(paste).not.toHaveBeenCalled(); expect(preventDefault).not.toHaveBeenCalled(); }); + + it.each(["structured", "text"])("EG-CUT / failed %s write does not invoke removal", (failedWrite) => { + const document = createJSONDocument({ blocks: payload.blocks }); + const editor = createDocumentEditor(document); + const before = structuredClone(editor.snapshot); + const remove = vi.fn(() => editor.cut()?.result ?? { ok: false, code: "selection.empty" }); + const binding = createWebClipboardBinding({ codec: documentClipboardCodec, read: () => editor.copy(), cut: remove, + paste: (clipboard) => editor.dispatch({ type: "clipboard.paste", clipboard }), + }); + const published: unknown[] = []; + const release = editor.subscribe((snapshot) => published.push(snapshot)); + const written = new Map(); + try { + const result = binding.cut({ + clipboardData: { types: [], getData: () => "", setData(format, data) { + if (format === (failedWrite === "structured" ? payload.type : "text/plain")) throw new Error("Clipboard write refused"); + written.set(format, data); + } }, + preventDefault() {}, + }); + expect(result).toMatchObject({ ok: false, code: "clipboard.unavailable" }); + expect(remove).not.toHaveBeenCalled(); + expect(document.value).toEqual(before.value); + expect(editor.snapshot).toMatchObject(before); + expect(published).toEqual([]); + // A preceding clipboard representation can remain written: this is not an OS transaction. + expect(written.size).toBe(failedWrite === "structured" ? 0 : 1); + } finally { release(); } + }); + + it("EG-CUT / rejected removal keeps the captured clipboard and document unchanged", () => { + const document = createJSONDocument({ blocks: payload.blocks }, { validate(candidate) { + return (candidate as { blocks: unknown[] }).blocks.length > 0 ? { ok: true } : { ok: false, code: "last-block-required" }; + } }); + const editor = createDocumentEditor(document); + const before = structuredClone(editor.snapshot); + const preventDefault = vi.fn(); + const written = new Map(); + const binding = createWebClipboardBinding({ codec: documentClipboardCodec, read: () => editor.copy(), + cut(captured) { + expect(captured).toEqual(payload); + expect(written.get(payload.type)).toBe(JSON.stringify(payload)); + expect(written.get("text/plain")).toBe("A"); + expect(preventDefault).toHaveBeenCalledOnce(); + const cut = editor.cut(); + expect(cut?.clipboard).toEqual(captured); + return cut!.result; + }, + paste: (clipboard) => editor.dispatch({ type: "clipboard.paste", clipboard }), + }); + const published: unknown[] = []; + const release = editor.subscribe((snapshot) => published.push(snapshot)); + try { + expect(binding.cut({ clipboardData: { types: [], getData: () => "", setData: (format, data) => { written.set(format, data); } }, preventDefault })) + .toMatchObject({ ok: false, code: "editing.rejected", reason: "last-block-required" }); + expect(editor.snapshot).toMatchObject(before); + expect(document.value).toEqual(before.value); + expect(published).toEqual([]); + } finally { release(); } + }); + + it("EG-CUT / successful Web cut captures before removal and undo restores the selection", () => { + const editor = createDocumentEditor({ blocks: payload.blocks }); + const before = structuredClone(editor.snapshot); + const written = new Map(); + const preventDefault = vi.fn(); + const binding = createWebClipboardBinding({ codec: documentClipboardCodec, read: () => editor.copy(), + cut(captured) { + expect(written.get(payload.type)).toBe(JSON.stringify(captured)); + expect(written.get("text/plain")).toBe(captured.text); + expect(preventDefault).toHaveBeenCalledOnce(); + return editor.cut()!.result; + }, + paste: (clipboard) => editor.dispatch({ type: "clipboard.paste", clipboard }), + }); + const result = binding.cut({ clipboardData: { types: [], getData: () => "", setData: (format, data) => { written.set(format, data); } }, preventDefault }); + expect(result).toMatchObject({ ok: true, operation: "cut", payload, result: { ok: true } }); + expect(editor.snapshot).toMatchObject({ value: { blocks: [] }, canUndo: true, canRedo: false }); + expect(editor.undo().ok).toBe(true); + expect(editor.snapshot).toMatchObject({ value: before.value, selection: before.selection, canUndo: false, canRedo: true }); + expect(written.get(payload.type)).toBe(JSON.stringify(payload)); + }); }); diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index e76ad76f..12071ad5 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -33,6 +33,7 @@ import virtualSelectionReactSource from "../../../../packages/json-document-reac import anchoredFloatingPositionReactSource from "../../../../packages/json-document-react/src/use-anchored-floating-position.ts?raw"; import documentTextControlSource from "../../../../packages/json-document-react/src/use-document-text-control.ts?raw"; import documentEditingSource from "../../../../packages/json-document-editing/src/document.ts?raw"; +import sheetEditingSource from "../../../../packages/json-document-editing/src/sheet.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"; @@ -207,6 +208,7 @@ const registeredUsageSources = new Map([ ["packages/json-document-react/src/use-anchored-floating-position.ts", anchoredFloatingPositionReactSource], ["packages/json-document-react/src/use-document-text-control.ts", documentTextControlSource], ["packages/json-document-editing/src/document.ts", documentEditingSource], + ["packages/json-document-editing/src/sheet.ts", sheetEditingSource], ["packages/json-document-editing/src/clipboard.ts", editingClipboardSource], ["packages/json-document-editing/src/session.ts", editingSessionSource], ["packages/json-document-editing/src/identity.ts", editingIdentitySource], @@ -1014,6 +1016,11 @@ const registeredPublicUsages = [ symbol: "documentSelectionFocus", sourcePath: "packages/json-document-editing/src/document.ts", }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createSheetEditor", + sourcePath: "packages/json-document-editing/src/sheet.ts", + }, { packageName: "@interactive-os/json-document-react", symbol: "useGridEditing", diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index f9c8af68..94ad4aaf 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -156,14 +156,16 @@ describe("Demo definition and source discovery", () => { ]); }); - test("registers React and Web Grid owner sources next to Sheet usage", async () => { - expect((await discoverDemoSources("routes/sheet-demo/SheetDemo.tsx")).map((file) => file.path)).toEqual([ + test("registers the Sheet editor, React and Web Grid owners next to Sheet usage", async () => { + const sources = await discoverDemoSources("routes/sheet-demo/SheetDemo.tsx"); + expect(sources.map((file) => file.path)).toEqual([ "routes/sheet-demo/SheetDemo.tsx", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-ui-primitives-react/src/product-shell.tsx", "packages/json-document-react/src/use-editing.ts", "packages/json-document-react/src/editing-observation.ts", "packages/json-document-web/src/clipboard.ts", + "packages/json-document-editing/src/sheet.ts", "packages/json-document-react/src/use-grid-editing.ts", "packages/json-document-editing/src/topology.ts", "packages/json-document-web/src/grid-cell.ts", @@ -172,6 +174,9 @@ describe("Demo definition and source discovery", () => { "packages/json-document-web/src/pointer-session.ts", "packages/json-document-affordance/src/interaction-handle.ts", ]); + const owner = sources.find((file) => file.path === "packages/json-document-editing/src/sheet.ts")!; + expect(owner.referencePath).toBe("/docs/api/editing"); + expect(await owner.load()).toContain("export function createSheetEditor"); }); test("registers the Composer lifecycle owner and its canonical domain closure next to Usage", async () => { diff --git a/standards/editing-grammar.md b/standards/editing-grammar.md new file mode 100644 index 00000000..68ba0d03 --- /dev/null +++ b/standards/editing-grammar.md @@ -0,0 +1,318 @@ +# 편집 문법의 안정화 설계 + +상태: Design Draft. 기존 public API와 Stable profile의 의미는 유지한다. +이 문서는 공통 편집 규칙의 소유자, Hands별 해석, 적합성 증거를 설계한다. +대표 binding의 실행 증거는 아래 적합성 표에 연결한다. 전체 제안의 구현 완료, +Stable admission이나 외부 상호운용성을 주장하지 않는다. + +## 목표와 범위 + +오랫동안 정착한 편집 문법을 구현과 제품이 바뀌어도 유지되는 계약으로 만든다. +작은 Core는 장기간 약속할 수 있는 의미만 소유한다. JSON Document의 여섯 +member와 편집 문법의 크기는 서로 다른 문제다. + +- Outcome: 같은 Hands profile을 소비하는 제품은 선택·편집·복사·복원에서 같은 + 관찰 가능한 의미를 얻는다. +- Done: 이 설계에서 공통 규칙과 profile 선택을 구분하고, 각 규칙의 기존 owner, + 현재 구현과의 차이, 이를 판정할 적합성 사례를 연결한다. +- Don't: 이번 설계로 Core API를 늘리거나 기존 동작을 변경하지 않는다. 새로운 + 범용 editor, command bus, profile registry나 package hierarchy를 만들지 않는다. + +구현 근거의 범위는 JSON Document, Selection, Editing, Affordance, Web과 +Document·Sheet·Rich Text의 대표 편집 경로다. Object의 전체 선택과 Database의 +cut 미지원은 profile 차이를 판정하는 사례로 포함한다. 모든 Hands의 완성도, +브라우저별 입력 일치, 협업 wire protocol의 수렴은 이 설계의 검증 결과가 아니다. + +## 현재 문제와 원인 + +`EditingSession`은 value·selection·history를 함께 처리하고 Selection family는 +대상의 전이를 공유한다. 그러나 `EditingIntent`의 공통 계약은 `type: string`과 +`dispatch` 형태이며, 동사의 사전 조건과 결과는 editor별 코드와 문서에 있다. +`Official Hands`도 아직 호환성 약속을 확정하지 않은 후보다. + +이 때문에 여러 editor가 같은 API 형태를 제공해도 같은 편집 문법을 따른다는 +증거가 되지 않는다. `selectAllAffordance`의 전체 선택 토글처럼 특정 관습이 +범용 기본값으로 보이고, `selection.move`처럼 문서 변경과 선택 변경이 이름에서 +구별되지 않는 사례도 있다. 동작을 선언하는 규칙과 그 규칙을 검증하는 사례의 +연결이 부족한 것이 원인이다. + +## 세 종류의 계약과 기존 소유자 + +다음은 새 runtime 계층이 아니라 기존 책임들이 약속할 내용의 구분이다. + +| 계약 | 고정할 의미 | 정본 소유자 | +| --- | --- | --- | +| 공통 편집 규칙 | 선택 전이, 편집의 원자성, 복사와 History의 관계 | Selection·Editing | +| Hands profile | 편집 대상, 범위 해석, 삭제·붙여넣기 결과, 지원 작업 | Document Type과 해당 editor의 기존 owner를 조합한 Hands | +| 입력 매핑 | 키·포인터·IME를 어떤 편집 의도로 해석하는가 | Adapter·Affordance와 framework Connector | + +```text +keyboard / pointer / IME ─ Adapter·Affordance ─┐ +직접 호출하는 프로그램 ──────────────────────┤ + ↓ + 해당 editor의 편집 의도 + + Selection / Topology + ↓ + EditingPlan + selectionAfter + ↓ + EditingSession → JSONDocument.commit + ↓ + 해당 편집의 snapshot / History +``` + +이는 대표적인 값 변경 흐름이다. Copy는 조회이며, 선택만 바꾸는 작업은 document +commit을 만들지 않고, Undo/Redo는 선택한 History owner를 실행한다. 순수 JSON +소비자에게 Editing이나 Hands 설치를 요구하지 않는다. + +| 기존 owner | 계속 소유할 책임 | 다른 곳으로 넘기지 않을 결정 | +| --- | --- | --- | +| `json-document` | JSON 값·주소·검증·원자적 Patch·변경 알림 | selection, clipboard, undo step을 Core에 넣지 않음 | +| `json-document-selection` | key/range/mask family, transition·map·reconcile·targets | DOM focus와 문서 mutation은 각각의 owner가 처리 | +| `json-document-editing` | `EditingPlan`, snapshot, transaction, local History, external History 연결 | 같은 transaction·History를 각 Hand에서 다시 구현하지 않음 | +| 기존 Document Type/editor 모듈 | 유효한 대상·문서 연산·projection, 의도를 plan으로 변환 | Host가 붙여넣기·삭제의 의미를 다시 결정하지 않음 | +| `json-document-affordance` | 선택·활성화·취소의 입력 의미, gesture lifecycle | 좌표·DOM capture는 Web에 연결 | +| `json-document-web` 및 text Web Adapter | platform event, clipboard 교환, native editing lifecycle | semantic edit는 해당 editor API로 전달 | +| React 등 Connector | framework subscription·render·focus lifecycle | 문서·선택·History의 별도 상태 소유자를 만들지 않음 | + +Document Type은 책임 이름이며 이 표로 package 재배치를 승인하지 않는다. 현재 +Document·Sheet 등은 Editing에, Rich Text는 자신의 package에 구현돼 있다. +Hands는 기존 공개 API들의 함께 검증된 조합이다. Host는 제품 데이터·권한·copy· +layout·concrete external instance와 조합을 소유한다. + +## 공통으로 고정할 편집 규칙 + +아래 ID는 설계 요구사항이다. 실제 동결은 owner의 versioned 계약과 적합성 +증거를 통해 이루어진다. DOM API 모양, JSON 저장 shape, 특정 키 조합은 이 +규칙의 전제가 아니다. + +| ID | 관찰 가능한 규칙 | Owner | +| --- | --- | --- | +| EG-SELECT | 선택만 바꾸는 작업은 document value와 문서 Undo/Redo 기록을 바꾸지 않는다. 범위 확장은 유효한 기존 anchor를 보존하고 focus를 이동한다. | Selection·Editing | +| EG-TARGET | navigation 위치, 선택된 대상, native text caret을 구별한다. 문서 안에서 대상이 이동했을 때 identity를 유지하는지와 삭제 후 선택의 도착점은 해당 profile이 정한다. | Selection·해당 editor | +| EG-EDIT | 값 변경은 patch와 `selectionAfter`를 한 편집 결과로 발행한다. 실패한 의도는 자신의 partial mutation·selection·History entry를 남기지 않는다. | Editing | +| EG-COPY | Copy는 현재 profile의 선택 대상을 구조화된 payload와 교환 표현으로 읽으며 document·selection·History를 바꾸지 않는다. | 해당 editor | +| EG-CUT | Cut은 복사할 대상을 확정한 뒤 그 대상에 대한 제거 plan을 실행한다. payload 확보 실패 시 제거하지 않는다. 제거 실패 시 문서는 유지되며 성공한 cut으로 보고하지 않는다. | Editing·해당 editor·Web | +| EG-PASTE | Paste는 profile이 정한 위치·대체 범위·identity 규칙으로 한 편집을 실행하고 결과 Selection을 함께 정한다. 호환되지 않는 payload와 범위 초과의 처리를 profile에 명시한다. | 해당 editor·Editing | +| EG-HISTORY | 한 사용자 작업의 Undo 단위를 명시한다. local History는 다른 변경이 개입하지 않은 undo/redo에서 기록된 document와 Selection을 복원한다. selection-only·no-op은 새 문서 History entry를 만들지 않는다. | Editing·선택한 History owner | +| EG-GESTURE | 구조적 gesture의 preview는 아직 확정되지 않은 결과다. commit은 그 preview에 해당하는 작업을 한 번 실행하고 cancel은 이를 실행하지 않는다. | Affordance·해당 editor 연결 | +| EG-RESULT | 각 편집 결과와 발행 snapshot은 그 편집의 value·selection·history 상태를 함께 설명한다. 재진입이나 외부 변경으로 더 최신 상태가 생겨도 이전 결과의 의미를 덮어쓰지 않는다. | Editing | + +EG-EDIT의 실패 보장은 해당 의도가 만든 효과에 적용한다. 이미 받아들인 외부 +document 변경을 실패한 의도 때문에 되돌리지 않는다. 이를 확인할 때 외부 +변경 동기화와 새 의도 실행의 관찰 시점을 분리한다. + +EG-CUT은 OS clipboard와 문서 사이의 분산 transaction을 약속하지 않는다. +Headless `cut()`은 보존 가능한 payload를 반환하고, Web event binding은 payload를 +쓴 뒤 제거를 실행한다. Clipboard 쓰기 실패, 제거 거절, 성공한 후의 Undo를 +서로 다른 결과로 검증한다. 브라우저가 custom edit를 다시 실행하지 않도록 하는 +event ownership은 Web이 소유한다. + +EG-HISTORY에서 외부 변경 이후의 의미는 선택한 History 계약을 따른다. 현재 +local inverse History는 이를 비우며, collaboration History는 내 기여를 선택적으로 +되돌린다. 둘을 같은 복원 알고리즘으로 고정하지 않는다. 이미 존재하는 +`EditingHistory`를 사용하고 여러 Hand가 공유하는 작업 단위도 그 owner가 정한다. + +EG-GESTURE는 구조 편집 preview에 대한 규칙이다. IME의 중간 DOM mutation과 +composition grouping은 [DOM 편집 lifecycle](dom-editing-lifecycle.md)의 별도 +계약을 따른다. `createGestureSession`의 존재만으로 Host의 preview가 문서를 +변경하지 않는다고 증명할 수 없으므로 실제 연결까지 검증한다. + +## Hands profile이 반드시 결정할 내용 + +각 profile은 다음 질문에 하나의 답 또는 명시적인 설정별 답을 제공한다. 이는 +문서와 적합성 사례의 항목이며 새로운 runtime descriptor type은 아니다. + +1. **대상:** 무엇을 편집하며 어떤 identity와 위치 단위를 사용하는가? +2. **선택:** 사용할 family, primary의 의미, 여러 범위 처리, focus와 선택의 관계는? +3. **Topology:** 가시 순서와 전체 대상 집합 중 무엇을 각 작업에 사용하는가? +4. **작업:** Select, Insert, Delete/Clear, Copy, Cut, Paste, Undo/Redo 중 무엇을 + 지원하며, 미지원 작업은 왜 해당 profile의 사용 목적과 양립하는가? +5. **결과:** 삭제 후 선택, 붙여넣기 위치·대체 규칙, 범위 초과, 새 identity는? +6. **History:** typing·composition·drag의 작업 경계와 외부 변경의 처리 방식은? +7. **입력:** 선택한 플랫폼 관습, 편집 중인 text field와 구조 탐색의 우선순위는? + +`unsupported`와 일시적으로 `unavailable`인 상태를 구별한다. Cut이 없는 editor와 +선택이 없어 Cut을 할 수 없는 editor는 같은 계약이 아니다. API나 UI에서 지원을 +선언한 작업은 동일한 지원 범위의 적합성 사례를 가져야 한다. 미지원 선언만으로 +기대되는 기본 편집 흐름을 생략할 수 없으며, 그 선택의 외부 관습이나 제품 목적을 +profile에서 설명한다. + +### 현재 구현을 설명하는 대표 매핑 + +이 표는 현재 동작의 관찰이다. 관찰된 기본값이 곧 영구히 고정할 규칙은 아니다. + +| 항목 | Document | Sheet | Rich Text | +| --- | --- | --- | --- | +| 편집 단위 | stable ID를 가진 블록과 text | stable row/column ID의 셀 | stable node ID와 text/child point | +| Copy 대상 | 선택된 블록 전체 | primary rectangle | 선택된 structured slice | +| Paste | 기본적으로 마지막 선택 블록 뒤에 삽입 | focus 셀부터 고정 경계 안에 기록 | 선택 구간과 schema에 맞게 slice 삽입 | +| 제거 | 선택 블록 제거 | Cut은 primary rectangle 값을 `null`로 비움 | 선택 구간 제거·schema 제약 적용 | +| 후속 선택 | 삽입 블록마다 collapsed range, 첫 블록 primary; 삭제 후 남은 이웃 | 붙여넣은 직사각형, Cut은 기존 선택 | transform 후 mapping된 text/child point | + +현재 binding은 각각 `json-document-editing/src/document.ts`, `sheet.ts`, +`json-document-rich-text/src/editor.ts`에 있다. Document의 text offset이 블록 +Copy를 부분 문자열 Copy로 바꾸지는 않는다. Sheet의 primary rectangle 정책도 +여러 범위 전체의 Copy와 구별한다. 이런 차이는 profile 이름과 사용법에서 드러나야 +하며, 공통 함수 이름 때문에 사용자가 동일한 대상을 예상하게 해서는 안 된다. + +## 구현에서 계약으로 옮길 때의 결정 + +| 현재 증거 | 설계 결정 | 동결 전 확인할 증거 | +| --- | --- | --- | +| Document `selection.move`는 블록을 옮김 | navigation과 content movement를 의미 어휘에서 구별한다. 현재 identifier 변경은 별도 binding 변경으로 다룬다. | 위치 이동은 document 불변, 블록 이동은 identity 보존·Undo 복원 | +| `selectAllAffordance`는 Mod+A 재입력 시 clear | Select All의 공통 의미는 대상 전체 선택이다. 재입력 토글은 이를 선택한 입력 profile의 매핑으로만 취급한다. | 같은 select-all 의도의 반복은 같은 선택; 토글 profile은 두 번째 키 입력을 clear 의도로 변환 | +| Database `cut` 부재가 테스트에 고정됨 | 현재 binding의 미지원으로 기록한다. 이를 모든 Database의 영구 문법으로 일반화하지 않는다. | Database profile에서 Cut 생략 근거 또는 지원 동작을 정한 뒤 admission 판정 | +| `historyGroup`과 external History가 공존 | 작업 묶음 정책과 기록·복원 메커니즘을 구별한다. external History owner가 step을 결정한다. | 같은 drag/composition 사례를 선택한 History owner에 연결한 결과 | +| `EditingIntent`는 `type: string` | 공통 contract test로 의미를 고정하고 실제 호출은 각 editor의 기존 API로 번역한다. | `dispatch`, `copy`, `cut`, `undo`, `redo`를 각 owner public API로 실행 | + +새로운 전역 command union이나 capability registry는 이 문제를 해결하는 전제가 +아니다. 이미 다른 의미를 가진 호출들을 한 이름으로 합치면 대상과 결과의 차이가 +숨는다. 공통 protocol은 공유하는 규칙에 두고, 문서의 의미는 기존 owner에 둔다. + +## 적합성 설계 + +테스트의 단위는 함수 유무가 아니라 **시작 상태 → 의미 있는 작업 → 관찰 결과**다. +Owner package의 테스트 harness가 public API에 작업을 연결한다. Harness는 자체 +편집 로직을 구현하지 않고 호출과 결과의 정규화만 수행한다. + +공통 rule의 vector와 runner는 해당 owner의 `tests/conformance/`에 두고, 장르별 +fixture와 API binding은 해당 editor owner에 둔다. 기존 Rich Text versioned +vectors와 Core suite는 원래 owner와 경로를 유지한다. 기존 unit test의 기대값을 +복사하는 대신 공통 rule에 해당하는 사례를 공용 runner로 승격한다. + +| 사례 | 작업 | 관찰할 결과 | 현재 근거와 추가 검증 | +| --- | --- | --- | --- | +| 선택 후 확장 | A 선택 → C까지 확장 | anchor A 유지, profile topology의 대상, document·History 불변 | Selection range tests → 여러 editor의 같은 rule binding | +| Copy의 무변경성 | 선택 → Copy 두 번 | 같은 의미의 payload, value·selection·Undo/Redo 불변 | clipboard surface test의 payload 확인에 상태 불변 관찰 추가 | +| 편집과 복원 | 선택 → 허용된 변경 → Undo → Redo | 각 단계의 value·selection·availability, snapshot 순서 | session history tests → Document·Sheet·Rich Text public binding | +| 거절의 원자성 | 경계 밖 Paste 또는 schema 거절 | 해당 작업의 partial value·selection·History·notification이 남지 않음 | Sheet와 Rich Text rejection 사례를 같은 관찰 계약에 연결 | +| Cut의 실패 경계 | clipboard 쓰기 실패 / 제거 거절 | 전자는 제거 미호출, 후자는 문서 불변·실패 결과 | Web clipboard rejection tests에 쓰기 실패 사례 연결 | +| Gesture 취소 | begin → preview 여러 번 → cancel | 확정 문서·History 불변, gesture 비활성 | gesture unit test와 실제 editor 연결을 함께 검증 | +| 반복 전체 선택 | select-all 두 번 | 같은 범위의 선택 유지 | KeySelection rule과 입력 profile의 토글 매핑을 별도로 검증 | +| 외부 변경 | 선택·편집 후 외부 삭제 → 읽기/구독 | profile의 유효한 선택, 선택한 History owner의 상태 | external-selection/session tests와 History binding 구분 | + +각 사례는 document, selection, History availability/대상, 발행 snapshot, clipboard +결과를 관찰한다. JSON-equal value, profile의 논리적 대상과 작업 순서를 비교한다. +Object identity, 내부 cache, snapshot 객체의 정확한 key 집합은 적합성 기준으로 +추가하지 않는다. Failure code와 optional field는 owning contract의 규칙을 따른다. + +새 identity를 생성하는 작업은 ID 문자열 자체의 일치를 요구하지 않는다. 입력에 +이미 있던 ID는 정확히 보존하고, 새 ID만 결과 사이의 일대일 대응으로 비교하며 +문서·Selection·clipboard 내부 참조에도 같은 대응을 적용한다. Harness의 이 정규화는 +잘못된 대상 선택, identity 재사용, 순서·내용 손실을 숨겨서는 안 된다. + +통과 보고서는 `rule → profile → public binding → vector → 결과`를 추적할 수 있어야 +한다. 공용 runner가 한 구현의 내부 model을 요구하거나 Host가 기대 결과를 만들기 +위해 edit를 재구현하면 이 설계가 실패한 것이다. 여러 장르의 구현을 통과한 결과는 +규칙의 적용 가능성 증거이며 같은 profile의 독립 구현 간 상호운용 증거와 구별한다. + +### 실행 가능한 대표 증거 + +[Editing의 공용 runner](../packages/json-document-editing/tests/conformance/editing-grammar.ts)는 +각 editor의 공개 entrypoint만 호출한다. [Document·Sheet binding](../packages/json-document-editing/tests/conformance/editing-grammar.test.ts)과 +[Rich Text binding](../packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts)은 +fixture와 기대 결과를 해당 owner에 둔다. 공통 관찰 규칙에는 domain별 분기가 없다. + +| 규칙 | Profile / public binding | 실행 vector와 추가 owner 증거 | +| --- | --- | --- | +| EG-SELECT | Document·Sheet `dispatch(selection.set)`; Rich Text는 RangeSelection 전이 후 `dispatch(selection.set)` | 공용 runner `extend preserves targets`: 빈 History·Undo 가능·Redo 가능 상태에서 anchor·focus·대상과 기록 보존 | +| EG-TARGET | 세 editor의 선택 및 external `JSONDocument.commit` | 공용 runner `external deletion`: 구독 유무 모두 missing endpoint 정리·local history 초기화; [Affordance 연결](../packages/json-document-affordance/tests/conformance/editing-grammar.test.ts)은 위치 선택과 블록 이동을 구별 | +| EG-EDIT | 세 editor `dispatch` | 공용 runner `rejection preserves`: 없는 블록, Sheet overflow, Rich Text 중복 ID를 거절하고 value·selection·publication·기존 undo/redo 대상 보존 | +| EG-COPY | 세 editor `copy` | 공용 runner `repeated structured copy`: 두 번의 payload와 전체 상태 불변, 기존 Redo 실행 결과 확인 | +| EG-CUT | 세 editor `cut`; Web `createWebClipboardBinding` | 공용 runner `capture target`; [Web clipboard failure](../packages/json-document-web/tests/clipboard-rejection.test.ts)는 첫/두 번째 표현 쓰기 실패·실제 validator 거절·성공 후 Undo를 검증 | +| EG-PASTE | 세 editor `dispatch(clipboard.paste)` | 공용 runner `profile paste`: 블록 뒤 삽입 / focus 셀 기록 / inline 구간 대체의 값·ID·후속 선택 및 한 step 복원 | +| EG-HISTORY | 세 editor의 기본 local `undo`, `redo` | 공용 runner `edit`, `profile paste`, `capture target`, `no-op`; 외부 owner와 grouping은 기존 [session-history](../packages/json-document-editing/tests/session-history.test.ts) 증거 유지 | +| EG-GESTURE | `createGestureSession` → Document `dispatch(selection.move)` | [Affordance 연결](../packages/json-document-affordance/tests/conformance/editing-grammar.test.ts): 여러 preview·cancel/pointer-cancel/lost-capture·중복 commit에서 실제 편집 횟수와 Undo 단위 | +| EG-RESULT | 세 editor `subscribe`와 각 결과의 snapshot | 공용 round trip은 편집·Undo·Redo의 publication 순서와 이전 결과 보존; 재진입은 기존 [session-composition](../packages/json-document-editing/tests/session-composition.test.ts) 증거 유지 | + +반복 전체 선택은 [Selection의 의미 vector](../packages/json-document-selection/tests/conformance/select-all.test.ts)가 +같은 universe에 대한 멱등성을 검사한다. Affordance 연결은 두 번째 Mod+A를 +별도의 `clear`로 해석하는 입력 profile을 검사한다. 기존의 단일 Affordance +토글 테스트와 Rich Text 기본 편집·복원 테스트는 이 공용 관찰 경로로 승격했다. +그 밖의 다중 범위·직사각형 Paste·schema 사례는 더 넓은 입력 증거이므로 유지한다. + +```sh +npm run test:projects -- --project json-document-editing --project json-document-rich-text --project json-document-selection --project json-document-affordance --project json-document-web +``` + +각 test 이름은 규칙 ID·profile·vector를 포함하고 Vitest가 실행 결과를 보고한다. +현재 fixture는 기존 ID를 그대로 비교하며 새 ID가 필요한 사례에는 결정적인 +`createId`를 주입한다. 공용 runner가 ID를 지우거나 text projection만 비교하지 +않는다. 서로 다른 임의 ID allocator 사이의 일대일 정규화나 같은 profile의 독립 +구현 binding은 아직 이 suite의 증거가 아니다. + +각 owner의 README가 profile 결정과 해당 테스트를 연결한다. 공개 API와 site +Usage/source 등록은 기존 Editing·Selection·Affordance·Web·Rich Text owner 경로를 +유지한다. 감사에서 빠져 있던 Sheet editor source를 기존 Sheet Usage에 연결하고 +정본 reference와 실제 source load를 검증한다. 새 runtime API나 Demo-local 편집 +구현을 추가하지 않는다. + +이 실행 표는 대표 경로의 적용 가능성에 대한 증거다. 모든 profile의 admission, +native caret/IME·브라우저 Clipboard 권한, 모든 Host callback, 협업 History의 전체 +수렴을 보증하지 않는다. #719의 세션 관찰·복구·협업 History 보완은 별도 변경이다. + +동결 전 남아 있는 관찰 사례도 구분한다. 초기 Document가 +`{ blocks: [{ id: "a", text: "Alpha" }] }`일 때 +`dispatch({ type: "selection.set", blockId: "a", offset: 2 })`는 성공을 반환하지만 +현재 같은 블록의 선택 offset은 0에 남는다. `document.ts`가 range 전이의 point +동등성을 block ID로 판단하는 경로다. 이번 Document fixture는 블록 간 선택과 +전체 블록 Copy를 검증하며 이 동작을 text-caret의 영구 규칙으로 채택하지 않는다. +이 재현 사례는 관찰된 공백으로 남기며, 이번 변경에서 runtime 동작을 바꾸거나 +동결하지 않는다. + +## 장기 호환성 + +약속하는 것은 **고정된 profile의 지원 입력과 관찰 가능한 결과**다. 같은 profile +revision 아래에서 target·selection·Undo 경계·failure/fallback 의미를 바꾸지 않는다. +새 API나 기능이 필요하면 기존 소비자의 결과를 보존하는 추가 계약으로 제공한다. + +- Profile 식별과 version은 정본 문서에 우선 둔다. 모든 JSON payload에 공통 + envelope나 version field를 추가하지 않는다. 기존 Rich Text profile URI 같은 + 저장 계약은 그 owner가 유지한다. +- 지원 입력을 넓혀 기존의 거절·fallback을 성공 처리로 바꾸는 것도 호환성 검토 + 대상이다. 의미를 보존하지 못하면 별도 profile revision으로 제공한다. +- 모호함이 발견되면 서로 다른 해석의 반례를 먼저 남긴다. 명확한 기존 규칙 위반은 + 구현 수정으로, 기존 규칙이 여러 동작을 허용했다면 profile 변경으로 판단한다. +- 이전 profile의 vectors와 실제 소비자 binding을 보존해 새 구현에서도 실행한다. + Current tests를 새 동작에 맞춰 일괄 수정한 것은 호환성 증거가 아니다. +- 구현 알고리즘·cache·DOM rendering은 이 결과를 보존하는 범위에서 바꿀 수 있다. + +Stable admission에는 공통 rule과 profile의 필수 작업이 모두 실행되고, 각 책임의 +public API·owner reference·site Usage·source registration이 연결돼야 한다. 같은 +profile의 독립 구현, 특히 명세 작성자 밖의 구현 경험에서 생긴 해석 차이를 해결해야 +장기 상호운용성의 근거가 된다. 사이트의 한 Demo나 내부 테스트 통과로 이를 대체하지 +않는다. + +## 비용과 재검토 조건 + +이 설계는 profile별 선택과 compatibility corpus를 유지하는 비용을 만든다. +그 비용을 줄이기 위해 실행 구조를 하나로 통일하는 대신 현재 owner의 runner를 +공유하고, 실제 지원하는 profile만 약속한다. 모든 장르에 한 Selection shape를 +강제하는 대안은 text range·grid rectangle·object set의 의미를 잃으므로 채택하지 +않는다. + +다음 관찰은 설계를 다시 검토할 근거다. + +- 공통 rule이 기존의 타당한 편집 관습을 표현하지 못하면 해당 rule을 profile + 선택으로 내리거나 사전 조건을 바로잡는다. +- 한 역할의 구현이 여러 Host에 남으면 기존 owner API의 부족 여부부터 확인한다. +- 규칙을 검증하려고 Core에 편집 상태를 넣어야 한다면 owner 분리가 잘못된 것이다. +- 같은 profile의 두 구현이 모두 suite를 통과해도 소비자 결과가 다르면 누락된 + 관찰 항목을 반례로 추가한다. + +## 외부 근거 + +- [Microsoft 표준 Edit 메뉴](https://learn.microsoft.com/en-us/windows/win32/uxguide/cmd-menus#standard-menus): + Undo/Redo·Cut/Copy/Paste·Select All·Delete의 정착된 명령 어휘. 모든 장르의 + 정확한 selection·paste·history 알고리즘을 규정하는 자료는 아니다. +- [W3C APG Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/): + focus와 selection의 구별, 예측 가능한 navigation. APG는 구현 지침이다. +- [W3C APG Listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/): + 복수 selection model과 선택 가능한 Ctrl+A 토글은 입력 관습이 하나가 아님을 보여준다. +- [Input Events Level 2](https://www.w3.org/TR/input-events-2/): + 물리 입력과 편집 의도의 구분을 참고한다. Working Draft의 event API를 영구 계약으로 + 채택하지 않으며 구조 편집 전체의 의미로 확대하지 않는다. +- [RFC 9413](https://www.rfc-editor.org/rfc/rfc9413.html#section-2.2): + 확장과 오류 처리를 명확히 규정하고 해석 차이를 유지보수로 해결하는 근거다. +- [W3C Implementation Experience](https://www.w3.org/policies/process/#implementation-experience): + 명세 작성자 이외의 구현과 실제 상호운용 경험을 별도 증거로 요구하는 근거다.