diff --git a/docs/README.md b/docs/README.md index 07333523..38efe832 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ docs | |-- order.md # Hands: 한 줄 목록 | |-- object.md # Hands: 키 선택 객체 | |-- tree.md # Hands: 보이는 나무 +| |-- database.md # Hands: 저장된 표 view | |-- adapters.md # Adapters: 공식 플랫폼 변환 | |-- connectors.md # Connectors: 공식 라이브러리 생태계 연결 | |-- react-editing.md # Connectors: React 선택·커서 질의 diff --git a/docs/changelog.md b/docs/changelog.md index 2bd1df53..d72d3255 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,19 @@ source and release history remains available from Git commits and version tags. ## Next +- Closed Database Hands on the header and cell: order, width, sort, + hide, and filter persist in the saved Table view, and the named + toolbar toggles no longer replace those hands. +- Added the Database guide as the Hands entrance, with the existing + Demo as its table surface. +- Closed empty-canvas press as `clear`, and Escape as one layer at a time: + cancel an open gesture, then clear selection. `pointercancel` still + cancels only the gesture. +- Closed plane hit selection: pressing an already selected object keeps + the set so a drag moves every selected object. Canvas Hands apply + `planeHitAffordance`. +- Attached typeahead to Order Hands and marquee, pan, snap, nudge, and + escape to Canvas Hands. Those catalog rows leave TBD. - Opened Affordance as the product-facing keyboard and mouse contract, with usage and API for select, fold, drag, and undo/redo. Live widget screens stay as proofs, not the catalog entrance. diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 5ef47556..8a6114b0 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -89,6 +89,7 @@ const publicDocs = { order: read("docs/public/order.md"), object: read("docs/public/object.md"), tree: read("docs/public/tree.md"), + database: read("docs/public/database.md"), }; const surfaces = { rootReadme: read("README.md"), @@ -168,6 +169,7 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ "collaboration.md", "concepts.md", "connectors.md", + "database.md", "hands.md", "history.md", "intent-guide.md", diff --git a/docs/public/affordance-cancel.md b/docs/public/affordance-cancel.md index e7d444b1..626f336b 100644 --- a/docs/public/affordance-cancel.md +++ b/docs/public/affordance-cancel.md @@ -1,40 +1,41 @@ # Escape -TBD. - -Escape는 진행 중인 손과 임시 UI를 버리는 손입니다. Escape 키는 메뉴·대화· -드래그를 닫고, `pointercancel`은 포인터 제스처를 중단합니다. +Escape는 안쪽 손부터 한 겹씩 닫습니다. 끌어 옮기는 중이면 그 손을 버리고, +열린 손이 없으면 고른 것을 지웁니다. `pointercancel`은 포인터 제스처만 +중단하고 선택은 건드리지 않습니다. ```ts -import { - applyAffordance, - escapeAffordance, -} from "@interactive-os/json-document-affordance"; +import { applyAffordance, escapeAffordance } from "@interactive-os/json-document-affordance"; function onKeyDown(event: KeyboardEvent) { - applyAffordance(escapeAffordance(event), { - hand: (hand) => { - if (hand.type !== "cancel") return; - setMarquee(null); - setDrag(null); - }, - }); -} - -function onPointerCancel(event: PointerEvent) { - applyAffordance(escapeAffordance(event), { - hand: (hand) => { - if (hand.type === "cancel") setDrag(null); + applyAffordance( + escapeAffordance({ + key: event.key, + grabbing: drag != null || marquee != null, + selected: editor.selectedIds.length > 0, + }), + { + hand: (hand) => { + if (hand.type === "cancel") { + setDrag(null); + setMarquee(null); + return; + } + if (hand.type === "clear") { + editor.dispatch({ type: "selection.set", objectIds: [], mode: "replace" }); + } + }, }, - }); + ); } ``` 호스트는 무엇이 열려 있는지를 가집니다. 버리는 손은 보통 호스트 화면 -상태입니다. 문서 값은 바꾸지 않습니다. +상태입니다. 선택은 `clear`로 json-document에 갑니다. 닫는 손: -- Escape: 메뉴, 대화, 드래그, Rename 중단 -- pointercancel / lostpointercapture: 포인터 제스처 중단 +- 제스처가 열려 있으면 `cancel` (드래그, 마키, 팬, 타입어헤드 버퍼, 메뉴, Rename) +- 제스처가 없고 고른 것이 있으면 `clear` +- `pointercancel` / `lostpointercapture`: 항상 `cancel` -근거: [APG Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/), [Pointer Events](https://www.w3.org/TR/pointerevents/) +근거: [APG Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/), [Pointer Events](https://www.w3.org/TR/pointerevents/), Finder/Figma/tldraw의 Escape 한 겹 닫기 diff --git a/docs/public/affordance-marquee.md b/docs/public/affordance-marquee.md index c0dea6f2..b19d0aa2 100644 --- a/docs/public/affordance-marquee.md +++ b/docs/public/affordance-marquee.md @@ -1,7 +1,5 @@ # Marquee -TBD. - Marquee는 빈 평면에서 사각형을 끌어 여러 대상을 집는 손입니다. 고른 대상을 옮기는 [Drag](affordance-drag.md)와 다릅니다. `crosshair` / `cell` 커서가 이 손을 가리킬 수 있습니다. @@ -11,12 +9,11 @@ import { applyAffordance, commitAffordance, marqueeAffordance, - pointerSelect, } from "@interactive-os/json-document-affordance"; function onPointerMove(event: PointerEvent) { const point = { x: event.offsetX, y: event.offsetY }; - applyAffordance(marqueeAffordance(origin, point), { + applyAffordance(marqueeAffordance(origin, point, event), { cursor: (cursor) => { event.currentTarget.style.cursor = cursor; }, @@ -27,20 +24,15 @@ function onPointerMove(event: PointerEvent) { } function onPointerUp(event: PointerEvent) { - const committed = commitAffordance(marqueeAffordance(origin, { x: event.offsetX, y: event.offsetY })); + const committed = commitAffordance(marqueeAffordance(origin, { x: event.offsetX, y: event.offsetY }, event)); if (!committed) return; applyAffordance(committed, { commit: (hand) => { if (hand.type !== "select" || !hand.rect) return; - applyAffordance(pointerSelect(event), { - hand: (selectHand) => { - if (selectHand.type !== "select") return; - editor.dispatch({ - type: "selection.set", - objectIds: hostHits(hand.rect), - mode: selectHand.operation, - }); - }, + editor.dispatch({ + type: "selection.set", + objectIds: hostHits(hand.rect), + mode: hand.operation === "extend" ? "add" : hand.operation, }); }, }); @@ -49,7 +41,7 @@ function onPointerUp(event: PointerEvent) { 호스트는 히트 테스트와 기하를 계산합니다. 어떤 키가 범위에 들어오는지는 호스트가 보고, 손이 replace인지 extend인지는 Affordance가 닫고, 선택은 -json-document로 갑니다. +json-document로 갑니다. 이동이 없는 빈 곳 누르기는 `clear`입니다. 닫는 손: - 빈 곳에서 pointerdown → move → up diff --git a/docs/public/affordance-nudge.md b/docs/public/affordance-nudge.md index f70d329b..a26d33bd 100644 --- a/docs/public/affordance-nudge.md +++ b/docs/public/affordance-nudge.md @@ -1,27 +1,28 @@ # Nudge -TBD. - Nudge는 고른 대상을 키보드로 조금 옮기는 손입니다. 화살표는 한 단위, Shift+화살표는 큰 단위입니다. 항목 이웃으로 초점을 옮기는 [Select](affordance-select.md)와 다릅니다. ```ts -import { nudgeAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, nudgeAffordance } from "@interactive-os/json-document-affordance"; function onKeyDown(event: KeyboardEvent) { - const hand = nudgeAffordance(event); - if (!hand) return; - editor.dispatch({ - type: "object.translate", - objectIds: editor.selectedObjects.map((object) => object.id), - dx: hand.dx * unit, - dy: hand.dy * unit, + applyAffordance(nudgeAffordance(event), { + hand: (hand) => { + if (hand.type !== "nudge") return; + editor.dispatch({ + type: "object.translate", + objectIds: editor.selectedObjects.map((object) => object.id), + dx: hand.dx, + dy: hand.dy, + }); + }, }); } ``` -호스트는 단위(px, 칸, 그리드)를 곱합니다. 이동은 json-document로 갑니다. +손은 1과 10을 닫습니다. 이동은 json-document로 갑니다. 닫는 손: - Arrow: 1 단위 diff --git a/docs/public/affordance-pan.md b/docs/public/affordance-pan.md index 342deab8..1a877b16 100644 --- a/docs/public/affordance-pan.md +++ b/docs/public/affordance-pan.md @@ -1,7 +1,5 @@ # Pan -TBD. - Pan은 대상을 옮기지 않고 보이는 평면을 옮기는 손입니다. `grab` / `grabbing` / `all-scroll` 커서가 이 손을 가리킵니다. diff --git a/docs/public/affordance-select.md b/docs/public/affordance-select.md index 1e688171..21263893 100644 --- a/docs/public/affordance-select.md +++ b/docs/public/affordance-select.md @@ -8,6 +8,7 @@ Select는 대상을 집는 손입니다. 클릭은 그 대상으로 바꾸고, S import { applyAffordance, pointerSelect, + planeHitAffordance, } from "@interactive-os/json-document-affordance"; function onPointerDown(event: PointerEvent, itemId: string) { @@ -19,9 +20,20 @@ function onPointerDown(event: PointerEvent, itemId: string) { }, }); } + +function onPlanePointerDown(event: PointerEvent, hitId: string, selectedIds: ReadonlyArray) { + applyAffordance(planeHitAffordance({ hitId, selectedIds, ...event }), { + hand: (hand) => { + if (hand.type !== "select" || !hand.objectIds) return; + editor.dispatch({ type: "selection.set", objectIds: hand.objectIds, mode: "replace" }); + }, + }); +} ``` 호스트는 보이는 키와 장르 Intent만 넘깁니다. keymap을 덮어쓰지 않습니다. +이미 고른 상자를 수정 키 없이 누르면 집합을 유지합니다. 안 고른 상자는 +그 상자만으로 바꿉니다. ## TBD diff --git a/docs/public/affordance-snap.md b/docs/public/affordance-snap.md index 2c71819c..8e5d4366 100644 --- a/docs/public/affordance-snap.md +++ b/docs/public/affordance-snap.md @@ -1,7 +1,5 @@ # Snap -TBD. - Snap은 [Drag](affordance-drag.md)·[Resize](affordance-resize.md)· [Nudge](affordance-nudge.md) 중에 그리드나 가이드에 붙는 손입니다. 수정 키를 누르면 붙지 않습니다. diff --git a/docs/public/affordance-typeahead.md b/docs/public/affordance-typeahead.md index 3f57d60e..d0412ddb 100644 --- a/docs/public/affordance-typeahead.md +++ b/docs/public/affordance-typeahead.md @@ -1,7 +1,5 @@ # Typeahead -TBD. - Typeahead는 인쇄 글쇠로 목록·나무에서 이름으로 건너뛰는 손입니다. 한 글자는 그 글자로 시작하는 다음 항목, 빠르게 이어 치면 그 문자열로 시작합니다. diff --git a/docs/public/affordance.md b/docs/public/affordance.md index ee921af8..f8008d38 100644 --- a/docs/public/affordance.md +++ b/docs/public/affordance.md @@ -61,10 +61,16 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command | Affordance | API | Hand | | --- | --- | --- | -| [Select](affordance-select.md) | `pointerSelect`, `resolveAffordanceKey` | 클릭, Shift 범위, Mod 토글, 화살표 | +| [Select](affordance-select.md) | `pointerSelect`, `planeHitAffordance`, `resolveAffordanceKey` | 클릭, 이미 고른 집합 유지, Shift 범위, Mod 토글, 화살표 | +| [Typeahead](affordance-typeahead.md) | `typeaheadAffordance` | 인쇄 글쇠 prefix 점프 | +| [Escape](affordance-cancel.md) | `escapeAffordance` | 제스처 `cancel`, 그다음 선택 `clear` | | [Expand/Collapse](affordance-fold.md) | `treeAffordance` | 나무 왼쪽 접힘, 오른쪽 펼침 | -| [Drag](affordance-drag.md) | `dragAffordance`, `commitAffordance` | 고른 대상을 포인터로 옮김 | | [Undo](affordance-history.md) | `historyAffordance` | Mod+Z, Mod+Shift+Z | +| [Nudge](affordance-nudge.md) | `nudgeAffordance` | 화살표 한 단위, Shift 큰 단위 | +| [Drag](affordance-drag.md) | `dragAffordance`, `commitAffordance` | 고른 대상을 포인터로 옮김 | +| [Marquee](affordance-marquee.md) | `marqueeAffordance`, `commitAffordance` | 빈 곳에서 사각형으로 여러 대상 | +| [Pan](affordance-pan.md) | `panAffordance` | Space+드래그, grab | +| [Snap](affordance-snap.md) | `snapAffordance` | 그리드·가이드, 수정 키로 해제 | ## 키보드 TBD @@ -72,12 +78,9 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command | --- | --- | --- | | [Focus](affordance-focus.md) | `focusAffordance` | Tab 사이, 화살표 안, 초점 ≠ 선택 | | [Caret](affordance-caret.md) | `caretAffordance`, `caretCursor` | I-beam 삽입점, 글 범위 | -| [Typeahead](affordance-typeahead.md) | `typeaheadAffordance` | 인쇄 글쇠 prefix 점프 | | [Activate](affordance-activate.md) | `activateAffordance` | Enter, Space, 기본 클릭 | -| [Escape](affordance-cancel.md) | `escapeAffordance` | Escape, pointercancel | | [Delete](affordance-delete.md) | `deleteAffordance` | Delete, Backspace. Delete chord는 이미 닫힘 | | [Rename](affordance-rename.md) | `renameAffordance` | F2, 느린 두 번 누르기 | -| [Nudge](affordance-nudge.md) | `nudgeAffordance` | 화살표 한 단위, Shift 큰 단위 | ## 마우스 TBD @@ -87,14 +90,11 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command | [Double-click](affordance-double-click.md) | `clickCountAffordance` | `detail` 2 | | [Triple-click](affordance-triple-click.md) | `clickCountAffordance` | `detail` 3 | | [Context menu](affordance-context-menu.md) | `contextMenuAffordance` | 오른쪽 클릭, Shift+F10, Menu | -| [Marquee](affordance-marquee.md) | `marqueeAffordance`, `commitAffordance` | 빈 곳에서 사각형으로 여러 대상 | | [Drop](affordance-drop.md) | `dropAffordance` | drop 대상, no-drop | | [Duplicate](affordance-copy-drag.md) | `dragOperation` | Alt/Option 드래그 복제 | | [Resize](affordance-resize.md) | `resizeCursor`, `resizeOffset` | 모서리, 칸, 분할선 | -| [Pan](affordance-pan.md) | `panAffordance` | Space+드래그, grab | | [Scroll](affordance-scroll.md) | `wheelAffordance`, `autoscrollAffordance` | wheel, autoscroll | | [Zoom](affordance-zoom.md) | `zoomAffordance`, `zoomCursor` | Mod+휠, +/− | -| [Snap](affordance-snap.md) | `snapAffordance` | 그리드·가이드, 수정 키로 해제 | | [Not-allowed](affordance-forbid.md) | `forbiddenCursor` | not-allowed, no-drop | ## 커서가 닫는 손 diff --git a/docs/public/database.md b/docs/public/database.md new file mode 100644 index 00000000..f71546d6 --- /dev/null +++ b/docs/public/database.md @@ -0,0 +1,10 @@ +# Database + +Database는 typed property와 레코드를 집는 편집기입니다. 저장된 Table +view가 순서·숨김·너비·정렬·필터를 가지고, editor는 그 view를 보이는 +격자로 투사합니다. + +헤더에서 열을 옮기고 접고 정렬하고 너비를 바꿉니다. 칸에서는 타입에 맞는 +값을 고칩니다. 그 결과는 canonical JSON의 view와 record에 남습니다. + +같은 동작을 [Database Demo](/demo/database)에서 만질 수 있습니다. diff --git a/docs/public/hands.md b/docs/public/hands.md index 0c23051d..a6380694 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -9,4 +9,4 @@ Hands는 idiom의 최소 완성본입니다. 제품이 아니고 컴포넌트도 - [Sheet](/demo/sheet) - [Tree](/docs/tree) - [Kanban](/demo/kanban) -- [Database](/demo/database) +- [Database](/docs/database) diff --git a/packages/json-document-affordance/src/drag.ts b/packages/json-document-affordance/src/drag.ts index 3378cb10..5cc1c0ba 100644 --- a/packages/json-document-affordance/src/drag.ts +++ b/packages/json-document-affordance/src/drag.ts @@ -1,4 +1,7 @@ -import type { WebModifierState } from "@interactive-os/json-document-web"; +import { + selectionOperationFromModifiers, + type WebModifierState, +} from "@interactive-os/json-document-web"; import type { AffordancePreview, AffordanceRect, @@ -45,10 +48,26 @@ function marqueeRect(origin: Point, point: Point): Rect { }; } -export function marqueeAffordance(origin: Point, point: Point): AffordancePreview { +export function marqueeAffordance( + origin: Point, + point: Point, + modifiers?: { + readonly shiftKey?: boolean; + readonly metaKey?: boolean; + readonly ctrlKey?: boolean; + }, +): AffordancePreview { const rect = marqueeRect(origin, point); + if (rect.width === 0 && rect.height === 0) { + return { hand: { type: "clear" } }; + } + const operation = selectionOperationFromModifiers({ + shiftKey: modifiers?.shiftKey ?? false, + metaKey: modifiers?.metaKey ?? false, + ctrlKey: modifiers?.ctrlKey ?? false, + }); return { - hand: { type: "select", operation: "replace", rect }, + hand: { type: "select", operation, rect }, cursor: "crosshair", }; } diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts index 22ea905c..33af0cf0 100644 --- a/packages/json-document-affordance/src/index.ts +++ b/packages/json-document-affordance/src/index.ts @@ -16,4 +16,4 @@ export type { AffordanceResult, SelectOperation, } from "./result.js"; -export { activateAffordance, clickCountAffordance, escapeAffordance, focusAffordance, pointerSelect, resolveAffordanceKey, selectAllAffordance, typeaheadAffordance } from "./select.js"; +export { activateAffordance, clickCountAffordance, escapeAffordance, focusAffordance, planeHitAffordance, pointerSelect, resolveAffordanceKey, selectAllAffordance, typeaheadAffordance } from "./select.js"; diff --git a/packages/json-document-affordance/src/result.ts b/packages/json-document-affordance/src/result.ts index f4f85fc7..1e4974b2 100644 --- a/packages/json-document-affordance/src/result.ts +++ b/packages/json-document-affordance/src/result.ts @@ -10,7 +10,12 @@ export type AffordanceRect = { }; export type AffordanceHand = - | { readonly type: "select"; readonly operation: SelectOperation; readonly rect?: AffordanceRect } + | { + readonly type: "select"; + readonly operation: SelectOperation; + readonly rect?: AffordanceRect; + readonly objectIds?: ReadonlyArray; + } | { readonly type: "move"; readonly direction: AffordanceMoveDirection; readonly operation: "replace" | "extend" } | { readonly type: "boundary"; readonly edge: "start" | "end"; readonly operation: "replace" | "extend" } | { readonly type: "toggle" } @@ -86,9 +91,6 @@ export function commitAffordance( const hand = result.hand; if (hand == null) return null; if (hand.type === "translate" && hand.dx === 0 && hand.dy === 0) return null; - if (hand.type === "select" && hand.rect && hand.rect.width === 0 && hand.rect.height === 0) { - return null; - } return result.cursor === undefined ? { hand, commit: true } : { hand, cursor: result.cursor, commit: true }; diff --git a/packages/json-document-affordance/src/select.ts b/packages/json-document-affordance/src/select.ts index 53098c3f..c0879bdb 100644 --- a/packages/json-document-affordance/src/select.ts +++ b/packages/json-document-affordance/src/select.ts @@ -30,6 +30,48 @@ export function pointerSelect(modifiers: { }; } +export function planeHitAffordance(input: { + readonly hitId: string; + readonly selectedIds: ReadonlyArray; + readonly shiftKey?: boolean; + readonly metaKey?: boolean; + readonly ctrlKey?: boolean; +}): AffordancePreview { + const operation = selectionOperationFromModifiers({ + shiftKey: input.shiftKey ?? false, + metaKey: input.metaKey ?? false, + ctrlKey: input.ctrlKey ?? false, + }); + const selected = input.selectedIds; + const hitId = input.hitId; + const hitSelected = selected.includes(hitId); + if (operation === "replace") { + return { + hand: { + type: "select", + operation, + objectIds: hitSelected ? selected : [hitId], + }, + }; + } + if (operation === "extend") { + return { + hand: { + type: "select", + operation, + objectIds: [...new Set([...selected, hitId])], + }, + }; + } + return { + hand: { + type: "select", + operation, + objectIds: hitSelected ? selected.filter((id) => id !== hitId) : [...selected, hitId], + }, + }; +} + export function resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview { return { hand: keyboard.resolve(stroke) }; } @@ -72,11 +114,19 @@ export function activateAffordance(input: { readonly key?: string; readonly deta return { hand: null }; } -export function escapeAffordance(input: { readonly key?: string; readonly type?: string }): AffordancePreview { - if (input.key === "Escape" || input.type === "pointercancel" || input.type === "lostpointercapture") { - return { hand: { type: "cancel" } }; - } - return { hand: null }; +export function escapeAffordance(input: { + readonly key?: string; + readonly type?: string; + readonly grabbing?: boolean; + readonly selected?: boolean; +}): AffordancePreview { + const pointerAbort = input.type === "pointercancel" || input.type === "lostpointercapture"; + if (pointerAbort) return { hand: { type: "cancel" } }; + if (input.key !== "Escape") return { hand: null }; + if (input.grabbing === true) return { hand: { type: "cancel" } }; + if (input.selected === true) return { hand: { type: "clear" } }; + if (input.grabbing === false && input.selected === false) return { hand: null }; + return { hand: { type: "cancel" } }; } export function focusAffordance(stroke: Pick): AffordancePreview { diff --git a/packages/json-document-affordance/tests/affordance.test.ts b/packages/json-document-affordance/tests/affordance.test.ts index e943dd28..d297b321 100644 --- a/packages/json-document-affordance/tests/affordance.test.ts +++ b/packages/json-document-affordance/tests/affordance.test.ts @@ -4,8 +4,10 @@ import { commitAffordance, dragAffordance, dropAffordance, + marqueeAffordance, escapeAffordance, historyAffordance, + planeHitAffordance, pointerSelect, resolveAffordanceKey, snapAffordance, @@ -164,6 +166,30 @@ describe("typeaheadAffordance", () => { }); }); +describe("marqueeAffordance", () => { + test("carries replace, extend, and toggle from modifiers", () => { + const origin = { x: 0, y: 0 }; + const point = { x: 10, y: 8 }; + expect(marqueeAffordance(origin, point).hand).toMatchObject({ type: "select", operation: "replace" }); + expect(marqueeAffordance(origin, point, { shiftKey: true }).hand).toMatchObject({ + type: "select", + operation: "extend", + }); + expect(marqueeAffordance(origin, point, { metaKey: true }).hand).toMatchObject({ + type: "select", + operation: "toggle", + }); + }); + + test("a stationary empty press is clear, not a zero rect", () => { + expect(marqueeAffordance({ x: 12, y: 8 }, { x: 12, y: 8 }).hand).toEqual({ type: "clear" }); + expect(commitAffordance(marqueeAffordance({ x: 12, y: 8 }, { x: 12, y: 8 }))).toEqual({ + hand: { type: "clear" }, + commit: true, + }); + }); +}); + describe("snapAffordance", () => { test("snaps to the grid unless disabled", () => { expect(snapAffordance({ x: 47, y: 51 }, { grid: 8 }).hand).toEqual({ type: "translate", dx: 48, dy: 48 }); @@ -177,6 +203,55 @@ describe("escapeAffordance", () => { expect(escapeAffordance({ type: "pointercancel" }).hand).toEqual({ type: "cancel" }); expect(escapeAffordance({ key: "Enter" }).hand).toBeNull(); }); + + test("pops a gesture before clearing selection", () => { + expect(escapeAffordance({ key: "Escape", grabbing: true, selected: true }).hand).toEqual({ type: "cancel" }); + expect(escapeAffordance({ key: "Escape", grabbing: false, selected: true }).hand).toEqual({ type: "clear" }); + expect(escapeAffordance({ key: "Escape", grabbing: false, selected: false }).hand).toBeNull(); + expect(escapeAffordance({ type: "pointercancel", selected: true }).hand).toEqual({ type: "cancel" }); + }); +}); + +describe("planeHitAffordance", () => { + test("keeps the selected set when pressing an already selected object", () => { + expect(planeHitAffordance({ + hitId: "card", + selectedIds: ["note", "card"], + }).hand).toEqual({ + type: "select", + operation: "replace", + objectIds: ["note", "card"], + }); + expect(planeHitAffordance({ + hitId: "chip", + selectedIds: ["note", "card"], + }).hand).toEqual({ + type: "select", + operation: "replace", + objectIds: ["chip"], + }); + }); + + test("extends and toggles the hit against the current set", () => { + expect(planeHitAffordance({ + hitId: "chip", + selectedIds: ["note"], + shiftKey: true, + }).hand).toEqual({ + type: "select", + operation: "extend", + objectIds: ["note", "chip"], + }); + expect(planeHitAffordance({ + hitId: "note", + selectedIds: ["note", "card"], + metaKey: true, + }).hand).toEqual({ + type: "select", + operation: "toggle", + objectIds: ["card"], + }); + }); }); describe("dropAffordance", () => { diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 9d7c593a..9c1a602c 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -34,7 +34,7 @@ history. Native text selection remains input/editor-owned and connects through an explicit edit lease rather than becoming a structural selection variant. `Database` keeps typed property schema and records in canonical JSON while its -saved Table views own property order and visibility, sort, and filter. The +saved Table views own property order, visibility, width, sort, and filter. The editor projects each saved view into a visible record/property topology for range selection, keeps native title/text caret state in the host, and restores structural selection with record and view mutations through history. diff --git a/packages/json-document-editing/benchmarks/editors.mjs b/packages/json-document-editing/benchmarks/editors.mjs index 053ecd79..ea4b9d7a 100644 --- a/packages/json-document-editing/benchmarks/editors.mjs +++ b/packages/json-document-editing/benchmarks/editors.mjs @@ -52,7 +52,7 @@ for (const size of config.sizes) { const database = { schema: { properties: columns.map((column) => ({ id: column.id, name: column.label, type: column.id === "score" ? "number" : column.id === "name" ? "title" : "text", options: [] })) }, records: rows.map((row) => ({ id: row.id, values: row.cells })), - views: [{ id: "table", name: "Table", type: "table", propertyOrder: columns.map((column) => column.id), propertyVisibility: {}, sort: { propertyId: "score", direction: "descending" }, filter: null }], + views: [{ id: "table", name: "Table", type: "table", propertyOrder: columns.map((column) => column.id), propertyVisibility: {}, propertyWidths: {}, sort: { propertyId: "score", direction: "descending" }, filter: null }], }; record("database sorted topology", size, measure(config, "database sorted topology", () => { const editor = createDatabaseEditor(database); diff --git a/packages/json-document-editing/src/database-validation.ts b/packages/json-document-editing/src/database-validation.ts index ab59420c..21541964 100644 --- a/packages/json-document-editing/src/database-validation.ts +++ b/packages/json-document-editing/src/database-validation.ts @@ -18,6 +18,12 @@ export function assertDatabaseView(view: DatabaseTableView, properties: Readonly assertUnique(view.propertyOrder, "view property"); if (view.propertyOrder.length !== properties.length || view.propertyOrder.some((id) => !available.has(id))) throw new Error(`Database view ${JSON.stringify(view.id)} must order every property exactly once.`); for (const propertyId of Object.keys(view.propertyVisibility)) if (!available.has(propertyId)) throw new Error(`Database view references unknown property ${JSON.stringify(propertyId)}.`); + for (const [propertyId, width] of Object.entries(view.propertyWidths)) { + if (!available.has(propertyId)) throw new Error(`Database view references unknown property ${JSON.stringify(propertyId)}.`); + if (typeof width !== "number" || !Number.isFinite(width) || width <= 0) { + throw new Error(`Database view ${JSON.stringify(view.id)} has an invalid width for ${JSON.stringify(propertyId)}.`); + } + } if (view.sort && !available.has(view.sort.propertyId)) throw new Error("Database sort property was not found."); if (view.filter && !available.has(view.filter.propertyId)) throw new Error("Database filter property was not found."); } diff --git a/packages/json-document-editing/src/database.ts b/packages/json-document-editing/src/database.ts index 3aa672b6..ff238f1d 100644 --- a/packages/json-document-editing/src/database.ts +++ b/packages/json-document-editing/src/database.ts @@ -56,6 +56,7 @@ export interface DatabaseTableView extends Record { readonly type: "table"; readonly propertyOrder: ReadonlyArray; readonly propertyVisibility: Readonly>; + readonly propertyWidths: Readonly>; readonly sort: DatabaseSort | null; readonly filter: DatabaseFilter | null; } @@ -127,6 +128,7 @@ export type DatabaseIntent = readonly viewId: string; readonly propertyOrder?: ReadonlyArray; readonly propertyVisibility?: Readonly>; + readonly propertyWidths?: Readonly>; readonly sort?: DatabaseSort | null; readonly filter?: DatabaseFilter | null; } @@ -413,6 +415,7 @@ function configureView( ...current, ...(intent.propertyOrder === undefined ? {} : { propertyOrder: [...intent.propertyOrder] }), ...(intent.propertyVisibility === undefined ? {} : { propertyVisibility: { ...intent.propertyVisibility } }), + ...(intent.propertyWidths === undefined ? {} : { propertyWidths: { ...intent.propertyWidths } }), ...(intent.sort === undefined ? {} : { sort: intent.sort }), ...(intent.filter === undefined ? {} : { filter: intent.filter }), }; diff --git a/packages/json-document-editing/tests/clipboard-surface.test.ts b/packages/json-document-editing/tests/clipboard-surface.test.ts index f4de787d..2932ed87 100644 --- a/packages/json-document-editing/tests/clipboard-surface.test.ts +++ b/packages/json-document-editing/tests/clipboard-surface.test.ts @@ -29,6 +29,7 @@ describe("editing clipboard surface", () => { type: "table", propertyOrder: ["title"], propertyVisibility: { title: true }, + propertyWidths: {}, sort: null, filter: null, }], diff --git a/packages/json-document-editing/tests/database-editor.test.ts b/packages/json-document-editing/tests/database-editor.test.ts index 4fe80456..ec14724e 100644 --- a/packages/json-document-editing/tests/database-editor.test.ts +++ b/packages/json-document-editing/tests/database-editor.test.ts @@ -25,6 +25,7 @@ const initial: DatabaseDocument = { type: "table", propertyOrder: ["name", "note", "score", "status", "done"], propertyVisibility: {}, + propertyWidths: {}, sort: null, filter: null, }], @@ -65,6 +66,13 @@ describe("Database editor", () => { const document = editor.snapshot.value as DatabaseDocument; expect(document.records).toEqual(initial.records); expect(document.views[0]?.sort).toEqual({ propertyId: "score", direction: "descending" }); + expect(editor.dispatch({ + type: "view.configure", + viewId: "table", + propertyWidths: { score: 160, name: 220 }, + }).ok).toBe(true); + expect((editor.snapshot.value as DatabaseDocument).views[0]?.propertyWidths).toEqual({ score: 160, name: 220 }); + expect(editor.undo().ok).toBe(true); expect(editor.undo().ok).toBe(true); expect(editor.tableTopology("table").recordIds).toEqual(["r1", "r2", "r3"]); }); diff --git a/packages/json-document-editing/tests/document-source.test.ts b/packages/json-document-editing/tests/document-source.test.ts index ddb5429b..195ef368 100644 --- a/packages/json-document-editing/tests/document-source.test.ts +++ b/packages/json-document-editing/tests/document-source.test.ts @@ -89,6 +89,7 @@ describe("document-backed domain editors", () => { type: "table", propertyOrder: ["title"], propertyVisibility: { title: true }, + propertyWidths: {}, sort: null, filter: null, }], diff --git a/packages/json-document-editing/tests/intent.test.ts b/packages/json-document-editing/tests/intent.test.ts index abc73d61..b669da24 100644 --- a/packages/json-document-editing/tests/intent.test.ts +++ b/packages/json-document-editing/tests/intent.test.ts @@ -63,6 +63,7 @@ describe("editing intent door", () => { type: "table", propertyOrder: ["title"], propertyVisibility: { title: true }, + propertyWidths: {}, sort: null, filter: null, }], diff --git a/packages/json-document-web/tests/web-adapters.test.ts b/packages/json-document-web/tests/web-adapters.test.ts index 5546ffa2..27b33d9e 100644 --- a/packages/json-document-web/tests/web-adapters.test.ts +++ b/packages/json-document-web/tests/web-adapters.test.ts @@ -113,6 +113,7 @@ describe("Web clipboard Adapter", () => { type: "table", propertyOrder: ["title"], propertyVisibility: { title: true }, + propertyWidths: {}, sort: null, filter: null, }], diff --git a/packages/json-document-zod/src/database-document.ts b/packages/json-document-zod/src/database-document.ts index aeb8bf2d..07cc60c0 100644 --- a/packages/json-document-zod/src/database-document.ts +++ b/packages/json-document-zod/src/database-document.ts @@ -36,6 +36,7 @@ export interface DatabaseDocumentFromZod extends Record { readonly type: "table"; readonly propertyOrder: ReadonlyArray; readonly propertyVisibility: Readonly>; + readonly propertyWidths: Readonly>; readonly sort: null; readonly filter: null; }>; @@ -143,6 +144,7 @@ export function databaseDocumentFromZod( type: "table", propertyOrder, propertyVisibility: {}, + propertyWidths: {}, sort: null, filter: null, }], diff --git a/site/package.json b/site/package.json index df7d351d..ee5888ca 100644 --- a/site/package.json +++ b/site/package.json @@ -11,12 +11,12 @@ "typecheck": "npm run check:ui && tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-selection": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-react-hook-form": "*", "@interactive-os/json-document-ajv": "*", - "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-tanstack-table": "*", "@interactive-os/json-document-web": "*", "@interactive-os/json-document-contenteditable": "*", diff --git a/site/site-routes.json b/site/site-routes.json index 8df70b02..7c9caec7 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -284,11 +284,21 @@ "navigationGroup": "Hands" }, { - "path": "/demo/database", + "path": "/docs/database", "label": "Database", + "title": "Database - json-document", + "heading": "Database", + "description": "저장된 Table view가 순서·숨김·너비·정렬·필터를 투사하는 Database editor를 설명합니다.", + "language": "ko", + "navigationGroup": "Hands", + "relatedDemoPath": "/demo/database" + }, + { + "path": "/demo/database", + "label": "Database Demo", "title": "Database Demo - json-document", "description": "A complete Database editor with typed properties, persistent views, selection, native text editing, and history.", - "navigationGroup": "Hands" + "parentPath": "/docs/database" }, { "path": "/adapters", @@ -483,6 +493,8 @@ "description": "글쇠를 누르면 그 글자로 시작하는 다음 항목으로 초점을 옮기는 손입니다.", "language": "ko", "navigationGroup": "Affordance", + "relatedDemoPath": "/demo/order", + "relatedDemoLabel": "증명 열기", "parentPath": "/docs/affordance" }, { @@ -503,6 +515,8 @@ "description": "Escape와 pointercancel로 진행 중인 손을 버리는 손입니다.", "language": "ko", "navigationGroup": "Affordance", + "relatedDemoPath": "/demo/canvas", + "relatedDemoLabel": "증명 열기", "parentPath": "/docs/affordance" }, { @@ -557,6 +571,8 @@ "description": "화살표로 고른 대상을 한 단위 옮기고, Shift로 큰 단위를 쓰는 손입니다.", "language": "ko", "navigationGroup": "Affordance", + "relatedDemoPath": "/demo/canvas", + "relatedDemoLabel": "증명 열기", "parentPath": "/docs/affordance" }, { @@ -619,6 +635,8 @@ "description": "빈 곳에서 끌어서 여러 대상을 한 번에 고르는 손입니다.", "language": "ko", "navigationGroup": "Affordance", + "relatedDemoPath": "/demo/canvas", + "relatedDemoLabel": "증명 열기", "parentPath": "/docs/affordance" }, { @@ -659,6 +677,8 @@ "description": "손바닥 커서로 평면을 밀고, Space+드래그로 화면을 옮기는 손입니다.", "language": "ko", "navigationGroup": "Affordance", + "relatedDemoPath": "/demo/canvas", + "relatedDemoLabel": "증명 열기", "parentPath": "/docs/affordance" }, { @@ -689,6 +709,8 @@ "description": "드래그와 크기 바꾸기 중 그리드·가이드에 붙고, 수정 키로 푸는 손입니다.", "language": "ko", "navigationGroup": "Affordance", + "relatedDemoPath": "/demo/canvas", + "relatedDemoLabel": "증명 열기", "parentPath": "/docs/affordance" }, { diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index ec4137ab..a574aa76 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -42,6 +42,7 @@ import { Route as PageDocsApiRouteImport } from "./routes/_page/docs/api"; import { Route as PageDocsClipboardRouteImport } from "./routes/_page/docs/clipboard"; import { Route as PageDocsConceptsRouteImport } from "./routes/_page/docs/concepts"; import { Route as PageDocsConnectorsRouteImport } from "./routes/_page/docs/connectors"; +import { Route as PageDocsDatabaseRouteImport } from "./routes/_page/docs/database"; import { Route as PageDocsHistoryRouteImport } from "./routes/_page/docs/history"; import { Route as PageDocsIntentRouteImport } from "./routes/_page/docs/intent"; import { Route as PageDocsIntentGuideRouteImport } from "./routes/_page/docs/intent-guide"; @@ -264,6 +265,11 @@ const PageDocsConnectorsRoute = PageDocsConnectorsRouteImport.update({ path: "/docs/connectors", getParentRoute: () => PageRoute, } as any); +const PageDocsDatabaseRoute = PageDocsDatabaseRouteImport.update({ + id: "/docs/database", + path: "/docs/database", + getParentRoute: () => PageRoute, +} as any); const PageDocsHistoryRoute = PageDocsHistoryRouteImport.update({ id: "/docs/history", path: "/docs/history", @@ -581,6 +587,7 @@ export interface FileRoutesByFullPath { "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/concepts": typeof PageDocsConceptsRoute; "/docs/connectors": typeof PageDocsConnectorsRoute; + "/docs/database": typeof PageDocsDatabaseRoute; "/docs/history": typeof PageDocsHistoryRoute; "/docs/intent": typeof PageDocsIntentRoute; "/docs/intent-guide": typeof PageDocsIntentGuideRoute; @@ -668,6 +675,7 @@ export interface FileRoutesByTo { "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/concepts": typeof PageDocsConceptsRoute; "/docs/connectors": typeof PageDocsConnectorsRoute; + "/docs/database": typeof PageDocsDatabaseRoute; "/docs/history": typeof PageDocsHistoryRoute; "/docs/intent": typeof PageDocsIntentRoute; "/docs/intent-guide": typeof PageDocsIntentGuideRoute; @@ -757,6 +765,7 @@ export interface FileRoutesById { "/_page/docs/clipboard": typeof PageDocsClipboardRoute; "/_page/docs/concepts": typeof PageDocsConceptsRoute; "/_page/docs/connectors": typeof PageDocsConnectorsRoute; + "/_page/docs/database": typeof PageDocsDatabaseRoute; "/_page/docs/history": typeof PageDocsHistoryRoute; "/_page/docs/intent": typeof PageDocsIntentRoute; "/_page/docs/intent-guide": typeof PageDocsIntentGuideRoute; @@ -846,6 +855,7 @@ export interface FileRouteTypes { | "/docs/clipboard" | "/docs/concepts" | "/docs/connectors" + | "/docs/database" | "/docs/history" | "/docs/intent" | "/docs/intent-guide" @@ -933,6 +943,7 @@ export interface FileRouteTypes { | "/docs/clipboard" | "/docs/concepts" | "/docs/connectors" + | "/docs/database" | "/docs/history" | "/docs/intent" | "/docs/intent-guide" @@ -1021,6 +1032,7 @@ export interface FileRouteTypes { | "/_page/docs/clipboard" | "/_page/docs/concepts" | "/_page/docs/connectors" + | "/_page/docs/database" | "/_page/docs/history" | "/_page/docs/intent" | "/_page/docs/intent-guide" @@ -1318,6 +1330,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsConnectorsRouteImport; parentRoute: typeof PageRoute; }; + "/_page/docs/database": { + id: "/_page/docs/database"; + path: "/docs/database"; + fullPath: "/docs/database"; + preLoaderRoute: typeof PageDocsDatabaseRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/docs/history": { id: "/_page/docs/history"; path: "/docs/history"; @@ -1720,6 +1739,7 @@ interface PageRouteChildren { PageDocsClipboardRoute: typeof PageDocsClipboardRoute; PageDocsConceptsRoute: typeof PageDocsConceptsRoute; PageDocsConnectorsRoute: typeof PageDocsConnectorsRoute; + PageDocsDatabaseRoute: typeof PageDocsDatabaseRoute; PageDocsHistoryRoute: typeof PageDocsHistoryRoute; PageDocsIntentRoute: typeof PageDocsIntentRoute; PageDocsIntentGuideRoute: typeof PageDocsIntentGuideRoute; @@ -1807,6 +1827,7 @@ const PageRouteChildren: PageRouteChildren = { PageDocsClipboardRoute: PageDocsClipboardRoute, PageDocsConceptsRoute: PageDocsConceptsRoute, PageDocsConnectorsRoute: PageDocsConnectorsRoute, + PageDocsDatabaseRoute: PageDocsDatabaseRoute, PageDocsHistoryRoute: PageDocsHistoryRoute, PageDocsIntentRoute: PageDocsIntentRoute, PageDocsIntentGuideRoute: PageDocsIntentGuideRoute, diff --git a/site/src/app/routes/_page/docs/database.tsx b/site/src/app/routes/_page/docs/database.tsx new file mode 100644 index 00000000..2db38a72 --- /dev/null +++ b/site/src/app/routes/_page/docs/database.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/database")({ + component: function DatabaseDocsRoute() { + return ; + }, +}); diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index d00b68c3..e1d4e3eb 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -1,4 +1,4 @@ -import { useState, type PointerEvent } from "react"; +import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { createObjectEditor, type ObjectDocument, @@ -8,10 +8,16 @@ import { applyAffordance, commitAffordance, dragAffordance, - pointerSelect, + escapeAffordance, + marqueeAffordance, + nudgeAffordance, + panAffordance, + planeHitAffordance, + snapAffordance, } from "@interactive-os/json-document-affordance"; import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; import { PageFrame, PageHeader, ProductApp } from "../../shared/ui/primitives"; +import { classes, ui } from "../../shared/ui/styles"; import { optionProps } from "../../shared/widget-binding"; const colors = ["#de6d55", "#60786f", "#c4a35a", "#4d6a8a"] as const; @@ -32,9 +38,36 @@ type DragState = { readonly dy: number; }; +type MarqueeState = { + readonly originX: number; + readonly originY: number; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +type PanState = { + readonly x: number; + readonly y: number; + readonly originX: number; + readonly originY: number; + readonly active: boolean; +}; + export function CanvasDemoRoute() { const [editor] = useState(() => createObjectEditor(initialObjects)); const [drag, setDrag] = useState(null); + const [marquee, setMarquee] = useState(null); + const [pan, setPan] = useState({ x: 0, y: 0, originX: 0, originY: 0, active: false }); + const [space, setSpace] = useState(false); + const surface = useRef(null); + const dragRef = useRef(null); + const marqueeRef = useRef(null); + const panRef = useRef(pan); + const spaceRef = useRef(space); + panRef.current = pan; + spaceRef.current = space; const editing = useEditing({ source: editor, selectedKeys: editor.selectedObjects.map((object) => object.id), @@ -47,60 +80,264 @@ export function CanvasDemoRoute() { }); }, }); - const snapshot = editing.snapshot; - const document = snapshot.value as ObjectDocument; + const document = editing.snapshot.value as ObjectDocument; + + function setDragState(next: DragState | null) { + dragRef.current = next; + setDrag(next); + } - function handlePointerDown(event: PointerEvent, objectId: string) { - let operation: "replace" | "extend" | "toggle" = "replace"; - applyAffordance(pointerSelect(event), { + function setMarqueeState(next: MarqueeState | null) { + marqueeRef.current = next; + setMarquee(next); + } + + function setPanState(next: PanState) { + panRef.current = next; + setPan(next); + } + + function isPanStart(event: PointerEvent) { + let grabbing = false; + applyAffordance(panAffordance({ spaceKey: spaceRef.current, buttons: event.buttons }), { + cursor: (cursor) => { + grabbing = cursor === "grabbing"; + }, hand: (hand) => { - if (hand.type !== "select") return; - operation = hand.operation; - editor.dispatch({ - type: "selection.set", - objectIds: [objectId], - mode: hand.operation === "extend" ? "add" : hand.operation, - }); + if (hand.type === "translate") grabbing = true; }, }); - const ids = operation !== "replace" - ? [...new Set([...editor.selectedObjects.map((object) => object.id), objectId])] - : [objectId]; - event.currentTarget.setPointerCapture(event.pointerId); - setDrag({ ids, originX: event.clientX, originY: event.clientY, dx: 0, dy: 0 }); + return grabbing || (spaceRef.current && event.buttons === 1); + } + + function startPan(event: PointerEvent) { + const current = panRef.current; + surface.current?.setPointerCapture(event.pointerId); + setPanState({ + x: current.x, + y: current.y, + originX: event.clientX - current.x, + originY: event.clientY - current.y, + active: true, + }); } - function handlePointerMove(event: PointerEvent) { - if (!drag) return; + function planePoint(event: PointerEvent) { + return { + x: event.nativeEvent.offsetX - panRef.current.x, + y: event.nativeEvent.offsetY - panRef.current.y, + }; + } + + function commitCurrentDrag(event: PointerEvent) { + const current = dragRef.current; + if (!current) return; + const committed = commitAffordance( + dragAffordance({ x: current.originX, y: current.originY }, { x: event.clientX, y: event.clientY }), + ); + if (committed) { + applyAffordance(committed, { + commit: (hand) => { + if (hand.type !== "translate") return; + applyAffordance( + snapAffordance( + { x: hand.dx, y: hand.dy }, + { grid: 8, disable: event.metaKey || event.ctrlKey }, + ), + { + hand: (snapped) => { + if (snapped.type !== "translate") return; + editor.dispatch({ + type: "object.translate", + objectIds: current.ids, + dx: snapped.dx, + dy: snapped.dy, + }); + }, + }, + ); + }, + }); + } + setDragState(null); + } + + function handleObjectPointerDown(event: PointerEvent, objectId: string) { + event.preventDefault(); + event.stopPropagation(); + surface.current?.focus({ preventScroll: true, focusVisible: false } as FocusOptions); + if (isPanStart(event)) { + startPan(event); + return; + } + event.currentTarget.setPointerCapture(event.pointerId); applyAffordance( - dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), + planeHitAffordance({ + hitId: objectId, + selectedIds: editor.selectedObjects.map((object) => object.id), + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + }), { hand: (hand) => { - if (hand.type === "translate") setDrag({ ...drag, dx: hand.dx, dy: hand.dy }); + if (hand.type !== "select" || !hand.objectIds) return; + editor.dispatch({ + type: "selection.set", + objectIds: hand.objectIds, + mode: "replace", + }); + setDragState({ + ids: hand.objectIds, + originX: event.clientX, + originY: event.clientY, + dx: 0, + dy: 0, + }); }, }, ); } - function handlePointerUp(event: PointerEvent) { - if (!drag) return; - const committed = commitAffordance( - dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), + function handleObjectPointerMove(event: PointerEvent) { + const current = dragRef.current; + if (!current) return; + applyAffordance( + dragAffordance({ x: current.originX, y: current.originY }, { x: event.clientX, y: event.clientY }), + { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type !== "translate") return; + setDragState({ ...current, dx: hand.dx, dy: hand.dy }); + }, + }, ); + } + + function handleSurfacePointerDown(event: PointerEvent) { + event.preventDefault(); + surface.current?.focus({ preventScroll: true, focusVisible: false } as FocusOptions); + if (isPanStart(event)) { + startPan(event); + return; + } + surface.current?.setPointerCapture(event.pointerId); + const origin = planePoint(event); + setMarqueeState({ originX: origin.x, originY: origin.y, x: origin.x, y: origin.y, width: 0, height: 0 }); + } + + function handleSurfacePointerMove(event: PointerEvent) { + const currentPan = panRef.current; + if (currentPan.active) { + applyAffordance( + panAffordance({ + spaceKey: true, + buttons: event.buttons, + origin: { x: currentPan.originX, y: currentPan.originY }, + point: { x: event.clientX, y: event.clientY }, + }), + { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type !== "translate") return; + setPanState({ ...currentPan, x: hand.dx, y: hand.dy }); + }, + }, + ); + return; + } + const currentMarquee = marqueeRef.current; + if (!currentMarquee) return; + const origin = { x: currentMarquee.originX, y: currentMarquee.originY }; + const point = planePoint(event); + applyAffordance(marqueeAffordance(origin, point, event), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type !== "select" || !hand.rect) return; + setMarqueeState({ originX: origin.x, originY: origin.y, ...hand.rect }); + }, + }); + } + + function handleSurfacePointerUp(event: PointerEvent) { + const currentPan = panRef.current; + if (currentPan.active) { + setPanState({ ...currentPan, active: false }); + return; + } + const currentMarquee = marqueeRef.current; + if (!currentMarquee) return; + const origin = { x: currentMarquee.originX, y: currentMarquee.originY }; + const point = planePoint(event); + const committed = commitAffordance(marqueeAffordance(origin, point, event)); if (committed) { applyAffordance(committed, { commit: (hand) => { - if (hand.type !== "translate") return; + if (hand.type === "clear") { + editor.dispatch({ type: "selection.set", objectIds: [], mode: "replace" }); + return; + } + if (hand.type !== "select" || !hand.rect) return; + const hits = document.objects + .filter((object) => intersects(hand.rect!, object)) + .map((object) => object.id); + if (hits.length === 0) return; editor.dispatch({ - type: "object.translate", - objectIds: drag.ids, - dx: hand.dx, - dy: hand.dy, + type: "selection.set", + objectIds: hits, + mode: hand.operation === "extend" ? "add" : hand.operation === "toggle" ? "toggle" : "replace", }); }, }); } - setDrag(null); + setMarqueeState(null); + } + + function cancelHands() { + setDragState(null); + setMarqueeState(null); + setPanState({ ...panRef.current, active: false }); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === " ") { + setSpace(true); + event.preventDefault(); + } + applyAffordance( + escapeAffordance({ + key: event.key, + grabbing: dragRef.current != null || marqueeRef.current != null || panRef.current.active, + selected: editor.selectedObjects.length > 0, + }), + { + hand: (hand) => { + if (hand.type === "cancel") { + cancelHands(); + event.preventDefault(); + return; + } + if (hand.type !== "clear") return; + editor.dispatch({ type: "selection.set", objectIds: [], mode: "replace" }); + event.preventDefault(); + }, + }, + ); + applyAffordance(nudgeAffordance(event), { + hand: (hand) => { + if (hand.type !== "nudge") return; + const ids = editor.selectedObjects.map((object) => object.id); + if (ids.length === 0) return; + editor.dispatch({ type: "object.translate", objectIds: ids, dx: hand.dx, dy: hand.dy }); + event.preventDefault(); + }, + }); } return ( @@ -122,35 +359,78 @@ export function CanvasDemoRoute() { ))} > -
- {document.objects.map((object) => { - const offset = drag?.ids.includes(object.id) ? drag : null; - const option = optionProps(editing.getItem(object.id)); - return ( - handlePointerDown(event, object.id)} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - className="absolute grid place-items-center" - style={{ - left: object.x + (offset?.dx ?? 0), - top: object.y + (offset?.dy ?? 0), - width: object.width, - height: object.height, - backgroundColor: object.color, - color: "#fff8f2", - }} - > - {object.label} - - ); - })} -
+
{ + applyAffordance(escapeAffordance(event), { + hand: (hand) => { + if (hand.type !== "cancel") return; + setMarqueeState(null); + setPanState({ ...panRef.current, active: false }); + }, + }); + }} + onKeyDown={onKeyDown} + onKeyUp={(event) => { + if (event.key === " ") setSpace(false); + }} + > +
+ {document.objects.map((object) => { + const offset = drag?.ids.includes(object.id) ? drag : null; + const option = optionProps(editing.getItem(object.id)); + return ( + handleObjectPointerDown(event, object.id)} + onPointerMove={handleObjectPointerMove} + onPointerUp={commitCurrentDrag} + onLostPointerCapture={(event) => { + if (event.buttons !== 0) return; + commitCurrentDrag(event); + }} + className={ui.interactive.planeItem} + style={{ + left: object.x + (offset?.dx ?? 0), + top: object.y + (offset?.dy ?? 0), + width: object.width, + height: object.height, + backgroundColor: object.color, + color: "#fff8f2", + }} + > + {object.label} + + ); + })} + {marquee ? ( +
+ ) : null} +
+
); } + +function intersects( + rect: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }, + object: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }, +): boolean { + return rect.x < object.x + object.width + && rect.x + rect.width > object.x + && rect.y < object.y + object.height + && rect.y + rect.height > object.y; +} diff --git a/site/src/routes/database-demo/DatabaseTableDemo.tsx b/site/src/routes/database-demo/DatabaseTableDemo.tsx index 6da4f236..d2c0962f 100644 --- a/site/src/routes/database-demo/DatabaseTableDemo.tsx +++ b/site/src/routes/database-demo/DatabaseTableDemo.tsx @@ -1,44 +1,76 @@ import { useRef, useState, - type DragEvent, type FocusEvent, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, } from "react"; import { createDatabaseEditor, type DatabaseDocument, type DatabaseEditor, + type DatabaseFilter, type DatabaseIntent, type DatabaseProperty, type DatabaseRecord, type DatabaseSelection, + type DatabaseSort, type EditingResult, } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; import { + activateAffordance, applyAffordance, commitAffordance, + disclosureAffordance, + dragAffordance, dropAffordance, pointerSelect, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; -import { ActionButton, SelectableItem, ToggleButton } from "../../shared/ui/interactive"; +import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; import { ProductApp } from "../../shared/ui/primitives"; import { classes, ui } from "../../shared/ui/styles"; import { gridCellProps, historyCommands } from "../../shared/widget-binding"; import { initialDatabase } from "./initial-database"; +const defaultWidth = 160; +const minWidth = 88; +const stubWidth = 36; + type NativeTextLease = { readonly recordId: string; readonly propertyId: string; readonly composing: boolean; }; +type HeaderMenu = { + readonly propertyId: string; + readonly x: number; + readonly y: number; +}; + +type HeaderDrag = { + readonly propertyId: string; + readonly originX: number; + readonly originY: number; + moved: boolean; +}; + +type HeaderResize = { + readonly propertyId: string; + readonly originX: number; + readonly originWidth: number; +}; + export function DatabaseTableDemo() { const [editor] = useState(() => createDatabaseEditor(initialDatabase)); const [lease, setLease] = useState(null); const [dragPreview, setDragPreview] = useState | null>(null); - const draggedProperty = useRef(null); + const [widthPreview, setWidthPreview] = useState> | null>(null); + const [menu, setMenu] = useState(null); + const headerDrag = useRef(null); + const headerResize = useRef(null); const [announcement, setAnnouncement] = useState("Database ready"); const [lastIntent, setLastIntent] = useState(null); const [lastResult, setLastResult] = useState<{ readonly ok: true } | { readonly ok: false; readonly code: string } | null>(null); @@ -48,8 +80,12 @@ export function DatabaseTableDemo() { const topology = editor.tableTopology(view.id); const visiblePropertyIds = (dragPreview ?? view.propertyOrder) .filter((propertyId) => view.propertyVisibility[propertyId] !== false); + const hiddenPropertyIds = view.propertyOrder.filter((propertyId) => view.propertyVisibility[propertyId] === false); const properties = visiblePropertyIds.map((id) => document.schema.properties.find((property) => property.id === id)!); + const hiddenProperties = hiddenPropertyIds.map((id) => document.schema.properties.find((property) => property.id === id)!); const records = topology.recordIds.map((id) => document.records.find((record) => record.id === id)!); + const widths = { ...view.propertyWidths, ...widthPreview }; + function dispatchIntent(intent: DatabaseIntent) { const result: EditingResult = editor.dispatch(intent); setLastIntent(intent); @@ -80,7 +116,7 @@ export function DatabaseTableDemo() { run(() => dispatchIntent({ type: "cell.commit", recordId, propertyId, value }), `${propertyId} committed`); } - function configure(patch: Parameters[0] & { readonly type: "view.configure" }) { + function configure(patch: Extract) { run(() => dispatchIntent(patch), "Table view saved in canonical JSON"); } @@ -96,84 +132,174 @@ export function DatabaseTableDemo() { run(() => dispatchIntent({ type: "record.delete", recordId }), "Record deleted"); } - function startPropertyDrag(event: DragEvent, propertyId: string) { - draggedProperty.current = propertyId; - setDragPreview([...view.propertyOrder]); - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", propertyId); + function propertyWidth(propertyId: string) { + return Math.max(minWidth, widths[propertyId] ?? defaultWidth); + } + + function cycleSort(propertyId: string) { + configure({ type: "view.configure", viewId: view.id, sort: nextSort(view.sort, propertyId) }); + } + + function hideProperty(propertyId: string) { + configure({ + type: "view.configure", + viewId: view.id, + propertyVisibility: { ...view.propertyVisibility, [propertyId]: false }, + }); + } + + function showProperty(propertyId: string) { + configure({ + type: "view.configure", + viewId: view.id, + propertyVisibility: { ...view.propertyVisibility, [propertyId]: true }, + }); } - function previewPropertyAt(propertyId: string) { - const source = draggedProperty.current; - if (!source || source === propertyId) return; + function setFilter(filter: DatabaseFilter | null) { + configure({ type: "view.configure", viewId: view.id, filter }); + } + + function startHeaderDrag(event: ReactPointerEvent, propertyId: string) { + if (event.button !== 0) return; + headerDrag.current = { propertyId, originX: event.clientX, originY: event.clientY, moved: false }; + event.currentTarget.setPointerCapture(event.pointerId); + } + + function moveHeaderDrag(event: ReactPointerEvent) { + const drag = headerDrag.current; + if (!drag) return; + applyAffordance(dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + if (!drag.moved && Math.hypot(event.clientX - drag.originX, event.clientY - drag.originY) < 6) return; + drag.moved = true; + const target = globalThis.document.elementFromPoint(event.clientX, event.clientY); + const propertyId = target instanceof Element + ? target.closest("[data-property-id]")?.getAttribute("data-property-id") + : null; + if (!propertyId || propertyId === drag.propertyId) return; + applyAffordance(dropAffordance({ canDrop: true }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); setDragPreview((current) => { const order = [...(current ?? view.propertyOrder)]; - const sourceIndex = order.indexOf(source); + const sourceIndex = order.indexOf(drag.propertyId); const targetIndex = order.indexOf(propertyId); if (sourceIndex < 0 || targetIndex < 0) return order; order.splice(sourceIndex, 1); - order.splice(targetIndex, 0, source); + order.splice(targetIndex, 0, drag.propertyId); return order; }); } - function finishPropertyDrag() { - const source = draggedProperty.current; + function finishHeaderDrag(event: ReactPointerEvent) { + const drag = headerDrag.current; + headerDrag.current = null; + event.currentTarget.style.cursor = ""; const next = dragPreview; - draggedProperty.current = null; setDragPreview(null); - if (!source) return; - if (next && next.join("\u0000") !== view.propertyOrder.join("\u0000")) { - configure({ type: "view.configure", viewId: view.id, propertyOrder: next }); + if (!drag) return; + if (drag.moved) { + const committed = commitAffordance(dragAffordance( + { x: drag.originX, y: drag.originY }, + { x: event.clientX, y: event.clientY }, + )); + if (committed) { + applyAffordance(committed, { + commit: () => { + if (next && next.join("\u0000") !== view.propertyOrder.join("\u0000")) { + configure({ type: "view.configure", viewId: view.id, propertyOrder: next }); + } + }, + }); + } + return; + } + applyAffordance(activateAffordance({ button: 0, detail: 1 }), { + hand: (hand) => { + if (hand.type !== "activate") return; + cycleSort(drag.propertyId); + }, + }); + } + + function startResize(event: ReactPointerEvent, propertyId: string) { + event.stopPropagation(); + headerDrag.current = null; + headerResize.current = { propertyId, originX: event.clientX, originWidth: propertyWidth(propertyId) }; + event.currentTarget.setPointerCapture(event.pointerId); + } + + function moveResize(event: ReactPointerEvent) { + event.stopPropagation(); + const resize = headerResize.current; + if (!resize) return; + event.currentTarget.style.cursor = "col-resize"; + setWidthPreview({ + ...view.propertyWidths, + [resize.propertyId]: Math.max(minWidth, resize.originWidth + (event.clientX - resize.originX)), + }); + } + + function finishResize(event: ReactPointerEvent) { + event.stopPropagation(); + const resize = headerResize.current; + headerResize.current = null; + const preview = widthPreview; + setWidthPreview(null); + event.currentTarget.style.cursor = ""; + if (!resize || !preview) return; + if (preview[resize.propertyId] === view.propertyWidths[resize.propertyId]) return; + configure({ type: "view.configure", viewId: view.id, propertyWidths: preview }); + } + + function openHeaderMenu(event: { preventDefault(): void; clientX: number; clientY: number }, propertyId: string) { + event.preventDefault(); + setMenu({ propertyId, x: event.clientX, y: event.clientY }); + } + + function onHeaderKeyDown(event: KeyboardEvent, propertyId: string, hidden: boolean) { + if (hidden) { + applyAffordance(disclosureAffordance({ key: event.key, expanded: false }), { + hand: (hand) => { + if (hand.type !== "expand") return; + event.preventDefault(); + showProperty(propertyId); + }, + }); + return; + } + if (event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey)) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + setMenu({ propertyId, x: rect.left, y: rect.bottom }); + return; } + applyAffordance(activateAffordance(event), { + hand: (hand) => { + if (hand.type !== "activate") return; + event.preventDefault(); + cycleSort(propertyId); + }, + }); } + const menuProperty = menu ? document.schema.properties.find((property) => property.id === menu.propertyId) : null; + return ( New record Delete selected ); @@ -355,6 +528,28 @@ function commitInput( commit(property.type === "number" ? Number(event.currentTarget.value) : event.currentTarget.value); } +function nextSort(sort: DatabaseSort | null, propertyId: string): DatabaseSort | null { + if (sort?.propertyId !== propertyId) return { propertyId, direction: "ascending" }; + if (sort.direction === "ascending") return { propertyId, direction: "descending" }; + return null; +} + +function ariaSort(sort: DatabaseSort | null, propertyId: string): "ascending" | "descending" | "none" { + if (sort?.propertyId !== propertyId) return "none"; + return sort.direction; +} + +function sortMark(sort: DatabaseSort | null, propertyId: string): string { + if (sort?.propertyId !== propertyId) return ""; + return sort.direction === "ascending" ? " ↑" : " ↓"; +} + +function filterItems(property: DatabaseProperty): ReadonlyArray<{ readonly label: string; readonly value: string | boolean }> { + if (property.type === "select") return property.options.map((option) => ({ label: option.name, value: option.id })); + if (property.type === "checkbox") return [{ label: "checked", value: true }, { label: "unchecked", value: false }]; + return []; +} + function cellKey(recordId: string, propertyId: string): string { return `${recordId}\u0000${propertyId}`; } diff --git a/site/src/routes/database-demo/initial-database.ts b/site/src/routes/database-demo/initial-database.ts index aeb23529..dda01fff 100644 --- a/site/src/routes/database-demo/initial-database.ts +++ b/site/src/routes/database-demo/initial-database.ts @@ -31,6 +31,7 @@ export const initialDatabase: DatabaseDocument = { type: "table", propertyOrder: ["name", "note", "score", "status", "complete"], propertyVisibility: {}, + propertyWidths: {}, sort: null, filter: null, }], diff --git a/site/src/routes/docs/DocsRoute.tsx b/site/src/routes/docs/DocsRoute.tsx index 0c7ca675..a17c7aad 100644 --- a/site/src/routes/docs/DocsRoute.tsx +++ b/site/src/routes/docs/DocsRoute.tsx @@ -48,6 +48,7 @@ const docIllustrations: Record = { order: "cursor", object: "peek", tree: "branch", + database: "database", intent: "braces", intentGuide: "terminal", api: "patch", diff --git a/site/src/routes/docs/MarkdownViewer.tsx b/site/src/routes/docs/MarkdownViewer.tsx index 5045a4ef..d38c9586 100644 --- a/site/src/routes/docs/MarkdownViewer.tsx +++ b/site/src/routes/docs/MarkdownViewer.tsx @@ -131,6 +131,7 @@ const markdownHrefs: Readonly> = { "order.md": "/docs/order", "object.md": "/docs/object", "tree.md": "/docs/tree", + "database.md": "/docs/database", }; function rewriteMarkdownHref(href: string | undefined): string | undefined { diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts index e6f48d84..9f777812 100644 --- a/site/src/routes/docs/doc-pages.ts +++ b/site/src/routes/docs/doc-pages.ts @@ -46,6 +46,7 @@ import reactEditingMarkdown from "../../../../docs/public/react-editing.md?raw"; import selectionMarkdown from "../../../../docs/public/selection.md?raw"; import topologyMarkdown from "../../../../docs/public/topology.md?raw"; import treeMarkdown from "../../../../docs/public/tree.md?raw"; +import databaseMarkdown from "../../../../docs/public/database.md?raw"; import { pageDescriptor } from "../../app/page-descriptors"; function docPage(path: string, source: string) { @@ -94,6 +95,7 @@ export const docPages = { order: docPage("/docs/order", orderMarkdown), object: docPage("/docs/object", objectMarkdown), tree: docPage("/docs/tree", treeMarkdown), + database: docPage("/docs/database", databaseMarkdown), intent: docPage("/docs/intent", intentMarkdown), intentGuide: docPage("/docs/intent-guide", intentGuideMarkdown), api: docPage("/docs/api", apiReferenceMarkdown), diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx index 79bde7b1..564e6a20 100644 --- a/site/src/routes/order-demo/OrderDemoRoute.tsx +++ b/site/src/routes/order-demo/OrderDemoRoute.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type KeyboardEvent } from "react"; import { createOrderEditor, type OrderClipboard, @@ -9,7 +9,9 @@ import { useEditing } from "@interactive-os/json-document-react"; import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; import { applyAffordance, + escapeAffordance, pointerSelect, + typeaheadAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; @@ -31,6 +33,7 @@ export function OrderDemoRoute() { const [clipboard, setClipboard] = useState(null); const [announcement, setAnnouncement] = useState("Ready"); const [lastIntent, setLastIntent] = useState(null); + const [typeahead, setTypeahead] = useState({ buffer: "", at: 0 }); function run(intent: OrderIntent, message: string) { const result = editor.dispatch(intent); @@ -84,6 +87,41 @@ export function OrderDemoRoute() { setAnnouncement(`Cut ${result.clipboard.items.length} item${result.clipboard.items.length === 1 ? "" : "s"}`); } + const focusKey = snapshot.selection.ranges[snapshot.selection.primaryIndex ?? 0]?.focus.itemId ?? null; + + function onKeyDown(event: KeyboardEvent) { + const names = document.items.map((item) => item.label); + const from = document.items.find((item) => item.id === focusKey)?.label ?? null; + const result = typeaheadAffordance({ + buffer: typeahead.buffer, + key: event.key, + elapsedMs: event.timeStamp - typeahead.at, + names, + from, + }); + let consumed = false; + applyAffordance(result, { + hand: (hand) => { + if (hand.type !== "typeahead") return; + consumed = true; + setTypeahead({ buffer: hand.buffer, at: event.timeStamp }); + const item = document.items.find((candidate) => candidate.label === hand.name); + if (item) run({ type: "selection.set", itemId: item.id, mode: "replace" }, "Selection changed"); + }, + }); + if (consumed) { + event.preventDefault(); + return; + } + applyAffordance(escapeAffordance(event), { + hand: (hand) => { + if (hand.type !== "cancel") return; + setTypeahead({ buffer: "", at: 0 }); + }, + }); + editing.getKeyDownHandler()(event); + } + return ( {document.items.map((item, index) => ( { - if (hand.type !== "select") return; - operation = hand.operation; - editor.dispatch({ - type: "selection.set", - objectIds: [objectId], - mode: hand.operation === "extend" ? "add" : hand.operation, - }); + applyAffordance( + planeHitAffordance({ + hitId: objectId, + selectedIds: editor.selectedObjects.map((object) => object.id), + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + }), + { + hand: (hand) => { + if (hand.type !== "select" || !hand.objectIds) return; + editor.dispatch({ + type: "selection.set", + objectIds: hand.objectIds, + mode: "replace", + }); + setDrag({ + ids: hand.objectIds, + originX: event.clientX, + originY: event.clientY, + dx: 0, + dy: 0, + }); + }, }, - }); - const ids = operation !== "replace" - ? [...new Set([...editor.selectedObjects.map((object) => object.id), objectId])] - : [objectId]; - setDrag({ ids, originX: event.clientX, originY: event.clientY, dx: 0, dy: 0 }); + ); return; } const origin = { x: event.nativeEvent.offsetX, y: event.nativeEvent.offsetY }; @@ -145,7 +155,7 @@ export function CanvasWidgetRoute() { if (marquee) { const origin = { x: marquee.originX, y: marquee.originY }; const point = { x: event.nativeEvent.offsetX, y: event.nativeEvent.offsetY }; - applyAffordance(marqueeAffordance(origin, point), { + applyAffordance(marqueeAffordance(origin, point, event), { cursor: (cursor) => { event.currentTarget.style.cursor = cursor; }, @@ -196,24 +206,23 @@ export function CanvasWidgetRoute() { if (marquee) { const origin = { x: marquee.originX, y: marquee.originY }; const point = { x: event.nativeEvent.offsetX, y: event.nativeEvent.offsetY }; - const committed = commitAffordance(marqueeAffordance(origin, point)); + const committed = commitAffordance(marqueeAffordance(origin, point, event)); if (committed) { applyAffordance(committed, { commit: (hand) => { + if (hand.type === "clear") { + editor.dispatch({ type: "selection.set", objectIds: [], mode: "replace" }); + return; + } if (hand.type !== "select" || !hand.rect) return; const hits = document.objects .filter((object) => intersects(hand.rect!, object)) .map((object) => object.id); if (hits.length === 0) return; - applyAffordance(pointerSelect(event), { - hand: (selectHand) => { - if (selectHand.type !== "select") return; - editor.dispatch({ - type: "selection.set", - objectIds: hits, - mode: selectHand.operation === "extend" ? "add" : selectHand.operation === "toggle" ? "toggle" : "replace", - }); - }, + editor.dispatch({ + type: "selection.set", + objectIds: hits, + mode: hand.operation === "extend" ? "add" : hand.operation === "toggle" ? "toggle" : "replace", }); }, }); @@ -227,15 +236,27 @@ export function CanvasWidgetRoute() { setSpace(true); event.preventDefault(); } - applyAffordance(escapeAffordance(event), { - hand: (hand) => { - if (hand.type !== "cancel") return; - setDrag(null); - setMarquee(null); - setPan((current) => ({ ...current, active: false })); - event.preventDefault(); + applyAffordance( + escapeAffordance({ + key: event.key, + grabbing: drag != null || marquee != null || pan.active, + selected: editor.selectedObjects.length > 0, + }), + { + hand: (hand) => { + if (hand.type === "cancel") { + setDrag(null); + setMarquee(null); + setPan((current) => ({ ...current, active: false })); + event.preventDefault(); + return; + } + if (hand.type !== "clear") return; + editor.dispatch({ type: "selection.set", objectIds: [], mode: "replace" }); + event.preventDefault(); + }, }, - }); + ); applyAffordance(nudgeAffordance(event), { hand: (hand) => { if (hand.type !== "nudge") return; @@ -287,7 +308,7 @@ export function CanvasWidgetRoute() { ) : null} diff --git a/site/src/shared/ui/styles.ts b/site/src/shared/ui/styles.ts index 2e23d7f3..b9ca786c 100644 --- a/site/src/shared/ui/styles.ts +++ b/site/src/shared/ui/styles.ts @@ -73,6 +73,7 @@ export const ui = { documentBlock: "border-b border-pencil-light/70 bg-transparent px-2 py-1.5 last:border-b-0", documentIndex: "rounded-none border-0 border-r border-pencil-light bg-paper-warm text-pencil shadow-none", empty: "rounded-[6px] border border-dashed border-pencil-light bg-paper-warm text-pencil", + marquee: "rounded-[6px] border border-dashed border-impact/50 bg-impact/10", }, interactive: { control: "cursor-pointer rounded-[6px] border px-3 py-2 text-xs font-medium outline-none transition-[background-color,border-color,color,box-shadow,transform] duration-150 active:translate-y-px focus-visible:border-impact focus-visible:ring-2 focus-visible:ring-impact/25 disabled:cursor-not-allowed disabled:translate-y-0 disabled:border-pencil-light/60 disabled:bg-transparent disabled:text-pencil disabled:shadow-none", @@ -89,7 +90,10 @@ export const ui = { disclosure: "group flex w-full cursor-pointer items-center justify-between gap-3 rounded-[6px] border border-transparent bg-transparent px-3 py-2 text-left text-sm font-medium text-ink-strong outline-none transition-colors hover:border-pencil-light hover:bg-paper-warm focus-visible:border-impact focus-visible:ring-2 focus-visible:ring-impact/25 disabled:cursor-not-allowed disabled:text-pencil", chevron: "text-pencil transition-transform group-aria-expanded:rotate-180", selectable: "cursor-pointer outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-paper-warm focus-visible:ring-2 focus-visible:ring-impact/25 focus-within:ring-2 focus-within:ring-impact/25 data-[selected=true]:relative data-[selected=true]:border-impact data-[selected=true]:bg-paper-warm data-[selected=true]:outline data-[selected=true]:outline-2 data-[selected=true]:-outline-offset-2 data-[selected=true]:outline-impact data-[focus=true]:outline data-[focus=true]:outline-2 data-[focus=true]:-outline-offset-1 data-[focus=true]:outline-ink", + planeItem: "absolute !absolute grid place-items-center", icon: "flex h-7 w-7 cursor-pointer items-center justify-center rounded-[4px] border border-transparent bg-transparent text-pencil outline-none transition-colors hover:border-pencil-light hover:bg-paper hover:text-ink-strong active:bg-paper-warm focus-visible:border-impact focus-visible:ring-2 focus-visible:ring-impact/25 disabled:cursor-not-allowed disabled:border-transparent disabled:bg-transparent disabled:text-pencil-light", + contextMenu: "absolute z-10 min-w-28 rounded-[6px] border border-pencil-light bg-paper py-1 shadow-[0_2px_4px_rgba(69,67,62,0.06),0_20px_48px_rgba(69,67,62,0.10)]", + contextMenuItem: "block w-full px-3 py-1 text-left text-xs text-ink hover:bg-paper-warm", }, field: { control: "rounded-[6px] border border-pencil-light bg-paper px-3 py-2 text-sm leading-6 text-ink-strong outline-none hover:border-pencil focus-visible:border-impact focus-visible:ring-2 focus-visible:ring-impact/25 disabled:cursor-not-allowed disabled:bg-paper-warm disabled:text-pencil", diff --git a/site/tests/browser/database-demo.spec.ts b/site/tests/browser/database-demo.spec.ts index a5da9743..09821574 100644 --- a/site/tests/browser/database-demo.spec.ts +++ b/site/tests/browser/database-demo.spec.ts @@ -32,28 +32,53 @@ test("Database Table edits five property types while native text lease stays out await expect(page.getByTestId("database-selection-json")).not.toContainText("composition"); }); -test("Database Table persists view projection and restores record plus selection with history", async ({ page }) => { +test("Database Table header hands persist view projection and restore records with history", async ({ page }) => { await page.goto("/demo/database"); - await page.getByRole("button", { name: "Score descending" }).click(); - await page.getByRole("button", { name: "Backlog only" }).click(); - await page.getByRole("button", { name: "Hide notes" }).click(); - await page.getByRole("button", { name: "Score first" }).click(); + await expect(page.getByRole("button", { name: "Backlog only" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Score descending" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Hide notes" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Score first" })).toHaveCount(0); + + const scoreHeader = page.getByRole("columnheader", { name: /Score number/ }); + await scoreHeader.click(); + await expect(scoreHeader).toHaveAttribute("aria-sort", "ascending"); + await scoreHeader.click(); + await expect(scoreHeader).toHaveAttribute("aria-sort", "descending"); + await expect(page.getByTestId("database-view-json")).toContainText('"direction": "descending"'); const rows = page.getByRole("grid", { name: "Notion-style database" }).locator("tbody tr"); + await expect(rows.nth(0)).toHaveAttribute("data-record-id", "page-2"); + + const noteHeader = page.getByRole("columnheader", { name: /Note text/ }); + await noteHeader.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Hide" }).click(); + await expect(page.getByRole("columnheader", { name: /Note text/ })).toHaveCount(0); + await expect(page.getByTestId("database-view-json")).toContainText('"note": false'); + + await page.getByRole("columnheader", { name: "Show Note" }).click(); + await expect(page.getByRole("columnheader", { name: /Note text/ })).toBeVisible(); + + const statusHeader = page.getByRole("columnheader", { name: /Status select/ }); + await statusHeader.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Filter Backlog" }).click(); await expect(rows).toHaveCount(2); await expect(rows.nth(0)).toHaveAttribute("data-record-id", "page-3"); await expect(rows.nth(1)).toHaveAttribute("data-record-id", "page-4"); - await expect(page.getByTestId("database-view-json")).toContainText('"propertyOrder"'); - await expect(page.getByTestId("database-view-json")).toContainText('"note": false'); - await expect(page.getByTestId("database-view-json")).toContainText('"direction": "descending"'); await expect(page.getByTestId("database-view-json")).toContainText('"value": "backlog"'); const nameHeader = page.getByRole("columnheader", { name: /Name title/ }); - const scoreHeader = page.getByRole("columnheader", { name: /Score number/ }); await nameHeader.dragTo(scoreHeader); - await expect(page.getByTestId("database-view-json")).toContainText('"name",\n "score"'); + await expect(page.getByTestId("database-view-json")).toContainText('"propertyOrder"'); + + const handle = page.locator("[data-resize-edge=e][data-property-id=score]"); + const box = await handle.boundingBox(); + if (!box) throw new Error("resize handle"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + 80, box.y + box.height / 2); + await page.mouse.up(); + await expect(page.getByTestId("database-view-json")).toContainText('"score":'); - await page.getByRole("button", { name: "Backlog only" }).click(); await page.getByRole("button", { name: "New record" }).click(); await expect(page.getByTestId("database-document-json")).toContainText('"id": "page-5"'); await expect(page.getByTestId("database-selection-json")).toContainText('"recordId": "page-5"'); diff --git a/site/tests/browser/editing-demos.spec.ts b/site/tests/browser/editing-demos.spec.ts index 66ccdd66..15c7e84f 100644 --- a/site/tests/browser/editing-demos.spec.ts +++ b/site/tests/browser/editing-demos.spec.ts @@ -64,7 +64,7 @@ test("Hands catalog lists the genre hands", async ({ page }) => { await expect(article.getByRole("link", { name: "Sheet" })).toHaveAttribute("href", "/demo/sheet"); await expect(article.getByRole("link", { name: "Tree" })).toHaveAttribute("href", "/docs/tree"); await expect(article.getByRole("link", { name: "Kanban" })).toHaveAttribute("href", "/demo/kanban"); - await expect(article.getByRole("link", { name: "Database" })).toHaveAttribute("href", "/demo/database"); + await expect(article.getByRole("link", { name: "Database" })).toHaveAttribute("href", "/docs/database"); }); test("legacy Showcase path opens Hands", async ({ page }) => { diff --git a/site/tests/browser/editor-slice-demos.spec.ts b/site/tests/browser/editor-slice-demos.spec.ts index 9d3cf41e..517d9a7b 100644 --- a/site/tests/browser/editor-slice-demos.spec.ts +++ b/site/tests/browser/editor-slice-demos.spec.ts @@ -1,5 +1,22 @@ import { expect, test } from "@playwright/test"; +test("Canvas selection keeps objects absolutely positioned", async ({ page }) => { + await page.goto("/demo/canvas"); + const note = page.getByRole("button", { name: "Note" }); + const card = page.getByRole("button", { name: "Card" }); + const chip = page.getByRole("button", { name: "Chip" }); + const before = await Promise.all([note, card, chip].map((item) => item.boundingBox())); + await card.click(); + for (const item of [note, card, chip]) { + await expect.poll(() => item.evaluate((el) => getComputedStyle(el).position)).toBe("absolute"); + } + const after = await Promise.all([note, card, chip].map((item) => item.boundingBox())); + after.forEach((box, index) => { + expect(box?.x).toBeCloseTo(before[index]?.x ?? 0, 0); + expect(box?.y).toBeCloseTo(before[index]?.y ?? 0, 0); + }); +}); + test("Canvas fills a selected object", async ({ page }) => { await page.goto("/demo/canvas"); await expect(page.getByRole("heading", { level: 1, name: "Canvas", exact: true })).toBeVisible(); @@ -8,6 +25,144 @@ test("Canvas fills a selected object", async ({ page }) => { await expect(page.getByRole("button", { name: "Note" })).toHaveCSS("background-color", "rgb(77, 106, 138)"); }); +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(); + await page.keyboard.type("T"); + await expect(page.getByRole("button", { name: /Today/ })).toHaveAttribute("data-selected", "true"); + await page.keyboard.press("Escape"); + await page.keyboard.type("I"); + await expect(page.getByRole("button", { name: /Inbox/ })).toHaveAttribute("data-selected", "true"); +}); + +test("Canvas marquee selects several objects and Escape cancels it", async ({ page }) => { + await page.goto("/demo/canvas"); + const note = page.getByRole("button", { name: "Note" }); + const card = page.getByRole("button", { name: "Card" }); + const chip = page.getByRole("button", { name: "Chip" }); + await card.click(); + await expect(card).toHaveAttribute("data-selected", "true"); + await expect(note).toHaveAttribute("data-selected", "false"); + + const canvas = page.getByLabel("Canvas", { exact: true }); + const box = await canvas.boundingBox(); + if (!box) throw new Error("canvas bounding box"); + await canvas.focus(); + await page.mouse.move(box.x + box.width - 8, box.y + box.height - 8); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 24, box.y + box.height - 24); + await page.keyboard.press("Escape"); + await page.mouse.up(); + await expect(card).toHaveAttribute("data-selected", "true"); + await expect(note).toHaveAttribute("data-selected", "false"); + + await page.mouse.move(box.x + 8, box.y + 8); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 8, box.y + box.height - 8); + await page.mouse.up(); + await expect(note).toHaveAttribute("data-selected", "true"); + await expect(card).toHaveAttribute("data-selected", "true"); + await expect(chip).toHaveAttribute("data-selected", "true"); +}); + +test("Canvas drags every selected object together", async ({ page }) => { + await page.goto("/demo/canvas"); + const note = page.getByRole("button", { name: "Note" }); + const card = page.getByRole("button", { name: "Card" }); + const chip = page.getByRole("button", { name: "Chip" }); + const canvas = page.getByLabel("Canvas", { exact: true }); + const box = await canvas.boundingBox(); + if (!box) throw new Error("canvas bounding box"); + await page.mouse.move(box.x + 8, box.y + 8); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 8, box.y + box.height - 8); + await page.mouse.up(); + await expect(note).toHaveAttribute("data-selected", "true"); + await expect(card).toHaveAttribute("data-selected", "true"); + await expect(chip).toHaveAttribute("data-selected", "true"); + + const before = await Promise.all([note, card, chip].map((item) => item.evaluate((el) => ({ + left: parseFloat((el as HTMLElement).style.left), + top: parseFloat((el as HTMLElement).style.top), + })))); + const noteBox = await note.boundingBox(); + if (!noteBox) throw new Error("note bounding box"); + await note.hover(); + await page.mouse.down(); + await page.mouse.move(noteBox.x + noteBox.width / 2 + 40, noteBox.y + noteBox.height / 2); + await page.mouse.up(); + const after = await Promise.all([note, card, chip].map((item) => item.evaluate((el) => ({ + left: parseFloat((el as HTMLElement).style.left), + top: parseFloat((el as HTMLElement).style.top), + })))); + const dx = after[0].left - before[0].left; + expect(dx).toBeGreaterThan(0); + expect(after[1].left - before[1].left).toBe(dx); + expect(after[2].left - before[2].left).toBe(dx); + expect(after[0].top).toBe(before[0].top); + expect(after[1].top).toBe(before[1].top); + expect(after[2].top).toBe(before[2].top); +}); + +test("Canvas empty click and idle Escape clear selection", async ({ page }) => { + await page.goto("/demo/canvas"); + const note = page.getByRole("button", { name: "Note" }); + const card = page.getByRole("button", { name: "Card" }); + const canvas = page.getByLabel("Canvas", { exact: true }); + await card.click(); + await expect(card).toHaveAttribute("data-selected", "true"); + const box = await canvas.boundingBox(); + if (!box) throw new Error("canvas bounding box"); + await canvas.click({ position: { x: box.width - 12, y: box.height - 12 } }); + await expect(card).toHaveAttribute("data-selected", "false"); + await expect(note).toHaveAttribute("data-selected", "false"); + + await note.click(); + await expect(note).toHaveAttribute("data-selected", "true"); + await canvas.focus(); + await page.keyboard.press("Escape"); + await expect(note).toHaveAttribute("data-selected", "false"); +}); + +test("Canvas pan moves the viewport without writing object positions", async ({ page }) => { + await page.goto("/demo/canvas"); + const note = page.getByRole("button", { name: "Note" }); + const canvas = page.getByLabel("Canvas", { exact: true }); + const origin = await note.boundingBox(); + if (!origin) throw new Error("note bounding box"); + await canvas.focus(); + await page.keyboard.down(" "); + await page.mouse.move(origin.x + origin.width / 2, origin.y + origin.height / 2); + await page.mouse.down(); + await page.mouse.move(origin.x + origin.width / 2 + 40, origin.y + origin.height / 2); + await page.mouse.up(); + await page.keyboard.up(" "); + const moved = await note.boundingBox(); + if (!moved) throw new Error("note bounding box after pan"); + expect(moved.x).toBeGreaterThan(origin.x + 20); + await expect(note).toHaveCSS("left", "24px"); +}); + +test("Canvas nudges a selected object and snaps a drag to the grid", async ({ page }) => { + await page.goto("/demo/canvas"); + const note = page.getByRole("button", { name: "Note" }); + await note.click(); + await page.getByLabel("Canvas", { exact: true }).focus(); + await page.keyboard.press("ArrowRight"); + await expect(note).toHaveCSS("left", "25px"); + + const box = await note.boundingBox(); + if (!box) throw new Error("note bounding box"); + await note.hover(); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 10, box.y + box.height / 2); + await page.mouse.up(); + await expect(note).toHaveCSS("left", "33px"); + await page.getByRole("button", { name: "Card" }).click(); + await expect(note).toHaveCSS("left", "33px"); + await expect(page.getByRole("button", { name: "Card" })).toHaveAttribute("data-selected", "true"); +}); + 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/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts index 714ca74f..fb848bb7 100644 --- a/site/tests/browser/site-shell.spec.ts +++ b/site/tests/browser/site-shell.spec.ts @@ -224,6 +224,7 @@ test("ordinary pages reuse one petite decorative cat without covering intro copy "/demo/clipboard", "/demo/history", "/editing/rich-text", + "/docs/database", "/demo/database", "/connectors", "/connectors/react", diff --git a/site/tests/unit/breadcrumb.test.ts b/site/tests/unit/breadcrumb.test.ts index 5bdad52b..be93fc06 100644 --- a/site/tests/unit/breadcrumb.test.ts +++ b/site/tests/unit/breadcrumb.test.ts @@ -69,7 +69,8 @@ describe("breadcrumbTrail", () => { "History:/docs/history", "History Demo:/demo/history", ]); - expect(trail("/demo/database")).toEqual(["Overview:/", "Hands:/editors", "Database:/demo/database"]); + expect(trail("/docs/database")).toEqual(["Overview:/", "Hands:/editors", "Database:/docs/database"]); + expect(trail("/demo/database")).toEqual(["Overview:/", "Hands:/editors", "Database:/docs/database", "Database Demo:/demo/database"]); expect(trail("/adapters")).toEqual(["Overview:/", "Adapter:/adapters"]); expect(trail("/adapters/keyboard")).toEqual(["Overview:/", "Adapter:/adapters", "Keyboard:/adapters/keyboard"]); expect(trail("/adapters/clipboard")).toEqual(["Overview:/", "Adapter:/adapters", "Clipboard adapter:/adapters/clipboard"]);