From a8fbef1702aed9defd5ca45042df5c4a3bd467ba 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: Sat, 22 Aug 2026 00:37:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=96=B4=ED=8F=AC=EB=8D=98=EC=8A=A4:=20?= =?UTF-8?q?=EC=B4=88=EC=A0=90=C2=B7=EC=BA=90=EB=9F=BF=C2=B7=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B0=94=EA=BE=B8=EA=B8=B0=EC=99=80=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=ED=81=B4=EB=A6=AD=EC=9D=84=20=EB=8B=AB=EB=8A=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-caret.md | 38 ++++---- docs/public/affordance-double-click.md | 11 ++- docs/public/affordance-focus.md | 21 ++--- docs/public/affordance-rename.md | 30 +++--- docs/public/affordance-triple-click.md | 15 ++- docs/public/affordance.md | 10 +- .../json-document-affordance/src/index.ts | 2 +- .../json-document-affordance/src/result.ts | 11 ++- .../json-document-affordance/src/select.ts | 66 +++++++++++++ .../tests/affordance.test.ts | 64 +++++++++++++ packages/json-document-editing/src/order.ts | 11 +++ .../tests/order-editor.test.ts | 11 +++ .../document-demo/DocumentDemoRoute.tsx | 44 ++++++++- site/src/routes/order-demo/OrderDemoRoute.tsx | 92 ++++++++++++++++++- site/tests/browser/editor-slice-demos.spec.ts | 32 +++++++ 16 files changed, 383 insertions(+), 77 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index d72d3255..7b38f442 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 +- Closed Focus, Caret, Rename, Double-click, and Triple-click as package hands, + and dogfooded them on the Order and Document product screens. - 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. diff --git a/docs/public/affordance-caret.md b/docs/public/affordance-caret.md index 1dfd1a9e..2d701677 100644 --- a/docs/public/affordance-caret.md +++ b/docs/public/affordance-caret.md @@ -1,39 +1,37 @@ # Caret -TBD. - Caret은 글 안의 삽입점입니다. 항목 [Select](affordance-select.md)와 다릅니다. `text` / `vertical-text` 커서가 이 손을 가리킵니다. 클릭은 삽입점을 두고, 드래그는 글 범위를 고릅니다. ```ts -import { caretAffordance, caretCursor } from "@interactive-os/json-document-affordance"; +import { applyAffordance, caretAffordance, caretCursor } from "@interactive-os/json-document-affordance"; function onPointerMove(event: PointerEvent) { event.currentTarget.style.cursor = caretCursor("horizontal"); } function onPointerDown(event: PointerEvent, blockId: string) { - const hand = caretAffordance({ type: "pointer", detail: event.detail }); - if (hand.type === "place") { - editor.dispatch({ - type: "selection.set", - blockId, - offset: hostHitOffset(event), - }); - } + applyAffordance(caretAffordance({ type: "pointer" }), { + hand: (hand) => { + if (hand.type !== "caret" || hand.action !== "place") return; + editor.dispatch({ type: "selection.set", blockId, offset: hostHitOffset(event) }); + }, + }); } function onKeyDown(event: KeyboardEvent) { - const hand = caretAffordance(event); - if (hand?.type === "move") { - editor.dispatch({ - type: "selection.set", - blockId: focus.blockId, - offset: hostMoveOffset(focus.offset, hand), - mode: hand.operation, - }); - } + applyAffordance(caretAffordance(event), { + hand: (hand) => { + if (hand.type !== "caret-move") return; + editor.dispatch({ + type: "selection.set", + blockId: focus.blockId, + offset: hostMoveOffset(focus.offset, hand), + mode: hand.operation, + }); + }, + }); } ``` diff --git a/docs/public/affordance-double-click.md b/docs/public/affordance-double-click.md index 2e0293e7..5bb1e9fc 100644 --- a/docs/public/affordance-double-click.md +++ b/docs/public/affordance-double-click.md @@ -1,17 +1,18 @@ # Double-click -TBD. - Double-click은 `UIEvent.detail === 2`입니다. 글에서는 단어를 고르고, 목록·파일에서는 열거나 [Rename](affordance-rename.md)으로 갈 수 있습니다. 횟수 손 자체는 닫혀 있고, 뜻은 장르 Intent입니다. ```ts -import { clickCountAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, clickCountAffordance } from "@interactive-os/json-document-affordance"; function onClick(event: MouseEvent, itemId: string) { - if (clickCountAffordance(event.detail) !== "double-click") return; - hostOpen(itemId); + applyAffordance(clickCountAffordance(event.detail), { + hand: (hand) => { + if (hand.type === "click" && hand.count === 2) hostOpen(itemId); + }, + }); } ``` diff --git a/docs/public/affordance-focus.md b/docs/public/affordance-focus.md index c14b1cc1..2e103c41 100644 --- a/docs/public/affordance-focus.md +++ b/docs/public/affordance-focus.md @@ -1,25 +1,20 @@ # Focus -TBD. - Focus는 키보드의 포인터입니다. Tab과 Shift+Tab은 컴포넌트 사이로 옮기고, 화살표·Home·End는 컴포넌트 안에서 옮깁니다. Focus와 [Select](affordance-select.md)는 다릅니다. Focus 표시는 항상 보여야 하고, 선택 표시와 겹치면 안 됩니다. ```ts -import { focusAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, focusAffordance } from "@interactive-os/json-document-affordance"; function onKeyDown(event: KeyboardEvent) { - const hand = focusAffordance(event); - if (hand?.type === "tab") return; - if (hand?.type === "move") { - const next = neighbor(focusKey, hand.direction); - setFocusKey(next); - return; - } - if (hand?.type === "boundary") { - setFocusKey(hand.edge === "start" ? ids[0] : ids.at(-1)); - } + applyAffordance(focusAffordance(event), { + hand: (hand) => { + if (hand.type === "tab") return; + if (hand.type === "move") setFocusKey(neighbor(focusKey, hand.direction)); + if (hand.type === "boundary") setFocusKey(hand.edge === "start" ? ids[0] : ids.at(-1)); + }, + }); } ``` diff --git a/docs/public/affordance-rename.md b/docs/public/affordance-rename.md index 18c7a3cc..5d0746db 100644 --- a/docs/public/affordance-rename.md +++ b/docs/public/affordance-rename.md @@ -1,27 +1,31 @@ # Rename -TBD. - Rename은 고른 대상의 레이블을 고치는 손입니다. F2와 느린 double-click이 같은 손을 엽니다. Escape는 [Escape](affordance-cancel.md)입니다. ```ts -import { renameAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, renameAffordance } from "@interactive-os/json-document-affordance"; function onKeyDown(event: KeyboardEvent) { - const hand = renameAffordance(event); - if (hand === "begin") setRenaming(focusKey); - if (hand === "cancel") setRenaming(null); - if (hand === "commit" && renaming) { - editor.dispatch({ type: "item.rename", itemId: renaming, label: draft }); - setRenaming(null); - } + applyAffordance(renameAffordance(event), { + hand: (hand) => { + if (hand.type !== "rename") return; + if (hand.action === "begin") setRenaming(focusKey); + if (hand.action === "cancel") setRenaming(null); + if (hand.action === "commit" && renaming) { + editor.dispatch({ type: "item.rename", itemId: renaming, label: draft }); + setRenaming(null); + } + }, + }); } function onClick(event: MouseEvent, itemId: string) { - if (renameAffordance({ type: "pointer", detail: event.detail, intervalMs }) === "begin") { - setRenaming(itemId); - } + applyAffordance(renameAffordance({ type: "pointer", detail: event.detail, intervalMs }), { + hand: (hand) => { + if (hand.type === "rename" && hand.action === "begin") setRenaming(itemId); + }, + }); } ``` diff --git a/docs/public/affordance-triple-click.md b/docs/public/affordance-triple-click.md index 99c353a3..40968487 100644 --- a/docs/public/affordance-triple-click.md +++ b/docs/public/affordance-triple-click.md @@ -1,20 +1,17 @@ # Triple-click -TBD. - Triple-click은 `UIEvent.detail === 3`입니다. 글에서는 줄 또는 문단을 고릅니다. 항목 목록에서는 보통 쓰지 않습니다. ```ts -import { clickCountAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, clickCountAffordance } from "@interactive-os/json-document-affordance"; function onClick(event: MouseEvent, blockId: string) { - if (clickCountAffordance(event.detail) !== "triple-click") return; - editor.dispatch({ - type: "selection.set", - blockId, - mode: "replace", - offset: 0, + applyAffordance(clickCountAffordance(event.detail), { + hand: (hand) => { + if (hand.type !== "click" || hand.count !== 3) return; + editor.dispatch({ type: "selection.set", blockId, mode: "replace", offset: 0 }); + }, }); } ``` diff --git a/docs/public/affordance.md b/docs/public/affordance.md index f8008d38..cc77a879 100644 --- a/docs/public/affordance.md +++ b/docs/public/affordance.md @@ -71,24 +71,24 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command | [Marquee](affordance-marquee.md) | `marqueeAffordance`, `commitAffordance` | 빈 곳에서 사각형으로 여러 대상 | | [Pan](affordance-pan.md) | `panAffordance` | Space+드래그, grab | | [Snap](affordance-snap.md) | `snapAffordance` | 그리드·가이드, 수정 키로 해제 | +| [Focus](affordance-focus.md) | `focusAffordance` | Tab 사이, 화살표 안, 초점 ≠ 선택 | +| [Caret](affordance-caret.md) | `caretAffordance`, `caretCursor` | I-beam 삽입점, 글 범위 | +| [Rename](affordance-rename.md) | `renameAffordance` | F2, 느린 두 번 누르기 | +| [Double-click](affordance-double-click.md) | `clickCountAffordance` | `detail` 2 | +| [Triple-click](affordance-triple-click.md) | `clickCountAffordance` | `detail` 3 | ## 키보드 TBD | Affordance | API | Hand | | --- | --- | --- | -| [Focus](affordance-focus.md) | `focusAffordance` | Tab 사이, 화살표 안, 초점 ≠ 선택 | -| [Caret](affordance-caret.md) | `caretAffordance`, `caretCursor` | I-beam 삽입점, 글 범위 | | [Activate](affordance-activate.md) | `activateAffordance` | Enter, Space, 기본 클릭 | | [Delete](affordance-delete.md) | `deleteAffordance` | Delete, Backspace. Delete chord는 이미 닫힘 | -| [Rename](affordance-rename.md) | `renameAffordance` | F2, 느린 두 번 누르기 | ## 마우스 TBD | Affordance | API | Hand | | --- | --- | --- | | [Hover](affordance-hover.md) | `hoverAffordance`, `hoverCursor` | hover, 툴팁 지연, 커서 교체 | -| [Double-click](affordance-double-click.md) | `clickCountAffordance` | `detail` 2 | -| [Triple-click](affordance-triple-click.md) | `clickCountAffordance` | `detail` 3 | | [Context menu](affordance-context-menu.md) | `contextMenuAffordance` | 오른쪽 클릭, Shift+F10, Menu | | [Drop](affordance-drop.md) | `dropAffordance` | drop 대상, no-drop | | [Duplicate](affordance-copy-drag.md) | `dragOperation` | Alt/Option 드래그 복제 | diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts index 33af0cf0..7a9ea489 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, planeHitAffordance, pointerSelect, resolveAffordanceKey, selectAllAffordance, typeaheadAffordance } from "./select.js"; +export { activateAffordance, caretAffordance, caretCursor, clickCountAffordance, escapeAffordance, focusAffordance, planeHitAffordance, pointerSelect, renameAffordance, 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 1e4974b2..f2931a48 100644 --- a/packages/json-document-affordance/src/result.ts +++ b/packages/json-document-affordance/src/result.ts @@ -30,6 +30,14 @@ export type AffordanceHand = | { readonly type: "clear" } | { readonly type: "typeahead"; readonly buffer: string; readonly name: string | null } | { readonly type: "click"; readonly count: number } + | { readonly type: "caret"; readonly action: "place" | "range"; readonly operation: "replace" | "extend" } + | { + readonly type: "caret-move"; + readonly direction?: AffordanceMoveDirection; + readonly edge?: "start" | "end"; + readonly operation: "replace" | "extend"; + } + | { readonly type: "rename"; readonly action: "begin" | "commit" | "cancel" } | { readonly type: "activate" } | { readonly type: "cancel" } | { readonly type: "tab"; readonly direction: "next" | "prev" } @@ -84,7 +92,6 @@ export function applyAffordance( if (result.hand) actions.hand?.(result.hand); if ("commit" in result && result.commit && result.hand) actions.commit?.(result.hand); } - export function commitAffordance( result: AffordancePreview, ): AffordanceCommit | null { @@ -95,5 +102,3 @@ export function commitAffordance( ? { 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 c0879bdb..22a0a8c9 100644 --- a/packages/json-document-affordance/src/select.ts +++ b/packages/json-document-affordance/src/select.ts @@ -108,6 +108,72 @@ export function clickCountAffordance(detail: number): AffordancePreview { return { hand: { type: "click", count: detail } }; } +export function caretCursor(direction: "horizontal" | "vertical"): "text" | "vertical-text" { + return direction === "vertical" ? "vertical-text" : "text"; +} + +export function caretAffordance(input: + | { readonly type: "pointer"; readonly dragging?: boolean } + | Pick +): AffordancePreview { + if (!("key" in input)) { + return { + hand: { + type: "caret", + action: input.dragging ? "range" : "place", + operation: input.dragging ? "extend" : "replace", + }, + cursor: "text", + }; + } + const command = keyboard.resolve({ + key: input.key, + shiftKey: input.shiftKey, + metaKey: false, + ctrlKey: false, + }); + if (command?.type === "move") { + return { + hand: { + type: "caret-move", + direction: command.direction, + operation: command.operation, + }, + }; + } + if (command?.type === "boundary") { + return { + hand: { + type: "caret-move", + edge: command.edge, + operation: command.operation, + }, + }; + } + return { hand: null }; +} + +export function renameAffordance(input: + | Pick + | { + readonly type: "pointer"; + readonly detail: number; + readonly intervalMs: number; + readonly slowMs?: number; + } +): AffordancePreview { + if (!("key" in input)) { + const slowMs = input.slowMs ?? 400; + return input.detail === 2 && input.intervalMs >= slowMs + ? { hand: { type: "rename", action: "begin" } } + : { hand: null }; + } + if (input.key === "F2") return { hand: { type: "rename", action: "begin" } }; + if (input.key === "Enter") return { hand: { type: "rename", action: "commit" } }; + if (input.key === "Escape") return { hand: { type: "rename", action: "cancel" } }; + return { hand: null }; +} + export function activateAffordance(input: { readonly key?: string; readonly detail?: number; readonly button?: number }): AffordancePreview { if (input.key === "Enter") return { hand: { type: "activate" } }; if (input.button === 0 && (input.detail ?? 1) === 1) return { hand: { type: "activate" } }; diff --git a/packages/json-document-affordance/tests/affordance.test.ts b/packages/json-document-affordance/tests/affordance.test.ts index d297b321..aaa32703 100644 --- a/packages/json-document-affordance/tests/affordance.test.ts +++ b/packages/json-document-affordance/tests/affordance.test.ts @@ -1,14 +1,19 @@ import { describe, expect, test } from "vitest"; import { applyAffordance, + caretAffordance, + caretCursor, + clickCountAffordance, commitAffordance, dragAffordance, dropAffordance, marqueeAffordance, escapeAffordance, historyAffordance, + focusAffordance, planeHitAffordance, pointerSelect, + renameAffordance, resolveAffordanceKey, snapAffordance, treeAffordance, @@ -166,6 +171,65 @@ describe("typeaheadAffordance", () => { }); }); +describe("focusAffordance", () => { + test("keeps component traversal separate from internal movement", () => { + expect(focusAffordance({ key: "Tab", shiftKey: false }).hand).toEqual({ type: "tab", direction: "next" }); + expect(focusAffordance({ key: "Tab", shiftKey: true }).hand).toEqual({ type: "tab", direction: "prev" }); + expect(focusAffordance({ key: "ArrowDown", shiftKey: true }).hand).toEqual({ + type: "move", + direction: "down", + operation: "replace", + }); + expect(focusAffordance({ key: "Home", shiftKey: false }).hand).toEqual({ + type: "boundary", + edge: "start", + operation: "replace", + }); + }); +}); + +describe("caretAffordance", () => { + test("leaves text geometry to the host while closing pointer and key intent", () => { + expect(caretAffordance({ type: "pointer" })).toEqual({ + hand: { type: "caret", action: "place", operation: "replace" }, + cursor: "text", + }); + expect(caretAffordance({ type: "pointer", dragging: true }).hand).toEqual({ + type: "caret", + action: "range", + operation: "extend", + }); + expect(caretAffordance({ key: "ArrowRight", shiftKey: true }).hand).toEqual({ + type: "caret-move", + direction: "right", + operation: "extend", + }); + expect(caretCursor("horizontal")).toBe("text"); + expect(caretCursor("vertical")).toBe("vertical-text"); + }); +}); + +describe("renameAffordance", () => { + test("begins, commits, and cancels without owning the draft", () => { + expect(renameAffordance({ key: "F2" }).hand).toEqual({ type: "rename", action: "begin" }); + expect(renameAffordance({ key: "Enter" }).hand).toEqual({ type: "rename", action: "commit" }); + expect(renameAffordance({ key: "Escape" }).hand).toEqual({ type: "rename", action: "cancel" }); + expect(renameAffordance({ type: "pointer", detail: 2, intervalMs: 500 }).hand).toEqual({ + type: "rename", + action: "begin", + }); + expect(renameAffordance({ type: "pointer", detail: 2, intervalMs: 200 }).hand).toBeNull(); + }); +}); + +describe("clickCountAffordance", () => { + test("reports native double and triple click counts", () => { + expect(clickCountAffordance(2).hand).toEqual({ type: "click", count: 2 }); + expect(clickCountAffordance(3).hand).toEqual({ type: "click", count: 3 }); + expect(clickCountAffordance(0).hand).toBeNull(); + }); +}); + describe("marqueeAffordance", () => { test("carries replace, extend, and toggle from modifiers", () => { const origin = { x: 0, y: 0 }; diff --git a/packages/json-document-editing/src/order.ts b/packages/json-document-editing/src/order.ts index b33673a5..feb01f95 100644 --- a/packages/json-document-editing/src/order.ts +++ b/packages/json-document-editing/src/order.ts @@ -54,6 +54,7 @@ export type OrderIntent = readonly mode?: "replace" | "extend" | "toggle"; } | { readonly type: "selection.remove" } + | { readonly type: "item.rename"; readonly itemId: string; readonly label: string } | { readonly type: "clipboard.paste"; readonly clipboard: OrderClipboard; readonly afterId?: string }; export interface OrderEditor { @@ -123,6 +124,16 @@ export function createOrderEditor( }); } + if (intent.type === "item.rename") { + const index = items.findIndex((item) => item.id === intent.itemId); + if (index < 0) return failure("rename.item-not-found"); + return session.apply({ + operations: [{ op: "replace", path: `/items/${index}/label`, value: intent.label }], + selectionAfter: session.snapshot.selection, + origin: intent.type, + }); + } + return removeSelected(selectedItemIds()); } diff --git a/packages/json-document-editing/tests/order-editor.test.ts b/packages/json-document-editing/tests/order-editor.test.ts index 7431d0ed..2692e09a 100644 --- a/packages/json-document-editing/tests/order-editor.test.ts +++ b/packages/json-document-editing/tests/order-editor.test.ts @@ -52,4 +52,15 @@ describe("ordered structural selection", () => { expect(editor.undo().ok).toBe(true); expect(editor.selectedItemIds).toEqual(["n1", "n2"]); }); + + test("renames an item and preserves its selection through undo", () => { + const editor = createOrderEditor(initial); + editor.dispatch({ type: "selection.set", itemId: "b" }); + expect(editor.dispatch({ type: "item.rename", itemId: "b", label: "Bravo" }).ok).toBe(true); + expect((editor.snapshot.value as OrderDocument).items[1]?.label).toBe("Bravo"); + expect(editor.selectedItemIds).toEqual(["b"]); + expect(editor.undo().ok).toBe(true); + expect((editor.snapshot.value as OrderDocument).items[1]?.label).toBe("Beta"); + expect(editor.selectedItemIds).toEqual(["b"]); + }); }); diff --git a/site/src/routes/document-demo/DocumentDemoRoute.tsx b/site/src/routes/document-demo/DocumentDemoRoute.tsx index 0daadd0e..359b1e05 100644 --- a/site/src/routes/document-demo/DocumentDemoRoute.tsx +++ b/site/src/routes/document-demo/DocumentDemoRoute.tsx @@ -18,6 +18,9 @@ import { } from "@interactive-os/json-document-web"; import { applyAffordance, + caretAffordance, + caretCursor, + clickCountAffordance, pointerSelect, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; @@ -47,6 +50,7 @@ export function DocumentDemoRoute() { const [announcement, setAnnouncement] = useState("Ready"); const [lastIntent, setLastIntent] = useState(null); const [lastResult, setLastResult] = useState<{ readonly ok: true } | { readonly ok: false; readonly code: string } | null>(null); + const [lastClickCount, setLastClickCount] = useState(0); const surfaceRef = useRef(null); function remember(intent: DocumentIntent, result: EditingResult) { @@ -169,6 +173,7 @@ export function DocumentDemoRoute() {
{editor.selectedBlockIds.length} selected · revision {snapshot.revision}
{announcement}
+
click count {lastClickCount}
)} >A deliberately small interface for selection, clipboard, history, keyboard input, and canonical JSON publication. @@ -255,7 +260,13 @@ export function DocumentDemoRoute() { label={`Block ${index + 1} text`} text={block.text} offset={item.getTextOffset()} - onFocusOffset={(offset) => dispatchIntent({ type: "selection.set", blockId: block.id, offset })} + onCaretRange={(from, to, mode) => { + dispatchIntent({ type: "selection.set", blockId: block.id, offset: from }); + if (mode === "extend" || to !== from) { + dispatchIntent({ type: "selection.set", blockId: block.id, offset: to, mode: "extend" }); + } + }} + onClickCount={setLastClickCount} onChange={(next) => dispatchIntent({ type: "text.replace", blockId: block.id, ...next })} /> @@ -279,7 +290,8 @@ function DocumentTextControl(props: { readonly label: string; readonly text: string; readonly offset: number | null; - readonly onFocusOffset: (offset: number) => void; + readonly onCaretRange: (from: number, to: number, mode: "replace" | "extend") => void; + readonly onClickCount: (count: number) => void; readonly onChange: (next: { readonly text: string; readonly offset: number }) => void; }) { const ref = useRef(null); @@ -290,10 +302,34 @@ function DocumentTextControl(props: { aria-label={props.label} value={props.text} rows={Math.max(1, Math.ceil(props.text.length / 64))} - onFocus={(event) => props.onFocusOffset(textInputFromControl(event).offset)} - onClick={(event) => props.onFocusOffset(textInputFromControl(event).offset)} + onFocus={(event) => { + const offset = textInputFromControl(event).offset; + props.onCaretRange(offset, offset, "replace"); + }} + onClick={(event) => { + applyAffordance(caretAffordance({ type: "pointer" }), { + hand: (hand) => { + if (hand.type !== "caret") return; + props.onCaretRange(event.currentTarget.selectionStart, event.currentTarget.selectionEnd, hand.operation); + }, + }); + applyAffordance(clickCountAffordance(event.detail), { + hand: (hand) => { + if (hand.type === "click") props.onClickCount(hand.count); + }, + }); + }} + onSelect={(event) => { + applyAffordance(caretAffordance({ type: "pointer", dragging: true }), { + hand: (hand) => { + if (hand.type !== "caret") return; + props.onCaretRange(event.currentTarget.selectionStart, event.currentTarget.selectionEnd, hand.operation); + }, + }); + }} onChange={(event) => props.onChange(textInputFromControl(event))} className={classes("min-h-11 resize-none", ui.field.seamless)} + style={{ cursor: caretCursor("horizontal") }} /> ); } diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx index 564e6a20..b616c872 100644 --- a/site/src/routes/order-demo/OrderDemoRoute.tsx +++ b/site/src/routes/order-demo/OrderDemoRoute.tsx @@ -1,4 +1,4 @@ -import { useState, type KeyboardEvent } from "react"; +import { useRef, useState, type KeyboardEvent } from "react"; import { createOrderEditor, type OrderClipboard, @@ -10,7 +10,9 @@ import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; import { applyAffordance, escapeAffordance, + focusAffordance, pointerSelect, + renameAffordance, typeaheadAffordance, } from "@interactive-os/json-document-affordance"; import { Inspector } from "../../shared/ui/inspector"; @@ -34,6 +36,9 @@ export function OrderDemoRoute() { const [announcement, setAnnouncement] = useState("Ready"); const [lastIntent, setLastIntent] = useState(null); const [typeahead, setTypeahead] = useState({ buffer: "", at: 0 }); + const [focusId, setFocusId] = useState(initialOrder.items[0]?.id ?? null); + const [renaming, setRenaming] = useState<{ readonly id: string; readonly draft: string } | null>(null); + const lastClick = useRef<{ readonly id: string; readonly at: number } | null>(null); function run(intent: OrderIntent, message: string) { const result = editor.dispatch(intent); @@ -87,9 +92,52 @@ 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; + const focusKey = focusId; + + function beginRename(itemId: string) { + const item = document.items.find((candidate) => candidate.id === itemId); + if (item) setRenaming({ id: item.id, draft: item.label }); + } + + function finishRename() { + if (!renaming) return; + run({ type: "item.rename", itemId: renaming.id, label: renaming.draft }, "Item renamed"); + setRenaming(null); + } function onKeyDown(event: KeyboardEvent) { + let focused = false; + applyAffordance(focusAffordance(event), { + hand: (hand) => { + if (hand.type === "tab") return; + if (hand.type === "move") { + focused = true; + const keys = ids(); + const from = focusId ?? keys[0]; + const next = from === undefined ? null : moveLinePoint(keys, from, hand.direction); + setFocusId(next); + if (next) event.currentTarget.querySelector(`[data-item-id="${CSS.escape(next)}"]`)?.focus(); + } + if (hand.type === "boundary") { + focused = true; + const next = lineBoundary(ids(), hand.edge); + setFocusId(next); + if (next) event.currentTarget.querySelector(`[data-item-id="${CSS.escape(next)}"]`)?.focus(); + } + }, + }); + if (focused) { + event.preventDefault(); + return; + } + applyAffordance(renameAffordance(event), { + hand: (hand) => { + if (hand.type !== "rename" || hand.action !== "begin" || !focusId) return; + event.preventDefault(); + beginRename(focusId); + }, + }); + if (event.defaultPrevented) return; const names = document.items.map((item) => item.label); const from = document.items.find((item) => item.id === focusKey)?.label ?? null; const result = typeaheadAffordance({ @@ -169,22 +217,58 @@ export function OrderDemoRoute() {
    - {document.items.map((item, index) => ( + {document.items.map((item, index) => renaming?.id === item.id ? ( + setRenaming({ id: item.id, draft: event.currentTarget.value })} + onKeyDown={(event) => { + event.stopPropagation(); + applyAffordance(renameAffordance(event), { + hand: (hand) => { + if (hand.type !== "rename") return; + if (hand.action === "commit") finishRename(); + if (hand.action === "cancel") setRenaming(null); + }, + }); + }} + className={classes("w-full", ui.field.seamless)} + /> + ) : ( setFocusId(item.id)} onClick={(event) => { + const previous = lastClick.current; + const intervalMs = previous?.id === item.id ? event.timeStamp - previous.at : 0; + lastClick.current = { id: item.id, at: event.timeStamp }; + setFocusId(item.id); applyAffordance(pointerSelect(event), { hand: (hand) => { if (hand.type !== "select") return; run({ type: "selection.set", itemId: item.id, mode: hand.operation }, "Selection changed"); }, }); + applyAffordance(renameAffordance({ + type: "pointer", + detail: event.detail, + intervalMs, + }), { + hand: (hand) => { + if (hand.type === "rename" && hand.action === "begin") beginRename(item.id); + }, + }); }} > {index + 1} diff --git a/site/tests/browser/editor-slice-demos.spec.ts b/site/tests/browser/editor-slice-demos.spec.ts index 517d9a7b..288830c4 100644 --- a/site/tests/browser/editor-slice-demos.spec.ts +++ b/site/tests/browser/editor-slice-demos.spec.ts @@ -35,6 +35,38 @@ test("Order typeahead jumps to the matching label and Escape clears the buffer", await expect(page.getByRole("button", { name: /Inbox/ })).toHaveAttribute("data-selected", "true"); }); +test("Order moves focus without changing selection and renames the focused item", async ({ page }) => { + await page.goto("/demo/order"); + const order = page.getByLabel("Editable order").locator("ol"); + const inbox = page.getByRole("button", { name: /Inbox/ }); + const today = page.getByRole("button", { name: /Today/ }); + await order.focus(); + await expect(inbox).toHaveAttribute("data-selected", "true"); + await expect(inbox).toHaveAttribute("data-focus", "true"); + + await page.keyboard.press("ArrowDown"); + await expect(today).toHaveAttribute("data-focus", "true"); + await expect(today).toHaveAttribute("data-selected", "false"); + await expect(inbox).toHaveAttribute("data-selected", "true"); + + await page.keyboard.press("F2"); + const rename = page.getByRole("textbox", { name: "Rename Today" }); + await rename.fill("Now"); + await rename.press("Enter"); + await expect(page.getByRole("button", { name: /Now/ })).toBeVisible(); + await expect(inbox).toHaveAttribute("data-selected", "true"); +}); + +test("Document dogfoods caret and native double and triple click counts", async ({ page }) => { + await page.goto("/demo"); + const text = page.getByRole("textbox", { name: "Block 1 text" }); + await expect(text).toHaveCSS("cursor", "text"); + await text.click({ clickCount: 2 }); + await expect(page.getByTestId("document-click-count")).toHaveText("click count 2"); + await text.click({ clickCount: 3 }); + await expect(page.getByTestId("document-click-count")).toHaveText("click count 3"); +}); + test("Canvas marquee selects several objects and Escape cancels it", async ({ page }) => { await page.goto("/demo/canvas"); const note = page.getByRole("button", { name: "Note" }); From cdf1268f3a404939a029b17ac135956f44baa0bd 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: Sat, 22 Aug 2026 00:48:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8:=20Order=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=82=A4=20=EB=8B=A8=EC=B6=95=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=ED=83=80=EC=9E=85=EC=96=B4=ED=97=A4=EB=93=9C?= =?UTF-8?q?=EB=B3=B4=EB=8B=A4=20=EB=A8=BC=EC=A0=80=20=EB=B3=B4=EB=82=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/routes/order-demo/OrderDemoRoute.tsx | 62 ++++++++++++++++++- site/tests/browser/editor-slice-demos.spec.ts | 14 +++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx index b616c872..25619350 100644 --- a/site/src/routes/order-demo/OrderDemoRoute.tsx +++ b/site/src/routes/order-demo/OrderDemoRoute.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type KeyboardEvent } from "react"; +import { useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react"; import { createOrderEditor, type OrderClipboard, @@ -6,7 +6,12 @@ import { type OrderIntent, } from "@interactive-os/json-document-editing"; import { useEditing } from "@interactive-os/json-document-react"; -import { lineBoundary, moveLinePoint } from "@interactive-os/json-document-web"; +import { + createWebClipboardBinding, + lineBoundary, + moveLinePoint, + orderClipboardCodec, +} from "@interactive-os/json-document-web"; import { applyAffordance, escapeAffordance, @@ -33,12 +38,19 @@ const initialOrder: OrderDocument = { export function OrderDemoRoute() { const [editor] = useState(() => createOrderEditor(initialOrder)); const [clipboard, setClipboard] = useState(null); + const [webClipboard] = useState(() => createWebClipboardBinding({ + codec: orderClipboardCodec, + read: () => editor.copy(), + cut: () => editor.cut()?.result ?? { ok: false, code: "selection.empty" }, + paste: (payload) => editor.dispatch({ type: "clipboard.paste", clipboard: payload }), + })); const [announcement, setAnnouncement] = useState("Ready"); const [lastIntent, setLastIntent] = useState(null); const [typeahead, setTypeahead] = useState({ buffer: "", at: 0 }); const [focusId, setFocusId] = useState(initialOrder.items[0]?.id ?? null); const [renaming, setRenaming] = useState<{ readonly id: string; readonly draft: string } | null>(null); const lastClick = useRef<{ readonly id: string; readonly at: number } | null>(null); + const orderRef = useRef(null); function run(intent: OrderIntent, message: string) { const result = editor.dispatch(intent); @@ -101,11 +113,30 @@ export function OrderDemoRoute() { function finishRename() { if (!renaming) return; + const itemId = renaming.id; run({ type: "item.rename", itemId: renaming.id, label: renaming.draft }, "Item renamed"); setRenaming(null); + restoreItemFocus(itemId); + } + + function cancelRename() { + if (!renaming) return; + const itemId = renaming.id; + setRenaming(null); + restoreItemFocus(itemId); + } + + function restoreItemFocus(itemId: string) { + requestAnimationFrame(() => { + orderRef.current?.querySelector(`[data-item-id="${CSS.escape(itemId)}"]`)?.focus(); + }); } function onKeyDown(event: KeyboardEvent) { + if (event.metaKey || event.ctrlKey || event.altKey) { + editing.getKeyDownHandler()(event); + return; + } let focused = false; applyAffordance(focusAffordance(event), { hand: (hand) => { @@ -170,6 +201,27 @@ export function OrderDemoRoute() { editing.getKeyDownHandler()(event); } + function handleNativeCopy(event: ClipboardEvent) { + const result = webClipboard.copy(event); + if (!result.ok) return setAnnouncement(result.code); + setClipboard(result.payload); + setAnnouncement(`Copied ${result.payload.items.length} structured item${result.payload.items.length === 1 ? "" : "s"}`); + } + + function handleNativeCut(event: ClipboardEvent) { + const result = webClipboard.cut(event); + if (!result.ok) return setAnnouncement(result.code); + setClipboard(result.payload); + setAnnouncement(`Cut ${result.payload.items.length} structured item${result.payload.items.length === 1 ? "" : "s"}`); + } + + function handleNativePaste(event: ClipboardEvent) { + const result = webClipboard.paste(event); + setAnnouncement(result.ok + ? `Pasted ${result.payload.items.length} structured item${result.payload.items.length === 1 ? "" : "s"}` + : result.code); + } + return (
      {document.items.map((item, index) => renaming?.id === item.id ? ( { if (hand.type !== "rename") return; if (hand.action === "commit") finishRename(); - if (hand.action === "cancel") setRenaming(null); + if (hand.action === "cancel") cancelRename(); }, }); }} diff --git a/site/tests/browser/editor-slice-demos.spec.ts b/site/tests/browser/editor-slice-demos.spec.ts index 288830c4..52ce5b47 100644 --- a/site/tests/browser/editor-slice-demos.spec.ts +++ b/site/tests/browser/editor-slice-demos.spec.ts @@ -55,6 +55,20 @@ test("Order moves focus without changing selection and renames the focused item" await rename.press("Enter"); await expect(page.getByRole("button", { name: /Now/ })).toBeVisible(); await expect(inbox).toHaveAttribute("data-selected", "true"); + + await page.keyboard.press("ControlOrMeta+Z"); + await expect(page.getByRole("button", { name: /Today/ })).toBeVisible(); +}); + +test("Order native copy and paste bypass typeahead", async ({ page }) => { + await page.goto("/demo/order"); + const today = page.getByRole("button", { name: /Today/ }); + const later = page.getByRole("button", { name: /Later/ }); + await today.click(); + await page.keyboard.press("ControlOrMeta+C"); + await later.click(); + await page.keyboard.press("ControlOrMeta+V"); + await expect(page.getByRole("button", { name: /Today/ })).toHaveCount(2); }); test("Document dogfoods caret and native double and triple click counts", async ({ page }) => {