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