diff --git a/docs/api-reference/affordance.md b/docs/api-reference/affordance.md index 15c900c2..370e11f0 100644 --- a/docs/api-reference/affordance.md +++ b/docs/api-reference/affordance.md @@ -366,7 +366,7 @@ createLineFocusSession(options: { readonly initialKey?: Key ## `createRenameSession` ```ts -createRenameSession(options: { readonly onCommit: (key: Key, draft: string) => void; readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; }): RenameSession +createRenameSession(options: ({ readonly onCommit: (key: Key, draft: string) => void; readonly tryCommit?: never; } | { readonly tryCommit: (key: Key, draft: string) => boolean; readonly onCommit?: never; }) & { readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; }): RenameSession ``` ## `createTypeaheadSession` @@ -781,7 +781,7 @@ resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview ## `selectAllAffordance` ```ts -selectAllAffordance(stroke: Pick, state: { readonly allSelected: boolean; }): AffordancePreview +selectAllAffordance(stroke: Pick, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview ``` ## `SelectOperation` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 711a3cfb..512f2cd1 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -841,6 +841,7 @@ interface DocumentEditor { ```ts type DocumentIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly blockId: string; readonly mode?: "replace" | "extend" | "toggle"; readonly offset?: number } | { readonly type: "text.replace"; readonly blockId: string; readonly text: string; readonly offset?: number } | { readonly type: "block.insert"; readonly afterId?: string; readonly text?: string } @@ -1322,6 +1323,7 @@ interface OrderEditor { ```ts type OrderIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly itemId: string; @@ -1454,6 +1456,7 @@ interface SheetEditor { ```ts type SheetIntent = + | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; readonly rowId: string; @@ -1558,6 +1561,7 @@ interface TreeEditor { ```ts type TreeIntent = + | { readonly type: "selection.select-all"; readonly topology: TreeTopology } | { readonly type: "selection.set"; readonly nodeId: string; diff --git a/docs/public/affordance-rename.md b/docs/public/affordance-rename.md index 9536ae5b..10e7f4fa 100644 --- a/docs/public/affordance-rename.md +++ b/docs/public/affordance-rename.md @@ -4,33 +4,34 @@ Rename은 고른 대상의 레이블을 고치는 손입니다. F2와 느린 dou 같은 손을 엽니다. Escape는 [Escape](affordance-cancel.md)입니다. ```ts -import { applyAffordance, renameAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, createRenameSession, renameAffordance } from "@interactive-os/json-document-affordance"; + +const rename = createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onSnapshot: renderDraft, + onFinish: restoreFocus, +}); function onKeyDown(event: KeyboardEvent) { applyAffordance(renameAffordance(event), { hand: (hand) => { - if (hand.type !== "rename") return; - if (hand.action === "begin") setRenaming(focusKey); - if (hand.action === "cancel") setRenaming(null); - if (hand.action === "commit" && renaming) { - editor.dispatch({ type: "item.rename", itemId: renaming, label: draft }); - setRenaming(null); - } + if (hand.type === "rename" && hand.action === "begin") rename.begin(focusKey, label); }, }); } -function onClick(event: MouseEvent, itemId: string) { - applyAffordance(renameAffordance({ type: "pointer", detail: event.detail, intervalMs }), { - hand: (hand) => { - if (hand.type === "rename" && hand.action === "begin") setRenaming(itemId); - }, - }); +function onClick(event: MouseEvent, itemId: string, label: string) { + rename.handlePointer(itemId, label, event.detail, event.timeStamp); +} + +function onDraftKeyDown(event: KeyboardEvent) { + if (rename.handleKey(event.key)) event.preventDefault(); } ``` -호스트는 레이블 필드를 그립니다. begin/cancel은 호스트 화면 상태이고, -commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다. +호스트는 session snapshot으로 레이블 필드를 그리고 입력 변경을 `rename.update`에 +연결합니다. session이 draft의 시작·확정·취소를 소유하고, 확정 시 domain editor가 +문서를 변경합니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다. 닫는 손: - F2 @@ -41,12 +42,20 @@ commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affo ## Session API -`createRenameSession({ onCommit, onCancel, onFinish, onSnapshot })`은 active key, draft, -slow double-click 간격과 commit/cancel을 소유합니다. Host는 snapshot으로 input을 -그리고 `onCommit(key, draft)`에서 domain rename Intent를 보냅니다. +`createRenameSession`은 active key, draft, slow double-click 간격과 commit/cancel을 +소유합니다. 기존 `onCommit(key, draft): void` 또는 동기 +`tryCommit(key, draft): boolean` 중 하나를 받습니다. 둘을 동시에 지정하거나 +비동기 `tryCommit`을 전달할 수 없습니다. + +`tryCommit`이 `false`이면 active key와 draft를 유지하고 `onFinish`를 호출하지 +않습니다. 사용자가 수정한 뒤 다시 확정할 수 있습니다. `true` 또는 기존 +`onCommit` 완료 시 snapshot을 비우고 `onFinish`를 한 번 호출합니다. +Escape는 commit 없이 종료합니다. 검증과 문서 변경은 domain이 소유합니다. `onCancel(key, draft)`는 저장되지 않은 값을 복구하거나, Calendar처럼 생성과 동시에 시작된 rename을 취소할 때 새 항목을 제거하는 제품 정책을 연결합니다. Calendar는 `useCalendarRenameInput(hand)`으로 focus/select, input change, Enter·Escape, blur를 같은 session에 연결합니다. Host는 반환된 `ref`, `value`, event handler만 제목 input에 전달합니다. + +Usage와 Source: [Order Demo](/demo/order)의 F2 → 입력 → Enter 또는 Escape. diff --git a/docs/public/affordance-select.md b/docs/public/affordance-select.md index 23996de5..664aa7a1 100644 --- a/docs/public/affordance-select.md +++ b/docs/public/affordance-select.md @@ -10,6 +10,7 @@ import { editingCommandFromWebKeyboardStroke, pointerSelect, planeHitAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; function onPointerDown(event: PointerEvent, itemId: string) { @@ -39,6 +40,18 @@ const editing = useEditing({ neighbor: (key, command) => neighborFromProductTopology(key, command), }, }); + +function onSelectAll(event: KeyboardEvent) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedItemIds.length === items.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + editor.dispatch({ type: "selection.select-all" }); + event.preventDefault(); + }, + }); +} ``` 호스트는 보이는 키와 장르 Intent만 넘깁니다. keymap을 덮어쓰지 않습니다. @@ -54,6 +67,18 @@ const editing = useEditing({ 공통 해석만 소유하며, 현재 focus와 다음 이웃을 결정하는 topology는 Host가 `focusKey`와 `neighbor`로 주입합니다. +### `selectAllAffordance(stroke, state, { repeat })` + +기본 편집 Usage는 `repeat: "preserve"`를 선택합니다. Mod+A를 반복해도 +`select-all`을 보내며 전체 선택을 해제하지 않습니다. 옵션 생략 또는 +`repeat: "toggle"`은 기존 `allSelected ? clear : select-all` 동작입니다. +Document·Order·Tree·Sheet의 `selection.select-all`은 대상 전체를 한 번의 +선택 전이로 만듭니다. Tree는 visible topology, Sheet는 선언한 행·열을 사용합니다. +빈 대상은 빈 선택이 되고, 선택 변경은 문서 Undo/Redo 기록을 추가하거나 지우지 않습니다. +실제 Demo는 Web의 `isWebEditingHostTarget`으로 내부 text field의 Mod+A를 보존합니다. + +Usage와 Source: [Order](/demo/order), [Tree](/demo/tree), [Sheet](/demo/sheet), [Document](/demo). + 닫는 손: - 클릭 replace, 이미 고른 집합 유지 - Shift+click 추가/제거 diff --git a/docs/public/intent.md b/docs/public/intent.md index 8e6c2040..7fda4da1 100644 --- a/docs/public/intent.md +++ b/docs/public/intent.md @@ -67,6 +67,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `blockId`, `mode?`, `offset?` | 블록 선택 변경 | +| `selection.select-all` | | 첫 블록 offset 0부터 마지막 블록 text 끝까지 한 번에 선택 | | `text.replace` | `blockId`, `text`, `offset?` | 블록 text 변경 | | `block.insert` | `afterId?`, `text?` | 블록 추가 | | `selection.remove` | | 선택한 블록 제거 | @@ -83,6 +84,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `rowId`, `columnId`, `mode?` | 셀 선택 변경 | +| `selection.select-all` | `topology?` | 지정한 행·열 전체를 하나의 범위로 선택 | | `selection.fill` | `value`, `topology?` | 선택한 셀 채우기 | | `cell.commit` | `rowId`, `columnId`, `value` | 한 셀의 값 확정 | | `clipboard.paste` | `clipboard`, `topology?` | Clipboard 셀 붙여넣기 | @@ -95,6 +97,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `nodeId`, `topology`, `mode?` | 보이는 노드 선택 변경 | +| `selection.select-all` | `topology` | 보이는 노드 전체를 하나의 범위로 선택 | | `selection.remove` | `topology` | 선택한 노드 제거 | | `clipboard.paste` | `clipboard`, `topology`, `afterId?` | 붙여넣기 | @@ -121,6 +124,8 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `itemId`, `mode?` | 항목 선택 변경 | +| `selection.select-all` | | 전체 항목을 하나의 범위로 선택 | +| `item.rename` | `itemId`, `label` | 레이블 확정; 실패 결과는 draft session에 전달 | | `selection.remove` | | 선택한 항목 제거 | | `clipboard.paste` | `clipboard`, `afterId?` | 붙여넣기 | diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 8a4d6313..607d3159 100644 --- a/packages/json-document-affordance/README.md +++ b/packages/json-document-affordance/README.md @@ -56,6 +56,20 @@ drop targets, and insertion positions without owning DOM or product color. own the reusable state that spans several events. Product selection and rename Intents remain callbacks supplied by the host. +`createRenameSession` accepts either the legacy `onCommit(key, draft): void` +or synchronous `tryCommit(key, draft): boolean`. A false result keeps the active +key and draft without publishing a finish. Updating and retrying can then +complete the edit; success or cancellation clears the draft and calls `onFinish` +once. The domain editor still owns validation and document changes: + +```ts +createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onSnapshot: renderDraft, + onFinish: restoreFocus, +}); +``` + `createBoardDragSession` owns the input-agnostic active item, drop-target preview, commit, and cancel lifecycle for Board Hands. Web pointer and HTML Drag and Drop sessions feed it; Hosts still resolve targets and dispatch the @@ -79,13 +93,14 @@ 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. +`selectAllAffordance(stroke, state, { repeat: "preserve" })` emits `select-all` +for Mod+A even when everything is selected. The default editing Usage chooses +this policy. Omission or `{ repeat: "toggle" }` retains the existing behavior: +emit `clear` when `state.allSelected`, otherwise `select-all`. This is an input +policy; domain editors own the selected universe and its semantic transition. [Editing grammar integration tests](tests/conformance/editing-grammar.test.ts) -connect that mapping to KeySelection and connect `createGestureSession` to +connect both mappings to selection, cover rejected draft commits, 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 diff --git a/packages/json-document-affordance/src/select.ts b/packages/json-document-affordance/src/select.ts index 2744c59c..879a7e39 100644 --- a/packages/json-document-affordance/src/select.ts +++ b/packages/json-document-affordance/src/select.ts @@ -111,13 +111,15 @@ export function resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePrevi return { hand: keyboard.resolve(stroke) }; } +/** Mod+A selects all. Choose preserve for repeated selection; omission retains the legacy toggle. */ export function selectAllAffordance( stroke: Pick, state: { readonly allSelected: boolean }, + options: { readonly repeat?: "preserve" | "toggle" } = {}, ): AffordancePreview { const mod = stroke.metaKey || stroke.ctrlKey; if (!mod || stroke.key.toLowerCase() !== "a") return { hand: null }; - return { hand: { type: state.allSelected ? "clear" : "select-all" } }; + return { hand: { type: state.allSelected && options.repeat !== "preserve" ? "clear" : "select-all" } }; } export function typeaheadAffordance(input: { diff --git a/packages/json-document-affordance/src/session.ts b/packages/json-document-affordance/src/session.ts index f2a63a2d..da75e6bc 100644 --- a/packages/json-document-affordance/src/session.ts +++ b/packages/json-document-affordance/src/session.ts @@ -79,8 +79,14 @@ export interface RenameSession { cancel(): void; } -export function createRenameSession(options: { +/** Owns the draft lifecycle. A false tryCommit retains the draft without finishing; onCommit always finishes. */ +export function createRenameSession(options: ({ readonly onCommit: (key: Key, draft: string) => void; + readonly tryCommit?: never; +} | { + readonly tryCommit: (key: Key, draft: string) => boolean; + readonly onCommit?: never; +}) & { readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; @@ -94,7 +100,11 @@ export function createRenameSession(options: { function finish(commit: boolean) { if (snapshot === null) return; const finished = snapshot; - if (commit) options.onCommit(finished.key, finished.draft); + if (commit) { + if (options.tryCommit !== undefined) { + if (!options.tryCommit(finished.key, finished.draft)) return; + } else options.onCommit(finished.key, finished.draft); + } else options.onCancel?.(finished.key, finished.draft); publish(null); options.onFinish?.(finished.key); diff --git a/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts index 6af1531c..43a7c7ca 100644 --- a/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts +++ b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts @@ -1,9 +1,99 @@ -import { createDocumentEditor } from "@interactive-os/json-document-editing"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createDocumentEditor, createOrderEditor, type OrderDocument } 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"; +import { createGestureSession, createRenameSession, selectAllAffordance, type GestureCancelReason } from "../../src/index.js"; describe("editing grammar / input mapping", () => { + test.each(["metaKey", "ctrlKey"] as const)("EG-SELECT / %s+A preserve profile retains all on repeat", (modifier) => { + const editor = createDocumentEditor({ blocks: [{ id: "a", text: "A" }, { id: "b", text: "B" }] }); + const stroke = { key: "A", metaKey: false, ctrlKey: false, [modifier]: true }; + for (const allSelected of [false, true, true]) { + const hand = selectAllAffordance(stroke, { allSelected }, { repeat: "preserve" }).hand; + expect(hand).toEqual({ type: "select-all" }); + if (hand?.type === "select-all") editor.dispatch({ type: "selection.select-all" }); + expect(editor.selectedBlockIds).toEqual(["a", "b"]); + expect(editor.snapshot).toMatchObject({ canUndo: false, canRedo: false }); + } + expect(selectAllAffordance(stroke, { allSelected: true }, { repeat: "toggle" }).hand).toEqual({ type: "clear" }); + expect(selectAllAffordance({ ...stroke, key: "x" }, { allSelected: true }, { repeat: "preserve" }).hand).toBeNull(); + expect(selectAllAffordance({ key: "a", metaKey: false, ctrlKey: false }, { allSelected: true }, { repeat: "preserve" }).hand).toBeNull(); + }); + + test("EG-COMMIT / rejection retains draft; retry finishes once; cancel never commits", () => { + const attempts: string[] = []; + const finished: string[] = []; + const cancelled: string[] = []; + const published: unknown[] = []; + const session = createRenameSession({ + tryCommit(key, draft) { attempts.push(`${key}:${draft}`); return draft.length > 0; }, + onFinish: (key) => finished.push(key), + onCancel: (key, draft) => cancelled.push(`${key}:${draft}`), + onSnapshot: (snapshot) => published.push(snapshot), + }); + session.begin("a", "Alpha"); + session.update(""); + expect(session.handleKey("Enter")).toBe(true); + session.commit(); + expect(session.getSnapshot()).toEqual({ key: "a", draft: "" }); + expect(published).toEqual([{ key: "a", draft: "Alpha" }, { key: "a", draft: "" }]); + expect(finished).toEqual([]); + session.update("Beta"); + expect(session.handleKey("Enter")).toBe(true); + session.commit(); + expect(session.getSnapshot()).toBeNull(); + expect(attempts).toEqual(["a:", "a:", "a:Beta"]); + expect(finished).toEqual(["a"]); + session.begin("b", "Draft"); + expect(session.handleKey("Escape")).toBe(true); + expect(session.getSnapshot()).toBeNull(); + expect(cancelled).toEqual(["b:Draft"]); + expect(finished).toEqual(["a", "b"]); + expect(attempts).toHaveLength(3); + }); + + test("rename callback contracts remain mutually exclusive and synchronous", () => { + const commits: string[] = []; + const legacy = createRenameSession({ onCommit: (_key, draft) => commits.push(draft) }); + legacy.begin("a", "Alpha"); + legacy.commit(); + expect(commits).toEqual(["Alpha"]); + expect(legacy.getSnapshot()).toBeNull(); + // These calls are checked by the owning package's test typecheck. + // @ts-expect-error Select exactly one commit contract. + createRenameSession({ onCommit: () => {}, tryCommit: () => true }); + // @ts-expect-error A commit contract is required. + createRenameSession({}); + // @ts-expect-error Result-aware commit is synchronous. + createRenameSession({ tryCommit: async () => true }); + }); + + test("EG-COMMIT / Order rejection preserves value, selection and history until retry", () => { + const initial: OrderDocument = { items: [{ id: "a", label: "Alpha" }] }; + const editor = createOrderEditor(createJSONDocument(initial, { + validate: (candidate) => (candidate as OrderDocument).items.every((item) => item.label.length > 0) + ? { ok: true } : { ok: false, code: "schema_violation" }, + })); + const before = editor.snapshot; + let finished = 0; + const rename = createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onFinish: () => { finished++; }, + }); + rename.begin("a", "Alpha"); + rename.update(""); + rename.handleKey("Enter"); + expect(editor.snapshot).toEqual(before); + expect(rename.getSnapshot()).toEqual({ key: "a", draft: "" }); + expect(finished).toBe(0); + rename.update("Beta"); + rename.handleKey("Enter"); + expect(rename.getSnapshot()).toBeNull(); + expect(finished).toBe(1); + expect(editor.snapshot).toMatchObject({ value: { items: [{ id: "a", label: "Beta" }] }, canUndo: true }); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { value: initial, selection: before.selection, canUndo: false } }); + }); + 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(); diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index f92b0d22..7d368264 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -114,6 +114,26 @@ no-op, canceled, preview, and remote-presence changes do not create local docume history. Native text selection remains input/editor-owned and connects through an explicit edit lease rather than becoming a structural selection variant. +`selection.select-all` replaces the complete range selection in one publication. +Document selects from the first block's offset 0 to the last block's text end; +Order selects the full item order. Tree requires `topology` and selects its +visible IDs. Sheet uses its document axes or the supplied `topology` row/column +order. An empty universe clears selection. Repeating the operation preserves +content and document Undo/Redo; it still publishes one selection revision under +the existing session contract. Tree Copy/Cut still includes selected nodes' +descendants, and Sheet Copy/Cut still uses the primary rectangle. + +```ts +documentEditor.dispatch({ type: "selection.select-all" }); +orderEditor.dispatch({ type: "selection.select-all" }); +treeEditor.dispatch({ type: "selection.select-all", topology: { visibleIds } }); +sheetEditor.dispatch({ type: "selection.select-all", topology: { rowIds, columnIds } }); +``` + +[Whole-selection conformance cases](tests/conformance/select-all.test.ts) cover +empty, single, repeated, reordered and filtered targets, one publication, and +document history retention. Usage: the Document, Order, Tree and Sheet demos. + `Database` keeps typed property schema and records in canonical JSON while its saved Table views own property order, visibility, width, sort, and filter. The editor projects each saved view into a visible record/property topology for diff --git a/packages/json-document-editing/src/document.ts b/packages/json-document-editing/src/document.ts index acda405f..ad3d2937 100644 --- a/packages/json-document-editing/src/document.ts +++ b/packages/json-document-editing/src/document.ts @@ -8,6 +8,7 @@ import { collapsedRangeSelection, emptyRangeSelection, reconcileRangeSelection, + replaceRangeSelection, selectRangePoint, } from "./range-selection.js"; import { lineInterval, lineTopology } from "./topology.js"; @@ -62,6 +63,7 @@ export const documentClipboardFormat = { }; export type DocumentIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly blockId: string; readonly mode?: "replace" | "extend" | "toggle"; readonly offset?: number } | { readonly type: "text.replace"; readonly blockId: string; readonly text: string; readonly offset?: number } | { readonly type: "block.insert"; readonly afterId?: string; readonly text?: string } @@ -113,6 +115,14 @@ export function createDocumentEditor(source: EditingDocumentSource { const blocks = value().blocks; + if (intent.type === "selection.select-all") { + const first = blocks[0]; + const last = blocks.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first && last ? { anchor: pointAt(first, 0), focus: pointAt(last, last.text.length) } : null, + (left, right) => left.blockId === right.blockId && left.offset === right.offset); + return success(session.select(asDocumentSelection(selection))); + } if (intent.type === "selection.set") { const index = blocks.findIndex((block) => block.id === intent.blockId); if (index < 0) return failure("selection.block-not-found"); diff --git a/packages/json-document-editing/src/order.ts b/packages/json-document-editing/src/order.ts index d0269535..86dc5fd1 100644 --- a/packages/json-document-editing/src/order.ts +++ b/packages/json-document-editing/src/order.ts @@ -10,6 +10,7 @@ import { collapsedRangeSelection, emptyRangeSelection, reconcileRangeSelection, + replaceRangeSelection, selectRangePoint, type RangeSelectionState, } from "./range-selection.js"; @@ -64,6 +65,7 @@ export const orderClipboardFormat = { }; export type OrderIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly itemId: string; @@ -117,6 +119,14 @@ export function createOrderEditor( function dispatch(intent: OrderIntent): EditingResult { const items = value().items; + if (intent.type === "selection.select-all") { + const first = items[0]; + const last = items.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first && last ? { anchor: { itemId: first.id }, focus: { itemId: last.id } } : null, + (left, right) => left.itemId === right.itemId); + return success(session.select(asOrderSelection(selection))); + } if (intent.type === "selection.set") { if (!items.some((item) => item.id === intent.itemId)) return failure("selection.item-not-found"); const point: OrderPoint = { itemId: intent.itemId }; diff --git a/packages/json-document-editing/src/range-selection.ts b/packages/json-document-editing/src/range-selection.ts index 33b9b73f..34a0dbbc 100644 --- a/packages/json-document-editing/src/range-selection.ts +++ b/packages/json-document-editing/src/range-selection.ts @@ -19,11 +19,7 @@ export function selectRangePoint( mode: RangeSelectionMode, sameTarget: (left: Point, right: Point) => boolean, ): RangeSelectionState { - const topology: OrderedTopology = { - equals: sameTarget, - interval: (anchor, focus) => sameTarget(anchor, focus) ? [anchor] : [anchor, focus], - reconcilePoint: (candidate) => candidate, - }; + const topology = pointTopology(sameTarget); const family = createRangeSelectionFamily(); return family.transition(current, mode === "replace" ? { type: "collapse", point } @@ -32,6 +28,25 @@ export function selectRangePoint( : { type: "toggle-point", point }, { topology }).state; } +/** Replace all ranges in one transition; a missing domain range clears selection. */ +export function replaceRangeSelection( + current: RangeSelectionState, + range: SelectionRange | null, + sameTarget: (left: Point, right: Point) => boolean, +): RangeSelectionState { + return createRangeSelectionFamily().transition(current, + range === null ? { type: "clear" } : { type: "replace-range", range }, + { topology: pointTopology(sameTarget) }).state; +} + +function pointTopology(equals: (left: Point, right: Point) => boolean): OrderedTopology { + return { + equals, + interval: (anchor, focus) => equals(anchor, focus) ? [anchor] : [anchor, focus], + reconcilePoint: (candidate) => candidate, + }; +} + export function collapsedRangeSelection(point: Point): RangeSelectionState { return collapsed(point); } diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index e531ea0f..1407bf56 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -11,7 +11,7 @@ import { } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import type { EditingHistoryOptions } from "./history.js"; -import { reconcileRangeSelection } from "./range-selection.js"; +import { reconcileRangeSelection, replaceRangeSelection } 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"; @@ -83,6 +83,7 @@ export const sheetClipboardFormat = { }; export type SheetIntent = + | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; readonly rowId: string; @@ -196,6 +197,18 @@ export function createSheetEditor(source: EditingDocumentSource, } function dispatch(intent: SheetIntent): EditingResult { + if (intent.type === "selection.select-all") { + const { rowIds, columnIds } = resolveTopology(value(), intent.topology, index()); + const firstRow = rowIds[0]; + const firstColumn = columnIds[0]; + const lastRow = rowIds.at(-1); + const lastColumn = columnIds.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + firstRow !== undefined && firstColumn !== undefined && lastRow !== undefined && lastColumn !== undefined + ? { anchor: { rowId: firstRow, columnId: firstColumn }, focus: { rowId: lastRow, columnId: lastColumn } } + : null, sameSheetPoint); + return success(session.select(withPrimaryAliases(selection))); + } if (intent.type === "selection.set") { const point = resolvePoint(value(), intent.rowId, intent.columnId, index()); if (point === null) return failure("selection.cell-not-found"); diff --git a/packages/json-document-editing/src/tree.ts b/packages/json-document-editing/src/tree.ts index e9e9565e..e6845085 100644 --- a/packages/json-document-editing/src/tree.ts +++ b/packages/json-document-editing/src/tree.ts @@ -5,7 +5,7 @@ import { 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 { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { createRangeSelectionFamily, @@ -75,6 +75,7 @@ export const treeClipboardFormat = { }; export type TreeIntent = + | { readonly type: "selection.select-all"; readonly topology: TreeTopology } | { readonly type: "selection.set"; readonly nodeId: string; @@ -171,6 +172,14 @@ export function createTreeEditor( function dispatch(intent: TreeIntent): EditingResult { const topology = resolveTopology(intent.topology); + if (intent.type === "selection.select-all") { + const first = topology.visibleIds[0]; + const last = topology.visibleIds.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first !== undefined && last !== undefined ? { anchor: { nodeId: first }, focus: { nodeId: last } } : null, + (left, right) => left.nodeId === right.nodeId); + return success(session.select(asTreeSelection(selection))); + } if (intent.type === "selection.set") { if (!(topologyCache.get(topology) as TreeTopologyIndex).visible.has(intent.nodeId)) { return failure("selection.node-not-visible"); diff --git a/packages/json-document-editing/tests/conformance/select-all.test.ts b/packages/json-document-editing/tests/conformance/select-all.test.ts new file mode 100644 index 00000000..2a32cde4 --- /dev/null +++ b/packages/json-document-editing/tests/conformance/select-all.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "vitest"; +import { + createDocumentEditor, createOrderEditor, createTreeEditor, createSheetEditor, + type EditingSnapshot, type EditingResult, +} from "../../src/index.js"; +import type { JSONValue } from "@interactive-os/json-document"; + +function assertSelectionTransition( + editor: { + readonly snapshot: EditingSnapshot; + subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; + }, + selectAll: () => EditingResult, + expected: unknown, +) { + const before = editor.snapshot; + const seen: Selection[] = []; + const release = editor.subscribe((snapshot) => seen.push(snapshot.selection)); + try { + for (let repeat = 0; repeat < 3; repeat++) { + expect(selectAll().ok).toBe(true); + expect(editor.snapshot.selection).toEqual(expected); + expect(editor.snapshot).toMatchObject({ value: before.value, canUndo: before.canUndo, canRedo: before.canRedo }); + // One final selection per dispatch, with no first/last intermediate state. + expect(seen).toHaveLength(repeat + 1); + expect(seen[repeat]).toEqual(expected); + } + } finally { release(); } +} + +const empty = { kind: "range", ranges: [], primaryIndex: null }; +const range = (anchor: JSONValue, focus: JSONValue) => ({ kind: "range", ranges: [{ anchor, focus }], primaryIndex: 0 }); + +test.each([0, 1, 3])("Document select-all covers %i blocks, including text endpoints", (count) => { + const blocks = [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }, { id: "c", text: "Gamma" }].slice(0, count); + const editor = createDocumentEditor({ blocks }); + const first = blocks[0], last = blocks.at(-1); + const expected = first && last ? range({ blockId: first.id, offset: 0 }, { blockId: last.id, offset: last.text.length }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.selectedBlockIds).toEqual(blocks.map((block) => block.id)); + if (first) { + editor.dispatch({ type: "text.replace", blockId: first.id, text: "Changed" }); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.redo().ok).toBe(true); + expect(editor.snapshot.canUndo).toBe(true); + } +}); + +test.each([0, 1, 3])("Order select-all covers %i items and preserves history", (count) => { + const items = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }].slice(0, count); + const editor = createOrderEditor({ items }); + const first = items[0], last = items.at(-1); + const expected = first && last ? range({ itemId: first.id }, { itemId: last.id }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.selectedItemIds).toEqual(items.map((item) => item.id)); + if (first) { + editor.dispatch({ type: "item.rename", itemId: first.id, label: "Changed" }); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test.each([[], ["b"], ["b", "a"]].map((visibleIds) => ({ visibleIds })))("Tree select-all uses exactly the visible topology $visibleIds", ({ visibleIds }) => { + const editor = createTreeEditor({ nodes: [ + { id: "a", parentId: null, label: "A" }, + { id: "hidden", parentId: "a", label: "Hidden" }, + { id: "b", parentId: null, label: "B" }, + ] }); + const topology = { visibleIds }; + const first = visibleIds[0], last = visibleIds.at(-1); + const expected = first && last ? range({ nodeId: first }, { nodeId: last }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all", topology }), expected); + expect(editor.selectedNodeIdsIn(topology)).toEqual(visibleIds); + if (visibleIds.includes("a")) { + // Selection scope remains visible; existing Copy/Cut descendant closure is unchanged. + expect(editor.copy(topology)?.nodes.map((node) => node.id)).toEqual(["a", "hidden", "b"]); + expect(editor.cut(topology)?.result.ok).toBe(true); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all", topology }), expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test.each([ + { rowIds: [], columnIds: ["x"] }, { rowIds: ["a"], columnIds: [] }, + { rowIds: ["b"], columnIds: ["y"] }, { rowIds: ["b", "a"], columnIds: ["y", "x"] }, +])("Sheet select-all respects declared axes $rowIds / $columnIds", (topology) => { + const editor = createSheetEditor({ + columns: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], + rows: [{ id: "a", cells: { x: 1, y: 2 } }, { id: "b", cells: { x: 3, y: 4 } }, { id: "hidden", cells: { x: 5, y: 6 } }], + }); + const anchor = topology.rowIds[0] && topology.columnIds[0] + ? { rowId: topology.rowIds[0], columnId: topology.columnIds[0] } : null; + const focus = anchor ? { rowId: topology.rowIds.at(-1)!, columnId: topology.columnIds.at(-1)! } : null; + const expected = { ...(anchor ? range(anchor, focus) : empty), anchor, focus }; + const select = () => editor.dispatch({ type: "selection.select-all", topology }); + assertSelectionTransition(editor, select, expected); + expect(editor.selectedCellsIn(topology).map(({ rowId, columnId }) => ({ rowId, columnId }))).toEqual( + topology.rowIds.flatMap((rowId) => topology.columnIds.map((columnId) => ({ rowId, columnId }))), + ); + if (anchor) { + expect(editor.dispatch({ type: "selection.fill", value: 0, topology }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, select, expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test("Sheet omitted topology covers all cells; empty documents clear selection", () => { + const sheet = createSheetEditor({ columns: [{ id: "x", label: "X" }], rows: [{ id: "a", cells: { x: 1 } }, { id: "b", cells: { x: 2 } }] }); + expect(sheet.dispatch({ type: "selection.select-all" }).ok).toBe(true); + expect(sheet.selectedCells).toHaveLength(2); + const emptySheet = createSheetEditor({ rows: [], columns: [] }); + assertSelectionTransition(emptySheet, () => emptySheet.dispatch({ type: "selection.select-all" }), { ...empty, anchor: null, focus: null }); + const tree = createTreeEditor({ nodes: [] }); + assertSelectionTransition(tree, () => tree.dispatch({ type: "selection.select-all", topology: { visibleIds: [] } }), empty); +}); diff --git a/packages/json-document-rich-text-web/README.md b/packages/json-document-rich-text-web/README.md index bd6b77cb..590202db 100644 --- a/packages/json-document-rich-text-web/README.md +++ b/packages/json-document-rich-text-web/README.md @@ -8,6 +8,14 @@ Selection round-trips both text offsets and container child boundaries. Copy, cut, and paste publish/consume structured Rich Text, safe semantic HTML, and plain text in that priority order. +Keyboard Undo/Redo consumes the Web package's `createWebKeyboardAdapter` defaults +(`Mod-z`, `Mod-Shift-z`). This binding retains its historical Alt variants through +explicit keymap entries. Root ownership and composition handling stay in this +binding. [Keyboard history tests](tests/history-keyboard.test.ts) exercise native +range replacement and meta/control Undo/Redo, including backward ranges and +selection movement after Undo without losing Redo. These synthetic DOM cases +complement the Rich Text demo's real-browser input tests. + IME composition uses a DOM reconciliation lease rather than inserting `compositionend.data` directly. The binding captures the canonical selection and pre-composition DOM text, lets the platform mutate the active DOM while diff --git a/packages/json-document-rich-text-web/src/contenteditable.ts b/packages/json-document-rich-text-web/src/contenteditable.ts index 348db05f..b77a22a8 100644 --- a/packages/json-document-rich-text-web/src/contenteditable.ts +++ b/packages/json-document-rich-text-web/src/contenteditable.ts @@ -4,9 +4,15 @@ import type { RichTextSelection, } from "@interactive-os/json-document-rich-text"; import { createRichTextNodeId } from "@interactive-os/json-document-rich-text"; -import { createWebClipboardBinding, isWebEditingHostTarget, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; +import { createWebClipboardBinding, createWebKeyboardAdapter, isWebEditingHostTarget, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; import { createRichTextClipboardCodec, createRichTextClipboardRepresentations } from "./clipboard.js"; +// Preserve this binding's historical Alt acceptance alongside the shared Mod-z defaults. +const keyboard = createWebKeyboardAdapter({ keymap: { + "Mod-Alt-z": { type: "undo" }, + "Mod-Alt-Shift-z": { type: "redo" }, +} }); + export interface RichTextContentEditableBinding { isComposing(): boolean; syncSelection(): RichTextSelection | null; @@ -174,9 +180,10 @@ export function createRichTextContentEditableBinding(options: { report("text.delete", editor.dispatch({ type: "text.delete", direction, unit: "character" })); return; } - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { + const command = keyboard.resolve(event); + if (command?.type === "undo" || command?.type === "redo") { event.preventDefault(); - report(event.shiftKey ? "redo" : "undo", event.shiftKey ? editor.redo() : editor.undo()); + report(command.type, command.type === "redo" ? editor.redo() : editor.undo()); } }; diff --git a/packages/json-document-rich-text-web/tests/history-keyboard.test.ts b/packages/json-document-rich-text-web/tests/history-keyboard.test.ts new file mode 100644 index 00000000..d45cfae8 --- /dev/null +++ b/packages/json-document-rich-text-web/tests/history-keyboard.test.ts @@ -0,0 +1,86 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { createRichTextEditor, type RichTextDocument, type RichTextSelection } from "@interactive-os/json-document-rich-text"; +import { afterEach, expect, test } from "vitest"; +import { createRichTextContentEditableBinding } 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: [] }] }], +}; +const range = (anchor: number, focus = anchor): RichTextSelection => ({ + kind: "range", primaryIndex: 0, ranges: [{ + anchor: { kind: "text", nodeId: "t", offset: anchor, affinity: "forward" }, + focus: { kind: "text", nodeId: "t", offset: focus, affinity: "forward" }, + }], +}); +const disposers: (() => void)[] = []; +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); + document.body.replaceChildren(); +}); +function fixture(backward = false) { + const editor = createRichTextEditor({ document: createJSONDocument(initial) }); + const root = document.createElement("article"); + root.contentEditable = "true"; + root.dataset.richTextContainerId = "doc"; + root.innerHTML = '

Alpha

'; + document.body.append(root); + const text = root.querySelector("span")!.firstChild!; + document.getSelection()!.setBaseAndExtent(text, backward ? 4 : 2, text, backward ? 2 : 4); + const actions: string[] = []; + const binding = createRichTextContentEditableBinding({ root, editor, onAction: (action) => actions.push(action) }); + disposers.push(() => binding.destroy()); + root.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: "X" })); + expect(editor.snapshot).toMatchObject({ + value: { content: [{ content: [{ id: "t", text: "AlXa" }] }] }, + selection: range(3), canUndo: true, canRedo: false, + }); + return { root, editor, actions }; +} +function key(target: HTMLElement, options: KeyboardEventInit) { + const event = new KeyboardEvent("keydown", { key: "z", bubbles: true, cancelable: true, ...options }); + target.dispatchEvent(event); + return event; +} + +test.each(["metaKey", "ctrlKey"] as const)("%s Undo/Redo restores directed selection through the real binding", (modifier) => { + for (const backward of [false, true]) for (const moveAfterUndo of [false, true]) { + const { root, editor, actions } = fixture(backward); + const before = backward ? range(4, 2) : range(2, 4); + expect(key(root, { [modifier]: true }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ value: initial, selection: before, canUndo: false, canRedo: true }); + if (moveAfterUndo) { + editor.dispatch({ type: "selection.set", selection: range(0) }); + expect(editor.snapshot).toMatchObject({ selection: range(0), canUndo: false, canRedo: true }); + } + expect(key(root, { [modifier]: true, shiftKey: true, key: "Z" }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ + value: { content: [{ content: [{ id: "t", text: "AlXa" }] }] }, + selection: range(3), canUndo: true, canRedo: false, + }); + expect(actions.slice(-2)).toEqual(["undo", "redo"]); + } +}); + +test.each(["metaKey", "ctrlKey"] as const)("%s preserves legacy Alt and composing modifier acceptance", (modifier) => { + const { root, editor } = fixture(); + expect(key(root, { [modifier]: true, altKey: true, isComposing: true }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ value: initial, selection: range(2, 4), canRedo: true }); + expect(key(root, { [modifier]: true, altKey: true, shiftKey: true }).defaultPrevented).toBe(true); + expect(editor.snapshot.selection).toEqual(range(3)); +}); + +test("unmatched keys and nested controls never consume the outer history", () => { + const { root, editor } = fixture(); + const before = editor.snapshot; + for (const options of [{}, { altKey: true }, { metaKey: true, key: "x" }]) { + expect(key(root, options).defaultPrevented).toBe(false); + } + for (const tag of ["input", "textarea", "select", "div"]) { + const nested = document.createElement(tag); + if (tag === "div") nested.setAttribute("contenteditable", "true"); + root.append(nested); + expect(key(nested, { metaKey: true }).defaultPrevented).toBe(false); + } + expect(editor.snapshot).toEqual(before); +}); 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 index d3a85ed1..5ea210d4 100644 --- a/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts +++ b/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts @@ -1,6 +1,6 @@ import { createJSONDocument } from "@interactive-os/json-document"; import { createRangeSelectionFamily } from "@interactive-os/json-document-selection"; -import { expect } from "vitest"; +import { expect, test } from "vitest"; import { editingGrammar } from "../../../json-document-editing/tests/conformance/editing-grammar.js"; import { createRichTextEditor, type RichTextClipboard, type RichTextDocument, @@ -19,6 +19,28 @@ const range = (anchor: number, focus = anchor): RichTextSelection => ({ kind: "range", ranges: [{ anchor: point(anchor), focus: point(focus) }], primaryIndex: 0, }); +test.each([ + { backward: false, moveAfterUndo: false }, { backward: true, moveAfterUndo: false }, + { backward: false, moveAfterUndo: true }, { backward: true, moveAfterUndo: true }, +])("EG-HISTORY / replacement restores directed range ($backward), redo survives selection ($moveAfterUndo)", ({ backward, moveAfterUndo }) => { + const editor = createRichTextEditor({ document: createJSONDocument(initial) }); + const before = backward ? range(4, 2) : range(2, 4); + expect(editor.dispatch({ type: "selection.set", selection: before }).ok).toBe(true); + expect(editor.dispatch({ type: "text.insert", text: "X" }).ok).toBe(true); + const after = { ...initial, content: [ + { id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "AlXa", marks: [] }] }, + initial.content[1], + ] }; + expect(editor.snapshot).toMatchObject({ value: after, selection: range(3), canUndo: true, canRedo: false }); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { value: initial, selection: before, canUndo: false, canRedo: true } }); + if (moveAfterUndo) { + expect(editor.dispatch({ type: "selection.set", selection: range(0) })).toMatchObject({ + ok: true, snapshot: { value: initial, selection: range(0), canUndo: false, canRedo: true }, + }); + } + expect(editor.redo()).toMatchObject({ ok: true, snapshot: { value: after, selection: range(3), canUndo: true, canRedo: false } }); +}); + editingGrammar("Rich Text v1 / inline slice / local history", () => { const document = createJSONDocument(initial); let id = 0; diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index 633d1da3..71eab282 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -555,17 +555,13 @@ export function CanvasDemoRoute() { applyAffordance( selectAllAffordance(event, { allSelected: editor.selectedObjects.length === unlockedIds.length && unlockedIds.length > 0, - }), + }, { repeat: "preserve" }), { hand: (hand) => { if (hand.type === "select-all") { editor.dispatch({ type: "selection.set", objectIds: unlockedIds, mode: "replace" }); event.preventDefault(); } - if (hand.type === "clear") { - editor.dispatch({ type: "selection.set", objectIds: [], mode: "replace" }); - event.preventDefault(); - } }, }, ); diff --git a/site/src/routes/document-demo/DocumentDemoRoute.tsx b/site/src/routes/document-demo/DocumentDemoRoute.tsx index 9b417f6b..3e39db0a 100644 --- a/site/src/routes/document-demo/DocumentDemoRoute.tsx +++ b/site/src/routes/document-demo/DocumentDemoRoute.tsx @@ -19,12 +19,15 @@ import { createWebClipboardSurface, createWebClipboardTextWriter, documentClipboardCodec, + isWebEditingHostTarget, lineBoundary, moveLinePoint, } from "@interactive-os/json-document-web"; import { historyAffordance, editingCommandFromWebKeyboardStroke, + applyAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { Command, Toggle, SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; @@ -205,7 +208,20 @@ export function DocumentDemoRoute() { ref={surfaceRef} tabIndex={0} {...clipboardSurface} - onKeyDown={editing.getKeyDownHandler()} + onKeyDown={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedBlockIds.length === document.blocks.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run(() => dispatchIntent({ type: "selection.select-all" }), "All blocks selected"); + event.preventDefault(); + }, + }); + } + if (!event.defaultPrevented) editing.getKeyDownHandler()(event); + }} className={ui.state.focus} > {document.blocks.length === 0 ? ( diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx index d031748d..2b9de43d 100644 --- a/site/src/routes/order-demo/OrderDemoRoute.tsx +++ b/site/src/routes/order-demo/OrderDemoRoute.tsx @@ -10,6 +10,7 @@ import { import { useEditing, useEditingObservation } from "@interactive-os/json-document-react"; import { focusWebItem, + isWebEditingHostTarget, createWebClipboardSurface, lineBoundary, moveLinePoint, @@ -25,6 +26,7 @@ import { applyAffordance, escapeAffordance, renameAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { Command, Field, SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; @@ -71,7 +73,7 @@ export function OrderDemoRoute() { }, })); const [renameSession] = useState(() => createRenameSession({ - onCommit: (itemId, label) => run({ type: "item.rename", itemId, label }, "Item renamed"), + tryCommit: (itemId, label) => run({ type: "item.rename", itemId, label }, "Item renamed").ok, onFinish: (itemId) => requestAnimationFrame(() => focusWebItem(orderRef.current, itemId)), onSnapshot: (snapshot) => setRenaming(snapshot === null ? null : { id: snapshot.key, draft: snapshot.draft }), })); @@ -133,6 +135,18 @@ export function OrderDemoRoute() { } function onKeyDown(event: KeyboardEvent) { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedItemIds.length === document.items.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run({ type: "selection.select-all" }, "All items selected"); + event.preventDefault(); + }, + }); + if (event.defaultPrevented) return; + } if (focusSession.handle(event, ids())) { event.preventDefault(); const next = focusSession.getFocusKey(); diff --git a/site/src/routes/sheet-demo/SheetDemo.tsx b/site/src/routes/sheet-demo/SheetDemo.tsx index 4890bbc7..5b2f8f46 100644 --- a/site/src/routes/sheet-demo/SheetDemo.tsx +++ b/site/src/routes/sheet-demo/SheetDemo.tsx @@ -20,12 +20,14 @@ import { moveGridPoint, rovingFocusItemProps, sheetClipboardCodec, + isWebEditingHostTarget, webGridCellAddressProps, } from "@interactive-os/json-document-web"; import { historyAffordance, editingCommandFromWebKeyboardStroke, applyAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Field, GridCell } from "@interactive-os/json-document-ui-primitives-react"; import { Inspector } from "../../shared/ui/inspector"; @@ -203,7 +205,20 @@ export function SheetDemo() { aria-label="Editable sheet" tabIndex={0} {...clipboardSurface} - onKeyDown={editing.getKeyDownHandler()} + onKeyDown={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedCells.length === sheet.rows.length * sheet.columns.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run(() => dispatchIntent({ type: "selection.select-all" }), "All cells selected"); + event.preventDefault(); + }, + }); + } + if (!event.defaultPrevented) editing.getKeyDownHandler()(event); + }} className={classes("min-w-0 overflow-auto", ui.state.focus)} > diff --git a/site/src/routes/tree-demo/TreeDemoRoute.tsx b/site/src/routes/tree-demo/TreeDemoRoute.tsx index 3b87a49c..35f9f6b0 100644 --- a/site/src/routes/tree-demo/TreeDemoRoute.tsx +++ b/site/src/routes/tree-demo/TreeDemoRoute.tsx @@ -11,10 +11,13 @@ import { useEditingObservation, useTreeEditing } from "@interactive-os/json-docu import { createWebClipboardSurface, treeClipboardCodec, + isWebEditingHostTarget, } from "@interactive-os/json-document-web"; import { historyAffordance, editingCommandFromWebKeyboardStroke, + applyAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { Command, SelectableItem } from "@interactive-os/json-document-ui-primitives-react"; @@ -152,7 +155,20 @@ export function TreeDemoRoute() { className="m-0 grid list-none gap-1 p-0" tabIndex={0} {...clipboardSurface} - onKeyDown={editing.getKeyDownHandler()} + onKeyDown={(event) => { + if (isWebEditingHostTarget(event.currentTarget, event.target)) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedNodeIdsIn(topology).length === topology.visibleIds.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + run({ type: "selection.select-all", topology }, "All visible nodes selected"); + event.preventDefault(); + }, + }); + } + if (!event.defaultPrevented) editing.getKeyDownHandler()(event); + }} > {rows.map((row) => { return ( diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts index 4988fbeb..754b4cc0 100644 --- a/site/src/shared/demo-workbench/demo-sources.ts +++ b/site/src/shared/demo-workbench/demo-sources.ts @@ -21,6 +21,7 @@ import markdownRendererSource from "../../../../packages/json-document-markdown- import dateValuesSource from "../../../../packages/json-document-calendar/src/date-values.ts?raw"; import editingItemSource from "../../../../packages/json-document-react/src/use-editing.ts?raw"; import affordanceSessionSource from "../../../../packages/json-document-affordance/src/session.ts?raw"; +import affordanceSelectSource from "../../../../packages/json-document-affordance/src/select.ts?raw"; import viewportPositionSource from "../../../../packages/json-document-affordance/src/viewport-position.ts?raw"; import anchoredFloatingPositionSource from "../../../../packages/json-document-affordance/src/anchored-floating-position.ts?raw"; import webFocusItemSource from "../../../../packages/json-document-web/src/focus-item.ts?raw"; @@ -33,6 +34,8 @@ 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 orderEditingSource from "../../../../packages/json-document-editing/src/order.ts?raw"; +import treeEditorSource from "../../../../packages/json-document-editing/src/tree.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"; @@ -196,6 +199,7 @@ const registeredUsageSources = new Map([ ["packages/json-document-react/src/editing-observation.ts", editingObservationSource], ["packages/json-document-react/src/use-editing.ts", editingItemSource], ["packages/json-document-affordance/src/session.ts", affordanceSessionSource], + ["packages/json-document-affordance/src/select.ts", affordanceSelectSource], ["packages/json-document-affordance/src/viewport-position.ts", viewportPositionSource], ["packages/json-document-affordance/src/anchored-floating-position.ts", anchoredFloatingPositionSource], ["packages/json-document-web/src/focus-item.ts", webFocusItemSource], @@ -208,6 +212,8 @@ 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/order.ts", orderEditingSource], + ["packages/json-document-editing/src/tree.ts", treeEditorSource], ["packages/json-document-editing/src/sheet.ts", sheetEditingSource], ["packages/json-document-editing/src/clipboard.ts", editingClipboardSource], ["packages/json-document-editing/src/session.ts", editingSessionSource], @@ -956,6 +962,26 @@ const registeredPublicUsages = [ symbol: "createRenameSession", sourcePath: "packages/json-document-affordance/src/session.ts", }, + { + packageName: "@interactive-os/json-document-affordance", + symbol: "selectAllAffordance", + sourcePath: "packages/json-document-affordance/src/select.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createDocumentEditor", + sourcePath: "packages/json-document-editing/src/document.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createOrderEditor", + sourcePath: "packages/json-document-editing/src/order.ts", + }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createTreeEditor", + sourcePath: "packages/json-document-editing/src/tree.ts", + }, { packageName: "@interactive-os/json-document-affordance", symbol: "createLineFocusSession", diff --git a/site/tests/browser/demo-workbench.spec.ts b/site/tests/browser/demo-workbench.spec.ts index e128fac2..61fd4043 100644 --- a/site/tests/browser/demo-workbench.spec.ts +++ b/site/tests/browser/demo-workbench.spec.ts @@ -1,5 +1,23 @@ import { expect, test } from "@playwright/test"; +for (const { route, entry, owner } of [ + { route: "/demo/order", entry: "OrderDemoRoute.tsx", owner: "order.ts" }, + { route: "/demo/tree", entry: "TreeDemoRoute.tsx", owner: "tree.ts" }, + { route: "/demo/sheet", entry: "SheetDemoRoute.tsx", owner: "sheet.ts" }, + { route: "/demo", entry: "DocumentDemoRoute.tsx", owner: "document.ts" }, +]) { + test(`${route} Usage links select-all to its canonical owner`, async ({ page }) => { + await page.goto(route); + const workbench = page.getByRole("region", { name: "Demo workbench" }); + await workbench.getByRole("tab", { name: entry, exact: true }).click(); + await workbench.getByRole("tab", { name: owner, exact: true }).click(); + await expect(workbench.getByRole("tabpanel").locator("pre")).toContainText('"selection.select-all"'); + await expect(workbench.getByRole("link", { name: "API Reference" })).toHaveAttribute("href", "/docs/api/editing"); + await workbench.getByRole("tab", { name: "select.ts", exact: true }).click(); + await expect(workbench.getByRole("tabpanel").locator("pre")).toContainText('"preserve"'); + }); +} + test("switches between the live demo and its actual full source without resetting demo state", async ({ page }) => { await page.goto("/demo"); diff --git a/site/tests/browser/document-demo.spec.ts b/site/tests/browser/document-demo.spec.ts index d16ff59e..38c6e7f3 100644 --- a/site/tests/browser/document-demo.spec.ts +++ b/site/tests/browser/document-demo.spec.ts @@ -1,5 +1,23 @@ import { expect, test, type Page } from "@playwright/test"; +test("Document repeated select-all preserves blocks and leaves native text selection owned by the field", async ({ page }) => { + await page.goto("/demo"); + const surface = page.getByRole("region", { name: "Editable document" }).locator('[tabindex="0"]'); + await surface.focus(); + for (const modifier of ["Meta", "Control"]) { + await surface.press(`${modifier}+a`); + await surface.press(`${modifier}+a`); + await expect(page.locator('article[data-block-id][data-selected="true"]')).toHaveCount(4); + } + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + const field = page.getByRole("textbox", { name: "Block 1 text" }); + await field.click(); + await field.press("ControlOrMeta+a"); + await expect.poll(() => field.evaluate((node: HTMLTextAreaElement) => [node.selectionStart, node.selectionEnd])) + .toEqual([0, (await field.inputValue()).length]); + await expect(page.locator('article[data-block-id][data-selected="true"]')).toHaveCount(1); +}); + test("Document keeps native caret and directional range offsets in the editor", async ({ page }) => { await page.goto("/demo"); await page.getByText("Inspect editing state", { exact: true }).click(); diff --git a/site/tests/browser/editor-slice-demos.spec.ts b/site/tests/browser/editor-slice-demos.spec.ts index c26626ac..f2ff27ac 100644 --- a/site/tests/browser/editor-slice-demos.spec.ts +++ b/site/tests/browser/editor-slice-demos.spec.ts @@ -54,6 +54,24 @@ test("Object composes native and toolbar paste with the same placement Intent", .toEqual([[24, 24], [48, 48], [48, 48]]); }); +test("Order repeated select-all and cancelled rename preserve selected items and document history", async ({ page }) => { + await page.goto("/demo/order"); + const order = page.getByLabel("Editable order").locator("ol"); + await order.focus(); + for (const modifier of ["Meta", "Control"]) { + await order.press(`${modifier}+a`); + await order.press(`${modifier}+a`); + await expect(order.locator('[data-selected="true"]')).toHaveCount(4); + } + await order.press("F2"); + const rename = page.getByRole("textbox", { name: "Rename Inbox" }); + await rename.fill("Unsaved"); + await rename.press("Escape"); + await expect(page.getByRole("button", { name: /Inbox/ })).toBeFocused(); + await expect(order.locator('[data-selected="true"]')).toHaveCount(4); + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); +}); + test("Order typeahead jumps to the matching label and Escape clears the buffer", async ({ page }) => { await page.goto("/demo/order"); await page.getByLabel("Editable order").locator("ol").focus(); @@ -245,6 +263,7 @@ test("Canvas select-all, delete, and locked objects", async ({ page }) => { const lock = page.getByRole("button", { name: "Lock", exact: true }); await canvas.focus(); await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("ControlOrMeta+A"); await expect(note).toHaveAttribute("data-selected", "true"); await expect(page.getByRole("button", { name: "Card" })).toHaveAttribute("data-selected", "true"); await expect(lock).toHaveAttribute("data-selected", "false"); @@ -332,6 +351,20 @@ test("Canvas copy-drag, constrain, resize, and zoom", async ({ page }) => { expect(scale).toMatch(/matrix\((1\.[1-9]|[2-9])/); }); +test("Tree repeated select-all uses the collapsed visible topology", async ({ page }) => { + await page.goto("/demo/tree"); + await page.getByRole("button", { name: "Collapse Fruit" }).click(); + const tree = page.getByLabel("Editable tree").locator("ul"); + await tree.focus(); + for (const modifier of ["Meta", "Control"]) { + await tree.press(`${modifier}+a`); + await tree.press(`${modifier}+a`); + await expect(tree.locator('[data-selected="true"]')).toHaveCount(4); + await expect(page.getByRole("button", { name: "Apple", exact: true })).toHaveCount(0); + } + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); +}); + test("Tree uses host visible order and restores a cut with undo", async ({ page }) => { await page.goto("/demo/tree"); await page.getByText("Inspect editing state", { exact: true }).click(); diff --git a/site/tests/browser/rich-text-demo.spec.ts b/site/tests/browser/rich-text-demo.spec.ts index 4bdcf95d..51b7b94f 100644 --- a/site/tests/browser/rich-text-demo.spec.ts +++ b/site/tests/browser/rich-text-demo.spec.ts @@ -1,5 +1,27 @@ import { expect, test, type Page } from "@playwright/test"; +for (const modifier of ["Meta", "Control"]) for (const backward of [false, true]) { + test(`Rich Text ${modifier} Undo restores ${backward ? "backward" : "forward"} range; selection movement retains Redo`, async ({ page }) => { + await page.goto("/editing/rich-text"); + const before = await json(page, "rich-text-document-json"); + const text = textNode(before, "text-editable").text; + await setSelection(page, "text-editable", backward ? 4 : 2, backward ? 2 : 4); + const selectionBefore = (await json(page, "rich-text-selection-json")).selection; + await page.keyboard.type("X"); + await expect.poll(async () => textNode(await json(page, "rich-text-document-json"), "text-editable").text).toBe(`${text.slice(0, 2)}X${text.slice(4)}`); + const after = await json(page, "rich-text-document-json"); + await page.keyboard.press(`${modifier}+z`); + await expect.poll(() => json(page, "rich-text-document-json")).toEqual(before); + await expect.poll(async () => (await json(page, "rich-text-selection-json")).selection).toEqual(selectionBefore); + await expect.poll(() => domSelection(page)).toEqual({ nodeId: "text-editable", anchorOffset: backward ? 4 : 2, focusOffset: backward ? 2 : 4 }); + await setSelection(page, "text-editable", 0, 0); + await expect(page.getByRole("button", { name: "Redo", exact: true })).toBeEnabled(); + await page.keyboard.press(`${modifier}+Shift+z`); + await expect.poll(() => json(page, "rich-text-document-json")).toEqual(after); + await expect.poll(() => domSelection(page)).toEqual({ nodeId: "text-editable", anchorOffset: 3, focusOffset: 3 }); + }); +} + 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); diff --git a/site/tests/browser/sheet-demo.spec.ts b/site/tests/browser/sheet-demo.spec.ts index e0af47e9..1b87492d 100644 --- a/site/tests/browser/sheet-demo.spec.ts +++ b/site/tests/browser/sheet-demo.spec.ts @@ -1,5 +1,22 @@ import { expect, test, type Page } from "@playwright/test"; +test("Sheet repeated select-all preserves the rectangle and native field select-all", async ({ page }) => { + await page.goto("/demo/sheet"); + const surface = page.getByLabel("Editable sheet"); + await surface.focus(); + for (const modifier of ["Meta", "Control"]) { + await surface.press(`${modifier}+a`); + await surface.press(`${modifier}+a`); + await expect(page.locator('td[data-selected="true"]')).toHaveCount(12); + } + await expect(page.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); + const field = page.getByRole("textbox", { name: "Name row 1" }); + await field.click(); + await field.press("ControlOrMeta+a"); + await expect.poll(() => field.evaluate((node: HTMLInputElement) => [node.selectionStart, node.selectionEnd])).toEqual([0, 5]); + await expect(page.locator('td[data-selected="true"]')).toHaveCount(1); +}); + test("Sheet demo completes rectangular selection, clipboard, edit, undo, and redo", async ({ page }) => { const consoleProblems: string[] = []; page.on("console", (message) => { diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index 6464367b..17d30ff5 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -83,37 +83,43 @@ describe("Demo definition and source discovery", () => { const document = await discoverDemoSources("routes/document-demo/DocumentDemoRoute.tsx"); expect(document.map((file) => file.path)).toEqual([ "routes/document-demo/DocumentDemoRoute.tsx", + "packages/json-document-web/src/input.ts", "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-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", - "packages/json-document-web/src/input.ts", "packages/json-document-editing/src/document.ts", ]); const source = await document[0]!.load(); expect(source).toContain("export function DocumentDemoRoute()"); expect(source).toContain('from "@interactive-os/json-document-react"'); expect(document.filter((file) => file.path.startsWith("packages/")).map((file) => file.path)).toEqual([ + "packages/json-document-web/src/input.ts", "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-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-document-text-control.ts", - "packages/json-document-web/src/input.ts", "packages/json-document-editing/src/document.ts", ]); expect(document.some((file) => file.path.includes("shared/ui"))).toBe(false); expect(document.filter((file) => file.path.startsWith("packages/")).map((file) => file.referencePath)).toEqual([ + "/docs/api/web", "/docs/api/ui-primitives-react", "/docs/api/ui-primitives-react", "/docs/api/react", "/docs/api/react", + "/docs/api/affordance", "/docs/api/web", - "/docs/api/react", "/docs/api/web", + "/docs/api/react", "/docs/api/editing", ]); }); @@ -144,6 +150,7 @@ describe("Demo definition and source discovery", () => { "packages/json-document-affordance/src/session.ts", "packages/json-document-ui-primitives-react/src/controls.tsx", "packages/json-document-react/src/use-editing.ts", + "packages/json-document-editing/src/order.ts", ]); }); @@ -163,10 +170,13 @@ describe("Demo definition and source discovery", () => { const sources = await discoverDemoSources("routes/sheet-demo/SheetDemo.tsx"); expect(sources.map((file) => file.path)).toEqual([ "routes/sheet-demo/SheetDemo.tsx", + "packages/json-document-web/src/input.ts", "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-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-editing/src/sheet.ts", "packages/json-document-react/src/use-grid-editing.ts", @@ -211,10 +221,14 @@ describe("Demo definition and source discovery", () => { test("registers Tree visibility and React binding sources next to Tree usage", async () => { expect((await discoverDemoSources("routes/tree-demo/TreeDemoRoute.tsx")).map((file) => file.path)).toEqual([ "routes/tree-demo/TreeDemoRoute.tsx", + "packages/json-document-web/src/input.ts", "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-affordance/src/select.ts", + "packages/json-document-web/src/keyboard.ts", + "packages/json-document-editing/src/tree.ts", "packages/json-document-web/src/clipboard.ts", "packages/json-document-react/src/use-tree-editing.ts", "packages/json-document-editing/src/tree-visibility.ts",