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
5 changes: 5 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ reconciliation까지의 DOM 편집 정본은 `standards/dom-editing-lifecycle.md
identifier나 동작을 바꾸지 않으며, 과거 version 문서는 정본 public surface의
Root symbol·six-member 계약을 확장하지 않습니다.

편집 문법의 안정화 설계는 `standards/editing-grammar.md`에 있습니다. 공통 편집
규칙, Hands profile의 선택, 입력 매핑의 소유자와 적합성 증거를 연결하는 Design
Draft이며 기존 Stable profile의 권위를 변경하지 않습니다. API reference와 Usage는
각 owner에 유지하고, 설계 문서를 별도의 API catalog로 사용하지 않습니다.

문서 원천, Pages 산출물, live 응답의 공개 계약 검사는
`public-contract-checks.mjs`가 소유합니다. Root symbol 수는 Core의
`public-contract.json`, 유효한 package 참조는 `api-reference/packages.mjs`에서
Expand Down
5 changes: 3 additions & 2 deletions docs/evaluate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function filesUnder(path) {
.flatMap((entry) => {
if (
entry.isDirectory()
&& [".git", ".npm-cache", "node_modules", "dist", "build", "coverage", "test-results"].includes(entry.name)
&& [".git", ".worktrees", ".npm-cache", "node_modules", "dist", "build", "coverage", "test-results"].includes(entry.name)
) {
return [];
}
Expand Down Expand Up @@ -220,10 +220,11 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([

if (JSON.stringify(fileNames("standards")) !== JSON.stringify([
"dom-editing-lifecycle.md",
"editing-grammar.md",
"repository-implementation-shape.md",
"repository-naming.md",
])) {
fail("standards: repository naming and implementation shape must be the only repository-wide standard files.");
fail("standards: only repository naming, implementation shape, DOM editing lifecycle, and the editing grammar design may appear at the root.");
}

for (const token of [
Expand Down
58 changes: 36 additions & 22 deletions docs/public/official-hands.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,17 @@ Get from Official Hands

## 왜 Official인가

문서, 목록, 표, 나무와 캔버스 편집기는 오랫동안 비슷한 행동으로
수렴했습니다. click은 선택하고, modifier-click은 선택을 토글하며, Shift는
범위를 확장합니다. Escape는 진행 중인 행동을 취소하고 undo는 변경 전
상태로 돌아갑니다.
문서, 목록, 표, 나무와 캔버스 편집기는 오랫동안 선택, 복사, 붙여넣기,
실행 취소 같은 행동을 공유해 왔습니다. 선택만 바꾸면 내용은 유지되고,
범위 확장은 기준점을 보존하며, 편집은 내용과 다음 작업 위치를 함께 정합니다.

Official은 제품 취향을 임의로 정한다는 뜻이 아닙니다. 여러 편집기에서
반복해서 검증된 기대를 기본 동작으로 제공한다는 뜻입니다.

```text
자의적인 product opinion
└─ "Kanban card에는 반드시 dueDate가 있다"

수렴한 editing opinion
├─ "Shift 선택은 범위를 확장한다"
├─ "Escape는 진행 중인 상호작용을 취소한다"
└─ "undo는 document와 Selection을 함께 복원한다"
```
공통 규칙과 입력 관습은 구별합니다. 범위를 확장한다는 의미는 Selection에
있고, Shift+click을 그 의미에 연결하는 일은 Adapter와 Affordance에 있습니다.
포커스가 이동할 때 선택도 바꿀지, 전체 선택 상태에서 Mod+A를 다시 누르면
선택을 해제할지는 사용하는 편집 방식이 정합니다.

대부분의 사용자는 Official Hands만으로 편집기를 완성할 수 있어야 합니다.
Custom Hands는 기본 경로가 아니라 제품에만 있는 차이를 위한 escape hatch입니다.
Expand Down Expand Up @@ -70,6 +64,27 @@ row identity, column identity와 cell addressability를 먼저 정해야 합니
Sheet다운 편집 행동이 무엇을 대상으로 하는지 안정적으로 정하기 위해
필요합니다.

### 공통 규칙과 profile의 선택

공통 편집 규칙은 Selection과 Editing이 소유합니다. Profile은 그 규칙이 자신의
문서에서 무엇을 대상으로 하며 어떤 결과를 만드는지 결정합니다. 다음 표는 현재
구현을 이해하기 위한 예입니다.

| 작업 | Document | Sheet | Rich Text |
| --- | --- | --- | --- |
| Copy 대상 | 선택된 블록 전체 | primary 직사각형 | 선택된 텍스트와 구조 |
| Paste 위치 | 기본적으로 마지막 선택 블록 뒤 | focus 셀부터 | 선택 구간에 적용 |
| Cut의 제거 | 선택 블록 제거 | primary 직사각형의 셀 값 비우기 | 선택 구간 제거 |

같은 Copy라도 무엇을 복사하는지는 profile의 약속입니다. 사용자는 각 profile에서
지원하는 작업, 여러 선택 범위의 처리, 붙여넣기 경계, 작업 후 선택 위치와 Undo
단위를 알 수 있어야 합니다. 작업 자체의 미지원과 현재 선택 때문에 실행할 수 없는
상태도 구별합니다.

이 구분은 새로운 공통 editor interface를 요구하지 않습니다. 기존 editor API와
Selection family, EditingSession을 사용하면서 입력부터 편집 결과까지 같은 의미를
유지하는 조합을 지향합니다.

## 자유롭게 남겨 두는 것

Official Hands가 최소 profile을 제공해도 완성 제품을 대신 소유하지는 않습니다.
Expand Down Expand Up @@ -161,14 +176,13 @@ Core는 어떤 Hands도 강제하지 않습니다. 사용자가 Official Hands
Affordance와 Adapter가 사람이 작업을 끝낼 수 있는 하나의 kit로 조합되는
방식을 검토합니다.

## 열린 질문
## 사용자가 기대할 계약

- Official Hands로 인정할 최소 완료 증거는 무엇인가?
- 수렴한 기본 동작과 제품별 policy를 어떤 기준으로 가르는가?
- profile의 configuration과 extension은 어느 수준까지 compatibility를 약속하는가?
- minimum schema에 Host field를 연결하는 공통 방식은 무엇인가?
- 여러 Official Hands가 같은 document에서 조합될 때 identity와 History를 어떻게 공유하는가?
- Custom Hands로 내려가야 하는 명확한 신호는 무엇인가?
Official profile의 지향점은 구현이 바뀌어도 같은 지원 입력에서 같은 편집 의미를
얻는 것입니다. 선택하고, 편집하고, 복사하고, 되돌리는 전체 흐름이 그 약속에
포함됩니다. Keyboard나 pointer로 실행해도 해당 editor를 직접 호출해도 같은
편집 의도는 같은 문서와 Selection의 결과로 이어져야 합니다.

이 질문이 닫히기 전에는 Official Hands 후보를 완성된 SDK contract로
설명하지 않습니다.
구체적인 profile별 필수 작업, Host field 연결, 여러 Hand가 공유하는 History
단위는 아직 확정하지 않았습니다. 현재 후보 목록과 위 동작 예시는 완성된 SDK의
호환성 보장이 아닙니다.
12 changes: 12 additions & 0 deletions packages/json-document-affordance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,15 @@ the final placement and available size while Tooltip, Menu, Dialog, and product
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.

[Editing grammar integration tests](tests/conformance/editing-grammar.test.ts)
connect that mapping to KeySelection 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
[DOM editing lifecycle](../../standards/dom-editing-lifecycle.md) contract.
12 changes: 0 additions & 12 deletions packages/json-document-affordance/tests/affordance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
panAffordance,
resizeAffordance,
resolveAffordanceKey,
selectAllAffordance,
snapAffordance,
treeAffordance,
createBoardDragSession,
Expand Down Expand Up @@ -656,17 +655,6 @@ describe("dropAffordance", () => {
});
});

describe("selectAllAffordance", () => {
test("toggles Mod+A between select-all and clear", () => {
expect(selectAllAffordance({ key: "a", metaKey: true, ctrlKey: false }, { allSelected: false }).hand)
.toEqual({ type: "select-all" });
expect(selectAllAffordance({ key: "a", metaKey: true, ctrlKey: false }, { allSelected: true }).hand)
.toEqual({ type: "clear" });
expect(selectAllAffordance({ key: "a", metaKey: false, ctrlKey: false }, { allSelected: false }).hand)
.toBeNull();
});
});

describe("drag constrain and copy", () => {
test("Shift constrains to the dominant axis and Alt shows copy", () => {
expect(dragAffordance({ x: 0, y: 0 }, { x: 12, y: 3 }, { shiftKey: true }).hand)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { createDocumentEditor } from "@interactive-os/json-document-editing";
import { createKeySelectionFamily, emptyKeySelection, type KeySelectionContext } from "@interactive-os/json-document-selection";
import { describe, expect, test } from "vitest";
import { createGestureSession, selectAllAffordance, type GestureCancelReason } from "../../src/index.js";

describe("editing grammar / input mapping", () => {
test.each(["metaKey", "ctrlKey"] as const)("EG-SELECT / %s+A toggle profile sends clear as a distinct intent", (modifier) => {
const context: KeySelectionContext = { keys: ["a", "b"], universe: "visible:v1", universeMismatch: "clear" };
const family = createKeySelectionFamily();
const stroke = { key: "a", metaKey: false, ctrlKey: false, [modifier]: true };
expect(selectAllAffordance(stroke, { allSelected: false }).hand).toEqual({ type: "select-all" });
const selected = family.transition(emptyKeySelection(), { type: "select-all", universe: context.universe }, context).state;
expect(family.targets(selected, context)).toEqual(["a", "b"]);
const second = selectAllAffordance(stroke, { allSelected: true }).hand;
expect(second).toEqual({ type: "clear" });
if (second?.type !== "clear") throw new Error("Expected the toggle profile to emit clear");
expect(family.targets(family.transition(selected, second, context).state, context)).toEqual([]);
expect(selectAllAffordance({ key: "a", metaKey: false, ctrlKey: false }, { allSelected: false }).hand).toBeNull();
});

test.each(["cancel", "pointer-cancel", "lost-capture"] satisfies GestureCancelReason[])("EG-GESTURE / %s discards preview; commit moves once", (reason) => {
const initial = { blocks: [{ id: "a", text: "A" }, { id: "b", text: "B" }, { id: "c", text: "C" }] };
const editor = createDocumentEditor(initial);
// Navigation changes the editing target without moving content.
expect(editor.dispatch({ type: "selection.set", blockId: "b" }).ok).toBe(true);
expect(editor.snapshot.value).toEqual(initial);
expect(editor.snapshot.canUndo).toBe(false);
const before = structuredClone(editor.snapshot);
const published: unknown[] = [];
const release = editor.subscribe((snapshot) => published.push(snapshot));
const gesture = createGestureSession<{ type: "block-move"; direction: -1 | 1 }>({
onCommit(preview) { expect(editor.dispatch({ type: "selection.move", direction: preview.direction }).ok).toBe(true); },
});
try {
gesture.begin({ type: "block-move", direction: -1 });
gesture.preview({ type: "block-move", direction: 1 });
gesture.preview({ type: "block-move", direction: -1 });
expect(editor.snapshot).toMatchObject(before);
gesture.cancel(reason);
expect(gesture.getActive()).toBeNull();
expect(gesture.commit()).toBeNull();
expect(editor.snapshot).toMatchObject(before);
expect(published).toEqual([]);

gesture.begin({ type: "block-move", direction: -1 });
gesture.preview({ type: "block-move", direction: 1 });
expect(editor.snapshot).toMatchObject(before);
expect(gesture.commit()).toEqual({ type: "block-move", direction: 1 });
expect(gesture.getActive()).toBeNull();
expect(gesture.commit()).toBeNull();
expect(published).toHaveLength(1);
expect(editor.snapshot.value).toEqual({ blocks: [initial.blocks[0], initial.blocks[2], initial.blocks[1]] });
expect(editor.selectedBlockIds).toEqual(["b"]);
expect(editor.undo().ok).toBe(true);
expect(editor.snapshot).toMatchObject({ value: before.value, selection: before.selection, canUndo: false, canRedo: true });
} finally { release(); }
});
});
32 changes: 32 additions & 0 deletions packages/json-document-editing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,35 @@ targets may use numbered `marker` presentations for instructions or a
serializable `{ type: "reaction", reaction: "like" | "dislike" }` presentation
for comment-free feedback. Both use the same create, move, delete, and history
contracts exposed by `createAnnotationEditor`.

## Editing grammar evidence (Draft)

The [editing grammar design](../../standards/editing-grammar.md) separates shared
rules from each Hand's interpretation. The [test-only runner](tests/conformance/editing-grammar.ts)
executes the same selection, copy, edit, cut, paste, rejection, no-op, and local
undo/redo observations through public APIs. Its [Document and Sheet bindings](tests/conformance/editing-grammar.test.ts)
keep their own types and expected values; Rich Text binds it from its own package.
These are applicability checks across different profiles, not independent
implementations of one frozen profile.

| Profile decision | Document binding | Sheet binding |
| --- | --- | --- |
| Target and identity | Stable block ID plus text offset; `selection.move` moves content | Stable row/column IDs; cell values |
| Selection | Directional block ranges; Copy includes whole blocks even with text offsets | Rectangular ranges; Copy/Cut use the primary rectangle |
| Topology | Document block order | Document axes by default; supplied visible axes for topology-aware operations |
| Supported operations | Select, insert, remove, move, duplicate, Copy/Cut/Paste, Undo/Redo | Select, commit/fill cells, Copy/Cut/Paste, Undo/Redo; Cut clears values to `null` |
| Paste and resulting selection | Insert after the last selected block by default (or `afterId`); fresh IDs; one collapsed range per inserted block, first primary | Start at focus without replacing the old rectangle; select the written rectangle; reject overflow with `paste.out-of-bounds` |
| Local history | Consecutive text changes in the same block can share a group; selection ends the active group | Consecutive commits to the same cell can share a group; selection ends the active group |
| Input | Headless calls; Web/Affordance choose physical bindings and native text arbitration | Headless calls; Web/Affordance choose physical bindings and native text arbitration |

Both bindings clear local history on external value changes and reconcile missing
selection endpoints. An injected `EditingHistory` retains its own step and
restoration policy. A missing command (the current Database binding has no
`cut`) is distinct from a supported command that is unavailable for the current
selection. The existing [clipboard surface test](tests/clipboard-surface.test.ts)
records that distinction; it does not decide every future Database profile.

Existing public API references and live Usage remain in
[Editing](https://developer-1px.github.io/json-document/docs/api/editing),
[Document](https://developer-1px.github.io/json-document/demo), and
[Sheet](https://developer-1px.github.io/json-document/demo/sheet).
Loading