diff --git a/docs/README.md b/docs/README.md index 231c9e69..07333523 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,15 +55,6 @@ Editing |-- Clipboard `-- History -Hands -|-- Document -|-- Order -|-- Object -|-- Sheet -|-- Tree -|-- Kanban -`-- Database - Adapter |-- Keyboard |-- Clipboard adapter @@ -76,14 +67,41 @@ Connector |-- Zod `-- TanStack Table -제품 화면 -|-- Toolbar -|-- Listbox -|-- Grid +Affordance +|-- Focus +|-- Caret +|-- Select +|-- Typeahead +|-- Activate +|-- Escape +|-- Expand/Collapse +|-- Undo +|-- Delete +|-- Rename +|-- Nudge +|-- Hover +|-- Double-click +|-- Triple-click +|-- Context menu +|-- Drag +|-- Marquee +|-- Drop +|-- Duplicate +|-- Resize +|-- Pan +|-- Scroll +|-- Zoom +|-- Snap +`-- Not-allowed + +Hands |-- Document -|-- Canvas +|-- Order +|-- Object +|-- Sheet |-- Tree -`-- Board +|-- Kanban +`-- Database ``` ## 규범 우선순위 diff --git a/docs/changelog.md b/docs/changelog.md index 68022853..2bd1df53 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,9 @@ source and release history remains available from Git commits and version tags. ## Next +- 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. - Opened Document, Canvas, Tree, and Board on 제품 화면, and made Toolbar, Listbox, Grid, and those widgets share prop bindings that live demos consume without forking. Database stays on Grid. diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index b3b015a1..5ef47556 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -51,6 +51,32 @@ const publicDocs = { quickstart: read("docs/public/quickstart.md"), api: read("docs/public/api.md"), adapters: read("docs/public/adapters.md"), + affordance: read("docs/public/affordance.md"), + affordanceSelect: read("docs/public/affordance-select.md"), + affordanceFold: read("docs/public/affordance-fold.md"), + affordanceDrag: read("docs/public/affordance-drag.md"), + affordanceHistory: read("docs/public/affordance-history.md"), + affordanceFocus: read("docs/public/affordance-focus.md"), + affordanceCaret: read("docs/public/affordance-caret.md"), + affordanceTypeahead: read("docs/public/affordance-typeahead.md"), + affordanceActivate: read("docs/public/affordance-activate.md"), + affordanceCancel: read("docs/public/affordance-cancel.md"), + affordanceDelete: read("docs/public/affordance-delete.md"), + affordanceRename: read("docs/public/affordance-rename.md"), + affordanceNudge: read("docs/public/affordance-nudge.md"), + affordanceHover: read("docs/public/affordance-hover.md"), + affordanceDoubleClick: read("docs/public/affordance-double-click.md"), + affordanceTripleClick: read("docs/public/affordance-triple-click.md"), + affordanceContextMenu: read("docs/public/affordance-context-menu.md"), + affordanceMarquee: read("docs/public/affordance-marquee.md"), + affordanceDrop: read("docs/public/affordance-drop.md"), + affordanceCopyDrag: read("docs/public/affordance-copy-drag.md"), + affordanceResize: read("docs/public/affordance-resize.md"), + affordancePan: read("docs/public/affordance-pan.md"), + affordanceScroll: read("docs/public/affordance-scroll.md"), + affordanceZoom: read("docs/public/affordance-zoom.md"), + affordanceSnap: read("docs/public/affordance-snap.md"), + affordanceForbid: read("docs/public/affordance-forbid.md"), connectors: read("docs/public/connectors.md"), reactEditing: read("docs/public/react-editing.md"), collaboration: read("docs/public/collaboration.md"), @@ -95,6 +121,7 @@ const activeCompanionPackages = new Set([ "@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-zod", "@interactive-os/json-document-tanstack-table", "@interactive-os/json-document-web", @@ -105,6 +132,32 @@ const activeCompanionPackages = new Set([ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ "adapters.md", + "affordance-activate.md", + "affordance-cancel.md", + "affordance-caret.md", + "affordance-context-menu.md", + "affordance-copy-drag.md", + "affordance-delete.md", + "affordance-double-click.md", + "affordance-drag.md", + "affordance-drop.md", + "affordance-focus.md", + "affordance-fold.md", + "affordance-forbid.md", + "affordance-history.md", + "affordance-hover.md", + "affordance-marquee.md", + "affordance-nudge.md", + "affordance-pan.md", + "affordance-rename.md", + "affordance-resize.md", + "affordance-scroll.md", + "affordance-select.md", + "affordance-snap.md", + "affordance-triple-click.md", + "affordance-typeahead.md", + "affordance-zoom.md", + "affordance.md", "api.md", "clipboard.md", "collaboration-history.md", diff --git a/docs/public/affordance-activate.md b/docs/public/affordance-activate.md new file mode 100644 index 00000000..748cb733 --- /dev/null +++ b/docs/public/affordance-activate.md @@ -0,0 +1,36 @@ +# Activate + +TBD. + +Activate는 대상의 기본 동작을 실행하는 손입니다. `pointer` 커서가 이 +손을 가리킵니다. Enter와 기본 클릭은 같은 손입니다. Listbox에서 Space는 +[Select](affordance-select.md)의 toggle입니다. + +```ts +import { + activateAffordance, + resolveAffordanceKey, +} from "@interactive-os/json-document-affordance"; + +function onPointerDown(event: PointerEvent, itemId: string) { + if (activateAffordance(event) === "activate") hostOpen(itemId); +} + +function onKeyDown(event: KeyboardEvent) { + if (activateAffordance(event) === "activate") hostOpen(focusKey); + const command = resolveAffordanceKey(event); + if (command?.type === "toggle") { + editor.dispatch({ type: "selection.set", itemId: focusKey, mode: "toggle" }); + } +} +``` + +호스트는 기본 동작이 무엇인지 정합니다. 목록 toggle은 json-document 선택으로, +열기는 호스트 Intent로 갑니다. + +닫는 손: +- 기본 클릭 (`detail` 1) +- Enter +- Space는 장르 Intent: 버튼은 activate, 목록은 toggle + +근거: [APG Button](https://www.w3.org/WAI/ARIA/apg/patterns/button/), [CSS UI cursor `pointer`](https://www.w3.org/TR/css-ui-4/#cursor) diff --git a/docs/public/affordance-cancel.md b/docs/public/affordance-cancel.md new file mode 100644 index 00000000..e7d444b1 --- /dev/null +++ b/docs/public/affordance-cancel.md @@ -0,0 +1,40 @@ +# Escape + +TBD. + +Escape는 진행 중인 손과 임시 UI를 버리는 손입니다. Escape 키는 메뉴·대화· +드래그를 닫고, `pointercancel`은 포인터 제스처를 중단합니다. + +```ts +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); + }, + }); +} +``` + +호스트는 무엇이 열려 있는지를 가집니다. 버리는 손은 보통 호스트 화면 +상태입니다. 문서 값은 바꾸지 않습니다. + +닫는 손: +- Escape: 메뉴, 대화, 드래그, Rename 중단 +- pointercancel / lostpointercapture: 포인터 제스처 중단 + +근거: [APG Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/), [Pointer Events](https://www.w3.org/TR/pointerevents/) diff --git a/docs/public/affordance-caret.md b/docs/public/affordance-caret.md new file mode 100644 index 00000000..1dfd1a9e --- /dev/null +++ b/docs/public/affordance-caret.md @@ -0,0 +1,48 @@ +# Caret + +TBD. + +Caret은 글 안의 삽입점입니다. 항목 [Select](affordance-select.md)와 다릅니다. +`text` / `vertical-text` 커서가 이 손을 가리킵니다. 클릭은 삽입점을 두고, +드래그는 글 범위를 고릅니다. + +```ts +import { caretAffordance, caretCursor } from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent) { + event.currentTarget.style.cursor = caretCursor("horizontal"); +} + +function onPointerDown(event: PointerEvent, blockId: string) { + const hand = caretAffordance({ type: "pointer", detail: event.detail }); + if (hand.type === "place") { + editor.dispatch({ + type: "selection.set", + blockId, + offset: hostHitOffset(event), + }); + } +} + +function onKeyDown(event: KeyboardEvent) { + const hand = caretAffordance(event); + if (hand?.type === "move") { + editor.dispatch({ + type: "selection.set", + blockId: focus.blockId, + offset: hostMoveOffset(focus.offset, hand), + mode: hand.operation, + }); + } +} +``` + +호스트는 I-beam과 글 기하를 그립니다. 값 삽입은 Hands입니다. + +닫는 손: +- 클릭: 삽입점 +- 드래그: 글 범위 +- 화살표 / Home / End: 글자·줄 끝 +- IME composition은 삽입 경로 + +근거: [CSS UI cursor `text`](https://www.w3.org/TR/css-ui-4/#cursor), [UI Events select](https://www.w3.org/TR/uievents/) diff --git a/docs/public/affordance-context-menu.md b/docs/public/affordance-context-menu.md new file mode 100644 index 00000000..2340b34b --- /dev/null +++ b/docs/public/affordance-context-menu.md @@ -0,0 +1,33 @@ +# Context menu + +TBD. + +Context menu는 대상이 있는 자리에서 보조 동작을 여는 손입니다. +`context-menu` 커서가 이 손을 가리킵니다. + +```ts +import { contextMenuAffordance } from "@interactive-os/json-document-affordance"; + +function onContextMenu(event: MouseEvent, itemId: string) { + if (contextMenuAffordance(event) !== "open") return; + event.preventDefault(); + setMenu({ itemId, x: event.clientX, y: event.clientY }); +} + +function onKeyDown(event: KeyboardEvent) { + if (contextMenuAffordance(event) === "open") { + setMenu({ itemId: focusKey, x: 0, y: 0 }); + } + if (contextMenuAffordance(event) === "cancel") setMenu(null); +} +``` + +호스트는 메뉴 항목을 그립니다. 여는 손은 호스트 화면 상태입니다. + +닫는 손: +- 보조 버튼 / `contextmenu` / `auxclick` +- Shift+F10 +- Menu 키 +- Escape로 [Escape](affordance-cancel.md) + +근거: [Pointer Events contextmenu](https://www.w3.org/TR/pointerevents/), [CSS UI cursor `context-menu`](https://www.w3.org/TR/css-ui-4/#cursor) diff --git a/docs/public/affordance-copy-drag.md b/docs/public/affordance-copy-drag.md new file mode 100644 index 00000000..45c523d2 --- /dev/null +++ b/docs/public/affordance-copy-drag.md @@ -0,0 +1,67 @@ +# Duplicate + +TBD. + +Duplicate는 수정 키를 누른 채 드래그하면 원본을 복제하는 손입니다. +`copy` / `alias` 커서가 이 손을 가리킵니다. + +```ts +import { + applyAffordance, + commitAffordance, + dragAffordance, + dragOperation, + dropAffordance, +} from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent) { + applyAffordance(dragOperation(event), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); +} + +function onPointerUp(event: PointerEvent) { + const committed = commitAffordance( + dragAffordance(origin, { x: event.clientX, y: event.clientY }), + ); + if (!committed) return; + applyAffordance(committed, { + commit: (translate) => { + if (translate.type !== "translate") return; + let copied = false; + applyAffordance(dragOperation(event), { + hand: (operation) => { + if (operation.type !== "copy") return; + copied = true; + hostDuplicate(objectIds, translate); + }, + }); + if (copied) return; + const drop = commitAffordance(dropAffordance({ canDrop: true })); + if (!drop) return; + applyAffordance(drop, { + commit: (hand) => { + if (hand.type !== "move-drop") return; + editor.dispatch({ + type: "object.translate", + objectIds, + dx: translate.dx, + dy: translate.dy, + }); + }, + }); + }, + }); +} +``` + +복제 생성은 장르 Intent(호스트 또는 editor)이고, 옮기기는 json-document로 +갑니다. 값의 클립보드 복사/붙이기는 Hands입니다. + +닫는 손: +- Alt/Option + 드래그 → copy +- 커서 `copy` 또는 `alias` + +근거: [Apple HIG pointing devices](https://developer.apple.com/design/human-interface-guidelines/pointing-devices), [CSS UI cursor `copy` / `alias`](https://www.w3.org/TR/css-ui-4/#cursor) diff --git a/docs/public/affordance-delete.md b/docs/public/affordance-delete.md new file mode 100644 index 00000000..a5d410d4 --- /dev/null +++ b/docs/public/affordance-delete.md @@ -0,0 +1,32 @@ +# Delete + +TBD. + +Delete는 고른 대상을 없애는 손입니다. `resolveAffordanceKey`는 이미 +Delete와 Backspace를 `delete` command로 닫습니다. 칸 비우기와 항목 제거는 +장르 Intent입니다. + +```ts +import { + deleteAffordance, + resolveAffordanceKey, +} from "@interactive-os/json-document-affordance"; + +function onKeyDown(event: KeyboardEvent) { + const command = resolveAffordanceKey(event); + const hand = deleteAffordance(event); + if (command?.type !== "delete" || !hand) return; + if (hand.source === "selection" || hand.source === "forward") { + editor.dispatch({ type: "selection.remove" }); + } +} +``` + +호스트는 장르 Intent만 가집니다. 값 삽입은 Hands입니다. + +닫는 손: +- Delete: 앞으로 지우기 +- Backspace: 뒤로 지우기 +- 범위가 있으면 고른 것만 지움 + +근거: 이미 닫힌 Delete chord, [APG](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) diff --git a/docs/public/affordance-double-click.md b/docs/public/affordance-double-click.md new file mode 100644 index 00000000..2e0293e7 --- /dev/null +++ b/docs/public/affordance-double-click.md @@ -0,0 +1,26 @@ +# Double-click + +TBD. + +Double-click은 `UIEvent.detail === 2`입니다. 글에서는 단어를 고르고, +목록·파일에서는 열거나 [Rename](affordance-rename.md)으로 갈 수 있습니다. +횟수 손 자체는 닫혀 있고, 뜻은 장르 Intent입니다. + +```ts +import { clickCountAffordance } from "@interactive-os/json-document-affordance"; + +function onClick(event: MouseEvent, itemId: string) { + if (clickCountAffordance(event.detail) !== "double-click") return; + hostOpen(itemId); +} +``` + +호스트는 장르 Intent만 가집니다. 글 단어 범위는 [Caret](affordance-caret.md)에 +붙입니다. + +닫는 손: +- click / dblclick, `detail` 2 +- 글: 단어 범위 +- 항목: 열기 또는 Rename + +근거: [UIEvent.detail](https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail) diff --git a/docs/public/affordance-drag.md b/docs/public/affordance-drag.md new file mode 100644 index 00000000..96ff75aa --- /dev/null +++ b/docs/public/affordance-drag.md @@ -0,0 +1,69 @@ +# Drag + +Drag는 고른 대상을 포인터로 옮기는 손입니다. 누른 점에서 현재 점까지 +이동량이 생기고, 이동량이 있을 때만 옮기기를 확정합니다. 기하와 히트 +테스트는 호스트가 계산하고, editor에는 대상 ID와 이동량만 넘깁니다. + +```ts +import { + applyAffordance, + commitAffordance, + dragAffordance, +} from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent) { + applyAffordance( + dragAffordance(origin, { x: event.clientX, y: event.clientY }), + { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type === "translate") setOffset({ dx: hand.dx, dy: hand.dy }); + }, + }, + ); +} + +function onPointerUp(event: PointerEvent) { + const committed = commitAffordance( + dragAffordance(origin, { x: event.clientX, y: event.clientY }), + ); + if (!committed) return; + applyAffordance(committed, { + commit: (hand) => { + if (hand.type !== "translate") return; + editor.dispatch({ + type: "object.translate", + objectIds, + dx: hand.dx, + dy: hand.dy, + }); + }, + }); +} +``` + +모양과 그림은 제품이 정합니다. 고른 것을 잡고 옮기는 문법은 닫혀 있습니다. + +## TBD + +```ts +function onKeyDown(event: KeyboardEvent) { + const hand = dragKeyboardAffordance({ key: event.key, grab: drag !== null }); + if (hand === "cancel") drag = null; + if (hand?.type === "nudge") { + editor.dispatch({ + type: "object.translate", + objectIds, + dx: hand.dx ?? 0, + dy: hand.dy ?? 0, + }); + } +} +``` + +- [Drop](affordance-drop.md) 대상과 `no-drop` +- [Duplicate](affordance-copy-drag.md) +- 키보드만으로 옮기기 (APG는 드래그의 키보드 대안을 요구함) +- pointer capture 수명 diff --git a/docs/public/affordance-drop.md b/docs/public/affordance-drop.md new file mode 100644 index 00000000..6dc46cdc --- /dev/null +++ b/docs/public/affordance-drop.md @@ -0,0 +1,48 @@ +# Drop + +TBD. + +Drop은 [Drag](affordance-drag.md)의 끝입니다. 어디에 둘 수 있는지를 +커서가 말하고, 못 두는 자리는 `no-drop`입니다. + +```ts +import { + applyAffordance, + commitAffordance, + dropAffordance, +} from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent) { + applyAffordance(dropAffordance({ canDrop: hostCanDrop(event) }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); +} + +function onPointerUp(event: PointerEvent, cardId: string, columnId: string) { + const drop = dropAffordance({ canDrop: hostCanDrop(event) }); + applyAffordance(drop, { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + const committed = commitAffordance(drop); + if (!committed) return; + applyAffordance(committed, { + commit: (hand) => { + if (hand.type !== "move-drop") return; + editor.dispatch({ type: "card.move", cardId, columnId }); + }, + }); +} +``` + +커서는 호스트 화면 상태이고, 허용된 놓기만 json-document로 갑니다. + +닫는 손: +- drop 대상 위: `move` / `copy` / `alias` +- 불가능한 자리: `no-drop` +- Escape: [Escape](affordance-cancel.md) + +근거: [HTML Drag and Drop](https://html.spec.whatwg.org/multipage/dnd.html), [CSS UI cursor `no-drop`](https://www.w3.org/TR/css-ui-4/#cursor) diff --git a/docs/public/affordance-focus.md b/docs/public/affordance-focus.md new file mode 100644 index 00000000..c14b1cc1 --- /dev/null +++ b/docs/public/affordance-focus.md @@ -0,0 +1,35 @@ +# Focus + +TBD. + +Focus는 키보드의 포인터입니다. Tab과 Shift+Tab은 컴포넌트 사이로 옮기고, +화살표·Home·End는 컴포넌트 안에서 옮깁니다. Focus와 [Select](affordance-select.md)는 +다릅니다. Focus 표시는 항상 보여야 하고, 선택 표시와 겹치면 안 됩니다. + +```ts +import { focusAffordance } from "@interactive-os/json-document-affordance"; + +function onKeyDown(event: KeyboardEvent) { + const hand = focusAffordance(event); + if (hand?.type === "tab") return; + if (hand?.type === "move") { + const next = neighbor(focusKey, hand.direction); + setFocusKey(next); + return; + } + if (hand?.type === "boundary") { + setFocusKey(hand.edge === "start" ? ids[0] : ids.at(-1)); + } +} +``` + +호스트는 마크업과 초점 고리를 그립니다. 화살표가 선택까지 바꿀지는 +장르 Intent입니다. keymap을 제품마다 열지 않습니다. + +닫는 손: +- Tab / Shift+Tab: 컴포넌트 사이 탭 순서 +- 화살표 / Home / End / PageUp / PageDown: 컴포넌트 안 +- roving tabindex 또는 `aria-activedescendant` +- `:focus-visible` 윤곽. 마우스 클릭 초점과 키보드 초점을 같은 그림으로 강제하지 않음 + +근거: [APG Developing a Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/), [CSS UI outline](https://www.w3.org/TR/css-ui-4/#outline) diff --git a/docs/public/affordance-fold.md b/docs/public/affordance-fold.md new file mode 100644 index 00000000..88837052 --- /dev/null +++ b/docs/public/affordance-fold.md @@ -0,0 +1,65 @@ +# Expand/Collapse + +Expand/Collapse는 나무에서 가지를 접고 펼치는 손입니다. 오른쪽은 닫힌 부모를 펼치고, +왼쪽은 열린 부모를 접습니다. 펼칠 가지가 없으면 그 방향은 이웃 이동으로 +남습니다. 위아래 화살표는 보이는 줄을 옮깁니다. + +```ts +import { + applyAffordance, + treeAffordance, +} from "@interactive-os/json-document-affordance"; + +applyAffordance( + treeAffordance(command, { + expanded: expanded.has(nodeId), + hasChildren: hasChildren(nodeId), + }), + { + hand: (hand) => { + if (hand.type === "expand") { + setExpanded((current) => new Set(current).add(nodeId)); + return; + } + if (hand.type === "collapse") { + setExpanded((current) => { + const next = new Set(current); + next.delete(nodeId); + return next; + }); + return; + } + if (hand.type === "move") { + editor.dispatch({ type: "selection.set", nodeId, topology, mode: "replace" }); + } + }, + }, +); +``` + +접힘 상태는 호스트가 가진 화면 상태입니다. editor는 보이는 줄 Topology만 +받습니다. expand/collapse는 호스트 접힘 집합으로, move는 json-document +선택으로 갑니다. + +## TBD + +```ts +function onKeyDown(event: KeyboardEvent) { + const hand = disclosureAffordance({ + key: event.key, + expanded: expanded.has(sectionId), + }); + if (hand === "expand") setExpanded((current) => new Set(current).add(sectionId)); + if (hand === "collapse") { + setExpanded((current) => { + const next = new Set(current); + next.delete(sectionId); + return next; + }); + } +} +``` + +- Accordion / Disclosure의 Enter·Space 접힘 +- `aria-expanded`와 호스트 접힘 집합의 동기 +- 가로 나무에서 위·아래가 접힘인지 이웃인지 diff --git a/docs/public/affordance-forbid.md b/docs/public/affordance-forbid.md new file mode 100644 index 00000000..c2d3b483 --- /dev/null +++ b/docs/public/affordance-forbid.md @@ -0,0 +1,27 @@ +# Not-allowed + +TBD. + +Not-allowed는 지금 그 손을 쓸 수 없음을 커서로 말하는 손입니다. 비활성 +대상은 `not-allowed`, 놓을 수 없는 자리는 `no-drop`입니다. + +```ts +import { forbiddenCursor } from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent, itemId: string) { + event.currentTarget.style.cursor = forbiddenCursor({ + allowed: hostCanEdit(itemId), + dropping: drag !== null, + }); +} +``` + +이 손은 json-document가 아니라 호스트 커서입니다. 거절된 포인터는 +`editor.dispatch`를 호출하지 않습니다. + +닫는 손: +- `not-allowed`: 요청한 동작 불가 +- `no-drop`: 이 자리에 놓을 수 없음 +- 비활성 대상의 초점 가능 여부는 APG 규칙, 호스트가 가짐 + +근거: [CSS UI cursor `not-allowed` / `no-drop`](https://www.w3.org/TR/css-ui-4/#cursor), [APG disabled focus](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) diff --git a/docs/public/affordance-history.md b/docs/public/affordance-history.md new file mode 100644 index 00000000..14601c6c --- /dev/null +++ b/docs/public/affordance-history.md @@ -0,0 +1,46 @@ +# Undo + +Undo는 값과 선택을 함께 되돌리는 손입니다. Mod+Z는 undo, Mod+Shift+Z는 +redo입니다. 버튼은 `canUndo` / `canRedo`를 읽어 꺼집니다. + +```ts +import { + applyAffordance, + historyAffordance, +} from "@interactive-os/json-document-affordance"; + +let commands = { + undo: { name: "undo" as const, disabled: true }, + redo: { name: "redo" as const, disabled: true }, +}; +applyAffordance(historyAffordance({ + canUndo: editor.snapshot.canUndo, + canRedo: editor.snapshot.canRedo, +}), { + hand: (hand) => { + if (hand.type === "history") commands = { undo: hand.undo, redo: hand.redo }; + }, +}); + + +``` + +History 기록 자체는 Editing이 가지고, 어포던스는 그 손을 닫습니다. + +## TBD + +```ts +function onKeyDown(event: KeyboardEvent) { + const command = resolveAffordanceKey(event); + if (command?.type === "redo") editor.redo(); +} +``` + +- Mod+Y redo 변종 +- 연속 입력의 묶음 단위 +- 협업 replica에서 내 기여만 되돌리기는 Collaboration History diff --git a/docs/public/affordance-hover.md b/docs/public/affordance-hover.md new file mode 100644 index 00000000..03e2c37c --- /dev/null +++ b/docs/public/affordance-hover.md @@ -0,0 +1,29 @@ +# Hover + +TBD. + +Hover는 누르지 않은 포인터가 대상 위에 있을 때 가능한 손을 드러냅니다. +커서가 바뀌고, 잠시 뒤 툴팁이 열릴 수 있습니다. `help` 커서가 도움말이 +있음을 가리킵니다. + +```ts +import { hoverAffordance, hoverCursor } from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent, itemId: string) { + event.currentTarget.style.cursor = hoverCursor("help"); + const hand = hoverAffordance({ elapsedMs, inside: true }); + if (hand === "tooltip") setTooltip(itemId); + if (hand === null) setTooltip(null); +} +``` + +호스트는 툴팁 그림을 그립니다. 이 손은 json-document가 아니라 호스트 +화면 상태입니다. + +닫는 손: +- pointerenter / pointerleave +- 짧은 체류: 커서 hint +- 기본 지연 후 tooltip +- `help` 커서 + +근거: [APG Tooltip](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/), [Apple HIG pointing devices](https://developer.apple.com/design/human-interface-guidelines/pointing-devices), [CSS UI cursor](https://www.w3.org/TR/css-ui-4/#cursor) diff --git a/docs/public/affordance-marquee.md b/docs/public/affordance-marquee.md new file mode 100644 index 00000000..c0dea6f2 --- /dev/null +++ b/docs/public/affordance-marquee.md @@ -0,0 +1,60 @@ +# Marquee + +TBD. + +Marquee는 빈 평면에서 사각형을 끌어 여러 대상을 집는 손입니다. 고른 +대상을 옮기는 [Drag](affordance-drag.md)와 다릅니다. `crosshair` / `cell` +커서가 이 손을 가리킬 수 있습니다. + +```ts +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), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type === "select" && hand.rect) setRect(hand.rect); + }, + }); +} + +function onPointerUp(event: PointerEvent) { + const committed = commitAffordance(marqueeAffordance(origin, { x: event.offsetX, y: event.offsetY })); + 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, + }); + }, + }); + }, + }); +} +``` + +호스트는 히트 테스트와 기하를 계산합니다. 어떤 키가 범위에 들어오는지는 +호스트가 보고, 손이 replace인지 extend인지는 Affordance가 닫고, 선택은 +json-document로 갑니다. + +닫는 손: +- 빈 곳에서 pointerdown → move → up +- Shift: 기존 고르기에 더함 +- Mod: 토글 +- 칸 위 `cell` 커서, 그림 위 `crosshair` + +근거: [Apple HIG band selection](https://developer.apple.com/design/human-interface-guidelines/pointing-devices), [CSS UI cursor `cell` / `crosshair`](https://www.w3.org/TR/css-ui-4/#cursor) diff --git a/docs/public/affordance-nudge.md b/docs/public/affordance-nudge.md new file mode 100644 index 00000000..f70d329b --- /dev/null +++ b/docs/public/affordance-nudge.md @@ -0,0 +1,31 @@ +# Nudge + +TBD. + +Nudge는 고른 대상을 키보드로 조금 옮기는 손입니다. 화살표는 한 단위, +Shift+화살표는 큰 단위입니다. 항목 이웃으로 초점을 옮기는 +[Select](affordance-select.md)와 다릅니다. + +```ts +import { 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, + }); +} +``` + +호스트는 단위(px, 칸, 그리드)를 곱합니다. 이동은 json-document로 갑니다. + +닫는 손: +- Arrow: 1 단위 +- Shift+Arrow: 10 단위 +- 키 반복은 호스트 플랫폼 기본 + +근거: 캔버스·슬라이드 편집기 관례. Object 장르 Intent가 단위를 가짐. diff --git a/docs/public/affordance-pan.md b/docs/public/affordance-pan.md new file mode 100644 index 00000000..342deab8 --- /dev/null +++ b/docs/public/affordance-pan.md @@ -0,0 +1,41 @@ +# Pan + +TBD. + +Pan은 대상을 옮기지 않고 보이는 평면을 옮기는 손입니다. `grab` / +`grabbing` / `all-scroll` 커서가 이 손을 가리킵니다. + +```ts +import { applyAffordance, panAffordance } from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent) { + applyAffordance( + panAffordance({ + spaceKey, + buttons: event.buttons, + origin, + point: { x: event.clientX, y: event.clientY }, + }), + { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type !== "translate") return; + setViewport({ x: hand.dx, y: hand.dy }); + }, + }, + ); +} +``` + +이 손은 json-document가 아니라 호스트 뷰포트입니다. 이동량은 +[Drag](affordance-drag.md)와 같은 `translate` 손입니다. + +닫는 손: +- Space + 드래그 +- 가운데 버튼 드래그 +- `grab` → `grabbing` +- `all-scroll` + +근거: [CSS UI cursor `grab` / `all-scroll`](https://www.w3.org/TR/css-ui-4/#cursor) diff --git a/docs/public/affordance-rename.md b/docs/public/affordance-rename.md new file mode 100644 index 00000000..18c7a3cc --- /dev/null +++ b/docs/public/affordance-rename.md @@ -0,0 +1,36 @@ +# Rename + +TBD. + +Rename은 고른 대상의 레이블을 고치는 손입니다. F2와 느린 double-click이 +같은 손을 엽니다. Escape는 [Escape](affordance-cancel.md)입니다. + +```ts +import { renameAffordance } from "@interactive-os/json-document-affordance"; + +function onKeyDown(event: KeyboardEvent) { + const hand = renameAffordance(event); + if (hand === "begin") setRenaming(focusKey); + if (hand === "cancel") setRenaming(null); + if (hand === "commit" && renaming) { + editor.dispatch({ type: "item.rename", itemId: renaming, label: draft }); + setRenaming(null); + } +} + +function onClick(event: MouseEvent, itemId: string) { + if (renameAffordance({ type: "pointer", detail: event.detail, intervalMs }) === "begin") { + setRenaming(itemId); + } +} +``` + +호스트는 레이블 필드를 그립니다. begin/cancel은 호스트 화면 상태이고, +commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다. + +닫는 손: +- F2 +- 느린 double-click (빠른 것은 [Double-click](affordance-double-click.md)) +- Enter로 확정, Escape로 취소 + +근거: Finder/Explorer/VS Code, [UIEvent.detail](https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail) diff --git a/docs/public/affordance-resize.md b/docs/public/affordance-resize.md new file mode 100644 index 00000000..2545b408 --- /dev/null +++ b/docs/public/affordance-resize.md @@ -0,0 +1,49 @@ +# Resize + +TBD. + +Resize는 가장자리·모서리·칸 경계·창 분할선을 움직이는 손입니다. +CSS predefined 리사이즈 커서가 이 손을 닫습니다. + +```ts +import { resizeCursor, resizeOffset } from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent, edge: "se") { + event.currentTarget.style.cursor = resizeCursor(edge); + const offset = resizeOffset( + origin, + { x: event.clientX, y: event.clientY }, + edge, + event, + ); + setPreview(offset); +} + +function onPointerUp(event: PointerEvent, objectId: string, edge: "se") { + const offset = resizeOffset( + origin, + { x: event.clientX, y: event.clientY }, + edge, + event, + ); + editor.dispatch({ + type: "object.resize", + objectId, + dx: offset.dx, + dy: offset.dy, + }); +} +``` + +커서는 호스트 화면 상태이고, 확정된 크기만 json-document로 갑니다. +분할선 화살표는 APG Window Splitter와 같고, Shift는 비율, Alt는 가운데 +기준입니다. + +닫는 손: +- 모서리: `n-resize` … `nwse-resize` +- 칸/행: `col-resize` / `row-resize` +- 분할선: 화살표, Enter로 접기 +- Shift: 비율 고정 +- Alt: 가운데 기준 + +근거: [CSS UI cursor resize](https://www.w3.org/TR/css-ui-4/#cursor), [CSS UI resize](https://www.w3.org/TR/css-ui-4/#resize), [APG Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) diff --git a/docs/public/affordance-scroll.md b/docs/public/affordance-scroll.md new file mode 100644 index 00000000..99413306 --- /dev/null +++ b/docs/public/affordance-scroll.md @@ -0,0 +1,39 @@ +# Scroll + +TBD. + +Scroll은 휠로 보이는 내용을 옮기는 손입니다. 드래그 중 가장자리에 닿으면 +자동으로 더 굴러갑니다. [Zoom](affordance-zoom.md)의 Mod+휠과 구분합니다. + +```ts +import { + wheelAffordance, + autoscrollAffordance, +} from "@interactive-os/json-document-affordance"; + +function onWheel(event: WheelEvent) { + const hand = wheelAffordance(event); + if (hand.type === "scroll") scroller.scrollBy(0, hand.dy); + if (hand.type === "zoom") setScale(hostZoom(scale, hand.delta)); +} + +function onPointerMove(event: PointerEvent) { + const auto = autoscrollAffordance({ + x: event.clientX, + y: event.clientY, + width: scroller.clientWidth, + height: scroller.clientHeight, + edge: 16, + }); + if (auto) scroller.scrollBy(auto.dx, auto.dy); +} +``` + +이 손은 호스트 overflow입니다. 문서 값은 바꾸지 않습니다. + +닫는 손: +- wheel (수정 키 없음) +- 드래그 중 가장자리 autoscroll +- 초점 대상이 보이도록 scroll-into-view는 호스트 + +근거: [UI Events wheel](https://www.w3.org/TR/uievents/), [Pointer Events](https://www.w3.org/TR/pointerevents/) diff --git a/docs/public/affordance-select.md b/docs/public/affordance-select.md new file mode 100644 index 00000000..1e688171 --- /dev/null +++ b/docs/public/affordance-select.md @@ -0,0 +1,50 @@ +# Select + +Select는 대상을 집는 손입니다. 클릭은 그 대상으로 바꾸고, Shift는 +범위를 늘리며, Mod는 토글합니다. 화살표는 이웃으로 옮기고, Shift+화살표는 +범위를 늘립니다. + +```ts +import { + applyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; + +function onPointerDown(event: PointerEvent, itemId: string) { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type === "select") { + editor.dispatch({ type: "selection.set", itemId, mode: hand.operation }); + } + }, + }); +} +``` + +호스트는 보이는 키와 장르 Intent만 넘깁니다. keymap을 덮어쓰지 않습니다. + +## TBD + +```ts +function onKeyDown(event: KeyboardEvent) { + const hand = selectAllAffordance({ + key: event.key, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + allSelected: editor.selectedItemIds.length === ids.length, + }); + if (hand === "select-all") { + editor.dispatch({ type: "selection.set", itemIds: ids, mode: "replace" }); + } + if (hand === "clear") { + editor.dispatch({ type: "selection.set", itemIds: [], mode: "replace" }); + } +} +``` + +- Mod+A Select all 토글 +- Home / End / PageUp / PageDown은 `resolveAffordanceKey`의 boundary +- selection follows focus vs focus-only move는 [Focus](affordance-focus.md) +- 글 단어·줄 범위는 [Double-click](affordance-double-click.md)· + [Triple-click](affordance-triple-click.md)·[Caret](affordance-caret.md) +- 빈 평면의 여러 대상은 [Marquee](affordance-marquee.md) diff --git a/docs/public/affordance-snap.md b/docs/public/affordance-snap.md new file mode 100644 index 00000000..2c71819c --- /dev/null +++ b/docs/public/affordance-snap.md @@ -0,0 +1,54 @@ +# Snap + +TBD. + +Snap은 [Drag](affordance-drag.md)·[Resize](affordance-resize.md)· +[Nudge](affordance-nudge.md) 중에 그리드나 가이드에 붙는 손입니다. +수정 키를 누르면 붙지 않습니다. + +```ts +import { + applyAffordance, + commitAffordance, + dragAffordance, + snapAffordance, +} from "@interactive-os/json-document-affordance"; + +function onPointerUp(event: PointerEvent) { + const committed = commitAffordance( + dragAffordance(origin, { x: event.clientX, y: event.clientY }), + ); + if (!committed) return; + 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, + dx: snapped.dx, + dy: snapped.dy, + }); + }, + }, + ); + }, + }); +} +``` + +가이드 기하는 호스트가 가지고, 붙은 좌표만 json-document로 갑니다. + +닫는 손: +- 이동·리사이즈 중 스냅 +- 수정 키로 스냅 해제 +- 회전 15° 스냅은 장르 Intent (Object) + +근거: 캔버스·슬라이드 편집기 관례. CSS predefined 커서는 없음. diff --git a/docs/public/affordance-triple-click.md b/docs/public/affordance-triple-click.md new file mode 100644 index 00000000..99c353a3 --- /dev/null +++ b/docs/public/affordance-triple-click.md @@ -0,0 +1,29 @@ +# Triple-click + +TBD. + +Triple-click은 `UIEvent.detail === 3`입니다. 글에서는 줄 또는 문단을 +고릅니다. 항목 목록에서는 보통 쓰지 않습니다. + +```ts +import { clickCountAffordance } from "@interactive-os/json-document-affordance"; + +function onClick(event: MouseEvent, blockId: string) { + if (clickCountAffordance(event.detail) !== "triple-click") return; + editor.dispatch({ + type: "selection.set", + blockId, + mode: "replace", + offset: 0, + }); +} +``` + +호스트는 줄/문단 기하를 그립니다. 횟수는 [Double-click](affordance-double-click.md)과 +같은 손을 씁니다. + +닫는 손: +- click `detail` 3 +- 글: 줄 / 문단 범위 + +근거: [UIEvent.detail](https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail) diff --git a/docs/public/affordance-typeahead.md b/docs/public/affordance-typeahead.md new file mode 100644 index 00000000..3f57d60e --- /dev/null +++ b/docs/public/affordance-typeahead.md @@ -0,0 +1,40 @@ +# Typeahead + +TBD. + +Typeahead는 인쇄 글쇠로 목록·나무에서 이름으로 건너뛰는 손입니다. 한 글자는 +그 글자로 시작하는 다음 항목, 빠르게 이어 치면 그 문자열로 시작합니다. + +```ts +import { + applyAffordance, + typeaheadAffordance, +} from "@interactive-os/json-document-affordance"; + +function onKeyDown(event: KeyboardEvent) { + applyAffordance(typeaheadAffordance({ + buffer, + key: event.key, + elapsedMs: event.timeStamp - lastType, + names: items.map((item) => item.label), + from: focusedLabel, + }), { + hand: (hand) => { + if (hand.type !== "typeahead") return; + setBuffer(hand.buffer); + const itemId = items.find((item) => item.label === hand.name)?.id; + if (itemId) editor.dispatch({ type: "selection.set", itemId, mode: "replace" }); + }, + }); +} +``` + +호스트는 보이는 이름을 줍니다. 점프한 키는 호스트 Focus로 가고, 선택까지 +바꿀지는 장르 Intent입니다. + +닫는 손: +- 한 문자: 그 prefix의 다음 항목 +- 연속 문자: 문자열 prefix +- 짧은 시간 안에만 이어 붙임 + +근거: [APG Listbox type-ahead](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) diff --git a/docs/public/affordance-zoom.md b/docs/public/affordance-zoom.md new file mode 100644 index 00000000..97cef23a --- /dev/null +++ b/docs/public/affordance-zoom.md @@ -0,0 +1,38 @@ +# Zoom + +TBD. + +Zoom은 보이는 배율을 바꾸는 손입니다. `zoom-in` / `zoom-out` 커서가 +이 손을 가리킵니다. CSS `resize`와 무관합니다. + +```ts +import { + zoomAffordance, + zoomCursor, + wheelAffordance, +} from "@interactive-os/json-document-affordance"; + +function onPointerMove(event: PointerEvent) { + event.currentTarget.style.cursor = zoomCursor("in"); +} + +function onWheel(event: WheelEvent) { + const hand = wheelAffordance(event); + if (hand.type === "zoom") setScale(hostZoom(scale, hand.delta)); +} + +function onKeyDown(event: KeyboardEvent) { + const hand = zoomAffordance(event); + if (hand?.type === "in") setScale(scale * 1.1); + if (hand?.type === "out") setScale(scale / 1.1); +} +``` + +이 손은 호스트 뷰포트입니다. 문서 값은 바꾸지 않습니다. + +닫는 손: +- Mod + wheel +- + / − +- `zoom-in` / `zoom-out` 커서 + +근거: [CSS UI cursor `zoom-in` / `zoom-out`](https://www.w3.org/TR/css-ui-4/#cursor), [CSS UI resize note](https://www.w3.org/TR/css-ui-4/#resize) diff --git a/docs/public/affordance.md b/docs/public/affordance.md new file mode 100644 index 00000000..ee921af8 --- /dev/null +++ b/docs/public/affordance.md @@ -0,0 +1,131 @@ +# Affordance + +제품의 화면은 호스트가 그립니다. json-document가 최전선에서 주는 것은 +리스트박스나 나무가 아니라, 30년 동안 같은 손으로 학습된 키보드와 +마우스와 커서입니다. + +`@interactive-os/json-document-affordance`가 그 계약을 닫습니다. 단축키를 +제품마다 바꾸지 않습니다. 구현이 없는 손은 TBD로 남기고, 화면 위젯으로 +대체하지 않습니다. TBD 페이지의 코드는 사용법 명세입니다. 패키지에 +아직 없습니다. + +호스트 이벤트와 json-document 사이를 이렇게 잇습니다. 시점은 리스너가 +고르고, 읽는 법은 `applyAffordance` 하나입니다. 미리보기는 `{ hand, cursor }` +이고, 쓰기는 `commitAffordance` 다음 `{ commit }` 포트입니다. + +```ts +import { + applyAffordance, + forbiddenCursor, + pointerSelect, +} from "@interactive-os/json-document-affordance"; + +function onPointerDown(event: PointerEvent, itemId: string) { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type === "select") { + editor.dispatch({ type: "selection.set", itemId, mode: hand.operation }); + } + }, + }); +} + +function onPointerMove(event: PointerEvent) { + applyAffordance(forbiddenCursor({ allowed: hostCanDrop(event), dropping: true }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); +} +``` + +```sh +npm i @interactive-os/json-document-affordance +``` + +Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command로 +번역합니다. Connector는 구독과 질의를 붙입니다. 어포던스는 그 command가 +무슨 손인지를 정합니다. 호스트는 마크업과 장르 Intent만 가집니다. + +이 층이 닫히는 축은 세 개입니다. + +- 키보드: [APG Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)가 + Tab은 컴포넌트 사이, 화살표는 컴포넌트 안, 초점과 선택은 다르다고 닫는다. +- 마우스: [Pointer Events](https://www.w3.org/TR/pointerevents/)와 + [UI Events](https://www.w3.org/TR/uievents/)가 click·auxclick·contextmenu· + wheel·pointer capture·`detail` 횟수를 닫는다. +- 커서: [CSS UI 4 predefined cursors](https://www.w3.org/TR/css-ui-4/#cursor)가 + 지금 이 자리에서 가능한 손을 키워드 집합으로 닫는다. + +## 이미 닫힌 손 + +| Affordance | API | Hand | +| --- | --- | --- | +| [Select](affordance-select.md) | `pointerSelect`, `resolveAffordanceKey` | 클릭, Shift 범위, Mod 토글, 화살표 | +| [Expand/Collapse](affordance-fold.md) | `treeAffordance` | 나무 왼쪽 접힘, 오른쪽 펼침 | +| [Drag](affordance-drag.md) | `dragAffordance`, `commitAffordance` | 고른 대상을 포인터로 옮김 | +| [Undo](affordance-history.md) | `historyAffordance` | Mod+Z, Mod+Shift+Z | + +## 키보드 TBD + +| Affordance | API | Hand | +| --- | --- | --- | +| [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 + +| Affordance | API | Hand | +| --- | --- | --- | +| [Hover](affordance-hover.md) | `hoverAffordance`, `hoverCursor` | hover, 툴팁 지연, 커서 교체 | +| [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 | + +## 커서가 닫는 손 + +CSS UI 4 ``는 이 집합이 끝입니다. 새 커서 이름을 +만들지 않습니다. + +| 커서 | 손 | +| --- | --- | +| `auto`, `default`, `none` | 호스트·UA 기본. 이 층이 새 문법을 열지 않음 | +| `pointer` | [Activate](affordance-activate.md) | +| `text`, `vertical-text` | [Caret](affordance-caret.md) | +| `cell`, `crosshair` | [Marquee](affordance-marquee.md), [Select](affordance-select.md) | +| `context-menu` | [Context menu](affordance-context-menu.md) | +| `help` | [Hover](affordance-hover.md) | +| `move`, `grab`, `grabbing` | [Drag](affordance-drag.md), [Pan](affordance-pan.md) | +| `copy`, `alias` | [Duplicate](affordance-copy-drag.md) | +| `no-drop`, `not-allowed` | [Not-allowed](affordance-forbid.md), [Drop](affordance-drop.md) | +| `n-resize` … `nwse-resize`, `col-resize`, `row-resize` | [Resize](affordance-resize.md) | +| `all-scroll` | [Pan](affordance-pan.md) | +| `zoom-in`, `zoom-out` | [Zoom](affordance-zoom.md) | +| `progress`, `wait` | UA 바쁨. 편집 손이 아님 | + +## 이 층이 아닌 것 + +- 값 삽입, IME로 글자를 쓰는 일, 클립보드로 값을 옮기는 완전한 편집기는 + Hands입니다. +- chord를 command로 번역하는 일은 Adapter입니다. +- 구독과 질의는 Connector입니다. +- 마크업, ARIA role, 히트 테스트, 기하, 장르 Intent는 호스트입니다. +- 터치·펜·눈은 이 패키지가 닫는 손이 아닙니다. + +라이브 화면은 닫힌 손을 호스트 그림에 붙인 증명입니다. 위젯을 가져가는 +입구가 아닙니다. diff --git a/docs/public/concepts.md b/docs/public/concepts.md index c48e5079..167d30c6 100644 --- a/docs/public/concepts.md +++ b/docs/public/concepts.md @@ -7,11 +7,11 @@ Quickstart에서는 JSON을 읽고, 변경을 적용하고, 결과를 구독했 json-document를 제품에 연결하는 흐름은 이렇게 이어집니다. 먼저 JSON Document가 값을 다루고, Editing이 사용자의 편집 상태를 더합니다. Adapter는 브라우저 플랫폼 계약을 붙이고, Connector는 라이브러리 생태계를 -연결합니다. +연결합니다. Affordance는 키보드와 마우스의 관례적 손을 최전선에서 닫습니다. ```txt -JSON Document → Editing → Adapter → Connector → 제품 화면 -값과 변경 선택과 작업 플랫폼 계약 라이브러리 렌더링과 입력 +JSON Document → Editing → Adapter → Connector → Affordance +값과 변경 선택과 작업 플랫폼 계약 라이브러리 키보드와 마우스 ``` ## JSON Document가 값을 다룬다 @@ -84,6 +84,8 @@ React의 구독 방식으로 전달합니다. TanStack Table Connector는 화면 - [Adapters](adapters.md): 키보드, clipboard, contenteditable 플랫폼 변환 - [Connectors](connectors.md): React, Zod, TanStack Table 같은 라이브러리 연결 - [React editing](react-editing.md): 선택 범위와 커서를 React 질의로 그리기 +- [Affordance](affordance.md): 키보드와 마우스의 관례적 손 같은 공책을 여러 참여자가 쓰려면 [Collaboration](collaboration.md)으로 -갑니다. 장르의 손은 [Hands](hands.md)에서 고릅니다. +갑니다. 장르 선반은 [Hands](hands.md)에서 고릅니다. Hands는 위 파이프라인 +한 층이 아닙니다. diff --git a/docs/public/overview.md b/docs/public/overview.md index 304955d6..662d5c3e 100644 --- a/docs/public/overview.md +++ b/docs/public/overview.md @@ -77,7 +77,7 @@ Hands는 그 전형적 손의 최소 완성본입니다. 한 줄 목록을 집 Object, Sheet, Tree, Kanban, Database가 그 목록입니다. 고르려면 [Hands](hands.md)로 갑니다. -## 플랫폼, 라이브러리, 제품 화면 +## 플랫폼, 라이브러리, Affordance 브라우저에서 쓰려면 키보드와 clipboard, contenteditable 같은 플랫폼 계약을 같은 공개 API에 맞춰 번역해야 합니다. Adapter가 그 일을 합니다. @@ -88,19 +88,20 @@ React로 그리거나 Zod로 검사하려면 이름 있는 라이브러리의 계약에 맞춰야 합니다. Connector가 그 일을 합니다. document 변경은 React 구독으로 흐르고, 표의 보이는 행과 열은 Sheet의 Topology가 됩니다. -툴바, 리스트박스, 격자, 문서 줄, 캔버스, 나무, 보드는 제품이 그리는 -화면입니다. 편집 상태를 읽어 되돌리기 버튼을 끄거나, 고른 칸을 칠합니다. +고르기, 접기, 드래그, 되돌리기는 제품이 json-document를 만지는 손입니다. +화면은 호스트가 그리고, 단축키와 마우스 문법은 Affordance가 닫습니다. -플랫폼 계약과 라이브러리 연결과 호스트 화면은 높이가 같고 하는 일만 -다릅니다. 필요한 것만 고릅니다. [Adapters](adapters.md)와 -[Connectors](connectors.md)에서 이어서 읽습니다. +Adapter와 Connector는 환경에 붙입니다. Affordance는 그 위에서 호스트가 +만지는 최전선입니다. 필요한 Adapter와 Connector만 고릅니다. +[Adapters](adapters.md)와 [Connectors](connectors.md), +[Affordance](affordance.md)에서 이어서 읽습니다. ## 편집 플랫폼의 커널 json-document는 여러 화면이 같은 문서를 쓰게 하는 편집 플랫폼의 커널입니다. JSON Document와 Collaboration이 값을 다룹니다. Editing이 -손과 기억을 더합니다. Hands가 장르의 전형을 보여 줍니다. Adapter와 -Connector와 제품 화면이 그 손을 실제 환경에 붙입니다. +손과 기억을 더합니다. Adapter와 Connector가 환경에 붙이고, Affordance가 +최전선의 손을 닫습니다. Hands는 그 다음에 보는 장르 선반입니다. 여러 화면이 같은 주소와 실행 취소와 협업을 쓰게 하려는 자리가 이 커널입니다. 먼저 값을 만져 보려면 [Quickstart](quickstart.md)에서 작은 diff --git a/package-lock.json b/package-lock.json index 4ef8d301..61a63001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/json-document-react", "packages/json-document-react-hook-form", "packages/json-document-ajv", + "packages/json-document-affordance", "packages/json-document-zod", "packages/json-document-tanstack-table", "packages/json-document-web", @@ -940,6 +941,10 @@ "resolved": "packages/json-document", "link": true }, + "node_modules/@interactive-os/json-document-affordance": { + "resolved": "packages/json-document-affordance", + "link": true + }, "node_modules/@interactive-os/json-document-ajv": { "resolved": "packages/json-document-ajv", "link": true @@ -5433,6 +5438,20 @@ "vitest": "^4.1.7" } }, + "packages/json-document-affordance": { + "name": "@interactive-os/json-document-affordance", + "version": "0.1.0-rc.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document-web": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1" + } + }, "packages/json-document-ajv": { "name": "@interactive-os/json-document-ajv", "version": "0.1.0-rc.0", @@ -5674,6 +5693,7 @@ "site": { "name": "@interactive-os/json-document-site", "dependencies": { + "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-ajv": "*", "@interactive-os/json-document-contenteditable": "*", "@interactive-os/json-document-editing": "*", diff --git a/package.json b/package.json index b02b44f5..37e4db44 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "packages/json-document-react", "packages/json-document-react-hook-form", "packages/json-document-ajv", + "packages/json-document-affordance", "packages/json-document-zod", "packages/json-document-tanstack-table", "packages/json-document-web", diff --git a/packages/json-document-affordance/LICENSE b/packages/json-document-affordance/LICENSE new file mode 100644 index 00000000..6a984193 --- /dev/null +++ b/packages/json-document-affordance/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 interactive-os contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md new file mode 100644 index 00000000..c5834684 --- /dev/null +++ b/packages/json-document-affordance/README.md @@ -0,0 +1,31 @@ +# @interactive-os/json-document-affordance + +Official keyboard and mouse editing affordances for json-document hosts. +Preview results are `{ hand, cursor? }`. A write is `commitAffordance` +then `applyAffordance(..., { commit })`. Hosts keep markup and genre +Intent. It does not render widgets. + +```sh +npm i @interactive-os/json-document-affordance +``` + +```ts +import { + applyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; + +applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type === "select") { + editor.dispatch({ type: "selection.set", itemId, mode: hand.operation }); + } + }, +}); +``` + +Keyboard Adapter still translates chords. This package decides the +affordance those commands mean. React Connector still answers selection +queries through `useEditing` ports. + +Usage: [Affordance](https://developer-1px.github.io/json-document/docs/affordance) diff --git a/packages/json-document-affordance/package.json b/packages/json-document-affordance/package.json new file mode 100644 index 00000000..fbf340d0 --- /dev/null +++ b/packages/json-document-affordance/package.json @@ -0,0 +1,44 @@ +{ + "name": "@interactive-os/json-document-affordance", + "version": "0.1.0-rc.0", + "description": "Official keyboard and mouse editing affordances for json-document hosts.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/json-document-affordance" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "tag": "next" + }, + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -b tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1" + }, + "devDependencies": { + "@interactive-os/json-document-web": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + } +} diff --git a/packages/json-document-affordance/src/drag.ts b/packages/json-document-affordance/src/drag.ts new file mode 100644 index 00000000..3378cb10 --- /dev/null +++ b/packages/json-document-affordance/src/drag.ts @@ -0,0 +1,134 @@ +import type { WebModifierState } from "@interactive-os/json-document-web"; +import type { + AffordancePreview, + AffordanceRect, +} from "./result.js"; + +export type Point = { + readonly x: number; + readonly y: number; +}; + +export type DragOffset = { + readonly dx: number; + readonly dy: number; +}; + +export type Rect = AffordanceRect; + +function dragOffset(origin: Point, point: Point): DragOffset { + return { + dx: point.x - origin.x, + dy: point.y - origin.y, + }; +} + +export function dragAffordance(origin: Point, point: Point): AffordancePreview { + const offset = dragOffset(origin, point); + return { + hand: { type: "translate", dx: offset.dx, dy: offset.dy }, + cursor: "grabbing", + }; +} + +export function dragOperation(modifiers: WebModifierState & { readonly altKey?: boolean }): AffordancePreview { + if (modifiers.altKey) return { hand: { type: "copy" }, cursor: "copy" }; + return { hand: { type: "move-drop" }, cursor: "move" }; +} + +function marqueeRect(origin: Point, point: Point): Rect { + return { + x: Math.min(origin.x, point.x), + y: Math.min(origin.y, point.y), + width: Math.abs(point.x - origin.x), + height: Math.abs(point.y - origin.y), + }; +} + +export function marqueeAffordance(origin: Point, point: Point): AffordancePreview { + const rect = marqueeRect(origin, point); + return { + hand: { type: "select", operation: "replace", rect }, + cursor: "crosshair", + }; +} + +export function panAffordance(input: { + readonly spaceKey: boolean; + readonly buttons: number; + readonly origin?: Point; + readonly point?: Point; +}): AffordancePreview { + if (!input.spaceKey && input.buttons !== 4) return { hand: null }; + if (input.buttons === 0) return { hand: null, cursor: "grab" }; + if (input.origin && input.point) { + return { + hand: { + type: "translate", + dx: input.point.x - input.origin.x, + dy: input.point.y - input.origin.y, + }, + cursor: "grabbing", + }; + } + return { hand: { type: "translate", dx: 0, dy: 0 }, cursor: "grabbing" }; +} + +export function snapAffordance( + point: Point, + options: { readonly grid: number; readonly disable?: boolean }, +): AffordancePreview { + if (options.disable || options.grid <= 0) { + return { hand: { type: "translate", dx: point.x, dy: point.y } }; + } + const grid = options.grid; + return { + hand: { + type: "translate", + dx: Math.round(point.x / grid) * grid, + dy: Math.round(point.y / grid) * grid, + }, + }; +} + +export function nudgeAffordance(stroke: { + readonly key: string; + readonly shiftKey: boolean; +}): AffordancePreview { + const step = stroke.shiftKey ? 10 : 1; + if (stroke.key === "ArrowRight") return { hand: { type: "nudge", dx: step, dy: 0 } }; + if (stroke.key === "ArrowLeft") return { hand: { type: "nudge", dx: -step, dy: 0 } }; + if (stroke.key === "ArrowDown") return { hand: { type: "nudge", dx: 0, dy: step } }; + if (stroke.key === "ArrowUp") return { hand: { type: "nudge", dx: 0, dy: -step } }; + return { hand: null }; +} + +export function dropAffordance(input: { + readonly canDrop: boolean; + readonly operation?: "move" | "copy"; +}): AffordancePreview { + if (!input.canDrop) return { hand: null, cursor: "no-drop" }; + if (input.operation === "copy") return { hand: { type: "copy" }, cursor: "copy" }; + return { hand: { type: "move-drop" }, cursor: "move" }; +} + +export function forbiddenCursor(input: { + readonly allowed: boolean; + readonly dropping?: boolean; +}): AffordancePreview { + if (input.allowed) { + return { hand: null, cursor: input.dropping ? "move" : "default" }; + } + return { hand: null, cursor: input.dropping ? "no-drop" : "not-allowed" }; +} + +export function hoverAffordance(input: { + readonly elapsedMs: number; + readonly inside: boolean; + readonly delayMs?: number; +}): AffordancePreview { + if (!input.inside) return { hand: null }; + const delayMs = input.delayMs ?? 400; + if (input.elapsedMs >= delayMs) return { hand: { type: "hover", phase: "tooltip" } }; + return { hand: { type: "hover", phase: "hint" }, cursor: "help" }; +} diff --git a/packages/json-document-affordance/src/fold.ts b/packages/json-document-affordance/src/fold.ts new file mode 100644 index 00000000..0860b13b --- /dev/null +++ b/packages/json-document-affordance/src/fold.ts @@ -0,0 +1,34 @@ +import type { AffordancePreview } from "./result.js"; + +export type TreeFoldNode = { + readonly expanded: boolean; + readonly hasChildren: boolean; +}; + +export type TreeMoveDirection = "up" | "down" | "left" | "right" | "previous" | "next"; + +export type TreeAffordance = + | { readonly type: "expand" } + | { readonly type: "collapse" } + | { readonly type: "move"; readonly direction: TreeMoveDirection }; + +export function treeAffordance( + command: { readonly type: "move"; readonly direction: TreeMoveDirection }, + node: TreeFoldNode, +): AffordancePreview { + if (command.direction === "right" && node.hasChildren && !node.expanded) { + return { hand: { type: "expand" } }; + } + if (command.direction === "left" && node.hasChildren && node.expanded) { + return { hand: { type: "collapse" } }; + } + return { hand: { type: "move", direction: command.direction, operation: "replace" } }; +} + +export function disclosureAffordance(input: { + readonly key: string; + readonly expanded: boolean; +}): AffordancePreview { + if (input.key !== "Enter" && input.key !== " ") return { hand: null }; + return { hand: { type: input.expanded ? "collapse" : "expand" } }; +} diff --git a/packages/json-document-affordance/src/history.ts b/packages/json-document-affordance/src/history.ts new file mode 100644 index 00000000..16cb0bf0 --- /dev/null +++ b/packages/json-document-affordance/src/history.ts @@ -0,0 +1,26 @@ +import type { AffordancePreview } from "./result.js"; + +export type HistoryAffordanceName = "undo" | "redo"; + +export type HistoryAffordance = { + readonly name: HistoryAffordanceName; + readonly disabled: boolean; +}; + +export type HistoryAffordanceMap = { + readonly undo: HistoryAffordance; + readonly redo: HistoryAffordance; +}; + +export function historyAffordance(snapshot: { + readonly canUndo: boolean; + readonly canRedo: boolean; +}): AffordancePreview { + return { + hand: { + type: "history", + undo: { name: "undo", disabled: !snapshot.canUndo }, + redo: { name: "redo", disabled: !snapshot.canRedo }, + }, + }; +} diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts new file mode 100644 index 00000000..22ea905c --- /dev/null +++ b/packages/json-document-affordance/src/index.ts @@ -0,0 +1,19 @@ +export { dragAffordance, dragOperation, dropAffordance, forbiddenCursor, hoverAffordance, marqueeAffordance, nudgeAffordance, panAffordance, snapAffordance } from "./drag.js"; +export type { Point, Rect } from "./drag.js"; +export { disclosureAffordance, treeAffordance } from "./fold.js"; +export type { TreeAffordance, TreeFoldNode, TreeMoveDirection } from "./fold.js"; +export { historyAffordance } from "./history.js"; +export type { HistoryAffordance, HistoryAffordanceMap, HistoryAffordanceName } from "./history.js"; +export { applyAffordance, commitAffordance } from "./result.js"; +export type { + AffordanceCommit, + AffordanceCommitActions, + AffordanceHand, + AffordanceMoveDirection, + AffordancePreview, + AffordancePreviewActions, + AffordanceRect, + AffordanceResult, + SelectOperation, +} from "./result.js"; +export { activateAffordance, clickCountAffordance, escapeAffordance, focusAffordance, 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 new file mode 100644 index 00000000..f4f85fc7 --- /dev/null +++ b/packages/json-document-affordance/src/result.ts @@ -0,0 +1,97 @@ +export type SelectOperation = "replace" | "extend" | "toggle"; + +export type AffordanceMoveDirection = "previous" | "next" | "up" | "down" | "left" | "right"; + +export type AffordanceRect = { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +export type AffordanceHand = + | { readonly type: "select"; readonly operation: SelectOperation; readonly rect?: AffordanceRect } + | { readonly type: "move"; readonly direction: AffordanceMoveDirection; readonly operation: "replace" | "extend" } + | { readonly type: "boundary"; readonly edge: "start" | "end"; readonly operation: "replace" | "extend" } + | { readonly type: "toggle" } + | { readonly type: "delete" } + | { readonly type: "undo" } + | { readonly type: "redo" } + | { readonly type: "expand" } + | { readonly type: "collapse" } + | { readonly type: "translate"; readonly dx: number; readonly dy: number } + | { readonly type: "nudge"; readonly dx: number; readonly dy: number } + | { readonly type: "select-all" } + | { readonly type: "clear" } + | { readonly type: "typeahead"; readonly buffer: string; readonly name: string | null } + | { readonly type: "click"; readonly count: number } + | { readonly type: "activate" } + | { readonly type: "cancel" } + | { readonly type: "tab"; readonly direction: "next" | "prev" } + | { readonly type: "hover"; readonly phase: "hint" | "tooltip" } + | { readonly type: "copy" } + | { readonly type: "move-drop" } + | { + readonly type: "history"; + readonly undo: { readonly name: "undo"; readonly disabled: boolean }; + readonly redo: { readonly name: "redo"; readonly disabled: boolean }; + }; + +export type AffordancePreview = { + readonly hand: H | null; + readonly cursor?: string; +}; + +export type AffordanceCommit = { + readonly hand: H; + readonly cursor?: string; + readonly commit: true; +}; + +export type AffordanceResult = + | AffordancePreview + | AffordanceCommit; + +export type AffordancePreviewActions = { + readonly cursor?: (cursor: string) => void; + readonly hand?: (hand: H) => void; +}; + +export type AffordanceCommitActions = + & AffordancePreviewActions + & { + readonly commit?: (hand: H) => void; + }; + +export function applyAffordance( + result: AffordanceCommit, + actions: AffordanceCommitActions, +): void; +export function applyAffordance( + result: AffordancePreview, + actions: AffordancePreviewActions, +): void; +export function applyAffordance( + result: AffordanceResult, + actions: AffordanceCommitActions, +): void { + if (result.cursor !== undefined) actions.cursor?.(result.cursor); + if (result.hand) actions.hand?.(result.hand); + if ("commit" in result && result.commit && result.hand) actions.commit?.(result.hand); +} + +export function commitAffordance( + result: AffordancePreview, +): AffordanceCommit | null { + 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 new file mode 100644 index 00000000..53098c3f --- /dev/null +++ b/packages/json-document-affordance/src/select.ts @@ -0,0 +1,94 @@ +import { + createWebKeyboardAdapter, + selectionOperationFromModifiers, + type WebKeyboardStroke, +} from "@interactive-os/json-document-web"; +import { + type AffordancePreview, + type SelectOperation, +} from "./result.js"; + + +const keyboard = createWebKeyboardAdapter(); + +export type { SelectOperation }; + +export function pointerSelect(modifiers: { + readonly shiftKey?: boolean; + readonly metaKey?: boolean; + readonly ctrlKey?: boolean; +}): AffordancePreview { + return { + hand: { + type: "select", + operation: selectionOperationFromModifiers({ + shiftKey: modifiers.shiftKey ?? false, + metaKey: modifiers.metaKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + }), + }, + }; +} + +export function resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview { + return { hand: keyboard.resolve(stroke) }; +} + +export function selectAllAffordance( + stroke: Pick, + state: { readonly allSelected: boolean }, +): AffordancePreview { + const mod = stroke.metaKey || stroke.ctrlKey; + if (!mod || stroke.key.toLowerCase() !== "a") return { hand: null }; + return { hand: { type: state.allSelected ? "clear" : "select-all" } }; +} + +export function typeaheadAffordance(input: { + readonly buffer: string; + readonly key: string; + readonly elapsedMs: number; + readonly names: ReadonlyArray; + readonly from: string | null; + readonly windowMs?: number; +}): AffordancePreview { + if (input.key.length !== 1 || input.key < " ") return { hand: null }; + const windowMs = input.windowMs ?? 500; + const buffer = input.elapsedMs <= windowMs ? `${input.buffer}${input.key}` : input.key; + const query = buffer.toLowerCase(); + const start = Math.max(0, input.names.findIndex((name) => name === input.from)); + const ordered = [...input.names.slice(start + 1), ...input.names.slice(0, start + 1)]; + const name = ordered.find((candidate) => candidate.toLowerCase().startsWith(query)) ?? null; + return { hand: { type: "typeahead", buffer, name } }; +} + +export function clickCountAffordance(detail: number): AffordancePreview { + if (detail < 1) return { hand: null }; + return { hand: { type: "click", count: detail } }; +} + +export function activateAffordance(input: { readonly key?: string; readonly detail?: number; readonly button?: number }): AffordancePreview { + if (input.key === "Enter") return { hand: { type: "activate" } }; + if (input.button === 0 && (input.detail ?? 1) === 1) return { hand: { type: "activate" } }; + 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 focusAffordance(stroke: Pick): AffordancePreview { + if (stroke.key === "Tab") { + return { hand: { type: "tab", direction: stroke.shiftKey ? "prev" : "next" } }; + } + const command = keyboard.resolve({ + key: stroke.key, + shiftKey: false, + metaKey: false, + ctrlKey: false, + }); + if (command?.type === "move" || command?.type === "boundary") return { hand: command }; + return { hand: null }; +} diff --git a/packages/json-document-affordance/tests/affordance.test.ts b/packages/json-document-affordance/tests/affordance.test.ts new file mode 100644 index 00000000..e943dd28 --- /dev/null +++ b/packages/json-document-affordance/tests/affordance.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "vitest"; +import { + applyAffordance, + commitAffordance, + dragAffordance, + dropAffordance, + escapeAffordance, + historyAffordance, + pointerSelect, + resolveAffordanceKey, + snapAffordance, + treeAffordance, + typeaheadAffordance, +} from "../src/index.js"; + +describe("pointerSelect", () => { + test("maps conventional modifiers to replace, extend, and toggle", () => { + const operations: string[] = []; + applyAffordance(pointerSelect({ shiftKey: false, metaKey: false, ctrlKey: false }), { + hand: (hand) => { + if (hand.type === "select") operations.push(hand.operation); + }, + }); + applyAffordance(pointerSelect({ shiftKey: true, metaKey: false, ctrlKey: false }), { + hand: (hand) => { + if (hand.type === "select") operations.push(hand.operation); + }, + }); + applyAffordance(pointerSelect({ shiftKey: false, metaKey: true, ctrlKey: false }), { + hand: (hand) => { + if (hand.type === "select") operations.push(hand.operation); + }, + }); + applyAffordance(pointerSelect({ shiftKey: false, metaKey: false, ctrlKey: true }), { + hand: (hand) => { + if (hand.type === "select") operations.push(hand.operation); + }, + }); + expect(operations).toEqual(["replace", "extend", "toggle", "toggle"]); + }); +}); + +describe("resolveAffordanceKey", () => { + test("closes the conventional keymap without a host override", () => { + const hands: unknown[] = []; + applyAffordance(resolveAffordanceKey({ key: "ArrowDown", shiftKey: false, metaKey: false, ctrlKey: false }), { + hand: (hand) => hands.push(hand), + }); + applyAffordance(resolveAffordanceKey({ key: "ArrowDown", shiftKey: true, metaKey: false, ctrlKey: false }), { + hand: (hand) => hands.push(hand), + }); + applyAffordance(resolveAffordanceKey({ key: "z", shiftKey: false, metaKey: true, ctrlKey: false }), { + hand: (hand) => hands.push(hand), + }); + applyAffordance(resolveAffordanceKey({ key: "z", shiftKey: true, metaKey: true, ctrlKey: false }), { + hand: (hand) => hands.push(hand), + }); + applyAffordance(resolveAffordanceKey({ key: "Delete", shiftKey: false, metaKey: false, ctrlKey: false }), { + hand: (hand) => hands.push(hand), + }); + expect(hands).toEqual([ + { type: "move", direction: "down", operation: "replace" }, + { type: "move", direction: "down", operation: "extend" }, + { type: "undo" }, + { type: "redo" }, + { type: "delete" }, + ]); + }); +}); + +describe("treeAffordance", () => { + test("uses right to expand a collapsed parent and left to collapse an expanded parent", () => { + expect(treeAffordance({ type: "move", direction: "right" }, { expanded: false, hasChildren: true }).hand).toEqual({ + type: "expand", + }); + expect(treeAffordance({ type: "move", direction: "left" }, { expanded: true, hasChildren: true }).hand).toEqual({ + type: "collapse", + }); + expect(treeAffordance({ type: "move", direction: "right" }, { expanded: true, hasChildren: true }).hand).toEqual({ + type: "move", + direction: "right", + operation: "replace", + }); + }); +}); + +describe("dragAffordance", () => { + test("preview carries translate facts without a commit slot", () => { + expect(dragAffordance({ x: 10, y: 20 }, { x: 14, y: 18 })).toEqual({ + hand: { type: "translate", dx: 4, dy: -2 }, + cursor: "grabbing", + }); + expect("commit" in dragAffordance({ x: 10, y: 20 }, { x: 14, y: 18 })).toBe(false); + }); +}); + +describe("commitAffordance", () => { + test("commits a moved drag and ignores a stationary one", () => { + expect(commitAffordance(dragAffordance({ x: 10, y: 20 }, { x: 14, y: 18 }))).toEqual({ + hand: { type: "translate", dx: 4, dy: -2 }, + cursor: "grabbing", + commit: true, + }); + expect(commitAffordance(dragAffordance({ x: 10, y: 20 }, { x: 10, y: 20 }))).toBeNull(); + }); +}); + +describe("historyAffordance", () => { + test("binds disabled to canUndo and canRedo", () => { + expect(historyAffordance({ canUndo: false, canRedo: true }).hand).toEqual({ + type: "history", + undo: { name: "undo", disabled: true }, + redo: { name: "redo", disabled: false }, + }); + }); +}); + +describe("applyAffordance", () => { + test("applies cursor and hand from a preview", () => { + const cursors: string[] = []; + const hands: string[] = []; + applyAffordance( + { hand: { type: "select", operation: "extend" }, cursor: "cell" }, + { + cursor: (cursor) => cursors.push(cursor), + hand: (hand) => hands.push(hand.type), + }, + ); + expect(cursors).toEqual(["cell"]); + expect(hands).toEqual(["select"]); + }); + + test("applies commit only on a commit result", () => { + const committed: string[] = []; + const previewed: string[] = []; + applyAffordance( + { hand: { type: "translate", dx: 4, dy: -2 }, cursor: "grabbing", commit: true }, + { + hand: (hand) => previewed.push(hand.type), + commit: (hand) => committed.push(hand.type), + }, + ); + expect(previewed).toEqual(["translate"]); + expect(committed).toEqual(["translate"]); + }); +}); + +describe("typeaheadAffordance", () => { + test("jumps to the next name with the typed prefix", () => { + expect(typeaheadAffordance({ + buffer: "", + key: "A", + elapsedMs: 0, + names: ["Inbox", "Today", "Later"], + from: "Inbox", + }).hand).toEqual({ type: "typeahead", buffer: "A", name: null }); + expect(typeaheadAffordance({ + buffer: "", + key: "T", + elapsedMs: 0, + names: ["Inbox", "Today", "Later"], + from: "Inbox", + }).hand).toEqual({ type: "typeahead", buffer: "T", name: "Today" }); + }); +}); + +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 }); + expect(snapAffordance({ x: 47, y: 51 }, { grid: 8, disable: true }).hand).toEqual({ type: "translate", dx: 47, dy: 51 }); + }); +}); + +describe("escapeAffordance", () => { + test("cancels Escape and pointercancel", () => { + expect(escapeAffordance({ key: "Escape" }).hand).toEqual({ type: "cancel" }); + expect(escapeAffordance({ type: "pointercancel" }).hand).toEqual({ type: "cancel" }); + expect(escapeAffordance({ key: "Enter" }).hand).toBeNull(); + }); +}); + +describe("dropAffordance", () => { + test("previews a drop; only commitAffordance mints the write", () => { + expect(dropAffordance({ canDrop: false })).toEqual({ hand: null, cursor: "no-drop" }); + expect(dropAffordance({ canDrop: true })).toEqual({ + hand: { type: "move-drop" }, + cursor: "move", + }); + expect("commit" in dropAffordance({ canDrop: true })).toBe(false); + expect(commitAffordance(dropAffordance({ canDrop: false }))).toBeNull(); + expect(commitAffordance(dropAffordance({ canDrop: true }))).toEqual({ + hand: { type: "move-drop" }, + cursor: "move", + commit: true, + }); + }); +}); diff --git a/packages/json-document-affordance/tsconfig.json b/packages/json-document-affordance/tsconfig.json new file mode 100644 index 00000000..ee593812 --- /dev/null +++ b/packages/json-document-affordance/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig/library.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "references": [{ "path": "../json-document-web" }], + "include": ["src/**/*.ts"] +} diff --git a/packages/json-document-affordance/tsconfig.test.json b/packages/json-document-affordance/tsconfig.test.json new file mode 100644 index 00000000..adf237ce --- /dev/null +++ b/packages/json-document-affordance/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true, + "rootDir": "../..", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/json-document-affordance/vitest.config.ts b/packages/json-document-affordance/vitest.config.ts new file mode 100644 index 00000000..e2670c2a --- /dev/null +++ b/packages/json-document-affordance/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineNodeProject } from "../../test/vitest.shared.js"; + +export default defineNodeProject("json-document-affordance", { + resolve: { + alias: { + "@interactive-os/json-document-web": new URL("../json-document-web/src/index.ts", import.meta.url).pathname, + "@interactive-os/json-document-selection": new URL("../json-document-selection/src/index.ts", import.meta.url).pathname, + "@interactive-os/json-document-editing": new URL("../json-document-editing/src/index.ts", import.meta.url).pathname, + "@interactive-os/json-document": new URL("../json-document/src/application/document/index.ts", import.meta.url).pathname, + }, + }, +}); diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index 7892df13..5d75d60d 100644 --- a/site/config/json-document-source-aliases.ts +++ b/site/config/json-document-source-aliases.ts @@ -31,6 +31,10 @@ export function jsonDocumentSourceAliases(): SourceAlias[] { find: "@interactive-os/json-document-ajv", replacement: sourceFile("packages/json-document-ajv/src/index.ts"), }, + { + find: "@interactive-os/json-document-affordance", + replacement: sourceFile("packages/json-document-affordance/src/index.ts"), + }, { find: "@interactive-os/json-document-zod", replacement: sourceFile("packages/json-document-zod/src/index.ts"), diff --git a/site/package.json b/site/package.json index aaed0a4d..df7d351d 100644 --- a/site/package.json +++ b/site/package.json @@ -16,6 +16,7 @@ "@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/scripts/route-checks.mjs b/site/scripts/route-checks.mjs index 04ebf17d..d60e6528 100644 --- a/site/scripts/route-checks.mjs +++ b/site/scripts/route-checks.mjs @@ -3,10 +3,10 @@ const navigationGroups = new Set([ "JSON Document", "Collaboration", "Editing", - "Hands", "Adapter", "Connector", - "제품 화면", + "Affordance", + "Hands", ]); export function validateSiteRoutes(routes, fail) { diff --git a/site/site-routes.json b/site/site-routes.json index 08468156..8df70b02 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -434,60 +434,327 @@ } }, { - "path": "/widgets", - "label": "제품 화면", - "title": "Widgets - json-document", - "description": "Sample widgets that bind only editing affordances: Toolbar, Listbox, Grid, Document, Canvas, Tree, and Board.", - "navigationGroup": "제품 화면", + "path": "/docs/affordance", + "label": "Affordance", + "title": "Affordance - json-document", + "heading": "Affordance", + "description": "제품이 json-document를 만지는 최전선의 키보드·마우스·커서 손을 API와 사용법으로 설명합니다.", + "language": "ko", + "navigationGroup": "Affordance", "sidebar": false }, + { + "path": "/docs/affordance/focus", + "label": "Focus", + "title": "Focus - json-document", + "heading": "Focus", + "description": "Tab은 컴포넌트 사이로, 화살표는 컴포넌트 안에서 초점을 옮기는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/caret", + "label": "Caret", + "title": "Caret - json-document", + "heading": "Caret", + "description": "I-beam 클릭으로 글 삽입점을 두고, 화살표로 글자 사이를 옮기는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/select", + "label": "Select", + "title": "Select - json-document", + "heading": "Select", + "description": "클릭, Shift 범위, Mod 토글, 화살표로 대상을 집는 손을 붙입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "relatedDemoPath": "/widgets/listbox", + "relatedDemoLabel": "증명 열기", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/typeahead", + "label": "Typeahead", + "title": "Typeahead - json-document", + "heading": "Typeahead", + "description": "글쇠를 누르면 그 글자로 시작하는 다음 항목으로 초점을 옮기는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/activate", + "label": "Activate", + "title": "Activate - json-document", + "heading": "Activate", + "description": "클릭·Enter·Space로 대상의 기본 동작을 실행하는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/cancel", + "label": "Escape", + "title": "Escape - json-document", + "heading": "Escape", + "description": "Escape와 pointercancel로 진행 중인 손을 버리는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/fold", + "label": "Expand/Collapse", + "title": "Expand/Collapse - json-document", + "heading": "Expand/Collapse", + "description": "나무에서 왼쪽은 접고 오른쪽은 펼치는 손을 붙입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "relatedDemoPath": "/widgets/tree", + "relatedDemoLabel": "증명 열기", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/history", + "label": "Undo", + "title": "Undo - json-document", + "heading": "Undo", + "description": "Mod+Z와 canUndo로 값과 선택을 함께 되돌리는 손을 붙입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "relatedDemoPath": "/widgets/toolbar", + "relatedDemoLabel": "증명 열기", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/delete", + "label": "Delete", + "title": "Delete - json-document", + "heading": "Delete", + "description": "Delete와 Backspace로 고른 대상을 지우는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/rename", + "label": "Rename", + "title": "Rename - json-document", + "heading": "Rename", + "description": "F2와 느린 두 번 누르기로 고른 대상의 이름을 고치는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/nudge", + "label": "Nudge", + "title": "Nudge - json-document", + "heading": "Nudge", + "description": "화살표로 고른 대상을 한 단위 옮기고, Shift로 큰 단위를 쓰는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/hover", + "label": "Hover", + "title": "Hover - json-document", + "heading": "Hover", + "description": "누르지 않고 포인터를 올려 손과 도움말을 드러내는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/double-click", + "label": "Double-click", + "title": "Double-click - json-document", + "heading": "Double-click", + "description": "click detail 2로 열기·단어 고르기·이름 바꾸기를 여는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/triple-click", + "label": "Triple-click", + "title": "Triple-click - json-document", + "heading": "Triple-click", + "description": "click detail 3으로 줄이나 문단을 고르는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/context-menu", + "label": "Context menu", + "title": "Context menu - json-document", + "heading": "Context menu", + "description": "오른쪽 클릭, Shift+F10, Menu 키로 자리 메뉴를 여는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/drag", + "label": "Drag", + "title": "Drag - json-document", + "heading": "Drag", + "description": "고른 대상을 포인터로 옮기는 손을 붙입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "relatedDemoPath": "/widgets/canvas", + "relatedDemoLabel": "증명 열기", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/marquee", + "label": "Marquee", + "title": "Marquee - json-document", + "heading": "Marquee", + "description": "빈 곳에서 끌어서 여러 대상을 한 번에 고르는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/drop", + "label": "Drop", + "title": "Drop - json-document", + "heading": "Drop", + "description": "드래그한 대상을 어디에 둘지 정하고, 못 두면 no-drop을 보여주는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/copy-drag", + "label": "Duplicate", + "title": "Duplicate - json-document", + "heading": "Duplicate", + "description": "Alt/Option을 누른 채 드래그하면 원본을 복제하는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/resize", + "label": "Resize", + "title": "Resize - json-document", + "heading": "Resize", + "description": "모서리·칸·분할선을 끌어 크기를 바꾸고, 화살표로 미세 조절하는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/pan", + "label": "Pan", + "title": "Pan - json-document", + "heading": "Pan", + "description": "손바닥 커서로 평면을 밀고, Space+드래그로 화면을 옮기는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/scroll", + "label": "Scroll", + "title": "Scroll - json-document", + "heading": "Scroll", + "description": "휠과 드래그 중 자동 스크롤로 보이는 줄을 옮기는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/zoom", + "label": "Zoom", + "title": "Zoom - json-document", + "heading": "Zoom", + "description": "Mod+휠과 +/- 키, zoom-in/out 커서로 보이는 배율을 바꾸는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/snap", + "label": "Snap", + "title": "Snap - json-document", + "heading": "Snap", + "description": "드래그와 크기 바꾸기 중 그리드·가이드에 붙고, 수정 키로 푸는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, + { + "path": "/docs/affordance/forbid", + "label": "Not-allowed", + "title": "Not-allowed - json-document", + "heading": "Not-allowed", + "description": "할 수 없는 자리에서 not-allowed와 no-drop 커서를 보여주는 손입니다.", + "language": "ko", + "navigationGroup": "Affordance", + "parentPath": "/docs/affordance" + }, { "path": "/widgets/toolbar", - "label": "Toolbar", - "title": "Toolbar Widget - json-document", + "label": "Toolbar proof", + "title": "Toolbar proof - json-document", "description": "Undo and Redo disabled states come from canUndo and canRedo.", - "navigationGroup": "제품 화면" + "parentPath": "/docs/affordance/history", + "sidebar": false }, { "path": "/widgets/listbox", - "label": "Listbox", - "title": "Listbox Widget - json-document", + "label": "Listbox proof", + "title": "Listbox proof - json-document", "description": "A listbox reads selected keys and focus from Order selection.", - "navigationGroup": "제품 화면" + "parentPath": "/docs/affordance/select", + "sidebar": false }, { "path": "/widgets/grid", - "label": "Grid", - "title": "Grid Widget - json-document", + "label": "Grid proof", + "title": "Grid proof - json-document", "description": "A grid reads visible topology and selected cells from Sheet selection.", - "navigationGroup": "제품 화면" + "parentPath": "/docs/affordance/select", + "sidebar": false }, { "path": "/widgets/document", - "label": "Document", - "title": "Document Widget - json-document", + "label": "Document proof", + "title": "Document proof - json-document", "description": "A document line reads selected keys, focus, and text offset from block selection.", - "navigationGroup": "제품 화면" + "parentPath": "/docs/affordance/select", + "sidebar": false }, { "path": "/widgets/canvas", - "label": "Canvas", - "title": "Canvas Widget - json-document", + "label": "Canvas proof", + "title": "Canvas proof - json-document", "description": "A canvas reads selected objects on a plane from Object selection.", - "navigationGroup": "제품 화면" + "parentPath": "/docs/affordance/drag", + "sidebar": false }, { "path": "/widgets/tree", - "label": "Tree", - "title": "Tree Widget - json-document", + "label": "Tree proof", + "title": "Tree proof - json-document", "description": "A tree reads the visible hierarchical line from Tree selection.", - "navigationGroup": "제품 화면" + "parentPath": "/docs/affordance/fold", + "sidebar": false }, { "path": "/widgets/board", - "label": "Board", - "title": "Board Widget - json-document", + "label": "Board proof", + "title": "Board proof - json-document", "description": "A board reads columns of cards and selected keys from Kanban selection.", - "navigationGroup": "제품 화면" + "parentPath": "/docs/affordance/drag", + "sidebar": false } ] diff --git a/site/src/app/legacy-page-redirects.ts b/site/src/app/legacy-page-redirects.ts index 7b348a85..3131189a 100644 --- a/site/src/app/legacy-page-redirects.ts +++ b/site/src/app/legacy-page-redirects.ts @@ -11,4 +11,8 @@ export const legacyPageRedirects = { from: "/demos", to: "/editors", }, + widgetsCatalog: { + from: "/widgets", + to: "/docs/affordance", + }, } as const; diff --git a/site/src/app/page-descriptors.ts b/site/src/app/page-descriptors.ts index fa87b7c1..efc0c493 100644 --- a/site/src/app/page-descriptors.ts +++ b/site/src/app/page-descriptors.ts @@ -8,7 +8,7 @@ export type SiteNavigationGroup = | "Hands" | "Adapter" | "Connector" - | "제품 화면"; + | "Affordance"; export type IntegrationKind = "adapter" | "connector"; export type SiteRoute = { diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index d80d82c0..ec4137ab 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -63,6 +63,32 @@ import { Route as PageWidgetsToolbarRouteImport } from "./routes/_page/widgets/t import { Route as PageWidgetsTreeRouteImport } from "./routes/_page/widgets/tree"; import { Route as PageConnectorsZodIndexRouteImport } from "./routes/_page/connectors/zod/index"; import { Route as PageConnectorsZodValidateRouteImport } from "./routes/_page/connectors/zod/validate"; +import { Route as PageDocsAffordanceIndexRouteImport } from "./routes/_page/docs/affordance/index"; +import { Route as PageDocsAffordanceActivateRouteImport } from "./routes/_page/docs/affordance/activate"; +import { Route as PageDocsAffordanceCancelRouteImport } from "./routes/_page/docs/affordance/cancel"; +import { Route as PageDocsAffordanceCaretRouteImport } from "./routes/_page/docs/affordance/caret"; +import { Route as PageDocsAffordanceContextMenuRouteImport } from "./routes/_page/docs/affordance/context-menu"; +import { Route as PageDocsAffordanceCopyDragRouteImport } from "./routes/_page/docs/affordance/copy-drag"; +import { Route as PageDocsAffordanceDeleteRouteImport } from "./routes/_page/docs/affordance/delete"; +import { Route as PageDocsAffordanceDoubleClickRouteImport } from "./routes/_page/docs/affordance/double-click"; +import { Route as PageDocsAffordanceDragRouteImport } from "./routes/_page/docs/affordance/drag"; +import { Route as PageDocsAffordanceDropRouteImport } from "./routes/_page/docs/affordance/drop"; +import { Route as PageDocsAffordanceFocusRouteImport } from "./routes/_page/docs/affordance/focus"; +import { Route as PageDocsAffordanceFoldRouteImport } from "./routes/_page/docs/affordance/fold"; +import { Route as PageDocsAffordanceForbidRouteImport } from "./routes/_page/docs/affordance/forbid"; +import { Route as PageDocsAffordanceHistoryRouteImport } from "./routes/_page/docs/affordance/history"; +import { Route as PageDocsAffordanceHoverRouteImport } from "./routes/_page/docs/affordance/hover"; +import { Route as PageDocsAffordanceMarqueeRouteImport } from "./routes/_page/docs/affordance/marquee"; +import { Route as PageDocsAffordanceNudgeRouteImport } from "./routes/_page/docs/affordance/nudge"; +import { Route as PageDocsAffordancePanRouteImport } from "./routes/_page/docs/affordance/pan"; +import { Route as PageDocsAffordanceRenameRouteImport } from "./routes/_page/docs/affordance/rename"; +import { Route as PageDocsAffordanceResizeRouteImport } from "./routes/_page/docs/affordance/resize"; +import { Route as PageDocsAffordanceScrollRouteImport } from "./routes/_page/docs/affordance/scroll"; +import { Route as PageDocsAffordanceSelectRouteImport } from "./routes/_page/docs/affordance/select"; +import { Route as PageDocsAffordanceSnapRouteImport } from "./routes/_page/docs/affordance/snap"; +import { Route as PageDocsAffordanceTripleClickRouteImport } from "./routes/_page/docs/affordance/triple-click"; +import { Route as PageDocsAffordanceTypeaheadRouteImport } from "./routes/_page/docs/affordance/typeahead"; +import { Route as PageDocsAffordanceZoomRouteImport } from "./routes/_page/docs/affordance/zoom"; import { Route as PageDocsCollaborationIndexRouteImport } from "./routes/_page/docs/collaboration/index"; import { Route as PageDocsCollaborationHistoryRouteImport } from "./routes/_page/docs/collaboration/history"; import { Route as PageDocsCollaborationLifecycleRouteImport } from "./routes/_page/docs/collaboration/lifecycle"; @@ -344,6 +370,151 @@ const PageConnectorsZodValidateRoute = path: "/connectors/zod/validate", getParentRoute: () => PageRoute, } as any); +const PageDocsAffordanceIndexRoute = PageDocsAffordanceIndexRouteImport.update({ + id: "/docs/affordance/", + path: "/docs/affordance/", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceActivateRoute = + PageDocsAffordanceActivateRouteImport.update({ + id: "/docs/affordance/activate", + path: "/docs/affordance/activate", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceCancelRoute = + PageDocsAffordanceCancelRouteImport.update({ + id: "/docs/affordance/cancel", + path: "/docs/affordance/cancel", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceCaretRoute = PageDocsAffordanceCaretRouteImport.update({ + id: "/docs/affordance/caret", + path: "/docs/affordance/caret", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceContextMenuRoute = + PageDocsAffordanceContextMenuRouteImport.update({ + id: "/docs/affordance/context-menu", + path: "/docs/affordance/context-menu", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceCopyDragRoute = + PageDocsAffordanceCopyDragRouteImport.update({ + id: "/docs/affordance/copy-drag", + path: "/docs/affordance/copy-drag", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceDeleteRoute = + PageDocsAffordanceDeleteRouteImport.update({ + id: "/docs/affordance/delete", + path: "/docs/affordance/delete", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceDoubleClickRoute = + PageDocsAffordanceDoubleClickRouteImport.update({ + id: "/docs/affordance/double-click", + path: "/docs/affordance/double-click", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceDragRoute = PageDocsAffordanceDragRouteImport.update({ + id: "/docs/affordance/drag", + path: "/docs/affordance/drag", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceDropRoute = PageDocsAffordanceDropRouteImport.update({ + id: "/docs/affordance/drop", + path: "/docs/affordance/drop", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceFocusRoute = PageDocsAffordanceFocusRouteImport.update({ + id: "/docs/affordance/focus", + path: "/docs/affordance/focus", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceFoldRoute = PageDocsAffordanceFoldRouteImport.update({ + id: "/docs/affordance/fold", + path: "/docs/affordance/fold", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceForbidRoute = + PageDocsAffordanceForbidRouteImport.update({ + id: "/docs/affordance/forbid", + path: "/docs/affordance/forbid", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceHistoryRoute = + PageDocsAffordanceHistoryRouteImport.update({ + id: "/docs/affordance/history", + path: "/docs/affordance/history", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceHoverRoute = PageDocsAffordanceHoverRouteImport.update({ + id: "/docs/affordance/hover", + path: "/docs/affordance/hover", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceMarqueeRoute = + PageDocsAffordanceMarqueeRouteImport.update({ + id: "/docs/affordance/marquee", + path: "/docs/affordance/marquee", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceNudgeRoute = PageDocsAffordanceNudgeRouteImport.update({ + id: "/docs/affordance/nudge", + path: "/docs/affordance/nudge", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordancePanRoute = PageDocsAffordancePanRouteImport.update({ + id: "/docs/affordance/pan", + path: "/docs/affordance/pan", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceRenameRoute = + PageDocsAffordanceRenameRouteImport.update({ + id: "/docs/affordance/rename", + path: "/docs/affordance/rename", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceResizeRoute = + PageDocsAffordanceResizeRouteImport.update({ + id: "/docs/affordance/resize", + path: "/docs/affordance/resize", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceScrollRoute = + PageDocsAffordanceScrollRouteImport.update({ + id: "/docs/affordance/scroll", + path: "/docs/affordance/scroll", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceSelectRoute = + PageDocsAffordanceSelectRouteImport.update({ + id: "/docs/affordance/select", + path: "/docs/affordance/select", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceSnapRoute = PageDocsAffordanceSnapRouteImport.update({ + id: "/docs/affordance/snap", + path: "/docs/affordance/snap", + getParentRoute: () => PageRoute, +} as any); +const PageDocsAffordanceTripleClickRoute = + PageDocsAffordanceTripleClickRouteImport.update({ + id: "/docs/affordance/triple-click", + path: "/docs/affordance/triple-click", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceTypeaheadRoute = + PageDocsAffordanceTypeaheadRouteImport.update({ + id: "/docs/affordance/typeahead", + path: "/docs/affordance/typeahead", + getParentRoute: () => PageRoute, + } as any); +const PageDocsAffordanceZoomRoute = PageDocsAffordanceZoomRouteImport.update({ + id: "/docs/affordance/zoom", + path: "/docs/affordance/zoom", + getParentRoute: () => PageRoute, +} as any); const PageDocsCollaborationIndexRoute = PageDocsCollaborationIndexRouteImport.update({ id: "/docs/collaboration/", @@ -434,10 +605,36 @@ export interface FileRoutesByFullPath { "/docs/": typeof PageDocsIndexRoute; "/widgets/": typeof PageWidgetsIndexRoute; "/connectors/zod/validate": typeof PageConnectorsZodValidateRoute; + "/docs/affordance/activate": typeof PageDocsAffordanceActivateRoute; + "/docs/affordance/cancel": typeof PageDocsAffordanceCancelRoute; + "/docs/affordance/caret": typeof PageDocsAffordanceCaretRoute; + "/docs/affordance/context-menu": typeof PageDocsAffordanceContextMenuRoute; + "/docs/affordance/copy-drag": typeof PageDocsAffordanceCopyDragRoute; + "/docs/affordance/delete": typeof PageDocsAffordanceDeleteRoute; + "/docs/affordance/double-click": typeof PageDocsAffordanceDoubleClickRoute; + "/docs/affordance/drag": typeof PageDocsAffordanceDragRoute; + "/docs/affordance/drop": typeof PageDocsAffordanceDropRoute; + "/docs/affordance/focus": typeof PageDocsAffordanceFocusRoute; + "/docs/affordance/fold": typeof PageDocsAffordanceFoldRoute; + "/docs/affordance/forbid": typeof PageDocsAffordanceForbidRoute; + "/docs/affordance/history": typeof PageDocsAffordanceHistoryRoute; + "/docs/affordance/hover": typeof PageDocsAffordanceHoverRoute; + "/docs/affordance/marquee": typeof PageDocsAffordanceMarqueeRoute; + "/docs/affordance/nudge": typeof PageDocsAffordanceNudgeRoute; + "/docs/affordance/pan": typeof PageDocsAffordancePanRoute; + "/docs/affordance/rename": typeof PageDocsAffordanceRenameRoute; + "/docs/affordance/resize": typeof PageDocsAffordanceResizeRoute; + "/docs/affordance/scroll": typeof PageDocsAffordanceScrollRoute; + "/docs/affordance/select": typeof PageDocsAffordanceSelectRoute; + "/docs/affordance/snap": typeof PageDocsAffordanceSnapRoute; + "/docs/affordance/triple-click": typeof PageDocsAffordanceTripleClickRoute; + "/docs/affordance/typeahead": typeof PageDocsAffordanceTypeaheadRoute; + "/docs/affordance/zoom": typeof PageDocsAffordanceZoomRoute; "/docs/collaboration/history": typeof PageDocsCollaborationHistoryRoute; "/docs/collaboration/lifecycle": typeof PageDocsCollaborationLifecycleRoute; "/docs/collaboration/replica": typeof PageDocsCollaborationReplicaRoute; "/connectors/zod/": typeof PageConnectorsZodIndexRoute; + "/docs/affordance/": typeof PageDocsAffordanceIndexRoute; "/docs/collaboration/": typeof PageDocsCollaborationIndexRoute; "/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; "/docs/collaboration/text/": typeof PageDocsCollaborationTextIndexRoute; @@ -495,10 +692,36 @@ export interface FileRoutesByTo { "/docs": typeof PageDocsIndexRoute; "/widgets": typeof PageWidgetsIndexRoute; "/connectors/zod/validate": typeof PageConnectorsZodValidateRoute; + "/docs/affordance/activate": typeof PageDocsAffordanceActivateRoute; + "/docs/affordance/cancel": typeof PageDocsAffordanceCancelRoute; + "/docs/affordance/caret": typeof PageDocsAffordanceCaretRoute; + "/docs/affordance/context-menu": typeof PageDocsAffordanceContextMenuRoute; + "/docs/affordance/copy-drag": typeof PageDocsAffordanceCopyDragRoute; + "/docs/affordance/delete": typeof PageDocsAffordanceDeleteRoute; + "/docs/affordance/double-click": typeof PageDocsAffordanceDoubleClickRoute; + "/docs/affordance/drag": typeof PageDocsAffordanceDragRoute; + "/docs/affordance/drop": typeof PageDocsAffordanceDropRoute; + "/docs/affordance/focus": typeof PageDocsAffordanceFocusRoute; + "/docs/affordance/fold": typeof PageDocsAffordanceFoldRoute; + "/docs/affordance/forbid": typeof PageDocsAffordanceForbidRoute; + "/docs/affordance/history": typeof PageDocsAffordanceHistoryRoute; + "/docs/affordance/hover": typeof PageDocsAffordanceHoverRoute; + "/docs/affordance/marquee": typeof PageDocsAffordanceMarqueeRoute; + "/docs/affordance/nudge": typeof PageDocsAffordanceNudgeRoute; + "/docs/affordance/pan": typeof PageDocsAffordancePanRoute; + "/docs/affordance/rename": typeof PageDocsAffordanceRenameRoute; + "/docs/affordance/resize": typeof PageDocsAffordanceResizeRoute; + "/docs/affordance/scroll": typeof PageDocsAffordanceScrollRoute; + "/docs/affordance/select": typeof PageDocsAffordanceSelectRoute; + "/docs/affordance/snap": typeof PageDocsAffordanceSnapRoute; + "/docs/affordance/triple-click": typeof PageDocsAffordanceTripleClickRoute; + "/docs/affordance/typeahead": typeof PageDocsAffordanceTypeaheadRoute; + "/docs/affordance/zoom": typeof PageDocsAffordanceZoomRoute; "/docs/collaboration/history": typeof PageDocsCollaborationHistoryRoute; "/docs/collaboration/lifecycle": typeof PageDocsCollaborationLifecycleRoute; "/docs/collaboration/replica": typeof PageDocsCollaborationReplicaRoute; "/connectors/zod": typeof PageConnectorsZodIndexRoute; + "/docs/affordance": typeof PageDocsAffordanceIndexRoute; "/docs/collaboration": typeof PageDocsCollaborationIndexRoute; "/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; "/docs/collaboration/text": typeof PageDocsCollaborationTextIndexRoute; @@ -558,10 +781,36 @@ export interface FileRoutesById { "/_page/docs/": typeof PageDocsIndexRoute; "/_page/widgets/": typeof PageWidgetsIndexRoute; "/_page/connectors/zod/validate": typeof PageConnectorsZodValidateRoute; + "/_page/docs/affordance/activate": typeof PageDocsAffordanceActivateRoute; + "/_page/docs/affordance/cancel": typeof PageDocsAffordanceCancelRoute; + "/_page/docs/affordance/caret": typeof PageDocsAffordanceCaretRoute; + "/_page/docs/affordance/context-menu": typeof PageDocsAffordanceContextMenuRoute; + "/_page/docs/affordance/copy-drag": typeof PageDocsAffordanceCopyDragRoute; + "/_page/docs/affordance/delete": typeof PageDocsAffordanceDeleteRoute; + "/_page/docs/affordance/double-click": typeof PageDocsAffordanceDoubleClickRoute; + "/_page/docs/affordance/drag": typeof PageDocsAffordanceDragRoute; + "/_page/docs/affordance/drop": typeof PageDocsAffordanceDropRoute; + "/_page/docs/affordance/focus": typeof PageDocsAffordanceFocusRoute; + "/_page/docs/affordance/fold": typeof PageDocsAffordanceFoldRoute; + "/_page/docs/affordance/forbid": typeof PageDocsAffordanceForbidRoute; + "/_page/docs/affordance/history": typeof PageDocsAffordanceHistoryRoute; + "/_page/docs/affordance/hover": typeof PageDocsAffordanceHoverRoute; + "/_page/docs/affordance/marquee": typeof PageDocsAffordanceMarqueeRoute; + "/_page/docs/affordance/nudge": typeof PageDocsAffordanceNudgeRoute; + "/_page/docs/affordance/pan": typeof PageDocsAffordancePanRoute; + "/_page/docs/affordance/rename": typeof PageDocsAffordanceRenameRoute; + "/_page/docs/affordance/resize": typeof PageDocsAffordanceResizeRoute; + "/_page/docs/affordance/scroll": typeof PageDocsAffordanceScrollRoute; + "/_page/docs/affordance/select": typeof PageDocsAffordanceSelectRoute; + "/_page/docs/affordance/snap": typeof PageDocsAffordanceSnapRoute; + "/_page/docs/affordance/triple-click": typeof PageDocsAffordanceTripleClickRoute; + "/_page/docs/affordance/typeahead": typeof PageDocsAffordanceTypeaheadRoute; + "/_page/docs/affordance/zoom": typeof PageDocsAffordanceZoomRoute; "/_page/docs/collaboration/history": typeof PageDocsCollaborationHistoryRoute; "/_page/docs/collaboration/lifecycle": typeof PageDocsCollaborationLifecycleRoute; "/_page/docs/collaboration/replica": typeof PageDocsCollaborationReplicaRoute; "/_page/connectors/zod/": typeof PageConnectorsZodIndexRoute; + "/_page/docs/affordance/": typeof PageDocsAffordanceIndexRoute; "/_page/docs/collaboration/": typeof PageDocsCollaborationIndexRoute; "/_page/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; "/_page/docs/collaboration/text/": typeof PageDocsCollaborationTextIndexRoute; @@ -621,10 +870,36 @@ export interface FileRouteTypes { | "/docs/" | "/widgets/" | "/connectors/zod/validate" + | "/docs/affordance/activate" + | "/docs/affordance/cancel" + | "/docs/affordance/caret" + | "/docs/affordance/context-menu" + | "/docs/affordance/copy-drag" + | "/docs/affordance/delete" + | "/docs/affordance/double-click" + | "/docs/affordance/drag" + | "/docs/affordance/drop" + | "/docs/affordance/focus" + | "/docs/affordance/fold" + | "/docs/affordance/forbid" + | "/docs/affordance/history" + | "/docs/affordance/hover" + | "/docs/affordance/marquee" + | "/docs/affordance/nudge" + | "/docs/affordance/pan" + | "/docs/affordance/rename" + | "/docs/affordance/resize" + | "/docs/affordance/scroll" + | "/docs/affordance/select" + | "/docs/affordance/snap" + | "/docs/affordance/triple-click" + | "/docs/affordance/typeahead" + | "/docs/affordance/zoom" | "/docs/collaboration/history" | "/docs/collaboration/lifecycle" | "/docs/collaboration/replica" | "/connectors/zod/" + | "/docs/affordance/" | "/docs/collaboration/" | "/docs/collaboration/text/lease" | "/docs/collaboration/text/"; @@ -682,10 +957,36 @@ export interface FileRouteTypes { | "/docs" | "/widgets" | "/connectors/zod/validate" + | "/docs/affordance/activate" + | "/docs/affordance/cancel" + | "/docs/affordance/caret" + | "/docs/affordance/context-menu" + | "/docs/affordance/copy-drag" + | "/docs/affordance/delete" + | "/docs/affordance/double-click" + | "/docs/affordance/drag" + | "/docs/affordance/drop" + | "/docs/affordance/focus" + | "/docs/affordance/fold" + | "/docs/affordance/forbid" + | "/docs/affordance/history" + | "/docs/affordance/hover" + | "/docs/affordance/marquee" + | "/docs/affordance/nudge" + | "/docs/affordance/pan" + | "/docs/affordance/rename" + | "/docs/affordance/resize" + | "/docs/affordance/scroll" + | "/docs/affordance/select" + | "/docs/affordance/snap" + | "/docs/affordance/triple-click" + | "/docs/affordance/typeahead" + | "/docs/affordance/zoom" | "/docs/collaboration/history" | "/docs/collaboration/lifecycle" | "/docs/collaboration/replica" | "/connectors/zod" + | "/docs/affordance" | "/docs/collaboration" | "/docs/collaboration/text/lease" | "/docs/collaboration/text"; @@ -744,10 +1045,36 @@ export interface FileRouteTypes { | "/_page/docs/" | "/_page/widgets/" | "/_page/connectors/zod/validate" + | "/_page/docs/affordance/activate" + | "/_page/docs/affordance/cancel" + | "/_page/docs/affordance/caret" + | "/_page/docs/affordance/context-menu" + | "/_page/docs/affordance/copy-drag" + | "/_page/docs/affordance/delete" + | "/_page/docs/affordance/double-click" + | "/_page/docs/affordance/drag" + | "/_page/docs/affordance/drop" + | "/_page/docs/affordance/focus" + | "/_page/docs/affordance/fold" + | "/_page/docs/affordance/forbid" + | "/_page/docs/affordance/history" + | "/_page/docs/affordance/hover" + | "/_page/docs/affordance/marquee" + | "/_page/docs/affordance/nudge" + | "/_page/docs/affordance/pan" + | "/_page/docs/affordance/rename" + | "/_page/docs/affordance/resize" + | "/_page/docs/affordance/scroll" + | "/_page/docs/affordance/select" + | "/_page/docs/affordance/snap" + | "/_page/docs/affordance/triple-click" + | "/_page/docs/affordance/typeahead" + | "/_page/docs/affordance/zoom" | "/_page/docs/collaboration/history" | "/_page/docs/collaboration/lifecycle" | "/_page/docs/collaboration/replica" | "/_page/connectors/zod/" + | "/_page/docs/affordance/" | "/_page/docs/collaboration/" | "/_page/docs/collaboration/text/lease" | "/_page/docs/collaboration/text/"; @@ -1138,6 +1465,188 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageConnectorsZodValidateRouteImport; parentRoute: typeof PageRoute; }; + "/_page/docs/affordance/": { + id: "/_page/docs/affordance/"; + path: "/docs/affordance"; + fullPath: "/docs/affordance/"; + preLoaderRoute: typeof PageDocsAffordanceIndexRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/activate": { + id: "/_page/docs/affordance/activate"; + path: "/docs/affordance/activate"; + fullPath: "/docs/affordance/activate"; + preLoaderRoute: typeof PageDocsAffordanceActivateRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/cancel": { + id: "/_page/docs/affordance/cancel"; + path: "/docs/affordance/cancel"; + fullPath: "/docs/affordance/cancel"; + preLoaderRoute: typeof PageDocsAffordanceCancelRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/caret": { + id: "/_page/docs/affordance/caret"; + path: "/docs/affordance/caret"; + fullPath: "/docs/affordance/caret"; + preLoaderRoute: typeof PageDocsAffordanceCaretRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/context-menu": { + id: "/_page/docs/affordance/context-menu"; + path: "/docs/affordance/context-menu"; + fullPath: "/docs/affordance/context-menu"; + preLoaderRoute: typeof PageDocsAffordanceContextMenuRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/copy-drag": { + id: "/_page/docs/affordance/copy-drag"; + path: "/docs/affordance/copy-drag"; + fullPath: "/docs/affordance/copy-drag"; + preLoaderRoute: typeof PageDocsAffordanceCopyDragRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/delete": { + id: "/_page/docs/affordance/delete"; + path: "/docs/affordance/delete"; + fullPath: "/docs/affordance/delete"; + preLoaderRoute: typeof PageDocsAffordanceDeleteRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/double-click": { + id: "/_page/docs/affordance/double-click"; + path: "/docs/affordance/double-click"; + fullPath: "/docs/affordance/double-click"; + preLoaderRoute: typeof PageDocsAffordanceDoubleClickRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/drag": { + id: "/_page/docs/affordance/drag"; + path: "/docs/affordance/drag"; + fullPath: "/docs/affordance/drag"; + preLoaderRoute: typeof PageDocsAffordanceDragRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/drop": { + id: "/_page/docs/affordance/drop"; + path: "/docs/affordance/drop"; + fullPath: "/docs/affordance/drop"; + preLoaderRoute: typeof PageDocsAffordanceDropRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/focus": { + id: "/_page/docs/affordance/focus"; + path: "/docs/affordance/focus"; + fullPath: "/docs/affordance/focus"; + preLoaderRoute: typeof PageDocsAffordanceFocusRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/fold": { + id: "/_page/docs/affordance/fold"; + path: "/docs/affordance/fold"; + fullPath: "/docs/affordance/fold"; + preLoaderRoute: typeof PageDocsAffordanceFoldRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/forbid": { + id: "/_page/docs/affordance/forbid"; + path: "/docs/affordance/forbid"; + fullPath: "/docs/affordance/forbid"; + preLoaderRoute: typeof PageDocsAffordanceForbidRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/history": { + id: "/_page/docs/affordance/history"; + path: "/docs/affordance/history"; + fullPath: "/docs/affordance/history"; + preLoaderRoute: typeof PageDocsAffordanceHistoryRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/hover": { + id: "/_page/docs/affordance/hover"; + path: "/docs/affordance/hover"; + fullPath: "/docs/affordance/hover"; + preLoaderRoute: typeof PageDocsAffordanceHoverRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/marquee": { + id: "/_page/docs/affordance/marquee"; + path: "/docs/affordance/marquee"; + fullPath: "/docs/affordance/marquee"; + preLoaderRoute: typeof PageDocsAffordanceMarqueeRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/nudge": { + id: "/_page/docs/affordance/nudge"; + path: "/docs/affordance/nudge"; + fullPath: "/docs/affordance/nudge"; + preLoaderRoute: typeof PageDocsAffordanceNudgeRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/pan": { + id: "/_page/docs/affordance/pan"; + path: "/docs/affordance/pan"; + fullPath: "/docs/affordance/pan"; + preLoaderRoute: typeof PageDocsAffordancePanRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/rename": { + id: "/_page/docs/affordance/rename"; + path: "/docs/affordance/rename"; + fullPath: "/docs/affordance/rename"; + preLoaderRoute: typeof PageDocsAffordanceRenameRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/resize": { + id: "/_page/docs/affordance/resize"; + path: "/docs/affordance/resize"; + fullPath: "/docs/affordance/resize"; + preLoaderRoute: typeof PageDocsAffordanceResizeRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/scroll": { + id: "/_page/docs/affordance/scroll"; + path: "/docs/affordance/scroll"; + fullPath: "/docs/affordance/scroll"; + preLoaderRoute: typeof PageDocsAffordanceScrollRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/select": { + id: "/_page/docs/affordance/select"; + path: "/docs/affordance/select"; + fullPath: "/docs/affordance/select"; + preLoaderRoute: typeof PageDocsAffordanceSelectRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/snap": { + id: "/_page/docs/affordance/snap"; + path: "/docs/affordance/snap"; + fullPath: "/docs/affordance/snap"; + preLoaderRoute: typeof PageDocsAffordanceSnapRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/triple-click": { + id: "/_page/docs/affordance/triple-click"; + path: "/docs/affordance/triple-click"; + fullPath: "/docs/affordance/triple-click"; + preLoaderRoute: typeof PageDocsAffordanceTripleClickRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/typeahead": { + id: "/_page/docs/affordance/typeahead"; + path: "/docs/affordance/typeahead"; + fullPath: "/docs/affordance/typeahead"; + preLoaderRoute: typeof PageDocsAffordanceTypeaheadRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/affordance/zoom": { + id: "/_page/docs/affordance/zoom"; + path: "/docs/affordance/zoom"; + fullPath: "/docs/affordance/zoom"; + preLoaderRoute: typeof PageDocsAffordanceZoomRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/docs/collaboration/": { id: "/_page/docs/collaboration/"; path: "/docs/collaboration"; @@ -1235,10 +1744,36 @@ interface PageRouteChildren { PageDocsIndexRoute: typeof PageDocsIndexRoute; PageWidgetsIndexRoute: typeof PageWidgetsIndexRoute; PageConnectorsZodValidateRoute: typeof PageConnectorsZodValidateRoute; + PageDocsAffordanceActivateRoute: typeof PageDocsAffordanceActivateRoute; + PageDocsAffordanceCancelRoute: typeof PageDocsAffordanceCancelRoute; + PageDocsAffordanceCaretRoute: typeof PageDocsAffordanceCaretRoute; + PageDocsAffordanceContextMenuRoute: typeof PageDocsAffordanceContextMenuRoute; + PageDocsAffordanceCopyDragRoute: typeof PageDocsAffordanceCopyDragRoute; + PageDocsAffordanceDeleteRoute: typeof PageDocsAffordanceDeleteRoute; + PageDocsAffordanceDoubleClickRoute: typeof PageDocsAffordanceDoubleClickRoute; + PageDocsAffordanceDragRoute: typeof PageDocsAffordanceDragRoute; + PageDocsAffordanceDropRoute: typeof PageDocsAffordanceDropRoute; + PageDocsAffordanceFocusRoute: typeof PageDocsAffordanceFocusRoute; + PageDocsAffordanceFoldRoute: typeof PageDocsAffordanceFoldRoute; + PageDocsAffordanceForbidRoute: typeof PageDocsAffordanceForbidRoute; + PageDocsAffordanceHistoryRoute: typeof PageDocsAffordanceHistoryRoute; + PageDocsAffordanceHoverRoute: typeof PageDocsAffordanceHoverRoute; + PageDocsAffordanceMarqueeRoute: typeof PageDocsAffordanceMarqueeRoute; + PageDocsAffordanceNudgeRoute: typeof PageDocsAffordanceNudgeRoute; + PageDocsAffordancePanRoute: typeof PageDocsAffordancePanRoute; + PageDocsAffordanceRenameRoute: typeof PageDocsAffordanceRenameRoute; + PageDocsAffordanceResizeRoute: typeof PageDocsAffordanceResizeRoute; + PageDocsAffordanceScrollRoute: typeof PageDocsAffordanceScrollRoute; + PageDocsAffordanceSelectRoute: typeof PageDocsAffordanceSelectRoute; + PageDocsAffordanceSnapRoute: typeof PageDocsAffordanceSnapRoute; + PageDocsAffordanceTripleClickRoute: typeof PageDocsAffordanceTripleClickRoute; + PageDocsAffordanceTypeaheadRoute: typeof PageDocsAffordanceTypeaheadRoute; + PageDocsAffordanceZoomRoute: typeof PageDocsAffordanceZoomRoute; PageDocsCollaborationHistoryRoute: typeof PageDocsCollaborationHistoryRoute; PageDocsCollaborationLifecycleRoute: typeof PageDocsCollaborationLifecycleRoute; PageDocsCollaborationReplicaRoute: typeof PageDocsCollaborationReplicaRoute; PageConnectorsZodIndexRoute: typeof PageConnectorsZodIndexRoute; + PageDocsAffordanceIndexRoute: typeof PageDocsAffordanceIndexRoute; PageDocsCollaborationIndexRoute: typeof PageDocsCollaborationIndexRoute; PageDocsCollaborationTextLeaseRoute: typeof PageDocsCollaborationTextLeaseRoute; PageDocsCollaborationTextIndexRoute: typeof PageDocsCollaborationTextIndexRoute; @@ -1296,10 +1831,36 @@ const PageRouteChildren: PageRouteChildren = { PageDocsIndexRoute: PageDocsIndexRoute, PageWidgetsIndexRoute: PageWidgetsIndexRoute, PageConnectorsZodValidateRoute: PageConnectorsZodValidateRoute, + PageDocsAffordanceActivateRoute: PageDocsAffordanceActivateRoute, + PageDocsAffordanceCancelRoute: PageDocsAffordanceCancelRoute, + PageDocsAffordanceCaretRoute: PageDocsAffordanceCaretRoute, + PageDocsAffordanceContextMenuRoute: PageDocsAffordanceContextMenuRoute, + PageDocsAffordanceCopyDragRoute: PageDocsAffordanceCopyDragRoute, + PageDocsAffordanceDeleteRoute: PageDocsAffordanceDeleteRoute, + PageDocsAffordanceDoubleClickRoute: PageDocsAffordanceDoubleClickRoute, + PageDocsAffordanceDragRoute: PageDocsAffordanceDragRoute, + PageDocsAffordanceDropRoute: PageDocsAffordanceDropRoute, + PageDocsAffordanceFocusRoute: PageDocsAffordanceFocusRoute, + PageDocsAffordanceFoldRoute: PageDocsAffordanceFoldRoute, + PageDocsAffordanceForbidRoute: PageDocsAffordanceForbidRoute, + PageDocsAffordanceHistoryRoute: PageDocsAffordanceHistoryRoute, + PageDocsAffordanceHoverRoute: PageDocsAffordanceHoverRoute, + PageDocsAffordanceMarqueeRoute: PageDocsAffordanceMarqueeRoute, + PageDocsAffordanceNudgeRoute: PageDocsAffordanceNudgeRoute, + PageDocsAffordancePanRoute: PageDocsAffordancePanRoute, + PageDocsAffordanceRenameRoute: PageDocsAffordanceRenameRoute, + PageDocsAffordanceResizeRoute: PageDocsAffordanceResizeRoute, + PageDocsAffordanceScrollRoute: PageDocsAffordanceScrollRoute, + PageDocsAffordanceSelectRoute: PageDocsAffordanceSelectRoute, + PageDocsAffordanceSnapRoute: PageDocsAffordanceSnapRoute, + PageDocsAffordanceTripleClickRoute: PageDocsAffordanceTripleClickRoute, + PageDocsAffordanceTypeaheadRoute: PageDocsAffordanceTypeaheadRoute, + PageDocsAffordanceZoomRoute: PageDocsAffordanceZoomRoute, PageDocsCollaborationHistoryRoute: PageDocsCollaborationHistoryRoute, PageDocsCollaborationLifecycleRoute: PageDocsCollaborationLifecycleRoute, PageDocsCollaborationReplicaRoute: PageDocsCollaborationReplicaRoute, PageConnectorsZodIndexRoute: PageConnectorsZodIndexRoute, + PageDocsAffordanceIndexRoute: PageDocsAffordanceIndexRoute, PageDocsCollaborationIndexRoute: PageDocsCollaborationIndexRoute, PageDocsCollaborationTextLeaseRoute: PageDocsCollaborationTextLeaseRoute, PageDocsCollaborationTextIndexRoute: PageDocsCollaborationTextIndexRoute, diff --git a/site/src/app/routes/_page/docs/affordance/activate.tsx b/site/src/app/routes/_page/docs/affordance/activate.tsx new file mode 100644 index 00000000..a2a7b192 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/activate.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/activate")({ + component: function AffordanceActivateDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/cancel.tsx b/site/src/app/routes/_page/docs/affordance/cancel.tsx new file mode 100644 index 00000000..f8571939 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/cancel.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/cancel")({ + component: function AffordanceCancelDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/caret.tsx b/site/src/app/routes/_page/docs/affordance/caret.tsx new file mode 100644 index 00000000..89295592 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/caret.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/caret")({ + component: function AffordanceCaretDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/context-menu.tsx b/site/src/app/routes/_page/docs/affordance/context-menu.tsx new file mode 100644 index 00000000..1eec96ee --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/context-menu.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/context-menu")({ + component: function AffordanceContextMenuDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/copy-drag.tsx b/site/src/app/routes/_page/docs/affordance/copy-drag.tsx new file mode 100644 index 00000000..f04cf1d5 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/copy-drag.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/copy-drag")({ + component: function AffordanceCopyDragDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/delete.tsx b/site/src/app/routes/_page/docs/affordance/delete.tsx new file mode 100644 index 00000000..9432c644 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/delete.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/delete")({ + component: function AffordanceDeleteDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/double-click.tsx b/site/src/app/routes/_page/docs/affordance/double-click.tsx new file mode 100644 index 00000000..043534e7 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/double-click.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/double-click")({ + component: function AffordanceDoubleClickDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/drag.tsx b/site/src/app/routes/_page/docs/affordance/drag.tsx new file mode 100644 index 00000000..3b6a7f44 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/drag.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/drag")({ + component: function AffordanceDragDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/drop.tsx b/site/src/app/routes/_page/docs/affordance/drop.tsx new file mode 100644 index 00000000..c343c8ca --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/drop.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/drop")({ + component: function AffordanceDropDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/focus.tsx b/site/src/app/routes/_page/docs/affordance/focus.tsx new file mode 100644 index 00000000..d3b6743c --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/focus.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/focus")({ + component: function AffordanceFocusDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/fold.tsx b/site/src/app/routes/_page/docs/affordance/fold.tsx new file mode 100644 index 00000000..e70825b5 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/fold.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/fold")({ + component: function AffordanceFoldDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/forbid.tsx b/site/src/app/routes/_page/docs/affordance/forbid.tsx new file mode 100644 index 00000000..2f64a387 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/forbid.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/forbid")({ + component: function AffordanceForbidDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/history.tsx b/site/src/app/routes/_page/docs/affordance/history.tsx new file mode 100644 index 00000000..0775731a --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/history.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/history")({ + component: function AffordanceHistoryDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/hover.tsx b/site/src/app/routes/_page/docs/affordance/hover.tsx new file mode 100644 index 00000000..92e8f569 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/hover.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/hover")({ + component: function AffordanceHoverDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/index.tsx b/site/src/app/routes/_page/docs/affordance/index.tsx new file mode 100644 index 00000000..d10ce56a --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { AffordanceDocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/")({ + component: AffordanceDocsRoute, +}); diff --git a/site/src/app/routes/_page/docs/affordance/marquee.tsx b/site/src/app/routes/_page/docs/affordance/marquee.tsx new file mode 100644 index 00000000..7cff632a --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/marquee.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/marquee")({ + component: function AffordanceMarqueeDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/nudge.tsx b/site/src/app/routes/_page/docs/affordance/nudge.tsx new file mode 100644 index 00000000..055bc5bc --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/nudge.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/nudge")({ + component: function AffordanceNudgeDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/pan.tsx b/site/src/app/routes/_page/docs/affordance/pan.tsx new file mode 100644 index 00000000..bb70d1fe --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/pan.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/pan")({ + component: function AffordancePanDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/rename.tsx b/site/src/app/routes/_page/docs/affordance/rename.tsx new file mode 100644 index 00000000..f1e02ba9 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/rename.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/rename")({ + component: function AffordanceRenameDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/resize.tsx b/site/src/app/routes/_page/docs/affordance/resize.tsx new file mode 100644 index 00000000..fc0795f6 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/resize.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/resize")({ + component: function AffordanceResizeDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/scroll.tsx b/site/src/app/routes/_page/docs/affordance/scroll.tsx new file mode 100644 index 00000000..6cf575e9 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/scroll.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/scroll")({ + component: function AffordanceScrollDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/select.tsx b/site/src/app/routes/_page/docs/affordance/select.tsx new file mode 100644 index 00000000..38cb567c --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/select.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/select")({ + component: function AffordanceSelectDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/snap.tsx b/site/src/app/routes/_page/docs/affordance/snap.tsx new file mode 100644 index 00000000..a1da11a7 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/snap.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/snap")({ + component: function AffordanceSnapDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/triple-click.tsx b/site/src/app/routes/_page/docs/affordance/triple-click.tsx new file mode 100644 index 00000000..cdc0024f --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/triple-click.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/triple-click")({ + component: function AffordanceTripleClickDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/typeahead.tsx b/site/src/app/routes/_page/docs/affordance/typeahead.tsx new file mode 100644 index 00000000..3687a2ae --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/typeahead.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/typeahead")({ + component: function AffordanceTypeaheadDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/affordance/zoom.tsx b/site/src/app/routes/_page/docs/affordance/zoom.tsx new file mode 100644 index 00000000..150d6c24 --- /dev/null +++ b/site/src/app/routes/_page/docs/affordance/zoom.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/affordance/zoom")({ + component: function AffordanceZoomDocsRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/widgets/index.tsx b/site/src/app/routes/_page/widgets/index.tsx index f460b6a2..91d28541 100644 --- a/site/src/app/routes/_page/widgets/index.tsx +++ b/site/src/app/routes/_page/widgets/index.tsx @@ -1,6 +1,6 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { WidgetCatalogRoute } from "../../../../routes/widgets/WidgetCatalogRoute"; +import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { legacyPageRedirects } from "../../../page-descriptors"; export const Route = createFileRoute("/_page/widgets/")({ - component: WidgetCatalogRoute, + component: () => , }); diff --git a/site/src/app/site-layers.ts b/site/src/app/site-layers.ts index 376ba0fb..f26f7efd 100644 --- a/site/src/app/site-layers.ts +++ b/site/src/app/site-layers.ts @@ -11,10 +11,10 @@ export const siteLayers: ReadonlyArray = [ { group: "JSON Document", path: "/docs", label: "JSON Document", blurb: "Values and changes" }, { group: "Collaboration", path: "/docs/collaboration", label: "Collaboration", blurb: "The same contract, other implementation" }, { group: "Editing", path: "/docs/intent-guide", label: "Editing", blurb: "Selection and work" }, - { group: "Hands", path: "/editors", label: "Hands", blurb: "Genre hands" }, { group: "Adapter", path: "/adapters", label: "Adapter", blurb: "Platform contracts" }, { group: "Connector", path: "/connectors", label: "Connector", blurb: "Library ecosystems" }, - { group: "제품 화면", path: "/widgets", label: "제품 화면", blurb: "Toolbar, Listbox, Grid, Document, Canvas, Tree, and Board" }, + { group: "Affordance", path: "/docs/affordance", label: "Affordance", blurb: "Keyboard, mouse, and cursor" }, + { group: "Hands", path: "/editors", label: "Hands", blurb: "Genre hands" }, ]; export const siteLayerGroups: ReadonlyArray = siteLayers.map((layer) => layer.group); diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx index 5bff568e..d00b68c3 100644 --- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx +++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx @@ -4,6 +4,12 @@ import { type ObjectDocument, } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; +import { + applyAffordance, + commitAffordance, + dragAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; import { PageFrame, PageHeader, ProductApp } from "../../shared/ui/primitives"; import { optionProps } from "../../shared/widget-binding"; @@ -45,9 +51,19 @@ export function CanvasDemoRoute() { const document = snapshot.value as ObjectDocument; function handlePointerDown(event: PointerEvent, objectId: string) { - editing.getItem(objectId).getPressHandler()(event); - const mode = event.shiftKey ? "extend" : event.metaKey || event.ctrlKey ? "toggle" : "replace"; - const ids = mode === "toggle" || mode === "extend" + let operation: "replace" | "extend" | "toggle" = "replace"; + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + operation = hand.operation; + editor.dispatch({ + type: "selection.set", + objectIds: [objectId], + mode: hand.operation === "extend" ? "add" : hand.operation, + }); + }, + }); + const ids = operation !== "replace" ? [...new Set([...editor.selectedObjects.map((object) => object.id), objectId])] : [objectId]; event.currentTarget.setPointerCapture(event.pointerId); @@ -56,17 +72,33 @@ export function CanvasDemoRoute() { function handlePointerMove(event: PointerEvent) { if (!drag) return; - setDrag({ - ...drag, - dx: event.clientX - drag.originX, - dy: event.clientY - drag.originY, - }); + applyAffordance( + dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), + { + hand: (hand) => { + if (hand.type === "translate") setDrag({ ...drag, dx: hand.dx, dy: hand.dy }); + }, + }, + ); } - function handlePointerUp() { + function handlePointerUp(event: PointerEvent) { if (!drag) return; - if (drag.dx !== 0 || drag.dy !== 0) { - editor.dispatch({ type: "object.translate", objectIds: drag.ids, dx: drag.dx, dy: drag.dy }); + const committed = commitAffordance( + dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), + ); + if (committed) { + applyAffordance(committed, { + commit: (hand) => { + if (hand.type !== "translate") return; + editor.dispatch({ + type: "object.translate", + objectIds: drag.ids, + dx: hand.dx, + dy: hand.dy, + }); + }, + }); } setDrag(null); } diff --git a/site/src/routes/database-demo/DatabaseTableDemo.tsx b/site/src/routes/database-demo/DatabaseTableDemo.tsx index 5adaf396..6da4f236 100644 --- a/site/src/routes/database-demo/DatabaseTableDemo.tsx +++ b/site/src/routes/database-demo/DatabaseTableDemo.tsx @@ -15,6 +15,12 @@ import { type EditingResult, } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; +import { + applyAffordance, + commitAffordance, + dropAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { ActionButton, SelectableItem, ToggleButton } from "../../shared/ui/interactive"; import { ProductApp } from "../../shared/ui/primitives"; @@ -112,9 +118,11 @@ export function DatabaseTableDemo() { } function finishPropertyDrag() { + const source = draggedProperty.current; 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 }); } @@ -199,8 +207,31 @@ export function DatabaseTableDemo() { aria-grabbed={draggedProperty.current === property.id} onDragStart={(event) => startPropertyDrag(event, property.id)} onDragEnter={() => previewPropertyAt(property.id)} - onDragOver={(event) => event.preventDefault()} - onDrop={(event) => event.preventDefault()} + onDragOver={(event) => { + event.preventDefault(); + applyAffordance(dropAffordance({ canDrop: true }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + }} + onDrop={(event) => { + event.preventDefault(); + const drop = dropAffordance({ canDrop: true }); + applyAffordance(drop, { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + const committed = commitAffordance(drop); + if (!committed) return; + applyAffordance(committed, { + commit: (hand) => { + if (hand.type !== "move-drop") return; + finishPropertyDrag(); + }, + }); + }} onDragEnd={finishPropertyDrag} className={classes("cursor-move px-3 py-2", ui.database.head)} > @@ -226,6 +257,22 @@ export function DatabaseTableDemo() { data-property-id={property.id} className={classes("min-w-32 p-0", ui.database.cell)} {...gridCellProps(item)} + onClick={(event) => { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + run( + () => dispatchIntent({ + type: "selection.set", + recordId: record.id, + propertyId: property.id, + mode: hand.operation, + }), + "Cell selection updated", + ); + }, + }); + }} > = { overview: "package", quickstart: "terminal", adapters: "peek", + affordance: "cursor", + affordanceSelect: "cursor", + affordanceFold: "branch", + affordanceDrag: "peek", + affordanceHistory: "clipboard", + affordanceFocus: "cursor", + affordanceCaret: "terminal", + affordanceTypeahead: "braces", + affordanceActivate: "peek", + affordanceCancel: "debug", + affordanceDelete: "patch", + affordanceRename: "terminal", + affordanceNudge: "peek", + affordanceHover: "cursor", + affordanceDoubleClick: "peek", + affordanceTripleClick: "peek", + affordanceContextMenu: "clipboard", + affordanceMarquee: "cursor", + affordanceDrop: "connector", + affordanceCopyDrag: "peek", + affordanceResize: "branch", + affordancePan: "sleep", + affordanceScroll: "sleep", + affordanceZoom: "peek", + affordanceSnap: "braces", + affordanceForbid: "debug", connectors: "connector", reactEditing: "cursor", collaboration: "package", @@ -43,6 +69,10 @@ export function AdapterDocsRoute() { return ; } +export function AffordanceDocsRoute() { + return ; +} + export function ConnectorDocsRoute() { return ; } @@ -83,7 +113,7 @@ export function DocsRoute({ pageId }: { readonly pageId: DocPageId }) { {demo ? (
- {demo.label} 열기 + {page.relatedDemoLabel ?? `${demo.label} 열기`}
) : null} diff --git a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx index b335c969..b59dafa6 100644 --- a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx +++ b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx @@ -4,6 +4,12 @@ import { type KanbanDocument, } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; +import { + applyAffordance, + commitAffordance, + dropAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; import { ActionButton } from "../../shared/ui/interactive"; import { PageFrame, PageHeader, ProductApp } from "../../shared/ui/primitives"; import { classes, ui } from "../../shared/ui/styles"; @@ -65,10 +71,30 @@ export function KanbanDemoRoute() {
event.preventDefault()} + onDragOver={(event) => { + event.preventDefault(); + applyAffordance(dropAffordance({ canDrop: true }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + }} onDrop={(event: DragEvent) => { event.preventDefault(); - moveTo(column.id); + const drop = dropAffordance({ canDrop: true }); + applyAffordance(drop, { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + const committed = commitAffordance(drop); + if (!committed) return; + applyAffordance(committed, { + commit: (hand) => { + if (hand.type !== "move-drop") return; + moveTo(column.id); + }, + }); }} className="grid content-start gap-2 p-3" > @@ -87,8 +113,13 @@ export function KanbanDemoRoute() { data-focus={option.focus ? "true" : "false"} aria-selected={option["aria-selected"]} onClick={option.onClick} - onDragStart={() => { - editor.dispatch({ type: "selection.set", cardId: card.id }); + onDragStart={(event) => { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + editor.dispatch({ type: "selection.set", cardId: card.id }); + }, + }); setDragging(card.id); }} onDragEnd={() => setDragging(null)} diff --git a/site/src/routes/object-demo/ObjectDemoRoute.tsx b/site/src/routes/object-demo/ObjectDemoRoute.tsx index 8111762f..ded53151 100644 --- a/site/src/routes/object-demo/ObjectDemoRoute.tsx +++ b/site/src/routes/object-demo/ObjectDemoRoute.tsx @@ -6,6 +6,10 @@ import { type ObjectIntent, } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; +import { + applyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; import { PageFrame, PageHeader, ProductApp } from "../../shared/ui/primitives"; @@ -133,6 +137,18 @@ export function ObjectDemoRoute() { data-object-id={object.id} className="absolute grid place-items-center" {...optionProps(editing.getItem(object.id))} + onClick={(event) => { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + run({ + type: "selection.set", + objectIds: [object.id], + mode: hand.operation === "extend" ? "add" : hand.operation, + }, "Selection changed"); + }, + }); + }} style={{ left: object.x, top: object.y, diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx index a236a945..79bde7b1 100644 --- a/site/src/routes/order-demo/OrderDemoRoute.tsx +++ b/site/src/routes/order-demo/OrderDemoRoute.tsx @@ -6,11 +6,16 @@ import { type OrderIntent, } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; +import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; +import { + applyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; import { PageFrame, PageHeader, ProductApp } from "../../shared/ui/primitives"; import { classes, ui } from "../../shared/ui/styles"; -import { historyCommands, optionProps } from "../../shared/widget-binding"; +import { editingCommandFromStroke, historyCommands, optionProps } from "../../shared/widget-binding"; const initialOrder: OrderDocument = { items: [ @@ -34,6 +39,7 @@ export function OrderDemoRoute() { return result; } + const ids = () => (editor.snapshot.value as OrderDocument).items.map((item) => item.id); const editing = useEditing({ source: editor, selectedKeys: editor.selectedItemIds, @@ -41,6 +47,24 @@ export function OrderDemoRoute() { onSelect: (itemId, mode) => { run({ type: "selection.set", itemId, mode }, "Selection changed"); }, + keyboard: { + resolve: (stroke) => editingCommandFromStroke(stroke), + focusKey: () => editor.snapshot.selection.ranges[editor.snapshot.selection.primaryIndex ?? 0]?.focus.itemId ?? undefined, + neighbor: (key, command) => command.type === "move" + ? moveLinePoint(ids(), key, command.direction) + : lineBoundary(ids(), command.edge), + onDelete: () => { + run({ type: "selection.remove" }, "Selection deleted"); + }, + onUndo: () => { + editor.undo(); + setAnnouncement("Undone"); + }, + onRedo: () => { + editor.redo(); + setAnnouncement("Redone"); + }, + }, }); const snapshot = editing.snapshot; const document = snapshot.value as OrderDocument; @@ -105,13 +129,25 @@ export function OrderDemoRoute() { )} >
-
    +
      {document.items.map((item, index) => ( { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + run({ type: "selection.set", itemId: item.id, mode: hand.operation }, "Selection changed"); + }, + }); + }} > {index + 1} {item.label} diff --git a/site/src/routes/sheet-demo/SheetDemo.tsx b/site/src/routes/sheet-demo/SheetDemo.tsx index 7f1a0e19..294e9584 100644 --- a/site/src/routes/sheet-demo/SheetDemo.tsx +++ b/site/src/routes/sheet-demo/SheetDemo.tsx @@ -11,16 +11,19 @@ import { import { useEditing } from "@interactive-os/json-document-react"; import { createWebClipboardBinding, - createWebKeyboardAdapter, gridBoundary, moveGridPoint, sheetClipboardCodec, } from "@interactive-os/json-document-web"; +import { + applyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; import { PageFrame, PageHeader, ProductApp } from "../../shared/ui/primitives"; import { classes, ui } from "../../shared/ui/styles"; -import { gridCellProps, historyCommands } from "../../shared/widget-binding"; +import { editingCommandFromStroke, gridCellProps, historyCommands } from "../../shared/widget-binding"; const initialSheet: SheetDocument = { columns: [ @@ -45,7 +48,6 @@ export function SheetDemo() { cut: () => editor.cut()?.result ?? { ok: false, code: "selection.empty" }, paste: (payload) => editor.dispatch({ type: "clipboard.paste", clipboard: payload }), })); - const [keyboard] = useState(() => createWebKeyboardAdapter()); const surfaceRef = useRef(null); const [announcement, setAnnouncement] = useState("Ready"); const [lastIntent, setLastIntent] = useState(null); @@ -77,7 +79,7 @@ export function SheetDemo() { ); }, keyboard: { - resolve: (stroke) => keyboard.resolve(stroke), + resolve: (stroke) => editingCommandFromStroke(stroke), focusKey: () => { const focus = editor.snapshot.selection.focus; return focus ? cellKey(focus.rowId, focus.columnId) : undefined; @@ -241,6 +243,22 @@ export function SheetDemo() { data-column-id={column.id} className={classes("p-0", ui.surface.gridCell)} {...gridCellProps(item)} + onClick={(event) => { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + run( + () => dispatchIntent({ + type: "selection.set", + rowId: row.id, + columnId: column.id, + mode: hand.operation, + }), + hand.operation === "extend" ? "Range extended" : hand.operation === "toggle" ? "Range toggled" : "Cell selected", + ); + }, + }); + }} > visibleTopology((editor.snapshot.value as TreeDocument).nodes, expanded), [editor.snapshot.value, expanded], ); + const rows = useMemo( + () => walkVisible((editor.snapshot.value as TreeDocument).nodes, expanded), + [editor.snapshot.value, expanded], + ); function run(intent: TreeIntent, message: string) { const result = editor.dispatch(intent); @@ -50,6 +60,54 @@ export function TreeDemoRoute() { onSelect: (nodeId, mode) => { run({ type: "selection.set", nodeId, topology, mode }, "Selection changed"); }, + keyboard: { + resolve: (stroke) => editingCommandFromStroke(stroke), + focusKey: () => editor.snapshot.selection.ranges[editor.snapshot.selection.primaryIndex ?? 0]?.focus.nodeId ?? undefined, + neighbor: (key, command) => { + const row = rows.find((item) => item.id === key); + const nodes = (editor.snapshot.value as TreeDocument).nodes; + if (row && command.type === "move") { + let stay = false; + applyAffordance( + treeAffordance(command, { + expanded: expanded.has(row.id), + hasChildren: nodes.some((node) => node.parentId === row.id), + }), + { + hand: (hand) => { + if (hand.type === "expand") { + setExpanded((current) => new Set(current).add(row.id)); + stay = true; + } + if (hand.type === "collapse") { + setExpanded((current) => { + const next = new Set(current); + next.delete(row.id); + return next; + }); + stay = true; + } + }, + }, + ); + if (stay) return key; + } + return command.type === "move" + ? moveLinePoint(topology.visibleIds, key, command.direction) + : lineBoundary(topology.visibleIds, command.edge); + }, + onDelete: () => { + run({ type: "selection.remove", topology }, "Selection deleted"); + }, + onUndo: () => { + editor.undo(); + setAnnouncement("Undone"); + }, + onRedo: () => { + editor.redo(); + setAnnouncement("Redone"); + }, + }, }); const snapshot = editing.snapshot; const document = snapshot.value as TreeDocument; @@ -123,7 +181,11 @@ export function TreeDemoRoute() { )} >
      -
        +
          {walkVisible(document.nodes, expanded).map((row) => { const childCount = document.nodes.filter((node) => node.parentId === row.id).length; return ( @@ -141,6 +203,14 @@ export function TreeDemoRoute() { data-node-id={row.id} className={classes("text-left", ui.surface.documentBlock)} {...optionProps(editing.getItem(row.id))} + onClick={(event) => { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + run({ type: "selection.set", nodeId: row.id, topology, mode: hand.operation }, "Selection changed"); + }, + }); + }} > {row.label} diff --git a/site/src/routes/widgets/BoardWidgetRoute.tsx b/site/src/routes/widgets/BoardWidgetRoute.tsx index 07c29b86..1a0a2972 100644 --- a/site/src/routes/widgets/BoardWidgetRoute.tsx +++ b/site/src/routes/widgets/BoardWidgetRoute.tsx @@ -1,6 +1,13 @@ -import { useState } from "react"; +import { useRef, useState, type PointerEvent } from "react"; import { createKanbanEditor, type KanbanDocument } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; +import { + applyAffordance, + commitAffordance, + dragAffordance, + dropAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; import { SelectableItem } from "../../shared/ui/interactive"; import { classes, ui } from "../../shared/ui/styles"; import { optionProps } from "../../shared/widget-binding"; @@ -19,16 +26,29 @@ const initialBoard: KanbanDocument = { ], }; +type DragState = { + readonly cardId: string; + readonly originX: number; + readonly originY: number; + readonly dx: number; + readonly dy: number; +}; + export function BoardWidgetRoute() { const [editor] = useState(() => createKanbanEditor(initialBoard)); + const [drag, setDrag] = useState(null); + const surface = useRef(null); const editing = useEditing({ source: editor, selectedKeys: editor.selectedCardIds, focusKey: editor.snapshot.selection.primaryKey, - onSelect: (cardId) => { - editor.dispatch({ type: "selection.set", cardId }); + onSelect: (cardId, mode) => { + editor.dispatch({ + type: "selection.set", + cardId, + mode: mode === "replace" ? "replace" : "toggle", + }); }, - operationFromEvent: () => "replace", }); const board = editing.snapshot.value as KanbanDocument; const cards = new Map(board.cards.map((card) => [card.id, card])); @@ -37,31 +57,108 @@ export function BoardWidgetRoute() { cardIds: column.cardIds, })); + function handleCardPointerDown(event: PointerEvent, cardId: string) { + event.stopPropagation(); + surface.current?.setPointerCapture(event.pointerId); + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + editor.dispatch({ + type: "selection.set", + cardId, + mode: hand.operation === "replace" ? "replace" : "toggle", + }); + }, + }); + setDrag({ cardId, originX: event.clientX, originY: event.clientY, dx: 0, dy: 0 }); + } + + function handlePointerMove(event: PointerEvent) { + if (!drag) return; + applyAffordance( + dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), + { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type === "translate") setDrag({ ...drag, dx: hand.dx, dy: hand.dy }); + }, + }, + ); + applyAffordance(dropAffordance({ canDrop: columnAt(event) !== null }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + } + + function handlePointerUp(event: PointerEvent) { + if (!drag) return; + const moved = commitAffordance( + dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), + ); + const columnId = columnAt(event); + const drop = dropAffordance({ canDrop: moved !== null && columnId !== null }); + applyAffordance(drop, { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + }); + const dropped = commitAffordance(drop); + if (dropped && columnId !== null) { + applyAffordance(dropped, { + commit: (hand) => { + if (hand.type !== "move-drop") return; + editor.dispatch({ type: "card.move", cardId: drag.cardId, columnId, beforeCardId: null }); + }, + }); + } + setDrag(null); + event.currentTarget.style.cursor = "default"; + } + return ( +
          {board.columns.map((column) => ( -
          +

          {column.title}

            {column.cardIds.map((cardId) => { const card = cards.get(cardId); if (!card) return null; + const option = optionProps(editing.getItem(card.id)); + const offset = drag?.cardId === card.id ? drag : null; return (
          • handleCardPointerDown(event, card.id)} > {card.title} @@ -82,3 +179,9 @@ export function BoardWidgetRoute() { /> ); } + +function columnAt(event: PointerEvent): string | null { + const node = globalThis.document.elementFromPoint(event.clientX, event.clientY); + if (!(node instanceof Element)) return null; + return node.closest("[data-column-id]")?.getAttribute("data-column-id") ?? null; +} diff --git a/site/src/routes/widgets/CanvasWidgetRoute.tsx b/site/src/routes/widgets/CanvasWidgetRoute.tsx index 8b72501c..7400ccd5 100644 --- a/site/src/routes/widgets/CanvasWidgetRoute.tsx +++ b/site/src/routes/widgets/CanvasWidgetRoute.tsx @@ -1,9 +1,22 @@ -import { useState } from "react"; +import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { createObjectEditor, type ObjectDocument } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; +import { + applyAffordance, + commitAffordance, + dragAffordance, + escapeAffordance, + forbiddenCursor, + marqueeAffordance, + nudgeAffordance, + panAffordance, + pointerSelect, + snapAffordance, +} from "@interactive-os/json-document-affordance"; import { SelectableItem } from "../../shared/ui/interactive"; -import { WidgetDemoFrame } from "./WidgetDemoFrame"; +import { classes, ui } from "../../shared/ui/styles"; import { optionProps } from "../../shared/widget-binding"; +import { WidgetDemoFrame } from "./WidgetDemoFrame"; const initialObjects: ObjectDocument = { objects: [ @@ -13,8 +26,30 @@ const initialObjects: ObjectDocument = { ], }; +type DragState = { + readonly ids: ReadonlyArray; + readonly originX: number; + readonly originY: number; + readonly dx: number; + readonly dy: number; +}; + +type MarqueeState = { + readonly originX: number; + readonly originY: number; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + export function CanvasWidgetRoute() { 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 editing = useEditing({ source: editor, selectedKeys: editor.selectedObjects.map((object) => object.id), @@ -29,32 +64,257 @@ export function CanvasWidgetRoute() { }); const document = editing.snapshot.value as ObjectDocument; + function handlePointerDown(event: PointerEvent, objectId?: string) { + surface.current?.setPointerCapture(event.pointerId); + let grabbing = false; + applyAffordance(panAffordance({ spaceKey: space, buttons: event.buttons }), { + cursor: (cursor) => { + grabbing = cursor === "grabbing"; + }, + hand: (hand) => { + if (hand.type === "translate") grabbing = true; + }, + }); + if (grabbing || (space && event.buttons === 1)) { + setPan({ x: pan.x, y: pan.y, originX: event.clientX - pan.x, originY: event.clientY - pan.y, active: true }); + return; + } + if (objectId) { + let operation: "replace" | "extend" | "toggle" = "replace"; + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + operation = hand.operation; + editor.dispatch({ + type: "selection.set", + objectIds: [objectId], + mode: hand.operation === "extend" ? "add" : hand.operation, + }); + }, + }); + 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 }; + setMarquee({ originX: origin.x, originY: origin.y, x: origin.x, y: origin.y, width: 0, height: 0 }); + } + + function handlePointerMove(event: PointerEvent) { + applyAffordance(forbiddenCursor({ allowed: true, dropping: drag !== null }), { + cursor: (cursor) => { + event.currentTarget.style.cursor = space ? "grab" : cursor; + }, + }); + if (pan.active) { + applyAffordance( + panAffordance({ + spaceKey: true, + buttons: event.buttons, + origin: { x: pan.originX, y: pan.originY }, + point: { x: event.clientX, y: event.clientY }, + }), + { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type !== "translate") return; + setPan((current) => ({ ...current, x: hand.dx, y: hand.dy })); + }, + }, + ); + return; + } + if (drag) { + applyAffordance( + dragAffordance({ x: drag.originX, y: drag.originY }, { x: event.clientX, y: event.clientY }), + { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type === "translate") setDrag({ ...drag, dx: hand.dx, dy: hand.dy }); + }, + }, + ); + return; + } + if (marquee) { + const origin = { x: marquee.originX, y: marquee.originY }; + const point = { x: event.nativeEvent.offsetX, y: event.nativeEvent.offsetY }; + applyAffordance(marqueeAffordance(origin, point), { + cursor: (cursor) => { + event.currentTarget.style.cursor = cursor; + }, + hand: (hand) => { + if (hand.type !== "select" || !hand.rect) return; + setMarquee({ originX: origin.x, originY: origin.y, ...hand.rect }); + }, + }); + } + } + + function handlePointerUp(event: PointerEvent) { + if (pan.active) { + setPan((current) => ({ ...current, active: false })); + return; + } + if (drag) { + const committed = commitAffordance( + dragAffordance({ x: drag.originX, y: drag.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: drag.ids, + dx: snapped.dx, + dy: snapped.dy, + }); + }, + }, + ); + }, + }); + } + setDrag(null); + return; + } + 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)); + if (committed) { + applyAffordance(committed, { + commit: (hand) => { + 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", + }); + }, + }); + }, + }); + } + setMarquee(null); + } + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === " ") { + 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(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 ( - {document.objects.map((object) => ( - - {object.label} - - ))} +
            handlePointerDown(event)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={(event) => { + applyAffordance(escapeAffordance(event), { + hand: (hand) => { + if (hand.type !== "cancel") return; + setDrag(null); + setMarquee(null); + setPan((current) => ({ ...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 ( + { + event.stopPropagation(); + handlePointerDown(event, object.id); + }} + > + {object.label} + + ); + })} + {marquee ? ( +
            + ) : null} +
            )} values={[ @@ -65,3 +325,13 @@ export function CanvasWidgetRoute() { /> ); } + +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/widgets/DocumentWidgetRoute.tsx b/site/src/routes/widgets/DocumentWidgetRoute.tsx index ebb98a26..895e74d5 100644 --- a/site/src/routes/widgets/DocumentWidgetRoute.tsx +++ b/site/src/routes/widgets/DocumentWidgetRoute.tsx @@ -4,7 +4,11 @@ import { useEditing } from "@interactive-os/json-document-react"; import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; import { SelectableItem } from "../../shared/ui/interactive"; import { classes, ui } from "../../shared/ui/styles"; -import { optionProps, useWidgetKeyboard } from "../../shared/widget-binding"; +import { + applyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; +import { editingCommandFromStroke, optionProps, useWidgetKeyboard } from "../../shared/widget-binding"; import { WidgetDemoFrame } from "./WidgetDemoFrame"; const initialDocument: BlockDocument = { @@ -28,7 +32,10 @@ export function DocumentWidgetRoute() { editor.dispatch({ type: "selection.set", blockId, mode }); }, keyboard: { - resolve: (stroke) => keyboard.resolve(stroke), + resolve: (stroke) => { + keyboard.resolve(stroke); + return editingCommandFromStroke(stroke); + }, focusKey: () => editor.selectedBlockIds.at(-1), neighbor: (key, command) => { const ids = (editor.snapshot.value as BlockDocument).blocks.map((block) => block.id); @@ -53,7 +60,7 @@ export function DocumentWidgetRoute() { return ( { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + editor.dispatch({ type: "selection.set", blockId: block.id, mode: hand.operation }); + }, + }); + }} > {block.text} diff --git a/site/src/routes/widgets/GridWidgetRoute.tsx b/site/src/routes/widgets/GridWidgetRoute.tsx index e2334a69..04038e28 100644 --- a/site/src/routes/widgets/GridWidgetRoute.tsx +++ b/site/src/routes/widgets/GridWidgetRoute.tsx @@ -4,7 +4,11 @@ import { useEditing } from "@interactive-os/json-document-react"; import { gridBoundary, moveGridPoint } from "@interactive-os/json-document-web"; import { SelectableItem } from "../../shared/ui/interactive"; import { classes, ui } from "../../shared/ui/styles"; -import { gridCellProps, useWidgetKeyboard } from "../../shared/widget-binding"; +import { + applyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; +import { editingCommandFromStroke, gridCellProps, useWidgetKeyboard } from "../../shared/widget-binding"; import { WidgetDemoFrame } from "./WidgetDemoFrame"; const initialSheet: SheetDocument = { @@ -32,7 +36,10 @@ export function GridWidgetRoute() { editor.dispatch({ type: "selection.set", rowId, columnId, mode }); }, keyboard: { - resolve: (stroke) => keyboard.resolve(stroke), + resolve: (stroke) => { + keyboard.resolve(stroke); + return editingCommandFromStroke(stroke); + }, focusKey: () => { const next = editor.snapshot.selection.focus; return next ? cellKey(next.rowId, next.columnId) : undefined; @@ -69,7 +76,7 @@ export function GridWidgetRoute() { return ( { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + editor.dispatch({ + type: "selection.set", + rowId: row.id, + columnId: column.id, + mode: hand.operation, + }); + }, + }); + }} > {String(row.cells[column.id] ?? "")} diff --git a/site/src/routes/widgets/ListboxWidgetRoute.tsx b/site/src/routes/widgets/ListboxWidgetRoute.tsx index 51cd1d92..4e368328 100644 --- a/site/src/routes/widgets/ListboxWidgetRoute.tsx +++ b/site/src/routes/widgets/ListboxWidgetRoute.tsx @@ -1,10 +1,16 @@ -import { useState } from "react"; +import { useState, type KeyboardEvent } from "react"; import { createOrderEditor, type OrderDocument } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; import { SelectableItem } from "../../shared/ui/interactive"; import { classes, ui } from "../../shared/ui/styles"; -import { optionProps, useWidgetKeyboard } from "../../shared/widget-binding"; +import { + applyAffordance, + escapeAffordance, + pointerSelect, + typeaheadAffordance, +} from "@interactive-os/json-document-affordance"; +import { editingCommandFromStroke, optionProps, useWidgetKeyboard } from "../../shared/widget-binding"; import { WidgetDemoFrame } from "./WidgetDemoFrame"; const initialOrder: OrderDocument = { @@ -18,6 +24,7 @@ const initialOrder: OrderDocument = { export function ListboxWidgetRoute() { const [editor] = useState(() => createOrderEditor(initialOrder)); + const [typeahead, setTypeahead] = useState({ buffer: "", at: 0 }); const keyboard = useWidgetKeyboard(); const ids = () => (editor.snapshot.value as OrderDocument).items.map((item) => item.id); const focusKey = editor.snapshot.selection.ranges[editor.snapshot.selection.primaryIndex ?? 0]?.focus.itemId ?? null; @@ -29,7 +36,10 @@ export function ListboxWidgetRoute() { editor.dispatch({ type: "selection.set", itemId, mode }); }, keyboard: { - resolve: (stroke) => keyboard.resolve(stroke), + resolve: (stroke) => { + keyboard.resolve(stroke); + return editingCommandFromStroke(stroke); + }, focusKey: () => editor.snapshot.selection.ranges[editor.snapshot.selection.primaryIndex ?? 0]?.focus.itemId ?? undefined, neighbor: (key, command) => command.type === "move" ? moveLinePoint(ids(), key, command.direction) @@ -47,10 +57,43 @@ export function ListboxWidgetRoute() { }); const document = editing.snapshot.value as OrderDocument; + 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) editor.dispatch({ type: "selection.set", itemId: item.id, mode: "replace" }); + }, + }); + 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) => ( @@ -68,6 +111,14 @@ export function ListboxWidgetRoute() { role="option" className={classes("w-full text-left", ui.surface.selectableBlock)} {...optionProps(editing.getItem(item.id))} + onClick={(event) => { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + editor.dispatch({ type: "selection.set", itemId: item.id, mode: hand.operation }); + }, + }); + }} > {item.label} diff --git a/site/src/routes/widgets/ToolbarWidgetRoute.tsx b/site/src/routes/widgets/ToolbarWidgetRoute.tsx index e8169c50..9a5eb69c 100644 --- a/site/src/routes/widgets/ToolbarWidgetRoute.tsx +++ b/site/src/routes/widgets/ToolbarWidgetRoute.tsx @@ -4,7 +4,12 @@ import { useEditing } from "@interactive-os/json-document-react"; import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; import { ActionButton, SelectableItem } from "../../shared/ui/interactive"; import { classes, ui } from "../../shared/ui/styles"; -import { historyCommands, optionProps, useWidgetKeyboard } from "../../shared/widget-binding"; +import { + applyAffordance, + historyAffordance, + pointerSelect, +} from "@interactive-os/json-document-affordance"; +import { editingCommandFromStroke, optionProps, useWidgetKeyboard } from "../../shared/widget-binding"; import { WidgetDemoFrame } from "./WidgetDemoFrame"; const initialOrder: OrderDocument = { @@ -27,7 +32,10 @@ export function ToolbarWidgetRoute() { editor.dispatch({ type: "selection.set", itemId, mode }); }, keyboard: { - resolve: (stroke) => keyboard.resolve(stroke), + resolve: (stroke) => { + keyboard.resolve(stroke); + return editingCommandFromStroke(stroke); + }, focusKey: () => editor.snapshot.selection.ranges[editor.snapshot.selection.primaryIndex ?? 0]?.focus.itemId ?? undefined, neighbor: (key, command) => command.type === "move" ? moveLinePoint(ids(), key, command.direction) @@ -45,12 +53,20 @@ export function ToolbarWidgetRoute() { }); const snapshot = editing.snapshot; const document = snapshot.value as OrderDocument; - const commands = historyCommands(snapshot); + let commands = { + undo: { name: "undo" as const, disabled: true }, + redo: { name: "redo" as const, disabled: true }, + }; + applyAffordance(historyAffordance(snapshot), { + hand: (hand) => { + if (hand.type === "history") commands = { undo: hand.undo, redo: hand.redo }; + }, + }); return ( { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + editor.dispatch({ type: "selection.set", itemId: item.id, mode: hand.operation }); + }, + }); + }} > {item.label} diff --git a/site/src/routes/widgets/TreeWidgetRoute.tsx b/site/src/routes/widgets/TreeWidgetRoute.tsx index 2e840102..c9412917 100644 --- a/site/src/routes/widgets/TreeWidgetRoute.tsx +++ b/site/src/routes/widgets/TreeWidgetRoute.tsx @@ -9,7 +9,12 @@ import { useEditing } from "@interactive-os/json-document-react"; import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; import { IconButton, SelectableItem } from "../../shared/ui/interactive"; import { classes, ui } from "../../shared/ui/styles"; -import { treeItemProps, useWidgetKeyboard } from "../../shared/widget-binding"; +import { + applyAffordance, + pointerSelect, + treeAffordance, +} from "@interactive-os/json-document-affordance"; +import { editingCommandFromStroke, treeItemProps, useWidgetKeyboard } from "../../shared/widget-binding"; import { WidgetDemoFrame } from "./WidgetDemoFrame"; const initialTree: TreeDocument = { @@ -38,11 +43,43 @@ export function TreeWidgetRoute() { editor.dispatch({ type: "selection.set", nodeId, topology, mode }); }, keyboard: { - resolve: (stroke) => keyboard.resolve(stroke), + resolve: (stroke) => { + keyboard.resolve(stroke); + return editingCommandFromStroke(stroke); + }, focusKey: () => editor.snapshot.selection.ranges[editor.snapshot.selection.primaryIndex ?? 0]?.focus.nodeId ?? undefined, - neighbor: (key, command) => command.type === "move" - ? moveLinePoint(topology.visibleIds, key, command.direction) - : lineBoundary(topology.visibleIds, command.edge), + neighbor: (key, command) => { + const row = rows.find((item) => item.id === key); + if (row && command.type === "move") { + let stay = false; + applyAffordance( + treeAffordance(command, { + expanded: expanded.has(row.id), + hasChildren: document.nodes.some((node) => node.parentId === row.id), + }), + { + hand: (hand) => { + if (hand.type === "expand") { + setExpanded((current) => new Set(current).add(row.id)); + stay = true; + } + if (hand.type === "collapse") { + setExpanded((current) => { + const next = new Set(current); + next.delete(row.id); + return next; + }); + stay = true; + } + }, + }, + ); + if (stay) return key; + } + return command.type === "move" + ? moveLinePoint(topology.visibleIds, key, command.direction) + : lineBoundary(topology.visibleIds, command.edge); + }, onDelete: () => { editor.dispatch({ type: "selection.remove", topology }); }, @@ -67,7 +104,7 @@ export function TreeWidgetRoute() { return ( { + applyAffordance(pointerSelect(event), { + hand: (hand) => { + if (hand.type !== "select") return; + editor.dispatch({ + type: "selection.set", + nodeId: row.id, + topology, + mode: hand.operation, + }); + }, + }); + }} > {row.label} diff --git a/site/src/routes/widgets/WidgetCatalogRoute.tsx b/site/src/routes/widgets/WidgetCatalogRoute.tsx deleted file mode 100644 index 7904d38d..00000000 --- a/site/src/routes/widgets/WidgetCatalogRoute.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { pageDescriptor } from "../../app/page-descriptors"; -import { ActionLink } from "../../shared/ui/interactive"; -import { PageFrame, PageHeader } from "../../shared/ui/primitives"; -import { classes, ui } from "../../shared/ui/styles"; - -const widgets = [ - { - ...pageDescriptor("/widgets/toolbar"), - affordance: "canUndo / canRedo", - }, - { - ...pageDescriptor("/widgets/listbox"), - affordance: "selected keys / focus", - }, - { - ...pageDescriptor("/widgets/grid"), - affordance: "topology / selected cells", - }, - { - ...pageDescriptor("/widgets/document"), - affordance: "block line / text offset", - }, - { - ...pageDescriptor("/widgets/canvas"), - affordance: "selected objects", - }, - { - ...pageDescriptor("/widgets/tree"), - affordance: "visible topology", - }, - { - ...pageDescriptor("/widgets/board"), - affordance: "columns / selected cards", - }, -] as const; - -export function WidgetCatalogRoute() { - return ( - - - Bindings turn editing values into Toolbar, Listbox, Grid, Document, Canvas, Tree, and Board props. The demos only render. Button and Card are not widgets. - - -
            - {widgets.map((widget) => ( -
            -

            {widget.affordance}

            -

            {widget.label}

            -

            {widget.description}

            - - Open {widget.label} - -
            - ))} -
            -
            - ); -} diff --git a/site/src/routes/widgets/WidgetDemoFrame.tsx b/site/src/routes/widgets/WidgetDemoFrame.tsx index f9bd6102..ca491027 100644 --- a/site/src/routes/widgets/WidgetDemoFrame.tsx +++ b/site/src/routes/widgets/WidgetDemoFrame.tsx @@ -22,7 +22,7 @@ export function WidgetDemoFrame(props: {
            -

            Widget

            +

            Proof

            {props.widgetLabel}

            {props.widget} {props.surface != null ? ( @@ -34,7 +34,7 @@ export function WidgetDemoFrame(props: {

            Editing values

            -

            What the widget reads

            +

            What this reads

            {props.values.map((item) => ( diff --git a/site/src/shared/widget-binding/history.ts b/site/src/shared/widget-binding/history.ts index 7dbd6f85..e0b0a306 100644 --- a/site/src/shared/widget-binding/history.ts +++ b/site/src/shared/widget-binding/history.ts @@ -1,21 +1,25 @@ -export type HistoryCommandName = "undo" | "redo"; +import { + applyAffordance, + historyAffordance, + type HistoryAffordance, + type HistoryAffordanceMap, + type HistoryAffordanceName, +} from "@interactive-os/json-document-affordance"; -export type HistoryCommand = { - readonly name: HistoryCommandName; - readonly disabled: boolean; -}; - -export type HistoryCommandMap = { - readonly undo: HistoryCommand; - readonly redo: HistoryCommand; -}; +export type { HistoryAffordance as HistoryCommand, HistoryAffordanceMap as HistoryCommandMap, HistoryAffordanceName as HistoryCommandName }; export function historyCommands(snapshot: { readonly canUndo: boolean; readonly canRedo: boolean; -}): HistoryCommandMap { - return { - undo: { name: "undo", disabled: !snapshot.canUndo }, - redo: { name: "redo", disabled: !snapshot.canRedo }, +}): HistoryAffordanceMap { + let commands: HistoryAffordanceMap = { + undo: { name: "undo", disabled: true }, + redo: { name: "redo", disabled: true }, }; + applyAffordance(historyAffordance(snapshot), { + hand: (hand) => { + if (hand.type === "history") commands = { undo: hand.undo, redo: hand.redo }; + }, + }); + return commands; } diff --git a/site/src/shared/widget-binding/index.ts b/site/src/shared/widget-binding/index.ts index 1d8238b1..1efffc91 100644 --- a/site/src/shared/widget-binding/index.ts +++ b/site/src/shared/widget-binding/index.ts @@ -1,4 +1,4 @@ export { historyCommands } from "./history"; export type { HistoryCommand, HistoryCommandMap, HistoryCommandName } from "./history"; -export { useWidgetKeyboard } from "./keyboard"; +export { editingCommandFromStroke, useWidgetKeyboard } from "./keyboard"; export { gridCellProps, optionProps, treeItemProps } from "./option"; diff --git a/site/src/shared/widget-binding/keyboard.ts b/site/src/shared/widget-binding/keyboard.ts index d6371fc3..b6af567a 100644 --- a/site/src/shared/widget-binding/keyboard.ts +++ b/site/src/shared/widget-binding/keyboard.ts @@ -1,9 +1,33 @@ import { useState } from "react"; +import { + applyAffordance, + resolveAffordanceKey, +} from "@interactive-os/json-document-affordance"; import { createWebKeyboardAdapter, type WebKeyboardCommand, + type WebKeyboardStroke, } from "@interactive-os/json-document-web"; +export function editingCommandFromStroke(stroke: WebKeyboardStroke): WebKeyboardCommand | null { + let command: WebKeyboardCommand | null = null; + applyAffordance(resolveAffordanceKey(stroke), { + hand: (hand) => { + if ( + hand.type === "move" + || hand.type === "boundary" + || hand.type === "toggle" + || hand.type === "delete" + || hand.type === "undo" + || hand.type === "redo" + ) { + command = hand; + } + }, + }); + return command; +} + export function useWidgetKeyboard() { const [keyboard] = useState(() => createWebKeyboardAdapter()); const [lastCommand, setLastCommand] = useState(null); diff --git a/site/tests/browser/site-shell.spec.ts b/site/tests/browser/site-shell.spec.ts index 94907181..714ca74f 100644 --- a/site/tests/browser/site-shell.spec.ts +++ b/site/tests/browser/site-shell.spec.ts @@ -15,10 +15,10 @@ test("official overview exposes the product hierarchy", async ({ page }) => { "JSON Document", "Collaboration", "Editing", - "Hands", "Adapter", "Connector", - "제품 화면", + "Affordance", + "Hands", ]); await expect(navigation.getByRole("link", { name: "Why" })).toHaveCount(0); await expect(navigation.getByRole("link", { name: "Replica" })).toHaveCount(0); @@ -60,15 +60,33 @@ test("official overview exposes the product hierarchy", async ({ page }) => { await expect(navigation.getByRole("group", { name: "Adapter" }).getByRole("link")).toHaveText(["Keyboard", "Clipboard adapter", "Contenteditable"]); await navigation.getByRole("button", { name: "Connector" }).click(); await expect(navigation.getByRole("group", { name: "Connector" }).getByRole("link")).toHaveText(["React", "React Hook Form", "Ajv", "Zod", "TanStack Table"]); - await navigation.getByRole("button", { name: "제품 화면" }).click(); - await expect(navigation.getByRole("group", { name: "제품 화면" }).getByRole("link")).toHaveText([ - "Toolbar", - "Listbox", - "Grid", - "Document", - "Canvas", - "Tree", - "Board", + await navigation.getByRole("button", { name: "Affordance" }).click(); + await expect(navigation.getByRole("group", { name: "Affordance" }).getByRole("link")).toHaveText([ + "Focus", + "Caret", + "Select", + "Typeahead", + "Activate", + "Escape", + "Expand/Collapse", + "Undo", + "Delete", + "Rename", + "Nudge", + "Hover", + "Double-click", + "Triple-click", + "Context menu", + "Drag", + "Marquee", + "Drop", + "Duplicate", + "Resize", + "Pan", + "Scroll", + "Zoom", + "Snap", + "Not-allowed", ]); await expect(navigation.getByRole("group", { name: "Demos" })).toHaveCount(0); await expect(navigation.getByRole("group", { name: "Reference" })).toHaveCount(0); @@ -76,10 +94,10 @@ test("official overview exposes the product hierarchy", async ({ page }) => { "JSON Document", "Collaboration", "Editing", - "Hands", "Adapter", "Connector", - "제품 화면", + "Affordance", + "Hands", ]); await expect(navigation.getByRole("link", { name: "Extensions" })).toHaveCount(0); expect(requests.some(isLegacyRequest)).toBe(false); @@ -97,7 +115,7 @@ test("mobile navigation preserves the product groups without duplicating documen await expect(siteNavigation.getByRole("group", { name: "Adapter" })).toBeVisible(); await expect(siteNavigation.getByRole("group", { name: "Demos" })).toHaveCount(0); await expect(siteNavigation.getByRole("group", { name: "Connector" })).toBeVisible(); - await expect(siteNavigation.getByRole("group", { name: "제품 화면" })).toBeVisible(); + await expect(siteNavigation.getByRole("group", { name: "Affordance" })).toBeVisible(); await expect(siteNavigation.getByRole("group", { name: "Collaboration" })).toBeVisible(); await page.goto("/docs/tutorial"); diff --git a/site/tests/browser/widgets.spec.ts b/site/tests/browser/widgets.spec.ts index 887b6882..866bea96 100644 --- a/site/tests/browser/widgets.spec.ts +++ b/site/tests/browser/widgets.spec.ts @@ -1,16 +1,21 @@ import { expect, test, type Page } from "@playwright/test"; -test("Widgets catalog lists only editing widgets", async ({ page }) => { +test("Widgets catalog redirects to affordance usage", async ({ page }) => { await page.goto("/widgets"); - await expect(page.getByRole("heading", { level: 1, name: "제품 화면" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Open Toolbar" })).toHaveAttribute("href", "/widgets/toolbar"); - await expect(page.getByRole("link", { name: "Open Listbox" })).toHaveAttribute("href", "/widgets/listbox"); - await expect(page.getByRole("link", { name: "Open Grid" })).toHaveAttribute("href", "/widgets/grid"); - await expect(page.getByRole("link", { name: "Open Document" })).toHaveAttribute("href", "/widgets/document"); - await expect(page.getByRole("link", { name: "Open Canvas" })).toHaveAttribute("href", "/widgets/canvas"); - await expect(page.getByRole("link", { name: "Open Tree" })).toHaveAttribute("href", "/widgets/tree"); - await expect(page.getByRole("link", { name: "Open Board" })).toHaveAttribute("href", "/widgets/board"); - await expect(page.getByRole("link", { name: "Open Database" })).toHaveCount(0); + await expect(page).toHaveURL(/\/docs\/affordance$/); + await expect(page.getByRole("heading", { level: 1, name: "Affordance" })).toBeVisible(); + const navigation = page.getByRole("navigation", { name: "Site navigation" }); + await expect(navigation.getByRole("link", { name: "Select", exact: true })).toHaveAttribute("href", "/docs/affordance/select"); + await expect(navigation.getByRole("link", { name: "Expand/Collapse", exact: true })).toHaveAttribute("href", "/docs/affordance/fold"); + await expect(navigation.getByRole("link", { name: "Drag", exact: true })).toHaveAttribute("href", "/docs/affordance/drag"); + await expect(navigation.getByRole("link", { name: "Undo", exact: true })).toHaveAttribute("href", "/docs/affordance/history"); + const content = page.getByRole("main"); + await expect(content.getByRole("link", { name: "Select" }).first()).toHaveAttribute("href", "/docs/affordance/select"); + await expect(content.getByRole("link", { name: "Expand/Collapse" }).first()).toHaveAttribute("href", "/docs/affordance/fold"); + await expect(content.getByRole("link", { name: "Drag" }).first()).toHaveAttribute("href", "/docs/affordance/drag"); + await expect(content.getByRole("link", { name: "Undo" }).first()).toHaveAttribute("href", "/docs/affordance/history"); + await expect(content.getByRole("link", { name: "Focus" }).first()).toHaveAttribute("href", "/docs/affordance/focus"); + await expect(content.getByRole("link", { name: "Resize" }).first()).toHaveAttribute("href", "/docs/affordance/resize"); }); test("Toolbar binds Undo and Redo to canUndo and canRedo", async ({ page }) => { @@ -41,6 +46,13 @@ test("Toolbar binds Undo and Redo to canUndo and canRedo", async ({ page }) => { expect(await json(page, "widget-toolbar-keyboard")).toEqual({ type: "undo" }); }); +test("Listbox typeahead jumps to the matching label", async ({ page }) => { + await page.goto("/widgets/listbox"); + await page.getByRole("listbox", { name: "Order items" }).focus(); + await page.keyboard.type("T"); + expect(await json(page, "widget-listbox-selected")).toEqual(["today"]); +}); + test("Listbox reads selected keys and focus from Order", async ({ page }) => { await page.goto("/widgets/listbox"); await page.getByRole("option", { name: "Today" }).click(); @@ -122,6 +134,19 @@ test("Tree reads visible topology and selected keys", async ({ page }) => { expect(await json(page, "widget-tree-focus")).toBe("apple"); }); +test("Tree left collapses and right expands the focused parent", async ({ page }) => { + await page.goto("/widgets/tree"); + await page.getByRole("treeitem", { name: "Fruit" }).click(); + await page.keyboard.press("ArrowLeft"); + expect(await json(page, "widget-tree-topology")).toEqual({ + visibleIds: ["fruit", "veg", "kale"], + }); + await page.keyboard.press("ArrowRight"); + expect(await json(page, "widget-tree-topology")).toEqual({ + visibleIds: ["fruit", "apple", "pear", "veg", "kale"], + }); +}); + test("Board reads columns and selected cards", async ({ page }) => { await page.goto("/widgets/board"); expect(await json(page, "widget-board-columns")).toEqual([ @@ -134,6 +159,38 @@ test("Board reads columns and selected cards", async ({ page }) => { expect(await json(page, "widget-board-focus")).toBe("draw"); }); +test("Board modifier click toggles cards and drag moves a card", async ({ page }) => { + await page.goto("/widgets/board"); + await page.getByRole("option", { name: "Write the brief" }).click(); + await page.keyboard.down("ControlOrMeta"); + await page.getByRole("option", { name: "Review copy" }).click(); + await page.keyboard.up("ControlOrMeta"); + expect(await json(page, "widget-board-selected")).toEqual(["write", "review"]); + + await page.getByRole("option", { name: "Write the brief" }).dragTo(page.getByRole("listbox", { name: "Done" })); + expect(await json(page, "widget-board-columns")).toEqual([ + { id: "todo", cardIds: ["review"] }, + { id: "doing", cardIds: ["draw"] }, + { id: "done", cardIds: ["write"] }, + ]); +}); + +test("Canvas escape cancels an in-progress marquee", async ({ page }) => { + await page.goto("/widgets/canvas"); + await page.getByRole("option", { name: "Card" }).click(); + expect(await json(page, "widget-canvas-selected")).toEqual(["card"]); + const canvas = page.getByRole("listbox", { name: "Canvas objects" }); + const box = await canvas.boundingBox(); + if (!box) throw new Error("canvas bounding box"); + await canvas.focus(); + await page.mouse.move(box.x + box.width - 24, box.y + box.height - 24); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 8, box.y + box.height - 8); + await page.keyboard.press("Escape"); + await page.mouse.up(); + expect(await json(page, "widget-canvas-selected")).toEqual(["card"]); +}); + async function json(page: Page, testId: string): Promise { return JSON.parse(await page.getByTestId(testId).innerText()); } diff --git a/site/tests/unit/app-shell.test.tsx b/site/tests/unit/app-shell.test.tsx index 2c0d072e..5ad7ed0e 100644 --- a/site/tests/unit/app-shell.test.tsx +++ b/site/tests/unit/app-shell.test.tsx @@ -39,10 +39,10 @@ describe("official site shell", () => { "JSON Document", "Collaboration", "Editing", - "Hands", "Adapter", "Connector", - "제품 화면", + "Affordance", + "Hands", ]); await user.click(nav.getByRole("button", { name: "JSON Document" })); expect(groupLinks(nav, "JSON Document")).toEqual([ @@ -82,18 +82,44 @@ describe("official site shell", () => { expect(groupLinks(nav, "Adapter")).toEqual(["Keyboard", "Clipboard adapter", "Contenteditable"]); await user.click(nav.getByRole("button", { name: "Connector" })); expect(groupLinks(nav, "Connector")).toEqual(["React", "React Hook Form", "Ajv", "Zod", "TanStack Table"]); - await user.click(nav.getByRole("button", { name: "제품 화면" })); - expect(groupLinks(nav, "제품 화면")).toEqual(["Toolbar", "Listbox", "Grid", "Document", "Canvas", "Tree", "Board"]); + await user.click(nav.getByRole("button", { name: "Affordance" })); + expect(groupLinks(nav, "Affordance")).toEqual([ + "Focus", + "Caret", + "Select", + "Typeahead", + "Activate", + "Escape", + "Expand/Collapse", + "Undo", + "Delete", + "Rename", + "Nudge", + "Hover", + "Double-click", + "Triple-click", + "Context menu", + "Drag", + "Marquee", + "Drop", + "Duplicate", + "Resize", + "Pan", + "Scroll", + "Zoom", + "Snap", + "Not-allowed", + ]); expect(nav.queryByRole("group", { name: "Demos" })).toBeNull(); expect(nav.queryByRole("group", { name: "Reference" })).toBeNull(); expect(nav.getAllByRole("group").map((group) => group.getAttribute("aria-label"))).toEqual([ "JSON Document", "Collaboration", "Editing", - "Hands", "Adapter", "Connector", - "제품 화면", + "Affordance", + "Hands", ]); expect(nav.queryByRole("link", { name: "Extensions" })).toBeNull(); diff --git a/site/tests/unit/breadcrumb.test.ts b/site/tests/unit/breadcrumb.test.ts index 5bfc0c88..5bdad52b 100644 --- a/site/tests/unit/breadcrumb.test.ts +++ b/site/tests/unit/breadcrumb.test.ts @@ -87,16 +87,17 @@ describe("breadcrumbTrail", () => { "Zod:/connectors/zod", "Validate:/connectors/zod/validate", ]); - expect(trail("/widgets")).toEqual(["Overview:/", "제품 화면:/widgets"]); - expect(trail("/widgets/toolbar")).toEqual([ + expect(trail("/docs/affordance")).toEqual(["Overview:/", "Affordance:/docs/affordance"]); + expect(trail("/docs/affordance/select")).toEqual([ "Overview:/", - "제품 화면:/widgets", - "Toolbar:/widgets/toolbar", + "Affordance:/docs/affordance", + "Select:/docs/affordance/select", ]); - expect(trail("/widgets/board")).toEqual([ + expect(trail("/widgets/toolbar")).toEqual([ "Overview:/", - "제품 화면:/widgets", - "Board:/widgets/board", + "Affordance:/docs/affordance", + "Undo:/docs/affordance/history", + "Toolbar proof:/widgets/toolbar", ]); }); diff --git a/site/tests/unit/docs-route.test.tsx b/site/tests/unit/docs-route.test.tsx index e643081f..14aa9e06 100644 --- a/site/tests/unit/docs-route.test.tsx +++ b/site/tests/unit/docs-route.test.tsx @@ -40,8 +40,8 @@ describe("documentation routes", () => { await user.click(nav.getByRole("button", { name: "Connector" })); await user.click(within(nav.getByRole("group", { name: "Connector" })).getByRole("link", { name: "React", exact: true })); - await waitFor(() => expect(document.title).toBe("React Connector Live Demo - json-document")); - expect(await screen.findByRole("heading", { level: 1, name: "React Connector" })).toBeTruthy(); + await waitFor(() => expect(document.title).toBe("React Connector Live Demo - json-document"), { timeout: 10000 }); + expect(await screen.findByRole("heading", { level: 1, name: "React Connector" }, { timeout: 10000 })).toBeTruthy(); await user.click(nav.getByRole("button", { name: "Editing" })); await user.click(within(nav.getByRole("group", { name: "Editing" })).getByRole("link", { name: "Topology" })); diff --git a/site/tests/unit/widget-binding.test.ts b/site/tests/unit/widget-binding.test.ts index e0e953b7..01aa37ac 100644 --- a/site/tests/unit/widget-binding.test.ts +++ b/site/tests/unit/widget-binding.test.ts @@ -1,5 +1,20 @@ import { describe, expect, test } from "vitest"; -import { gridCellProps, historyCommands, optionProps, treeItemProps } from "../../src/shared/widget-binding"; +import { editingCommandFromStroke, gridCellProps, historyCommands, optionProps, treeItemProps } from "../../src/shared/widget-binding"; + +describe("editingCommandFromStroke", () => { + test("fills the editing keyboard port from applyAffordance", () => { + expect(editingCommandFromStroke({ + key: "ArrowDown", + shiftKey: false, + metaKey: false, + ctrlKey: false, + })).toEqual({ + type: "move", + direction: "down", + operation: "replace", + }); + }); +}); describe("historyCommands", () => { test("binds disabled to canUndo and canRedo", () => { diff --git a/site/tsconfig.json b/site/tsconfig.json index 6076f736..c733fc06 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -12,6 +12,7 @@ "@interactive-os/json-document-react": ["../packages/json-document-react/src/index.ts"], "@interactive-os/json-document-react-hook-form": ["../packages/json-document-react-hook-form/src/index.ts"], "@interactive-os/json-document-ajv": ["../packages/json-document-ajv/src/index.ts"], + "@interactive-os/json-document-affordance": ["../packages/json-document-affordance/src/index.ts"], "@interactive-os/json-document-tanstack-table": ["../packages/json-document-tanstack-table/src/index.ts"], "@interactive-os/json-document-web": ["../packages/json-document-web/src/index.ts"], "@interactive-os/json-document-contenteditable": ["../packages/json-document-contenteditable/src/index.ts"], diff --git a/standards/repository-implementation-shape.md b/standards/repository-implementation-shape.md index 8050be23..6d60b128 100644 --- a/standards/repository-implementation-shape.md +++ b/standards/repository-implementation-shape.md @@ -204,7 +204,7 @@ foundation으로 유지한다. ## 현재 package 분류 -아래 표는 현재 15개 library package를 이 문서의 모형으로 빠짐없이 분류한다. +아래 표는 현재 16개 library package를 이 문서의 모형으로 빠짐없이 분류한다. `후속`은 이 RFC가 source를 이동하지 않고 별도 이슈가 책임짐을 뜻한다. | Package path | 정본 모형 | 현재 판단 | @@ -215,6 +215,7 @@ foundation으로 유지한다. | `packages/json-document-react` | Single-native Connector | 하나의 React subscription/lifecycle entry로 flat 유지 | | `packages/json-document-react-hook-form` | Single-native Connector | RHF lifecycle이 하나인 동안 flat 유지; 독립 binding이 생기면 분리 | | `packages/json-document-ajv` | Single-native Connector | 하나의 validator translation으로 flat 유지 | +| `packages/json-document-affordance` | Responsibility family | select/fold/drag/history 책임 file과 root facade 유지 | | `packages/json-document-zod` | Composite Connector | validator와 Database translation을 책임 file로 분리한 현재 모양 유지 | | `packages/json-document-tanstack-table` | Single-native Connector | 하나의 Table/Sheet binding으로 flat 유지 | | `packages/json-document-web` | Adapter family | keyboard/clipboard/input/modifier 책임 file과 root facade 유지 | diff --git a/tsconfig.build.json b/tsconfig.build.json index 54776707..12edb1eb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,6 +8,7 @@ { "path": "./packages/json-document-react" }, { "path": "./packages/json-document-react-hook-form" }, { "path": "./packages/json-document-ajv" }, + { "path": "./packages/json-document-affordance" }, { "path": "./packages/json-document-zod" }, { "path": "./packages/json-document-tanstack-table" }, { "path": "./packages/json-document-web" },