Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/api-reference/affordance.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ createLineFocusSession<Key extends string>(options: { readonly initialKey?: Key
## `createRenameSession`

```ts
createRenameSession<Key>(options: { readonly onCommit: (key: Key, draft: string) => void; readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot<Key> | null) => void; }): RenameSession<Key>
createRenameSession<Key>(options: ({ readonly onCommit: (key: Key, draft: string) => void; readonly tryCommit?: never; } | { readonly tryCommit: (key: Key, draft: string) => boolean; readonly onCommit?: never; }) & { readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot<Key> | null) => void; }): RenameSession<Key>
```
## `createTypeaheadSession`

Expand Down Expand Up @@ -781,7 +781,7 @@ resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview
## `selectAllAffordance`

```ts
selectAllAffordance(stroke: Pick<WebKeyboardStroke, "key" | "metaKey" | "ctrlKey">, state: { readonly allSelected: boolean; }): AffordancePreview
selectAllAffordance(stroke: Pick<WebKeyboardStroke, "key" | "metaKey" | "ctrlKey">, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview
```
## `SelectOperation`

Expand Down
4 changes: 4 additions & 0 deletions docs/api-reference/editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,7 @@ interface DocumentEditor {

```ts
type DocumentIntent =
| { readonly type: "selection.select-all" }
| { readonly type: "selection.set"; readonly blockId: string; readonly mode?: "replace" | "extend" | "toggle"; readonly offset?: number }
| { readonly type: "text.replace"; readonly blockId: string; readonly text: string; readonly offset?: number }
| { readonly type: "block.insert"; readonly afterId?: string; readonly text?: string }
Expand Down Expand Up @@ -1322,6 +1323,7 @@ interface OrderEditor {

```ts
type OrderIntent =
| { readonly type: "selection.select-all" }
| {
readonly type: "selection.set";
readonly itemId: string;
Expand Down Expand Up @@ -1454,6 +1456,7 @@ interface SheetEditor {

```ts
type SheetIntent =
| { readonly type: "selection.select-all"; readonly topology?: SheetTopology }
| {
readonly type: "selection.set";
readonly rowId: string;
Expand Down Expand Up @@ -1558,6 +1561,7 @@ interface TreeEditor {

```ts
type TreeIntent =
| { readonly type: "selection.select-all"; readonly topology: TreeTopology }
| {
readonly type: "selection.set";
readonly nodeId: string;
Expand Down
47 changes: 28 additions & 19 deletions docs/public/affordance-rename.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,34 @@ Rename은 고른 대상의 레이블을 고치는 손입니다. F2와 느린 dou
같은 손을 엽니다. Escape는 [Escape](affordance-cancel.md)입니다.

```ts
import { applyAffordance, renameAffordance } from "@interactive-os/json-document-affordance";
import { applyAffordance, createRenameSession, renameAffordance } from "@interactive-os/json-document-affordance";

const rename = createRenameSession<string>({
tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok,
onSnapshot: renderDraft,
onFinish: restoreFocus,
});

function onKeyDown(event: KeyboardEvent) {
applyAffordance(renameAffordance(event), {
hand: (hand) => {
if (hand.type !== "rename") return;
if (hand.action === "begin") setRenaming(focusKey);
if (hand.action === "cancel") setRenaming(null);
if (hand.action === "commit" && renaming) {
editor.dispatch({ type: "item.rename", itemId: renaming, label: draft });
setRenaming(null);
}
if (hand.type === "rename" && hand.action === "begin") rename.begin(focusKey, label);
},
});
}

function onClick(event: MouseEvent, itemId: string) {
applyAffordance(renameAffordance({ type: "pointer", detail: event.detail, intervalMs }), {
hand: (hand) => {
if (hand.type === "rename" && hand.action === "begin") setRenaming(itemId);
},
});
function onClick(event: MouseEvent, itemId: string, label: string) {
rename.handlePointer(itemId, label, event.detail, event.timeStamp);
}

function onDraftKeyDown(event: KeyboardEvent) {
if (rename.handleKey(event.key)) event.preventDefault();
}
```

호스트는 레이블 필드를 그립니다. begin/cancel은 호스트 화면 상태이고,
commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다.
호스트는 session snapshot으로 레이블 필드를 그리고 입력 변경을 `rename.update`에
연결합니다. session이 draft의 시작·확정·취소를 소유하고, 확정 시 domain editor가
문서를 변경합니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다.

닫는 손:
- F2
Expand All @@ -41,12 +42,20 @@ commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affo

## Session API

`createRenameSession({ onCommit, onCancel, onFinish, onSnapshot })`은 active key, draft,
slow double-click 간격과 commit/cancel을 소유합니다. Host는 snapshot으로 input을
그리고 `onCommit(key, draft)`에서 domain rename Intent를 보냅니다.
`createRenameSession`은 active key, draft, slow double-click 간격과 commit/cancel을
소유합니다. 기존 `onCommit(key, draft): void` 또는 동기
`tryCommit(key, draft): boolean` 중 하나를 받습니다. 둘을 동시에 지정하거나
비동기 `tryCommit`을 전달할 수 없습니다.

`tryCommit`이 `false`이면 active key와 draft를 유지하고 `onFinish`를 호출하지
않습니다. 사용자가 수정한 뒤 다시 확정할 수 있습니다. `true` 또는 기존
`onCommit` 완료 시 snapshot을 비우고 `onFinish`를 한 번 호출합니다.
Escape는 commit 없이 종료합니다. 검증과 문서 변경은 domain이 소유합니다.
`onCancel(key, draft)`는 저장되지 않은 값을 복구하거나, Calendar처럼 생성과
동시에 시작된 rename을 취소할 때 새 항목을 제거하는 제품 정책을 연결합니다.

Calendar는 `useCalendarRenameInput(hand)`으로 focus/select, input change,
Enter·Escape, blur를 같은 session에 연결합니다. Host는 반환된 `ref`, `value`,
event handler만 제목 input에 전달합니다.

Usage와 Source: [Order Demo](/demo/order)의 F2 → 입력 → Enter 또는 Escape.
25 changes: 25 additions & 0 deletions docs/public/affordance-select.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
editingCommandFromWebKeyboardStroke,
pointerSelect,
planeHitAffordance,
selectAllAffordance,
} from "@interactive-os/json-document-affordance";

function onPointerDown(event: PointerEvent, itemId: string) {
Expand Down Expand Up @@ -39,6 +40,18 @@ const editing = useEditing({
neighbor: (key, command) => neighborFromProductTopology(key, command),
},
});

function onSelectAll(event: KeyboardEvent) {
applyAffordance(selectAllAffordance(event, {
allSelected: editor.selectedItemIds.length === items.length,
}, { repeat: "preserve" }), {
hand: (hand) => {
if (hand.type !== "select-all") return;
editor.dispatch({ type: "selection.select-all" });
event.preventDefault();
},
});
}
```

호스트는 보이는 키와 장르 Intent만 넘깁니다. keymap을 덮어쓰지 않습니다.
Expand All @@ -54,6 +67,18 @@ const editing = useEditing({
공통 해석만 소유하며, 현재 focus와 다음 이웃을 결정하는 topology는 Host가
`focusKey`와 `neighbor`로 주입합니다.

### `selectAllAffordance(stroke, state, { repeat })`

기본 편집 Usage는 `repeat: "preserve"`를 선택합니다. Mod+A를 반복해도
`select-all`을 보내며 전체 선택을 해제하지 않습니다. 옵션 생략 또는
`repeat: "toggle"`은 기존 `allSelected ? clear : select-all` 동작입니다.
Document·Order·Tree·Sheet의 `selection.select-all`은 대상 전체를 한 번의
선택 전이로 만듭니다. Tree는 visible topology, Sheet는 선언한 행·열을 사용합니다.
빈 대상은 빈 선택이 되고, 선택 변경은 문서 Undo/Redo 기록을 추가하거나 지우지 않습니다.
실제 Demo는 Web의 `isWebEditingHostTarget`으로 내부 text field의 Mod+A를 보존합니다.

Usage와 Source: [Order](/demo/order), [Tree](/demo/tree), [Sheet](/demo/sheet), [Document](/demo).

닫는 손:
- 클릭 replace, 이미 고른 집합 유지
- Shift+click 추가/제거
Expand Down
5 changes: 5 additions & 0 deletions docs/public/intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다.
| `type` | 필드 | 결과 |
| --- | --- | --- |
| `selection.set` | `blockId`, `mode?`, `offset?` | 블록 선택 변경 |
| `selection.select-all` | | 첫 블록 offset 0부터 마지막 블록 text 끝까지 한 번에 선택 |
| `text.replace` | `blockId`, `text`, `offset?` | 블록 text 변경 |
| `block.insert` | `afterId?`, `text?` | 블록 추가 |
| `selection.remove` | | 선택한 블록 제거 |
Expand All @@ -83,6 +84,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다.
| `type` | 필드 | 결과 |
| --- | --- | --- |
| `selection.set` | `rowId`, `columnId`, `mode?` | 셀 선택 변경 |
| `selection.select-all` | `topology?` | 지정한 행·열 전체를 하나의 범위로 선택 |
| `selection.fill` | `value`, `topology?` | 선택한 셀 채우기 |
| `cell.commit` | `rowId`, `columnId`, `value` | 한 셀의 값 확정 |
| `clipboard.paste` | `clipboard`, `topology?` | Clipboard 셀 붙여넣기 |
Expand All @@ -95,6 +97,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다.
| `type` | 필드 | 결과 |
| --- | --- | --- |
| `selection.set` | `nodeId`, `topology`, `mode?` | 보이는 노드 선택 변경 |
| `selection.select-all` | `topology` | 보이는 노드 전체를 하나의 범위로 선택 |
| `selection.remove` | `topology` | 선택한 노드 제거 |
| `clipboard.paste` | `clipboard`, `topology`, `afterId?` | 붙여넣기 |

Expand All @@ -121,6 +124,8 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다.
| `type` | 필드 | 결과 |
| --- | --- | --- |
| `selection.set` | `itemId`, `mode?` | 항목 선택 변경 |
| `selection.select-all` | | 전체 항목을 하나의 범위로 선택 |
| `item.rename` | `itemId`, `label` | 레이블 확정; 실패 결과는 draft session에 전달 |
| `selection.remove` | | 선택한 항목 제거 |
| `clipboard.paste` | `clipboard`, `afterId?` | 붙여넣기 |

Expand Down
25 changes: 20 additions & 5 deletions packages/json-document-affordance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ drop targets, and insertion positions without owning DOM or product color.
own the reusable state that spans several events. Product selection and rename
Intents remain callbacks supplied by the host.

`createRenameSession` accepts either the legacy `onCommit(key, draft): void`
or synchronous `tryCommit(key, draft): boolean`. A false result keeps the active
key and draft without publishing a finish. Updating and retrying can then
complete the edit; success or cancellation clears the draft and calls `onFinish`
once. The domain editor still owns validation and document changes:

```ts
createRenameSession<string>({
tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok,
onSnapshot: renderDraft,
onFinish: restoreFocus,
});
```

`createBoardDragSession` owns the input-agnostic active item, drop-target
preview, commit, and cancel lifecycle for Board Hands. Web pointer and HTML
Drag and Drop sessions feed it; Hosts still resolve targets and dispatch the
Expand All @@ -79,13 +93,14 @@ open/focus semantics remain outside this geometry contract.

Usage: [Affordance](https://developer-1px.github.io/json-document/docs/affordance)

`selectAllAffordance` implements an explicit Mod+A toggle input convention:
when everything is selected it emits `clear`; otherwise it emits `select-all`.
The semantic `select-all` command itself is idempotent. Hosts choosing this
input convention consume the existing Affordance API.
`selectAllAffordance(stroke, state, { repeat: "preserve" })` emits `select-all`
for Mod+A even when everything is selected. The default editing Usage chooses
this policy. Omission or `{ repeat: "toggle" }` retains the existing behavior:
emit `clear` when `state.allSelected`, otherwise `select-all`. This is an input
policy; domain editors own the selected universe and its semantic transition.

[Editing grammar integration tests](tests/conformance/editing-grammar.test.ts)
connect that mapping to KeySelection and connect `createGestureSession` to
connect both mappings to selection, cover rejected draft commits, and connect `createGestureSession` to
Document's `selection.move`. Structural preview and cancellation leave committed
value/history unchanged; commit dispatches the latest preview once. This proves
the tested composition, not every Host callback. IME composition has a separate
Expand Down
4 changes: 3 additions & 1 deletion packages/json-document-affordance/src/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,15 @@ export function resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePrevi
return { hand: keyboard.resolve(stroke) };
}

/** Mod+A selects all. Choose preserve for repeated selection; omission retains the legacy toggle. */
export function selectAllAffordance(
stroke: Pick<WebKeyboardStroke, "key" | "metaKey" | "ctrlKey">,
state: { readonly allSelected: boolean },
options: { readonly repeat?: "preserve" | "toggle" } = {},
): AffordancePreview {
const mod = stroke.metaKey || stroke.ctrlKey;
if (!mod || stroke.key.toLowerCase() !== "a") return { hand: null };
return { hand: { type: state.allSelected ? "clear" : "select-all" } };
return { hand: { type: state.allSelected && options.repeat !== "preserve" ? "clear" : "select-all" } };
}

export function typeaheadAffordance(input: {
Expand Down
14 changes: 12 additions & 2 deletions packages/json-document-affordance/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,14 @@ export interface RenameSession<Key> {
cancel(): void;
}

export function createRenameSession<Key>(options: {
/** Owns the draft lifecycle. A false tryCommit retains the draft without finishing; onCommit always finishes. */
export function createRenameSession<Key>(options: ({
readonly onCommit: (key: Key, draft: string) => void;
readonly tryCommit?: never;
} | {
readonly tryCommit: (key: Key, draft: string) => boolean;
readonly onCommit?: never;
}) & {
readonly onCancel?: (key: Key, draft: string) => void;
readonly onFinish?: (key: Key) => void;
readonly onSnapshot?: (snapshot: RenameSessionSnapshot<Key> | null) => void;
Expand All @@ -94,7 +100,11 @@ export function createRenameSession<Key>(options: {
function finish(commit: boolean) {
if (snapshot === null) return;
const finished = snapshot;
if (commit) options.onCommit(finished.key, finished.draft);
if (commit) {
if (options.tryCommit !== undefined) {
if (!options.tryCommit(finished.key, finished.draft)) return;
} else options.onCommit(finished.key, finished.draft);
}
else options.onCancel?.(finished.key, finished.draft);
publish(null);
options.onFinish?.(finished.key);
Expand Down
Loading