diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc6758be5..81f5676d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,68 +1,14 @@ name: CI on: - pull_request: - push: - branches: [main] workflow_dispatch: permissions: contents: read jobs: - plan: - name: Plan - runs-on: ubuntu-latest - outputs: - full: ${{ steps.plan.outputs.full }} - package_required: ${{ steps.plan.outputs.package_required }} - package_workspaces: ${{ steps.plan.outputs.package_workspaces }} - browser_specs: ${{ steps.plan.outputs.browser_specs }} - docs: ${{ steps.plan.outputs.docs }} - site: ${{ steps.plan.outputs.site }} - standards: ${{ steps.plan.outputs.standards }} - external_kit: ${{ steps.plan.outputs.external_kit }} - reason: ${{ steps.plan.outputs.reason }} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - node-version: "22" - - name: Select verification scope - id: plan - env: - EVENT_NAME: ${{ github.event_name }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - if [ "$EVENT_NAME" = "pull_request" ]; then - node scripts/ci-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT" - else - node scripts/ci-plan.mjs --full >> "$GITHUB_OUTPUT" - fi - - name: Summarize verification scope - env: - PLAN_REASON: ${{ steps.plan.outputs.reason }} - PACKAGE_WORKSPACES: ${{ steps.plan.outputs.package_workspaces }} - BROWSER_SPECS: ${{ steps.plan.outputs.browser_specs }} - EXTERNAL_KIT: ${{ steps.plan.outputs.external_kit }} - run: | - { - echo "## CI 검증 계획" - echo "- 판단: $PLAN_REASON" - echo "- 패키지: $PACKAGE_WORKSPACES" - echo "- 브라우저: $BROWSER_SPECS" - echo "- 외부 kit: $EXTERNAL_KIT" - } >> "$GITHUB_STEP_SUMMARY" - - name: Verify workspace contract and planner - run: npm run workspace:check && npm run ci:plan:test - package: name: Package - needs: plan - if: needs.plan.outputs.package_required == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -71,31 +17,15 @@ jobs: node-version: "22" - name: Install dependencies run: npm ci --no-audit --no-fund + - name: Verify planner commands + run: npm run ci:plan:test - name: Verify full release graph - if: needs.plan.outputs.full == 'true' env: npm_config_cache: ${{ runner.temp }}/npm-cache run: npm run release:check - - name: Verify affected libraries - if: needs.plan.outputs.full != 'true' && needs.plan.outputs.package_workspaces != '[]' - env: - AFFECTED_WORKSPACES: ${{ needs.plan.outputs.package_workspaces }} - run: >- - node --input-type=module --eval - 'import { spawnSync } from "node:child_process"; - const workspaces = JSON.parse(process.env.AFFECTED_WORKSPACES); - const result = spawnSync("node", ["scripts/workspace-tasks.mjs", "verify", ...workspaces], { stdio: "inherit" }); - process.exit(result.status ?? 1);' - - name: Verify standards - if: needs.plan.outputs.full != 'true' && needs.plan.outputs.standards == 'true' - run: npm run standard:check - - name: Verify documentation - if: needs.plan.outputs.full != 'true' && needs.plan.outputs.docs == 'true' - run: npm run docs:evaluate playground-site: name: Site - needs: plan runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -107,22 +37,12 @@ jobs: - name: Install Rich Text browser engines run: npx playwright install --with-deps firefox webkit - name: Verify site - if: needs.plan.outputs.site == 'true' run: npm run typecheck -w @interactive-os/json-document-site && npm test -w @interactive-os/json-document-site && npm run site:verify:pages - name: Verify browser behavior - env: - BROWSER_SPECS: ${{ needs.plan.outputs.browser_specs }} - run: >- - node --input-type=module --eval - 'import { spawnSync } from "node:child_process"; - const specs = JSON.parse(process.env.BROWSER_SPECS); - const result = spawnSync("npm", ["run", "browser:test", "--", ...specs], { stdio: "inherit" }); - process.exit(result.status ?? 1);' + run: npm run browser:test external-kit: name: External kit - needs: plan - if: needs.plan.outputs.external_kit == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/collaboration-soak.yml b/.github/workflows/collaboration-soak.yml index dfc1f21f5..9b3261226 100644 --- a/.github/workflows/collaboration-soak.yml +++ b/.github/workflows/collaboration-soak.yml @@ -2,8 +2,6 @@ name: Collaboration Soak on: workflow_dispatch: - schedule: - - cron: "0 19 * * 1" permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 92bb93651..e2b2e0da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,21 @@ # Repository agent instructions +## Local verification and remote workflows + +This is a solo-maintained project. Agents own change-scoped local verification +before handoff: run the relevant tests, type checks, builds, and browser flows. +Report the commands, results, and any unexecuted or failing checks separately. +An absent remote check must not be reported as a pass. + +Pull requests do not automatically run full CI. Main pushes run the Pages +build/deploy workflow; release tags retain package verification on a clean +runner. Full CI and the extended collaboration soak are manual workflows. +Do not wait for automatic PR checks or routinely dispatch manual CI to recreate +the removed gate. Use remote full verification when deliberately requested. + +Keep product tests and verification commands available. The local workflow and +manual commands are documented in the root README's development section. + ## Canonical module principle This repository follows **the same role, the same responsibility, the same diff --git a/README.md b/README.md index f0a076109..c43626005 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,14 @@ stateless JSON Patch | 목적 | 위치 | | --- | --- | -| 빠른 사용 예제 | [docs/public/quickstart.md](docs/public/quickstart.md) | +| 빠른 사용 예제 | [Intent guide](docs/public/intent-guide.md) | +| 목표 구조와 TBD | [Concept Map](docs/public/concepts.md), [Foundation](docs/public/foundation.md) | | JSON Document 개념 | [docs/public/overview.md](docs/public/overview.md) | -| JSON Document API | [docs/public/api.md](docs/public/api.md) | +| JSON Document Protocol | [docs/public/api.md](docs/public/api.md) | +| Editing Protocol | [docs/public/editing.md](docs/public/editing.md) | +| Document Types · TBD | [후보와 완료 조건](docs/public/document-types.md) | +| Official Hands · TBD | [Profile의 목표와 현재 증거](docs/public/official-hands.md) | +| Building Blocks | [독립적인 네 책임](docs/public/building-blocks.md) | | 편집 개념 | [docs/public/selection.md](docs/public/selection.md), [history](docs/public/history.md), [clipboard](docs/public/clipboard.md), [topology](docs/public/topology.md) | | Adapter | [docs/public/adapters.md](docs/public/adapters.md) | | Connector | [docs/public/connectors.md](docs/public/connectors.md) | @@ -68,7 +73,10 @@ Editing, Adapter, Connector와 collaboration package는 독립 version과 releas lifecycle을 가집니다. Selection, clipboard, history는 editing companion이 제공하는 headless lifecycle 위에서 도메인별 모델을 조합합니다. 플랫폼 계약은 공식 Adapter가 맡고, external framework와 schema의 반복 glue는 공식 Connector가 -맡으며, persistence와 제품별 UI 의미는 host가 소유합니다. +맡습니다. Host는 조합·실행 순서·제품 정책 값·copy·fixture·layout과 구체 +persistence 인스턴스 주입을 소유합니다. 재사용 모델·연산·투영·UI 행동은 각 +정본 모듈에 둡니다. Document Type 후보와 Official Hands의 전체 Profile은 +아직 TBD이며 기존 package/API의 존재만으로 완료를 선언하지 않습니다. 일반 DOM과 Input Events 정규화가 필요한 제품은 별도 수명 주기의 `@interactive-os/editable`도 검토할 수 있습니다. @@ -89,12 +97,15 @@ optional editing companion이 제공하는 것: - range-set과 set-selection transition family - Document·Order·Sheet·Object·Tree domain slice와 selection-restoring history -편집 제품이 계속 소유하는 것: +Application/Host가 소유하는 것: -- rendering, DOM focus, keyboard, drag/drop UI와 geometry hit-test -- DOM focus, system clipboard와 제품별 interaction policy -- formula engine과 제품별 grid projection 정책 -- product command 이름, layout, route, remote protocol +- 정본 모듈의 조합과 실행 순서 +- 제품의 권한·기본값·copy·fixture·layout·route +- 구체 persistence·network 인스턴스와 제품별 정책 값의 주입 + +문서 의미·formula·grid projection은 해당 의미 owner에, DOM focus·keyboard· +clipboard·geometry 관찰은 Adapter에, 조작 수명주기와 재사용 UI는 Affordance· +Connector·UI Primitives에 둡니다. Core 밖의 책임이 모두 Host 책임은 아닙니다. 공식 Adapter가 제공하는 것: @@ -124,3 +135,35 @@ npm test -w @interactive-os/json-document npm run typecheck -w @interactive-os/json-document npm run build -w @interactive-os/json-document ``` + +### 검증 운영 + +1인 개발과 agent의 로컬 검증을 기본으로 합니다. 개발·PR에서는 변경 범위에 맞는 +테스트·타입·빌드·브라우저 검증을 수행하고, 실행 명령과 결과를 인계합니다. +실행하지 못한 검사와 실패한 검사는 통과와 구분합니다. 자동 CI가 없다는 사실을 +검증 완료로 간주하지 않습니다. + +| 시점 | 원격 실행 | +| --- | --- | +| PR 생성·갱신 | 자동 전체 CI 없음 | +| main push | Pages 빌드·배포와 live 확인 | +| 릴리스 tag | 깨끗한 runner에서 기존 패키지 검증 후 publish | +| 필요 시 | 전체 CI와 장시간 collaboration soak 수동 실행 | + +전체 검증이 필요하면 기존 명령을 사용합니다. 제품 테스트와 검사 CLI는 유지하며, +변경 영향도 선택기 CLI는 자동 CI의 gate로 사용하지 않습니다. + +```sh +npm run verify +npm run release:check +npm run external-kit:verify +npm run test:collaboration:soak +``` + +원격의 깨끗한 환경에서 확인하려면 GitHub Actions의 해당 workflow에서 +`Run workflow`를 선택하거나 아래 명령으로 명시적으로 실행합니다. + +```sh +gh workflow run ci.yml --ref main +gh workflow run collaboration-soak.yml --ref main +``` diff --git a/audits/document-types.json b/audits/document-types.json index 9e08c16c0..f3723c67e 100644 --- a/audits/document-types.json +++ b/audits/document-types.json @@ -32,17 +32,19 @@ }, "object": { "why": "캔버스 object는 identity, geometry와 presentation 값을 하나의 의미 단위로 유지해야 하기 때문입니다.", - "does": "object의 위치·크기·색상 모델을 정의하고 translate·resize·fill 같은 공간 연산의 기반을 제공합니다.", - "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}", + "does": "object의 위치·크기·본문·스타일 모델을 정의하고 translate·resize·fill·style·text의 원자적 연산을 제공합니다.", + "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}\n\ninterface CanvasTextFormat {\n readonly fontSize?: number;\n readonly fontWeight?: 400 | 700;\n readonly textAlign?: \"left\" | \"center\" | \"right\";\n}\ntype CanvasObject = DocumentObject & (\n | (CanvasTextFormat & { kind: \"text\"; fontSize: number })\n | (CanvasTextFormat & { kind: \"rectangle\" | \"ellipse\" | \"sticky-note\"; textColor?: string; strokeColor?: string; strokeWidth?: number })\n | { kind: \"path\"; points: ReadonlyArray<{ x: number; y: number }>; strokeWidth: number }\n | { kind: \"image\"; source: string }\n);\ninterface CanvasDocument extends ObjectDocument {\n readonly profile: \"canvas/1\";\n readonly width: number; readonly height: number;\n readonly objects: ReadonlyArray;\n}", "fields": [ { "name": "objects", "description": "같은 공간에서 배치·선택되는 object의 ordered collection입니다." }, { "name": "objects[].id", "description": "selection, transform과 clipboard가 object를 안정적으로 참조하는 identity입니다." }, - { "name": "objects[].label", "description": "object의 사용자 가시 이름입니다." }, + { "name": "objects[].label", "description": "text·도형·노트의 실제 본문이며 별도 text 복사본이 없습니다. image·path에서는 이름입니다." }, { "name": "x / y", "description": "문서 좌표계에서 object 좌상단의 위치입니다." }, { "name": "width / height", "description": "resize 이후에도 유효해야 하는 object의 공간 크기입니다." }, - { "name": "color", "description": "object가 소유하는 현재 presentation 값입니다." } + { "name": "color / textColor", "description": "color는 도형·노트 채우기·독립 글자색·path 선 색이고 textColor는 도형·노트 본문 색입니다." }, + { "name": "fontSize / fontWeight / textAlign", "description": "본문의 크기·굵기·정렬입니다. 도형·노트의 생략된 크기는 24, 굵기는 400, 정렬은 도형 center·노트와 독립 글자 left입니다." }, + { "name": "strokeColor / strokeWidth", "description": "도형 테두리는 기본 #000000·0이며 path는 color와 양의 strokeWidth를 사용합니다." } ], - "sourcePath": "packages/json-document-editing/src/object.ts", + "sourcePath": "packages/json-document-object-document/src/object-model.ts", "symbol": "ObjectDocument" }, "tree": { @@ -86,7 +88,7 @@ { "name": "recurrence.freq / interval / until", "description": "반복 주기, 양의 간격과 반복 종료 경계를 정의합니다." }, { "name": "excludeDates", "description": "반복 projection에서 제외할 occurrence date 목록입니다." } ], - "sourcePath": "packages/json-document-editing/src/calendar.ts", + "sourcePath": "packages/json-document-calendar-document/src/calendar-model.ts", "symbol": "CalendarDocument" }, "sheet": { @@ -134,17 +136,292 @@ } }, "audits": { + "object": { + "status": "owner-closed", + "denominator": 15, + "horizon": { + "enumerators": [ + "packages/json-document-object-document/src/index.ts", + "packages/json-document-editing/src/object.ts", + "packages/json-document-canvas/src/index.ts", + "site/src/app/live-demo-registry.tsx", + "site/src/shared/demo-workbench/demo-sources.ts" + ], + "exclusions": [ + "Calendar와 Annotation의 독립 문서 의미", + "fixture·copy·layout-only CSS·tests·generated route" + ] + }, + "occurrences": [ + { + "id": "style", + "role": "Document Style", + "knowledge": "종류별 스타일·유효 기본값·혼합 선택·요청 검증", + "decision": "표시와 편집이 같은 스타일 의미를 소비", + "changeReason": "스타일 속성과 적용 가능성 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "readObjectStyle의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-style.ts와 공개 API·소유 API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-style.ts", + "symbol": "readObjectStyle", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "혼합 스타일·no-op·원자적 거절 회귀와 두 Canvas Host" + }, + { + "id": "model", + "role": "Document Model", + "knowledge": "ID·순서·한 장·variant 문서 어휘", + "decision": "기존 Object와 Canvas가 같은 Object 모델을 공유", + "changeReason": "프로파일의 값·variant 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "CanvasDocument의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-model.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-model.ts", + "symbol": "CanvasDocument", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "validation", + "role": "Validation", + "knowledge": "JSON·ID·유한 기하·정규화 path", + "decision": "잘못된 문서 전체를 거절", + "changeReason": "유효성·직렬화 계약 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "assertCanvasDocument의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-validation.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-validation.ts", + "symbol": "assertCanvasDocument", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "operation", + "role": "Document Operation", + "knowledge": "Object 의미 연산과 JSON Patch", + "decision": "대상·결과 검증 후 atomic 계획", + "changeReason": "문서 변형 의미 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "planObjectOperation의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-operation.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-operation.ts", + "symbol": "planObjectOperation", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "projection", + "role": "Projection", + "knowledge": "Object bounds·path 정규화·본문 capability와 배치", + "decision": "preview/commit의 동일한 기하와 표시/입력의 동일한 본문 projection", + "changeReason": "기하 표현 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "transformObject와 projectObjectText의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-projection.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-projection.ts", + "symbol": "transformObject", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "editing", + "role": "Editing lifecycle", + "knowledge": "Object Intent와 post-selection", + "decision": "ID 생성·명령 실행·선택 publication", + "changeReason": "편집 문법 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createObjectEditor의 public 계약", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "packages/json-document-editing/src/object.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-editing/src/object.ts", + "symbol": "createObjectEditor", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "selection", + "role": "Selection", + "knowledge": "안정된 key 집합", + "decision": "키 선택 transition과 reconciliation", + "changeReason": "Selection family 규칙 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createKeySelectionFamily의 public 계약", + "currentOwner": "@interactive-os/json-document-selection", + "canonicalEvidence": "packages/json-document-selection/src/key/index.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-selection/src/key/index.ts", + "symbol": "createKeySelectionFamily", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-selection", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "history", + "role": "History", + "knowledge": "commit·inverse·selection", + "decision": "한 transaction의 undo/redo와 외부 변경 무효화", + "changeReason": "History lifecycle 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createEditingSession의 public 계약", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "packages/json-document-editing/src/session.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-editing/src/session.ts", + "symbol": "createEditingSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "gesture", + "role": "Affordance", + "knowledge": "입력 독립 gesture lifecycle", + "decision": "begin/preview/commit/cancel", + "changeReason": "interaction 의미 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createGestureSession의 public 계약", + "currentOwner": "@interactive-os/json-document-affordance", + "canonicalEvidence": "packages/json-document-affordance/src/gesture-session.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-affordance/src/gesture-session.ts", + "symbol": "createGestureSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-affordance", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "adapter", + "role": "Web Adapter", + "knowledge": "Pointer Events·capture", + "decision": "pointer identity와 release/cancel", + "changeReason": "Web platform 입력 계약 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createWebPointerSession의 public 계약", + "currentOwner": "@interactive-os/json-document-web", + "canonicalEvidence": "packages/json-document-web/src/pointer-session.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-web/src/pointer-session.ts", + "symbol": "createWebPointerSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-web", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "ui", + "role": "Reusable UI behavior", + "knowledge": "Handle descriptor와 React/Web lifecycle", + "decision": "공통 capture continuation과 unmount 정리", + "changeReason": "handle UI 행동 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "useInteractionHandle의 public 계약", + "currentOwner": "@interactive-os/json-document-ui-primitives-react", + "canonicalEvidence": "packages/json-document-ui-primitives-react/src/surfaces.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-ui-primitives-react/src/surfaces.tsx", + "symbol": "useInteractionHandle", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-ui-primitives-react", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "hand", + "role": "Hand composition", + "knowledge": "Canvas 도구·Object Editing·Web·Affordance", + "decision": "transient preview와 text draft를 Intent에 연결", + "changeReason": "Canvas 입력 조합 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "useCanvasHand의 public 계약", + "currentOwner": "@interactive-os/json-document-canvas", + "canonicalEvidence": "packages/json-document-canvas/src/use-canvas-hand.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-canvas/src/use-canvas-hand.ts", + "symbol": "useCanvasHand", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-canvas", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "demo", + "role": "Host composition", + "knowledge": "빈 fixture·제품 값·레이아웃", + "decision": "정본 CanvasHand 조합", + "changeReason": "제품 fixture와 화면 구성 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "fixture·public API 입력 → 화면 조합", + "currentOwner": "Canvas Demo Host", + "canonicalEvidence": "site/src/routes/canvas-demo/CanvasDemoRoute.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "site/src/routes/canvas-demo/CanvasDemoRoute.tsx", + "symbol": "CanvasDemoRoute", + "disposition": "Host composition", + "intendedOwner": "Canvas Demo Host", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "widget", + "role": "Host composition", + "knowledge": "샘플 fixture·관찰 UI", + "decision": "같은 CanvasHand와 Connector 조합", + "changeReason": "샘플과 관찰 화면 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "fixture·public API 입력 → 화면 조합", + "currentOwner": "Canvas Widget Host", + "canonicalEvidence": "site/src/routes/widgets/CanvasWidgetRoute.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "site/src/routes/widgets/CanvasWidgetRoute.tsx", + "symbol": "CanvasWidgetRoute", + "disposition": "Host composition", + "intendedOwner": "Canvas Widget Host", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "object-demo", + "role": "Host composition", + "knowledge": "기존 Object fixture·작업 조합", + "decision": "기존 ObjectEditor 및 Web clipboard 소비 유지", + "changeReason": "Object Demo 구성 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "fixture·public API 입력 → 화면 조합", + "currentOwner": "Object Demo Host", + "canonicalEvidence": "site/src/routes/object-demo/ObjectDemoRoute.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "site/src/routes/object-demo/ObjectDemoRoute.tsx", + "symbol": "ObjectDemoRoute", + "disposition": "Host composition", + "intendedOwner": "Object Demo Host", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + } + ], + "closure": { + "owner": "@interactive-os/json-document-object-document", + "publicEntry": "packages/json-document-object-document/src/index.ts", + "referencePath": "packages/json-document-object-document/docs/api.md", + "apiPath": "/docs/api/object-document", + "usagePath": "/demo/canvas", + "usagePagePath": "/docs/api/canvas", + "sourceRegistration": "site/src/shared/demo-workbench/demo-sources.ts", + "verification": [ + "packages/json-document-object-document/tests/object-document.test.ts", + "packages/json-document-editing/tests/canvas-profile.test.ts", + "packages/json-document-canvas/tests/canvas-hand.test.tsx", + "site/tests/unit/demo-workbench.test.tsx" + ] + } + }, "calendar": { - "status": "audited-tbd", - "denominator": 10, + "status": "owner-closed", + "denominator": 12, "horizon": { "enumerators": [ + "packages/json-document-calendar-document/src/index.ts", "packages/json-document-editing/src/index.ts", "packages/json-document-calendar/src/index.ts", "site/src/shared/demo-workbench/demo-sources.ts", "site/src/app/live-demo-registry.tsx" ], - "predicate": "Calendar의 model, invariant, semantic operation, projection, Editing lifecycle, platform translation, affordance, Hand와 reusable UI 책임", + "predicate": "Calendar의 model, invariant, semantic operation, projection, Editing lifecycle, platform translation, affordance, Hand와 reusable UI 책임; 정본 선택 대상과 명시적 paste cursor, Calendar Intent binding과 generic gesture lifecycle을 구분", "excluded": "fixtures, copy, layout-only CSS, tests and generated route files" }, "occurrences": [ @@ -156,13 +433,13 @@ "changeReason": "Calendar document vocabulary changes", "stateLifecycle": "immutable document value", "inputsOutputs": "CalendarDocument and nested JSON values", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "Document Type boundary: model survives without Editing and UI", - "sourcePath": "packages/json-document-editing/src/calendar.ts", + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-model.ts", "symbol": "CalendarDocument", - "disposition": "mislocated module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "admit and name the Calendar Document Type owner before moving the public type" + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "기존 Editing type export는 호환 경로이며 새 소비자는 Document Type API를 사용" }, { "id": "calendar-invariant", @@ -172,13 +449,13 @@ "changeReason": "Calendar validity rules change", "stateLifecycle": "stateless validation", "inputsOutputs": "CalendarDocument to validation failure or success", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "Document Type boundary owns schema and invariants", - "sourcePath": "packages/json-document-editing/src/calendar-validation.ts", - "symbol": "assertCalendarDocument", - "disposition": "mislocated module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "define a stable validation contract at the admitted owner" + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-validation.ts", + "symbol": "validateCalendarDocument", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "독립 validator와 Editing 생성자의 malformed/legacy 회귀 검사" }, { "id": "calendar-occurrence-projection", @@ -188,13 +465,13 @@ "changeReason": "Calendar recurrence semantics change", "stateLifecycle": "stateless projection", "inputsOutputs": "CalendarEvent and range to CalendarOccurrence list", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "Document Type boundary owns projections independent of Editing", - "sourcePath": "packages/json-document-editing/src/calendar-occurrence.ts", + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-occurrence.ts", "symbol": "projectCalendarOccurrences", - "disposition": "mislocated module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "separate recurrence projection from occurrence editing transitions" + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "독립 projection과 Editing 호환 export가 같은 구현을 소비" }, { "id": "calendar-semantic-operation", @@ -202,15 +479,15 @@ "knowledge": "Calendar event and recurrence mutation meaning", "decision": "the semantic event update represented by a Calendar operation", "changeReason": "Calendar mutation semantics change", - "stateLifecycle": "operation value interpreted by Editing", - "inputsOutputs": "CalendarIntent event cases to document patch", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "CalendarIntent currently mixes document operations with selection and clipboard intents", - "sourcePath": "packages/json-document-editing/src/calendar.ts", - "symbol": "CalendarIntent", - "disposition": "missing canonical module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "split input-independent Calendar operations from Editing-only intents" + "stateLifecycle": "stateless document operation plan", + "inputsOutputs": "CalendarEventOperation to events, JSON Patch and affected occurrence; no Editing selection", + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-operation.ts", + "symbol": "planCalendarEventEdit", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "편집·preview·그룹 이동 및 발생분/원본 제거·visibility는 Document Type 공개 계획을 소비" }, { "id": "calendar-editor", @@ -221,28 +498,28 @@ "stateLifecycle": "stateful Editing session", "inputsOutputs": "EditingDocumentSource and Calendar Intent to EditingResult", "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "repository naming standard assigns Intent, Selection, Clipboard and History to Editing", + "canonicalEvidence": "EditingSession이 Document Type 공개 연산을 실행하며 primaryOccurrence를 기본 paste target으로 소비", "sourcePath": "packages/json-document-editing/src/calendar.ts", "symbol": "createCalendarEditor", - "disposition": "canonical API gap", + "disposition": "canonical consumer", "intendedOwner": "@interactive-os/json-document-editing", - "nextCheck": "consume the future Calendar Document Type API instead of locally owning its model and rules" + "nextCheck": "공통 Calendar Editing Grammar와 API/Hand/초기/외부 selection 경로 회귀 검사" }, { "id": "calendar-pointer-interpretation", - "role": "Affordance", - "knowledge": "input-independent pointer release gesture meaning", - "decision": "which Calendar resize or move intent a gesture means", - "changeReason": "Calendar gesture grammar changes", + "role": "Editing intent binding", + "knowledge": "Calendar interval geometry, scope and CalendarIntent", + "decision": "bind normalized release values to Calendar-specific Intent", + "changeReason": "Calendar intent mapping changes", "stateLifecycle": "pointer gesture release", "inputsOutputs": "pointer release value to Calendar intent", "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "repository naming standard assigns human manipulation grammar to Affordance", + "canonicalEvidence": "generic gesture lifecycle은 Affordance, Calendar별 Intent binding은 Editing, DOM capture/좌표는 Web", "sourcePath": "packages/json-document-editing/src/calendar-time-grid-pointer.ts", "symbol": "interpretCalendarTimeGridPointer", - "disposition": "mislocated module", - "intendedOwner": "@interactive-os/json-document-affordance", - "nextCheck": "compare all-day and month pointer interpreters before choosing one Calendar affordance module" + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "time/all-day/month의 bind/interpret가 Document Type 시간 규칙을 소비하는지 확인" }, { "id": "calendar-web-input", @@ -274,7 +551,7 @@ "symbol": "useCalendarHand", "disposition": "canonical consumer", "intendedOwner": "@interactive-os/json-document-calendar", - "nextCheck": "keep composition only after Document Type capabilities move behind a public API" + "nextCheck": "선택 회차는 editor.primaryOccurrence를 읽고 빈 슬롯 paste target만 해당 Editing revision에 묶어 보존" }, { "id": "calendar-month-grid", @@ -290,7 +567,7 @@ "symbol": "CalendarMonthGrid", "disposition": "canonical consumer", "intendedOwner": "@interactive-os/json-document-calendar", - "nextCheck": "ensure projection inputs come from the future Document Type API" + "nextCheck": "Calendar UI는 Document Type의 공개 기간/occurrence projection을 직접 소비" }, { "id": "calendar-host", @@ -306,9 +583,55 @@ "symbol": "CalendarDemoRoute", "disposition": "Host composition", "intendedOwner": "site Calendar route", - "nextCheck": "audit the runtime import closure for local model, schema, operation or projection implementations" + "nextCheck": "CalendarDemoRoute와 navigator는 공개 model/projection/Editing/Hand를 사용하며 fixture·copy·layout·제품 정책만 유지" + }, + { + "id": "calendar-selection-target", + "role": "Selection target consumption", + "knowledge": "canonical occurrence and explicit paste destination", + "decision": "derive edit targets from primaryOccurrence; keep only an explicit paste cursor", + "changeReason": "Editing selection or paste destination changes", + "stateLifecycle": "derived selection and revision-scoped paste cursor", + "inputsOutputs": "CalendarEditor primaryOccurrence to inspector/edit/delete/default paste", + "currentOwner": "@interactive-os/json-document-calendar", + "canonicalEvidence": "Calendar protocol identity and direct API/Hand/initial/external selection regression tests", + "sourcePath": "packages/json-document-calendar/src/use-calendar-hand.ts", + "symbol": "useCalendarHand", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar", + "nextCheck": "같은 회차를 수정하고 explicit paste target은 상태 변경 후 재사용하지 않음" + }, + { + "id": "calendar-gesture-lifecycle", + "role": "Affordance", + "knowledge": "input-independent gesture lifecycle", + "decision": "begin/preview/commit/cancel without Calendar model or DOM capture", + "changeReason": "generic gesture lifecycle changes", + "stateLifecycle": "GestureSession", + "inputsOutputs": "generic data to preview or committed/cancelled result", + "currentOwner": "@interactive-os/json-document-affordance", + "canonicalEvidence": "Calendar useCalendarPointerInteractions consumes public createGestureSession", + "sourcePath": "packages/json-document-affordance/src/gesture-session.ts", + "symbol": "createGestureSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-affordance", + "nextCheck": "Calendar protocol tests preserve cancellation and one Editing transaction" } - ] + ], + "closure": { + "owner": "@interactive-os/json-document-calendar-document", + "publicEntry": "packages/json-document-calendar-document/src/index.ts", + "referencePath": "packages/json-document-calendar-document/docs/api.md", + "apiPath": "/docs/api/calendar-document", + "usagePath": "/demo/calendar", + "usagePagePath": "/editors#calendar-editor", + "sourceRegistration": "site/src/shared/demo-workbench/demo-sources.ts", + "verification": [ + "packages/json-document-calendar-document/tests/calendar-document.test.ts", + "packages/json-document-editing/tests/conformance/calendar-grammar.test.ts", + "packages/json-document-calendar/tests/calendar-protocol.test.tsx" + ] + } } } } diff --git a/docs/README.md b/docs/README.md index bf5b97316..783de0c01 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,192 +1,118 @@ # 문서 구조 -이 디렉터리는 외부 사용자에게 공개할 문서 원천만 보관한다. -릴리스 과정, 검토 루프, 과거 판단 기록은 Git issue와 version history에 남기고 -현재 문서 트리에는 복제하지 않는다. +이 디렉터리는 외부 사용자에게 공개할 문서 원천과 생성된 owner API reference를 +보관한다. 릴리스 과정·검토 루프·과거 판단은 Git issue와 version history에 남긴다. -```txt +```text docs -|-- changelog.md # 사용자 영향 중심 변경 기록 -|-- evaluate.mjs # 공개 문서 구조·내용 검증 -|-- public-contract-checks.mjs # 문서 원천·Pages 산출물·live 응답의 공통 공개 계약 검증 -`-- public -| |-- overview.md # JSON Document: Why / How / What -| |-- api.md # JSON Document: 레퍼런스 -| |-- concepts.md # Core에서 Artifact까지의 책임·의존 지도 -| |-- selection.md # Editing: 구조 선택 -| |-- history.md # Editing: 로컬 undo/redo -| |-- clipboard.md # Editing: 구조화된 payload -| |-- topology.md # Editing: 화면 줄과 선택 -| |-- intent.md # Editing: Intent 시그니처 -| |-- intent-guide.md # Editing: Intent 따라 하기 -| |-- official-hands.md # Hands TBD: 완성된 기본 SDK와 확장 경계 -| |-- collaboration.md # JSON Document: 같은 계약의 협업 구현 -| |-- hands.md # Hands: 사람의 편집 도구 -| |-- composer.md # Hands: agent 지시와 구조화된 맥락 -| |-- mention.md # Hands: 안정 entity reference atom -| |-- order.md # Hands: 한 줄 목록 -| |-- object.md # Hands: 키 선택 객체 -| |-- tree.md # Hands: 보이는 나무 -| |-- database.md # Hands: 저장된 표 view -| |-- adapters.md # Adapters: 공식 플랫폼 변환 -| |-- adapter-keyboard.md # Adapter: Keyboard / Press / ARIA -| |-- adapter-grid-cell.md # Adapter: GridPoint / DOM cell address -| |-- adapter-interaction.md # Adapter: Pointer / Drag and Drop session -| |-- adapter-clipboard.md # Adapter: ClipboardEvent -| |-- adapter-contenteditable.md # Adapter: native-input DOM -| |-- connectors.md # Connectors: 공식 라이브러리 생태계 연결 -| |-- connector-react.md # Connector: React 구독 -| |-- connector-react-hook-form.md # Connector: form draft와 commit -| |-- connector-ajv.md # Connector: Ajv validation -| |-- connector-zod.md # Connector: Zod database 변환 -| |-- connector-zod-validate.md # Connector: Zod validation -| |-- connector-tanstack-table.md # Connector: visible table topology -| |-- react-editing.md # Connectors: React 선택·커서 질의 -| `-- llms.txt # machine-readable 공개 문서 +├─ public/ # 한국어 개념·계약·사용법, llms.txt +├─ api-reference/ # owner package별 생성 reference와 등록표 +├─ changelog.md # 사용자 영향 중심 변경 기록 +├─ evaluate.mjs # 문서·등록·증거 연결 검사 +└─ public-contract-checks.mjs # 원천·Pages·live의 공통 공개 계약 검사 ``` -사이트의 문서 탐색은 공개 컨셉 트리를 사용한다. 파일은 `public/`의 평평한 책임 -폴더에 유지하고, 별도 중첩 폴더를 개념으로 추가하지 않는다. - -```txt -JSON Document -|-- Why -|-- Concept Map -`-- API - -Editing -|-- Intent guide -|-- Intent -|-- Topology -|-- Selection -|-- Clipboard -`-- History - -Adapter -|-- Overview -|-- Keyboard -|-- Clipboard -`-- Contenteditable - -Connector -|-- Overview -|-- React -| `-- React editing -|-- React Hook Form -|-- Ajv -|-- Zod -| `-- Validate -`-- TanStack Table - -Affordance -|-- Focus -|-- Caret -|-- Select -|-- Typeahead -|-- Activate -|-- Escape -|-- Expand/Collapse -|-- Undo -|-- Delete -|-- Rename -|-- Nudge -|-- Hover -|-- Double-click -|-- Triple-click -|-- Context menu -|-- Drag -|-- Marquee -|-- Drop -|-- Duplicate -|-- Resize -|-- Pan -|-- Scroll -|-- Zoom -|-- Snap -`-- Not-allowed - +## 사이트의 읽기 구조 + +탐색 섹션은 `site/src/app/site-layers.ts`, 페이지 제목·URL·문서 원천의 연결은 +`site/site-routes.json`의 `documentSource`가 소유한다. `doc-pages.ts`는 그 원천을 +읽고, Markdown 링크도 같은 등록표에서 사이트 URL을 찾는다. 별도 파일명→URL +카탈로그를 유지하지 않는다. 아래는 개념 수준의 지도이며 leaf 페이지 목록을 +복제한 탐색 정본이 아니다. + +```text +Introduction +├─ Why +├─ Concept Map +└─ How We Build +Foundation +├─ Overview +├─ JSON Document Protocol +├─ Document Types · TBD +│ └─ 후보별 관찰된 schema·목표 owner·완료 증거 +├─ Editing Protocol +│ └─ Intent · Topology · Selection · Clipboard · History +└─ Collaboration + └─ Replica · Lifecycle · History · Text +Building Blocks +├─ Overview +├─ Adapter +├─ Connector +├─ Affordance +└─ UI Primitives Hands -|-- Overview -|-- Official Hands (TBD) -|-- Order -|-- Object -|-- Tree -|-- Database -|-- Composer -`-- Mention - ----------------------------------------- - -Collaboration -|-- Replica -|-- Lifecycle -|-- Collaborative History -`-- Text - `-- native-input DOM lease +└─ 현재 Usage와 Official Hands Profile · TBD +Artifact +└─ Content Prototype · TBD +Applications +└─ 제품 조합과 제품에서 발견한 책임 ``` +읽기 순서는 필수 package dependency chain이 아니다. Collaboration은 같은 +JSONDocument의 대체 구현이고 Adapter와 Connector는 독립적으로 선택한다. +API reference는 각 owner package의 위치에 유지한다. 탐색 분류는 owner의 +책임 종류나 새 runtime 계층이 아니다. Core 안내를 전체 package catalog처럼 +별도의 Reference 섹션에 중복 노출하지 않는다. + ## 규범 우선순위 Repository 전체의 개념과 이름 정본은 `standards/repository-naming.md`, package 내부 책임 배치 정본은 `standards/repository-implementation-shape.md`, browser event부터 model -reconciliation까지의 DOM 편집 정본은 `standards/dom-editing-lifecycle.md`입니다. +reconciliation까지의 DOM 편집 정본은 `standards/dom-editing-lifecycle.md`다. 현재 v3 portable root의 compatibility 정본은 `standards/json-document-v3/profile.md`, -`standards/json-document-v3/public-surface.json`, 그리고 profile이 -지정한 conformance vector와 language binding입니다. 이름 정본은 stable v3 -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`에서 -읽습니다. 각 evaluator의 파일·HTTP 읽기와 재시도 정책은 그대로 유지합니다. - -## 책임 기준 - -| 위치 | 책임 | 독자 | -| --- | --- | --- | -| `changelog.md` | 사용자 영향 중심 변경 기록 | 외부 사용자, 릴리스 확인자 | -| `public/` | 사용법과 프로젝트 이해를 위한 공식 문서 원천 | 외부 사용자, LLM, 사이트 방문자 | +`standards/json-document-v3/public-surface.json`, 지정된 conformance vector와 +language binding이다. 이름 정본은 Stable v3 identifier나 동작을 바꾸지 않는다. + +EditingSession의 확정된 공통 의미는 `standards/editing-session.md`가 소유한다. +ES 규칙과 현재 TypeScript binding·local History 정책은 구별하며 각 규칙을 +owner의 행동 테스트에 연결한다. 이 확정은 전체 Hands의 Stable 선언이 아니다. + +편집 문법의 안정화 설계는 `standards/editing-grammar.md`의 Design Draft다. +공통 규칙·Profile 선택·입력 owner·적합성 증거를 연결하지만 기존 Stable +계약의 권위를 변경하지 않는다. API reference와 Usage를 대체하지 않는다. + +## 현재 계약과 TBD + +- Core v3 Stable, EditingSession 공통 의미, 개별 package API의 범위를 구분한다. +- Document Type은 책임 이름과 경계가 정해졌더라도 후보별 owner 수렴이 남으면 + `TBD`를 유지한다. schema나 API가 존재한다는 이유만으로 완료 처리하지 않는다. +- Official Hands는 지원 입력·실패·선택 복원·History와 조합 적합성의 미확정 + 경계 및 완료 증거를 표시한다. +- Artifact visual prototype은 시각 가설의 증거다. 실제 문서·Hands 연결이나 + 파일 호환성이 없는 경우 제목·설명에서 `TBD`로 드러낸다. +- TBD는 막연한 미래 목록이 아니라 현재 증거, 목표 책임, 남은 완료 조건을 담는다. + +## 책임과 검증 + +| 원천 | 책임 | +| --- | --- | +| `public/` | 외부 사용자와 사이트 방문자의 개념·계약·Usage | +| `public/llms.txt` | 같은 목표와 현재 계약을 요약한 기계 판독 문서 | +| `api-reference/packages.mjs` | owner package의 source entrypoint와 사이트 탐색 분류 | +| `api-reference/*.md` | package root와 공개 subpath에서 생성한 API reference | +| `changelog.md` | 사용자 영향 중심 변경 기록 | + +`package.json#exports`의 TypeScript 진입점과 API 등록을 비교해 subpath 누락을 +검출한다. `docs:evaluate`는 원천 등록과 상대 파일 링크, ES 규칙의 행동 증거 +연결을 확인한다. API 생성 검사는 등록된 파일의 self-consistency만으로 끝내지 않는다. + +문서의 목차는 실제 렌더러가 만든 heading과 ID에서 읽는다. site 테스트는 실제 +본문 링크와 목차 대상의 존재, 목표 탐색·제목·TBD 경로를 검증한다. +문자열이나 링크의 존재는 의미 적합성을 대신하지 않으며 package/browser 검증을 +함께 봐야 한다. + +문서 원천·Pages·live의 공통 계약은 `public-contract-checks.mjs`가 소유한다. +Root symbol 수는 Core `public-contract.json`에서 읽는다. ## 작성 원칙 -- 본문은 한글로 쓴다. -- 코드 식별자, 명령어, 파일 경로, 표준명은 원문을 유지한다. -- public 문서는 usage와 프로젝트 이해만 다룬다. -- 페이지마다 할 일 하나만 쓴다. overview는 Why/How/What 배경, - api는 시그니처 레퍼런스, selection은 구조 - 선택, history는 로컬 undo/redo, clipboard는 구조화된 payload, - topology는 화면 줄과 선택, intent는 편집 Intent 시그니처, - intent-guide는 Intent 따라 하기, adapters는 플랫폼 변환, - connectors는 연결 방법이다. react-editing은 React에서 선택과 커서를 - 그리는 사용법이다. - concepts는 JSON Document에서 Artifact까지의 책임과 의존 지도다. - official-hands는 디자인과 제품 데이터는 열어 두고 수렴된 편집 기능을 - 완성된 SDK로 제공하는 TBD 관점이다. - collaboration은 같은 JSON Document의 다른 구현이다. hands는 - Editing 위 장르의 손이다. order·object·tree는 그 손의 - 나머지 slice다. database는 저장된 표 view의 손이다. - composer·mention은 Rich Text와 구조화된 context로 구현한 손이다. App 이름과 - 출력 표현은 Hands로 올리지 않는다. -- 배경 문서는 왜 만들었는지부터 쓴다. 컨셉 페이지는 그 아이디어를 - 한 문서 위에서 만져보게 한다. 레퍼런스는 호출과 계약부터 쓴다. -- 새 개념은 독자가 그 개념을 필요로 하는 상황을 본 뒤에 이름 붙인다. -- 초안을 쓴 뒤 선언 전 사용, 선제 부정, 메타 안내와 중복을 제거하고 - 앞뒤 페이지를 이어 읽는다. -- 무엇을 하는지로 정의한다. 소유하지 않는 것의 목록으로 시작하지 않는다. -- 같은 아키텍처 다이어그램과 패키지 카탈로그를 페이지마다 복제하지 않는다. -- 이름·구현 모양 정본과 profile은 `standards/`에 두고 public 가이드에서 인용하지 - 않는다. -- 릴리스 history, 검토 loop, maintainer-only gate는 public 문서에 쓰지 않는다. -- 내부 구현 경로는 public 문서에 쓰지 않는다. -- 새 문서는 기존 책임 폴더 중 하나에 들어가야 한다. -- 새 책임 폴더가 필요하면 먼저 이 파일의 책임 표를 갱신한다. -- 새 public concept와 이름은 - `standards/repository-naming.md`의 admission과 문법을 먼저 - 통과해야 한다. +- 본문은 한글로 쓰고 코드 식별자·명령어·경로·표준명은 원문을 유지한다. +- 페이지마다 책임 하나를 설명하고 인접 owner의 설명과 시그니처는 연결한다. +- 새로운 개념은 책임과 필요성이 드러난 뒤 이름 붙인다. +- 같은 아키텍처 다이어그램과 package 목록을 여러 문서에 복제하지 않는다. +- 표준·명명·구현 배치 정본은 `standards/`에 두며 public 가이드는 사용자 계약과 + 사용법을 설명한다. 내부 경로·검토 루프·maintainer-only gate를 본문에 노출하지 않는다. +- 재사용 모델·연산·입력 해석·projection·UI를 Host 책임으로 설명하지 않는다. + Host는 조합·실행 순서·제품 정책 값·copy·fixture·layout·구체 인스턴스를 소유한다. +- 새 public 개념과 이름은 정본 naming admission을 먼저 통과해야 한다. diff --git a/docs/api-reference/a2ui.md b/docs/api-reference/a2ui.md index c3b66a660..b7a753a8a 100644 --- a/docs/api-reference/a2ui.md +++ b/docs/api-reference/a2ui.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-a2ui API -**Owner:** Connector +**탐색 분류:** Connector -A2UI streaming document connector의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +A2UI streaming document connector의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-a2ui/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/affordance.md b/docs/api-reference/affordance.md index 370e11f01..ac1aa6da8 100644 --- a/docs/api-reference/affordance.md +++ b/docs/api-reference/affordance.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-affordance API -**Owner:** Affordance +**탐색 분류:** Affordance -입력 문법과 interaction session의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +입력 문법과 interaction session의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-affordance/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -363,6 +363,11 @@ createInteractionHandleSession(): InteractionHandleSession ```ts createLineFocusSession(options: { readonly initialKey?: Key | null; readonly onFocus: (key: Key | null) => void; readonly wrap?: boolean; }): LineFocusSession ``` +## `createPlaneSelectProfile` + +```ts +createPlaneSelectProfile(options?: PlaneSelectProfileOptions): PlaneSelectProfile +``` ## `createRenameSession` ```ts @@ -381,7 +386,7 @@ createViewportPositionSession(options: ViewportPositionOptions): Viewp ## `deleteAffordance` ```ts -deleteAffordance(input: { readonly key?: string; }): AffordancePreview +deleteAffordance(input: Partial): AffordancePreview ``` ## `disclosureAffordance` @@ -690,6 +695,99 @@ panAffordance(input: { readonly spaceKey?: boolean; readonly buttons?: number; r ```ts planeHitAffordance(input: { readonly hitId: string; readonly selectedIds: ReadonlyArray; readonly shiftKey?: boolean; readonly metaKey?: boolean; readonly ctrlKey?: boolean; readonly nestedId?: string; readonly locked?: boolean; }): AffordancePreview ``` +## `PlaneSelectCommit` + +```ts +interface PlaneSelectCommit { + readonly selection: PlaneSelectSelection; + readonly translation: PlaneSelectTranslation | null; +} +``` +## `PlaneSelectContext` + +```ts +interface PlaneSelectContext { + readonly items: ReadonlyArray; + readonly selection: PlaneSelectSelection; +} +``` +## `PlaneSelectInput` + +```ts +interface PlaneSelectInput extends PlaneSelectModifiers { + readonly point: Point; + readonly hitKey: string | null; +} +``` +## `PlaneSelectKeyResult` + +```ts +type PlaneSelectKeyResult = + | { readonly type: "selection"; readonly selection: PlaneSelectSelection } + | { readonly type: "delete"; readonly keys: readonly string[] } + | { readonly type: "duplicate"; readonly keys: readonly string[] } + | { readonly type: "translate"; readonly keys: readonly string[]; readonly dx: number; readonly dy: number } + | { readonly type: "edit"; readonly key: string } + | { readonly type: "cancel" }; +``` +## `PlaneSelectModifiers` + +```ts +interface PlaneSelectModifiers { + readonly shiftKey?: boolean; + readonly altKey?: boolean; +} +``` +## `PlaneSelectPreview` + +```ts +interface PlaneSelectPreview { + readonly selection: PlaneSelectSelection; + readonly marquee: Rect | null; + readonly translation: PlaneSelectTranslation | null; +} +``` +## `PlaneSelectProfile` + +```ts +interface PlaneSelectProfile { + begin(context: PlaneSelectContext, input: PlaneSelectInput): PlaneSelectPreview; + preview(point: Point, modifiers?: PlaneSelectModifiers): PlaneSelectPreview | null; + commit(point: Point, modifiers?: PlaneSelectModifiers): PlaneSelectCommit | null; + /** Reproject a stationary drag when a modifier changes; omitted preview modifiers retain this state. */ + updateModifiers(modifiers: PlaneSelectModifiers): PlaneSelectPreview | null; + cancel(reason?: GestureCancelReason): void; + getPreview(): PlaneSelectPreview | null; + /** Discrete activation (e.g. Space), not focus. Shift toggles, plain activation replaces. */ + select(context: PlaneSelectContext, key: string | null, shiftKey?: boolean): PlaneSelectSelection; + /** Native editable/IME ownership is checked by the platform binding before calling. */ + keyDown(stroke: WebKeyboardStroke, context: PlaneSelectContext, grabbing?: boolean): PlaneSelectKeyResult | null; +} +``` +## `PlaneSelectProfileOptions` + +```ts +interface PlaneSelectProfileOptions { + /** In the input coordinate space. Defaults to 3; movement is latched once crossed. */ + readonly dragThreshold?: number; + readonly contain?: "intersect" | "inside"; +} +``` +## `PlaneSelectSelection` + +```ts +type PlaneSelectSelection = Extract; +``` +## `PlaneSelectTranslation` + +```ts +interface PlaneSelectTranslation { + readonly operation: "move" | "copy"; + readonly keys: readonly string[]; + readonly dx: number; + readonly dy: number; +} +``` ## `Point` ```ts @@ -757,7 +855,7 @@ interface RenameSessionSnapshot { ## `resizeAffordance` ```ts -resizeAffordance(origin: Point, point: Point, edge: ResizeEdge, modifiers?: { readonly shiftKey?: boolean; readonly altKey?: boolean; }): AffordancePreview +resizeAffordance(origin: Point, point: Point, edge: ResizeEdge, modifiers?: { readonly shiftKey?: boolean; readonly altKey?: boolean; }, size?: Pick): AffordancePreview ``` ## `ResizeEdge` @@ -781,7 +879,7 @@ resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePreview ## `selectAllAffordance` ```ts -selectAllAffordance(stroke: Pick, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview +selectAllAffordance(stroke: Pick & Partial>, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview ``` ## `SelectOperation` diff --git a/docs/api-reference/ajv.md b/docs/api-reference/ajv.md index 5d288f97f..2a4af2713 100644 --- a/docs/api-reference/ajv.md +++ b/docs/api-reference/ajv.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-ajv API -**Owner:** Connector +**탐색 분류:** Connector -Ajv validation connector의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Ajv validation connector의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-ajv/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/animation-react.md b/docs/api-reference/animation-react.md index eb0951e2a..5073bcd16 100644 --- a/docs/api-reference/animation-react.md +++ b/docs/api-reference/animation-react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-animation-react API -**Owner:** UI Primitives +**탐색 분류:** UI Primitives -생성 대기 시각 언어의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +생성 대기 시각 언어의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-animation-react/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/annotation.md b/docs/api-reference/annotation.md new file mode 100644 index 000000000..4e8cfed6c --- /dev/null +++ b/docs/api-reference/annotation.md @@ -0,0 +1,99 @@ +# @interactive-os/json-document-annotation API + +**탐색 분류:** Hands + +Annotation interaction과 SVG projection의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-annotation/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `AnnotationHand` + +```ts +AnnotationHand(props: AnnotationHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` +## `AnnotationHandClassNames` + +```ts +interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} +``` +## `AnnotationHandLabels` + +```ts +interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} +``` +## `AnnotationHandProps` + +```ts +interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} +``` +## `AnnotationOutput` + +```ts +interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} +``` +## `AnnotationOutputOptions` + +```ts +interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +``` +## `AnnotationTool` + +```ts +type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +``` +## `annotationTools` + +```ts +const annotationTools: readonly [{ readonly id: "select"; readonly label: "Select"; readonly shortcut: "V"; readonly icon: ForwardRefExoticComponent & RefAttributes>; }, ... 4 more ..., { ...; }] +``` +## `useAnnotationOutput` + +```ts +useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput +``` diff --git a/docs/api-reference/calendar-document.md b/docs/api-reference/calendar-document.md new file mode 100644 index 000000000..08fad9e21 --- /dev/null +++ b/docs/api-reference/calendar-document.md @@ -0,0 +1,359 @@ +# @interactive-os/json-document-calendar-document API + +**탐색 분류:** Document Types + +Calendar 문서 모델·검증·의미 연산·projection 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-calendar-document/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `addCalendarDate` + +```ts +addCalendarDate(day: string, days: number): string | null +``` +## `assertCalendarDocument` + +```ts +assertCalendarDocument(value: unknown): void +``` +## `calendarAllDayLayout` + +```ts +calendarAllDayLayout(events: ReadonlyArray, days: ReadonlyArray): ReadonlyArray<{ readonly event: CalendarEvent; readonly startIndex: number; readonly span: number; readonly lane: number; readonly laneCount: number; }> +``` +## `calendarAllDaySpan` + +```ts +calendarAllDaySpan(originDay: string, targetDay: string): { readonly start: string; readonly end: string; } | null +``` +## `calendarBusyDates` + +```ts +calendarBusyDates(events: ReadonlyArray, rangeStart: string, rangeEnd: string): ReadonlySet +``` +## `CalendarCalendar` + +```ts +interface CalendarCalendar extends Record { + readonly id: string; + readonly title: string; + readonly hidden: boolean; + readonly color: string; +} +``` +## `calendarDatePart` + +```ts +calendarDatePart(value: string): string +``` +## `calendarDaysBetween` + +```ts +calendarDaysBetween(from: Temporal.PlainDate, to: Temporal.PlainDate): number +``` +## `CalendarDocument` + +```ts +interface CalendarDocument extends Record { + readonly calendars: ReadonlyArray; + readonly events: ReadonlyArray; +} +``` +## `calendarDocumentCalendar` + +```ts +calendarDocumentCalendar(document: CalendarDocument, calendarId: string): CalendarCalendar | null +``` +## `calendarDocumentCalendars` + +```ts +calendarDocumentCalendars(document: CalendarDocument): ReadonlyArray +``` +## `calendarDocumentEvents` + +```ts +calendarDocumentEvents(document: CalendarDocument): ReadonlyArray +``` +## `CalendarEvent` + +```ts +interface CalendarEvent extends Record { + readonly id: string; + readonly title: string; + readonly start: string; + readonly end: string; + readonly allDay: boolean; + readonly calendarId: string; + readonly recurrence: CalendarRecurrence | null; + readonly excludeDates: ReadonlyArray; +} +``` +## `calendarEventBounds` + +```ts +calendarEventBounds(event: Pick): { readonly from: Temporal.PlainDateTime; readonly to: Temporal.PlainDateTime; } | null +``` +## `calendarEventExcludeDates` + +```ts +calendarEventExcludeDates(event: CalendarEvent): ReadonlyArray +``` +## `calendarEventIntervalAt` + +```ts +calendarEventIntervalAt(event: Pick, start: string): { readonly start: string; readonly end: string; } | null +``` +## `CalendarEventOperation` + +```ts +type CalendarEventOperation = + | { + readonly type: "event.create"; + readonly start: string; + readonly end: string; + readonly title?: string; + readonly allDay?: boolean; + readonly calendarId?: string; + readonly recurrence?: CalendarRecurrence | null; + } + | { readonly type: "event.move"; readonly eventId: string; readonly start: string } + | { readonly type: "event.resize"; readonly eventId: string; readonly edge: "start" | "end"; readonly instant: string } + | { readonly type: "event.move-day"; readonly eventId: string; readonly day: string } + | { + readonly type: "event.update"; + readonly eventId: string; + readonly title?: string; + readonly start?: string; + readonly end?: string; + readonly allDay?: boolean; + readonly calendarId?: string; + readonly recurrence?: CalendarRecurrence | null; + } + | { + readonly type: "occurrence.edit"; + readonly eventId: string; + readonly occurrenceStart: string; + readonly scope: "this" | "this-and-following" | "all"; + readonly title?: string; + readonly start?: string; + readonly end?: string; + }; +``` +## `CalendarEventPlan` + +```ts +type CalendarEventPlan = { + readonly ok: true; + readonly events: ReadonlyArray; + readonly operations: ReadonlyArray; + readonly affectedOccurrence: CalendarOccurrencePoint; +} | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `calendarEventRecurrence` + +```ts +calendarEventRecurrence(event: CalendarEvent): CalendarRecurrence | null +``` +## `calendarEventsInMonth` + +```ts +calendarEventsInMonth(events: ReadonlyArray, month: string): ReadonlyArray +``` +## `calendarEventsOnDay` + +```ts +calendarEventsOnDay(events: ReadonlyArray, day: string): ReadonlyArray +``` +## `CalendarEventsPlan` + +```ts +type CalendarEventsPlan = (Extract & { readonly events: ReadonlyArray }) + | Extract; +``` +## `calendarInstantAt` + +```ts +calendarInstantAt(day: string, minutesFromMidnight: number): string | null +``` +## `calendarIntervalLastDate` + +```ts +calendarIntervalLastDate(start: string, end: string, allDay: boolean): string +``` +## `calendarMinutesBetween` + +```ts +calendarMinutesBetween(from: Temporal.PlainDateTime, to: Temporal.PlainDateTime): number +``` +## `calendarMonthDayLayout` + +```ts +calendarMonthDayLayout(events: ReadonlyArray, day: string, rowLimit: number): { readonly events: ReadonlyArray; readonly hiddenCount: number; } +``` +## `calendarMonthWeekLayout` + +```ts +calendarMonthWeekLayout(events: ReadonlyArray, days: ReadonlyArray, rowLimit: number): { readonly items: ReadonlyArray<{ readonly event: CalendarEvent; readonly startIndex: number; readonly span: number; readonly lane: number; }>; readonly hiddenCounts: ReadonlyArray; readonly laneCount: number; } +``` +## `calendarNowMarker` + +```ts +calendarNowMarker(nowInstant: string, day: string): { readonly minutes: number; } | null +``` +## `CalendarOccurrence` + +```ts +type CalendarOccurrence = { + readonly event: CalendarEvent; + readonly start: string; + readonly end: string; +}; +``` +## `CalendarOccurrenceInterval` + +```ts +interface CalendarOccurrenceInterval { + readonly eventId: string; + readonly start: string; + readonly end: string; +} +``` +## `CalendarOccurrencePoint` + +```ts +interface CalendarOccurrencePoint extends Record { + readonly eventId: string; + readonly occurrenceStart: string; +} +``` +## `CalendarOccurrenceRemoval` + +```ts +type CalendarOccurrenceRemoval = { + readonly eventId: string; + readonly occurrenceStart: string; + readonly scope: "this" | "this-and-following" | "all"; +}; +``` +## `CalendarPatchPlan` + +```ts +type CalendarPatchPlan = { readonly ok: true; readonly operations: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `CalendarRecurrence` + +```ts +interface CalendarRecurrence extends Record { + readonly freq: "daily" | "weekly" | "monthly" | "yearly"; + readonly interval: number; + readonly until: string; +} +``` +## `calendarRecurrenceWithFrequency` + +```ts +calendarRecurrenceWithFrequency(current: CalendarRecurrence | null, value: unknown): CalendarRecurrence | null +``` +## `calendarRecurrenceWithInterval` + +```ts +calendarRecurrenceWithInterval(current: CalendarRecurrence | null, value: unknown): CalendarRecurrence | null +``` +## `calendarRecurrenceWithUntil` + +```ts +calendarRecurrenceWithUntil(current: CalendarRecurrence | null, until: string): CalendarRecurrence | null +``` +## `calendarShiftInstant` + +```ts +calendarShiftInstant(instant: string, minutes: number): string | null +``` +## `calendarTimedLayout` + +```ts +calendarTimedLayout(events: ReadonlyArray, day: string): ReadonlyArray<{ readonly event: CalendarEvent; readonly startMinutes: number; readonly endMinutes: number; readonly lane: number; readonly laneCount: number; }> +``` +## `CalendarValidationResult` + +```ts +type CalendarValidationResult = { readonly ok: true } | { + readonly ok: false; readonly code: string; readonly reason: string; +}; +``` +## `calendarVisibleEvents` + +```ts +calendarVisibleEvents(document: CalendarDocument): ReadonlyArray +``` +## `formatCalendarDate` + +```ts +formatCalendarDate(value: Temporal.PlainDate): string +``` +## `formatCalendarInstant` + +```ts +formatCalendarInstant(value: Temporal.PlainDateTime): string +``` +## `isCalendarAllDay` + +```ts +isCalendarAllDay(event: Pick): boolean +``` +## `isCalendarRecurrence` + +```ts +isCalendarRecurrence(value: unknown): value is CalendarRecurrence +``` +## `parseCalendarDate` + +```ts +parseCalendarDate(value: string): Temporal.PlainDate | null +``` +## `parseCalendarInstant` + +```ts +parseCalendarInstant(value: string): Temporal.PlainDateTime | null +``` +## `planCalendarEventEdit` + +```ts +planCalendarEventEdit(events: ReadonlyArray, intent: CalendarEventOperation, options: { readonly allocateId: () => string; readonly calendarIds?: ReadonlySet; readonly defaultCalendarId?: string; }): CalendarEventPlan +``` +## `planCalendarEventRemoval` + +```ts +planCalendarEventRemoval(events: ReadonlyArray, eventIds: ReadonlyArray): CalendarEventsPlan +``` +## `planCalendarOccurrenceRemoval` + +```ts +planCalendarOccurrenceRemoval(events: ReadonlyArray, removal: CalendarOccurrenceRemoval): CalendarEventsPlan +``` +## `planCalendarVisibility` + +```ts +planCalendarVisibility(document: CalendarDocument, calendarId: string, hidden: boolean): CalendarPatchPlan +``` +## `projectCalendarOccurrences` + +```ts +projectCalendarOccurrences(events: ReadonlyArray, rangeStart: string, rangeEnd: string): ReadonlyArray +``` +## `resolveCalendarOccurrence` + +```ts +resolveCalendarOccurrence(events: ReadonlyArray, point: CalendarOccurrencePoint): CalendarOccurrenceInterval | null +``` +## `validateCalendarDocument` + +```ts +validateCalendarDocument(value: unknown): CalendarValidationResult +``` +## `validateCalendarEvent` + +```ts +validateCalendarEvent(value: unknown, calendarIds?: ReadonlySet): CalendarValidationResult +``` diff --git a/docs/api-reference/calendar.md b/docs/api-reference/calendar.md index ddd6773cb..af3f9cd6a 100644 --- a/docs/api-reference/calendar.md +++ b/docs/api-reference/calendar.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-calendar API -**Owner:** Hands +**탐색 분류:** Hands -Calendar React lifecycle와 occurrence interaction 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Calendar React lifecycle와 occurrence interaction 계약 ([시간·반복·거절 계약: Editing의 Calendar protocol profile](/docs/api/editing#calendar-protocol-profile-rc))의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-calendar/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -216,7 +216,7 @@ interface CalendarHand { undo(): void; redo(): void; copy(): CalendarClipboard | null; - cut(): EditingResult | null; + cut(clipboard?: CalendarClipboard): EditingResult | null; paste(clipboard: CalendarClipboard): EditingResult; } ``` @@ -226,6 +226,7 @@ interface CalendarHand { type CalendarHandOptions = { readonly initialOccurrence?: CalendarOccurrenceRange; readonly defaultTitle?: string; + readonly onResult?: (result: EditingResult) => void; }; ``` ## `CalendarKeyboardOptions` @@ -348,6 +349,8 @@ type CalendarPeriod = "day" | CalendarGrain; ```ts interface CalendarPointerInteractions { + /** Bind to one Calendar surface; canonical grids attach it automatically. */ + readonly rootRef: RefObject; readonly hoveredTime: { readonly day: string; readonly instant: string; readonly minutes: number } | null; instantAt(day: string, clientY: number, grid: Element): string | null; timePointerDown(event: PointerEvent, day: string, id: string | null, start: string | null, end: string | null, handle: CalendarTimeGridHandle | null): void; diff --git a/docs/api-reference/canvas.md b/docs/api-reference/canvas.md new file mode 100644 index 000000000..8c8d842c4 --- /dev/null +++ b/docs/api-reference/canvas.md @@ -0,0 +1,63 @@ +# @interactive-os/json-document-canvas API + +**탐색 분류:** Hands + +한 장짜리 Canvas의 입력·preview·UI 조합의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-canvas/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `CanvasClipboardPolicy` + +```ts +interface CanvasClipboardPolicy { + readonly textColor: string; + readonly fontSize: number; + readonly files?: FileAcceptancePolicy; + readonly maxImagePixels?: number; +} +``` +## `CanvasCreationStyle` + +```ts +interface CanvasCreationStyle { + readonly color: string; + readonly textColor: string; + readonly fontSize: number; + readonly strokeWidth: number; + /** Sticky-note fill; omitted hosts reuse their ordinary object fill. */ + readonly stickyNoteColor?: string; +} +``` +## `CanvasHand` + +```ts +CanvasHand(props: CanvasHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` +## `CanvasHandProps` + +```ts +interface CanvasHandProps { + readonly editor: ObjectEditor; + readonly creationStyle: CanvasCreationStyle; + readonly className?: string; + readonly slideStyle?: CSSProperties; + readonly label?: string; + /** Optional policy instance; one profile per mounted Hand. */ + readonly selectProfile?: PlaneSelectProfile; +} +``` +## `CanvasTool` + +```ts +type CanvasTool = "select" | Exclude; +``` +## `createCanvasClipboardBinding` + +```ts +createCanvasClipboardBinding(editor: ObjectEditor, policy: CanvasClipboardPolicy, options?: { readonly readRaster?: typeof readWebRasterFile; readonly onResult?: (result: { readonly ok: boolean; readonly code?: string; readonly reason?: string; }) => void; readonly onPendingChange?: (pending: boolean) => void; }): { ...; } +``` +## `useCanvasHand` + +```ts +useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile): { document: CanvasDocument; snapshot: import("/packages/json-document-editing/src/session").EditingSnapshot; ... 23 more ...; surfaceProps: { ...; }; } +``` diff --git a/docs/api-reference/collaboration.md b/docs/api-reference/collaboration.md index 42a5ce0c6..09f7b84a6 100644 --- a/docs/api-reference/collaboration.md +++ b/docs/api-reference/collaboration.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-collaboration API -**Owner:** Collaboration +**탐색 분류:** Collaboration -replica, history, text collaboration runtime의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +replica, history, text collaboration runtime의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-collaboration/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -504,6 +504,181 @@ interface HistoryStatus { ```ts restoreHistoryRuntime(input: unknown, options: CollaborationRestoreOptions): HistoryRestoreResult ``` +## `@interactive-os/json-document-collaboration/text` + +아래 API는 package root가 아닌 이 subpath에서 import합니다. +### `createTextRuntime` + +```ts +createTextRuntime(initial: unknown, options: CollaborationRuntimeOptions): TextRuntime +``` +### `History` + +```ts +interface History { + status(): HistoryStatus; + canUndo(): JSONPatchValidationResult; + undo(): HistoryResult; + canRedo(): JSONPatchValidationResult; + redo(): HistoryResult; +} +``` +### `HistoryResult` + +```ts +type HistoryResult = + | { + readonly ok: true; + readonly changeId: ChangeId; + readonly target: ChangeId; + readonly didChangeDocument: boolean; + /** This operation's applied change; null when it only changes causal history. */ + readonly change: JSONAppliedChange | null; + /** Captured before subscribers can author a later transition. */ + readonly status: HistoryStatus & { + readonly canUndo: boolean; + readonly canRedo: boolean; + }; + } + | { + readonly ok: false; + readonly code: string; + readonly reason?: string; + }; +``` +### `HistoryStatus` + +```ts +interface HistoryStatus { + readonly undoTarget: ChangeId | null; + readonly redoTarget: ChangeId | null; + readonly undoDepth: number; + readonly redoDepth: number; + readonly revision: number; +} +``` +### `restoreTextRuntime` + +```ts +restoreTextRuntime(input: unknown, options: CollaborationRestoreOptions): TextRestoreResult +``` +### `Text` + +```ts +interface Text { + capture(pointer: string): TextCaptureResult; + plan( + capture: TextCapture, + observation: TextObservation, + ): TextPlanResult; + commit( + plan: TextPlan, + options?: JSONDocumentCommitOptions, + ): TextCommitResult; +} +``` +### `TextCapture` + +```ts +interface TextCapture { + readonly pointer: string; + readonly target: MemberId; + readonly textNode: TextNodeId; + readonly value: string; +} +``` +### `TextCaptureResult` + +```ts +type TextCaptureResult = + | { + readonly ok: true; + readonly capture: TextCapture; + } + | { + readonly ok: false; + readonly code: string; + readonly reason: string; + }; +``` +### `TextCommitResult` + +```ts +type TextCommitResult = + | { + readonly ok: true; + readonly change: JSONAppliedChange; + readonly changeId: ChangeId | null; + readonly didChangeDocument: boolean; + readonly value: string; + readonly selection: TextSelection | null; + } + | { + readonly ok: false; + readonly code: string; + readonly reason: string; + }; +``` +### `TextObservation` + +```ts +interface TextObservation { + readonly value: string; + readonly selection?: TextSelection; +} +``` +### `TextPlan` + +```ts +interface TextPlan { + readonly pointer: string; + readonly value: string; + readonly selection?: TextSelection; +} +``` +### `TextPlanResult` + +```ts +type TextPlanResult = + | { + readonly ok: true; + readonly plan: TextPlan; + } + | { + readonly ok: false; + readonly code: string; + readonly reason: string; + }; +``` +### `TextRestoreResult` + +```ts +type TextRestoreResult = + | { + readonly ok: true; + readonly runtime: TextRuntime; + } + | { + readonly ok: false; + readonly code: string; + readonly reason: string; + }; +``` +### `TextRuntime` + +```ts +interface TextRuntime extends HistoryRuntime { + readonly text: Text; +} +``` +### `TextSelection` + +```ts +interface TextSelection { + readonly anchor: number; + readonly focus: number; +} +``` ## `@interactive-os/json-document-collaboration/editing` 아래 API는 package root가 아닌 이 subpath에서 import합니다. diff --git a/docs/api-reference/composer-react.md b/docs/api-reference/composer-react.md index 61f515351..b0ebeb427 100644 --- a/docs/api-reference/composer-react.md +++ b/docs/api-reference/composer-react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-composer-react API -**Owner:** Hands +**탐색 분류:** Hands -Composer React interaction과 reference projection의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Composer React interaction과 reference projection의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-composer-react/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -18,6 +18,10 @@ interface ComposerBinding["attachments"]; readonly model: Model; readonly hasContent: boolean; + readonly isPreparingAttachments: boolean; + readonly attachmentError: EditingPreparationFailure | null; + readonly canSubmit: boolean; + cancelAttachments(): void; readonly commandKind: "mention" | "skill" | null; readonly commandMenu: RichTextSuggestionBinding; readonly commandOpen: boolean; @@ -61,6 +65,8 @@ interface UseComposerOptions & { readonly suggestions: ReadonlyArray }; readonly ports: ComposerHostPorts; + readonly maxImagePixels?: number; + readonly readRaster?: typeof readWebRasterFile; readonly labels: { readonly mentionSuggestions: string; readonly skillSuggestions: string; diff --git a/docs/api-reference/composer.md b/docs/api-reference/composer.md index f182ef1a8..a93049302 100644 --- a/docs/api-reference/composer.md +++ b/docs/api-reference/composer.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-composer API -**Owner:** Hands +**탐색 분류:** Hands -Composer draft와 reference/trigger command 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Composer draft와 reference/trigger command 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-composer/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -34,18 +34,20 @@ const COMPOSER_SKILL_NODE: "os.interactive/skill" ## `ComposerAttachment` ```ts -interface ComposerAttachment extends Record { +type ComposerAttachment = Record & { readonly id: string; readonly kind: "document" | "image"; readonly name: string; readonly size: number; readonly mediaType: string | null; -} + /** Absent for metadata-only attachments. Presence retains actual embedded raster content. */ + readonly image?: RasterImageContent; +}; ``` ## `ComposerAttachmentCandidate` ```ts -type ComposerAttachmentCandidate = FileCandidate; +type ComposerAttachmentCandidate = FileCandidate & { readonly image?: RasterImageContent }; ``` ## `ComposerAttachmentPolicy` @@ -144,6 +146,7 @@ interface ComposerKeyStroke { readonly key: string; readonly shiftKey?: boolean; readonly commandKey?: boolean; + readonly altKey?: boolean; } ``` ## `ComposerReference` diff --git a/docs/api-reference/contenteditable-collaboration.md b/docs/api-reference/contenteditable-collaboration.md index 7dc1a6dac..563aa0ee3 100644 --- a/docs/api-reference/contenteditable-collaboration.md +++ b/docs/api-reference/contenteditable-collaboration.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-contenteditable-collaboration API -**Owner:** Collaboration +**탐색 분류:** Collaboration -collaborative contenteditable lease의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +collaborative contenteditable lease의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/contenteditable-collaboration/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/contenteditable.md b/docs/api-reference/contenteditable.md index bdae70d6e..221c9a7db 100644 --- a/docs/api-reference/contenteditable.md +++ b/docs/api-reference/contenteditable.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-contenteditable API -**Owner:** Adapter +**탐색 분류:** Adapter -contenteditable platform adapter의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +contenteditable platform adapter의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-contenteditable/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/database.md b/docs/api-reference/database.md index b4e8fa552..eb127e3de 100644 --- a/docs/api-reference/database.md +++ b/docs/api-reference/database.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-database API -**Owner:** Hands +**탐색 분류:** Hands -Database Hand domain 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Database 문서 모델·연산·saved-view projection의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-database/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -14,7 +14,7 @@ createDatabaseResource>(resource: ## `createDatabaseView` ```ts -createDatabaseView(id: string, name: string, propertyIds: ReadonlyArray, ownership?: DatabaseViewDocument["ownership"]): DatabaseViewDocument +createDatabaseView(id: string, name: string, propertyIds: ReadonlyArray, ownership?: DatabaseTableView["ownership"]): DatabaseTableView ``` ## `Database` @@ -37,12 +37,7 @@ interface DatabaseCapabilities { ## `DatabaseColumnProjection` ```ts -interface DatabaseColumnProjection { - readonly propertyId: string; - readonly visible: boolean; - readonly width?: number; - readonly pinned?: "start" | "end"; -} +interface DatabaseColumnProjection extends Record { readonly propertyId: string; readonly visible: boolean; readonly width: number | null; readonly pinned: "start" | "end" | null; } ``` ## `DatabaseContextValue` @@ -52,15 +47,15 @@ interface DatabaseContextValue { readonly rows: ReadonlyArray; readonly total: number; readonly nextCursor?: string; - readonly view: DatabaseViewDocument; - readonly views: ReadonlyArray; + readonly view: DatabaseTableView; + readonly views: ReadonlyArray; readonly status: DatabaseStatus; readonly capabilities: Required; readonly selectedRowIds: ReadonlyArray; readonly activeRow: Row | null; readonly isCreating: boolean; - setView(view: DatabaseViewDocument): void; - saveView(view: DatabaseViewDocument): Promise; + setView(view: DatabaseTableView): void; + saveView(view: DatabaseTableView): Promise; selectRows(ids: ReadonlyArray): void; openRow(row: Row | null): void; startCreate(): void; @@ -86,45 +81,25 @@ interface DatabaseDeleteResult { ```ts type DatabaseFailureKind = "network" | "validation" | "conflict" | "partial" | "unknown"; ``` -## `DatabaseFilterGroup` +## `DatabaseFilter` ```ts -interface DatabaseFilterGroup { - readonly id: string; - readonly conjunction: "and" | "or"; - readonly items: ReadonlyArray; -} +interface DatabaseFilter extends Record { readonly id: string; readonly propertyId: string; readonly operator: DatabaseFilterOperator; readonly value: JSONValue; } ``` -## `DatabaseFilterOperator` +## `DatabaseFilterGroup` ```ts -type DatabaseFilterOperator = - | "equals" - | "not-equals" - | "contains" - | "greater-than" - | "greater-than-or-equal" - | "less-than" - | "less-than-or-equal" - | "is-empty"; +interface DatabaseFilterGroup extends Record { readonly id: string; readonly conjunction: "and" | "or"; readonly items: ReadonlyArray; } ``` -## `DatabaseFilterRule` +## `DatabaseFilterOperator` ```ts -interface DatabaseFilterRule { - readonly id: string; - readonly propertyId: string; - readonly operator: DatabaseFilterOperator; - readonly value?: unknown; -} +type DatabaseFilterOperator = "equals" | "not-equals" | "contains" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-empty"; ``` -## `DatabaseGroupRule` +## `DatabaseGroup` ```ts -interface DatabaseGroupRule { - readonly propertyId: string; - readonly direction: "ascending" | "descending"; -} +interface DatabaseGroup extends Record { readonly propertyId: string; readonly direction: "ascending" | "descending"; } ``` ## `DatabaseHand` @@ -149,6 +124,7 @@ interface DatabaseHandChange { readonly records: ReadonlyArray; readonly origin: "cell.commit" | "record.add" | "record.delete" | "undo" | "redo"; readonly revision: number; + readonly updates?: ReadonlyArray<{ readonly recordId: string; readonly patch: Partial }>; } ``` ## `DatabaseHandContext` @@ -303,13 +279,7 @@ interface DatabaseOperations, Upd ## `DatabaseProjection` ```ts -interface DatabaseProjection { - readonly search: string; - readonly filter: DatabaseFilterGroup; - readonly sorts: ReadonlyArray; - readonly groups: ReadonlyArray; - readonly columns: ReadonlyArray; -} +interface DatabaseProjection extends Record { readonly search: string; readonly filter: DatabaseFilterGroup; readonly sorts: ReadonlyArray; readonly groups: ReadonlyArray; readonly columns: ReadonlyArray; } ``` ## `DatabaseProvider` @@ -322,11 +292,11 @@ DatabaseProvider, Update = Partia interface DatabaseProviderProps, Update = Partial> { readonly resource: DatabaseResource; readonly operations: DatabaseOperations; - readonly defaultView: DatabaseViewDocument; - readonly view?: DatabaseViewDocument; - readonly onViewChange?: (view: DatabaseViewDocument) => void; - readonly views?: ReadonlyArray; - readonly onSaveView?: (view: DatabaseViewDocument) => Promise | void; + readonly defaultView: DatabaseTableView; + readonly view?: DatabaseTableView; + readonly onViewChange?: (view: DatabaseTableView) => void; + readonly views?: ReadonlyArray; + readonly onSaveView?: (view: DatabaseTableView) => Promise | void; readonly capabilities?: DatabaseCapabilities; readonly pageSize?: number; readonly children: ReactNode; @@ -336,7 +306,7 @@ interface DatabaseProviderProps, ```ts interface DatabaseQueryRequest { - readonly view: DatabaseViewDocument; + readonly view: DatabaseTableView; readonly cursor?: string; readonly pageSize: number; readonly signal: AbortSignal; @@ -371,10 +341,10 @@ type DatabaseRow = Record; ```ts type DatabaseRowId = string; ``` -## `DatabaseSortRule` +## `DatabaseSort` ```ts -interface DatabaseSortRule { +interface DatabaseSort extends Record { readonly propertyId: string; readonly direction: "ascending" | "descending"; } @@ -399,16 +369,10 @@ interface DatabaseTableProps { readonly density?: "comfortable" | "compact"; } ``` -## `DatabaseViewDocument` +## `DatabaseTableView` ```ts -interface DatabaseViewDocument { - readonly id: string; - readonly name: string; - readonly ownership: "personal" | "shared" | "locked"; - readonly layout: "table"; - readonly projection: DatabaseProjection; -} +interface DatabaseTableView extends Record { readonly id: string; readonly name: string; readonly ownership: "personal" | "shared" | "locked"; readonly layout: "table"; readonly projection: DatabaseProjection; } ``` ## `useDatabase` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 512f2cd1e..416969b9f 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-editing API -**Owner:** Editing +**탐색 분류:** Editing -intent, editor, history 편집 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +intent, editor, history 편집 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-editing/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -26,6 +26,11 @@ interface Annotation extends Record { readonly id: string; re ```ts const ANNOTATION_PROFILE_V1: "urn:interactive-os:json-document:annotation:1" ``` +## `AnnotationBounds` + +```ts +interface AnnotationBounds extends AnnotationPoint { readonly width: number; readonly height: number } +``` ## `AnnotationDocument` ```ts @@ -62,6 +67,11 @@ type AnnotationPresentation = | { readonly type: "stroke" } | { readonly type: "arrow" }; ``` +## `annotationResizeHandle` + +```ts +annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null +``` ## `AnnotationSelection` ```ts @@ -76,6 +86,18 @@ type AnnotationSelector = | { readonly type: "path"; readonly points: ReadonlyArray } | { readonly type: "arrow"; readonly from: AnnotationPoint; readonly to: AnnotationPoint }; ``` +## `annotationSelectorBounds` + +```ts +annotationSelectorBounds(selector: AnnotationSelector): AnnotationBounds +``` +## `AnnotationSelectorTransform` + +```ts +type AnnotationSelectorTransform = + | { readonly type: "move"; readonly dx: number; readonly dy: number } + | { readonly type: "resize"; readonly handle: "end" | "south-east"; readonly dx: number; readonly dy: number }; +``` ## `AnnotationSource` ```ts @@ -218,8 +240,8 @@ interface CalendarEditor { ): CalendarSelectionDragSource | null; dispatch(intent: CalendarIntent): EditingResult; copy(occurrences?: ReadonlyArray): CalendarClipboard | null; - cut(occurrences?: ReadonlyArray): EditingClipboardCut> | null; - paste(clipboard: CalendarClipboard, target?: string): EditingResult; + cut(source?: ReadonlyArray | CalendarClipboard): EditingClipboardCut> | null; + paste(clipboard: CalendarClipboard, target?: string, options?: { readonly calendarId?: string }): EditingResult; undo(): EditingResult; redo(): EditingResult; subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; @@ -284,37 +306,7 @@ type CalendarIntent = readonly target: CalendarSelectionMoveTarget; readonly scope?: "this" | "this-and-following" | "all"; } - | { - readonly type: "event.create"; - readonly start: string; - readonly end: string; - readonly title?: string; - readonly allDay?: boolean; - readonly calendarId?: string; - readonly recurrence?: CalendarRecurrence | null; - } - | { readonly type: "event.move"; readonly eventId: string; readonly start: string } - | { readonly type: "event.resize"; readonly eventId: string; readonly edge: "start" | "end"; readonly instant: string } - | { readonly type: "event.move-day"; readonly eventId: string; readonly day: string } - | { - readonly type: "event.update"; - readonly eventId: string; - readonly title?: string; - readonly start?: string; - readonly end?: string; - readonly allDay?: boolean; - readonly calendarId?: string; - readonly recurrence?: CalendarRecurrence | null; - } - | { - readonly type: "occurrence.edit"; - readonly eventId: string; - readonly occurrenceStart: string; - readonly scope: "this" | "this-and-following" | "all"; - readonly title?: string; - readonly start?: string; - readonly end?: string; - } + | CalendarEventOperation | { readonly type: "occurrence.remove"; readonly eventId: string; @@ -405,11 +397,7 @@ type CalendarOccurrenceRange = { ## `CalendarOccurrenceSelection` ```ts -interface CalendarOccurrenceSelection { - readonly eventId: string; - readonly start: string; - readonly end: string; -} +type CalendarOccurrenceSelection = CalendarOccurrenceInterval; ``` ## `calendarOccurrenceTopology` @@ -548,6 +536,31 @@ calendarVisibleEvents(document: CalendarDocument): ReadonlyArray ```ts calendarVisibleHourBand(startMinutes: number, endMinutes: number, hourStart: number, hourEnd: number): { readonly startMinutes: number; readonly endMinutes: number; } | null ``` +## `CanvasClipboardContent` + +```ts +type CanvasClipboardContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "images"; readonly images: ReadonlyArray<{ readonly source: string; readonly width: number; readonly height: number; readonly label: string }> } + | { readonly type: "mixed"; readonly items: ReadonlyArray }; +``` +## `CanvasClipboardItem` + +```ts +type CanvasClipboardItem = { readonly type: "text"; readonly text: string } + | ({ readonly type: "image" } & Parameters[0]); +``` +## `CanvasClipboardOptions` + +```ts +interface CanvasClipboardOptions { + readonly bounds: ObjectBounds; + readonly textColor: string; + readonly fontSize: number; + readonly imageOffset?: number; + readonly contentGap?: number; +} +``` ## `createAnnotationEditor` ```ts @@ -558,6 +571,11 @@ createAnnotationEditor(source: EditingDocumentSource, option ```ts createCalendarEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; readonly initialEventIds?: ReadonlyArray; }): CalendarEditor ``` +## `createCanvasClipboard` + +```ts +createCanvasClipboard(content: CanvasClipboardContent, options: CanvasClipboardOptions): ObjectClipboard +``` ## `createDatabaseEditor` ```ts @@ -573,6 +591,16 @@ createDocumentEditor(source: EditingDocumentSource, options?: Edi ```ts createEditingId(prefix: string): string ``` +## `createEditingIdAllocator` + +```ts +createEditingIdAllocator(existingIds: Iterable, createId: () => string, subject: string): () => string +``` +## `createEditingPreparationQueue` + +```ts +createEditingPreparationQueue(options: { readonly apply: (value: Value) => Result; readonly onResult?: (result: Result | EditingPreparationFailure) => void; readonly onPendingChange?: (pending: boolean) => void; readonly cancelCode?: string; readonly errorCode?: string; }): EditingPreparationQueue +``` ## `createEditingSession` ```ts @@ -588,6 +616,11 @@ createKanbanEditor(source: EditingDocumentSource, options?: Edit ```ts createObjectEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; }): ObjectEditor ``` +## `createObjectPasteSession` + +```ts +createObjectPasteSession(editor: ObjectEditor, options?: { readonly placement?: ObjectPastePlacement; readonly onResult?: (result: EditingResult) => void; readonly onPendingChange?: (pending: boolean) => void; }): ObjectPasteSession +``` ## `createOrderEditor` ```ts @@ -629,6 +662,11 @@ interface DatabaseClipboard extends Record { ```ts const databaseClipboardFormat: { mimeType: "application/vnd.interactive-os.database+json"; parse(value: unknown): DatabaseClipboard | null; } ``` +## `DatabaseColumnProjection` + +```ts +interface DatabaseColumnProjection extends Record { readonly propertyId: string; readonly visible: boolean; readonly width: number | null; readonly pinned: "start" | "end" | null; } +``` ## `DatabaseDocument` ```ts @@ -657,11 +695,22 @@ interface DatabaseEditor { ## `DatabaseFilter` ```ts -interface DatabaseFilter extends Record { - readonly propertyId: string; - readonly operator: "equals"; - readonly value: JSONValue; -} +interface DatabaseFilter extends Record { readonly id: string; readonly propertyId: string; readonly operator: DatabaseFilterOperator; readonly value: JSONValue; } +``` +## `DatabaseFilterGroup` + +```ts +interface DatabaseFilterGroup extends Record { readonly id: string; readonly conjunction: "and" | "or"; readonly items: ReadonlyArray; } +``` +## `DatabaseFilterOperator` + +```ts +type DatabaseFilterOperator = "equals" | "not-equals" | "contains" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-empty"; +``` +## `DatabaseGroup` + +```ts +interface DatabaseGroup extends Record { readonly propertyId: string; readonly direction: "ascending" | "descending"; } ``` ## `DatabaseIntent` @@ -691,11 +740,7 @@ type DatabaseIntent = | { readonly type: "view.configure"; readonly viewId: string; - readonly propertyOrder?: ReadonlyArray; - readonly propertyVisibility?: Readonly>; - readonly propertyWidths?: Readonly>; - readonly sort?: DatabaseSort | null; - readonly filter?: DatabaseFilter | null; + readonly projection: DatabaseProjection; } | { readonly type: "clipboard.paste"; @@ -711,6 +756,11 @@ interface DatabasePoint extends Record { readonly propertyId: string; } ``` +## `DatabaseProjection` + +```ts +interface DatabaseProjection extends Record { readonly search: string; readonly filter: DatabaseFilterGroup; readonly sorts: ReadonlyArray; readonly groups: ReadonlyArray; readonly columns: ReadonlyArray; } +``` ## `DatabaseProperty` ```ts @@ -772,16 +822,7 @@ interface DatabaseSort extends Record { ## `DatabaseTableView` ```ts -interface DatabaseTableView extends Record { - readonly id: string; - readonly name: string; - readonly type: "table"; - readonly propertyOrder: ReadonlyArray; - readonly propertyVisibility: Readonly>; - readonly propertyWidths: Readonly>; - readonly sort: DatabaseSort | null; - readonly filter: DatabaseFilter | null; -} +interface DatabaseTableView extends Record { readonly id: string; readonly name: string; readonly ownership: "personal" | "shared" | "locked"; readonly layout: "table"; readonly projection: DatabaseProjection; } ``` ## `DatabaseTopology` @@ -853,14 +894,8 @@ type DocumentIntent = ## `DocumentObject` ```ts -interface DocumentObject extends Record { +interface DocumentObject extends ObjectDraft { readonly id: string; - readonly label: string; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly color: string; } ``` ## `DocumentPoint` @@ -981,6 +1016,25 @@ interface EditingPlan { readonly historyGroup?: string; } ``` +## `EditingPreparation` + +```ts +type EditingPreparation = { readonly ok: true; readonly value: Value } | EditingPreparationFailure; +``` +## `EditingPreparationFailure` + +```ts +type EditingPreparationFailure = { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `EditingPreparationQueue` + +```ts +interface EditingPreparationQueue { + readonly isPending: boolean; + enqueue(prepare: () => EditingPreparation | Promise>, cancelPreparation?: () => void): Promise; + cancel(): void; +} +``` ## `EditingResult` ```ts @@ -1202,11 +1256,13 @@ nextDatabasePropertySort(sort: DatabaseSort | null, propertyId: string): Databas ## `ObjectClipboard` ```ts -interface ObjectClipboard extends Record { +type ObjectClipboard = Record & { readonly type: "application/vnd.interactive-os.objects+json"; readonly objects: ReadonlyArray; readonly text: string; -} + /** Optional for legacy payloads; remapped to the corresponding new ID on paste. */ + readonly primaryKey?: string | null; +}; ``` ## `objectClipboardFormat` @@ -1238,13 +1294,20 @@ interface ObjectEditor { ```ts type ObjectIntent = + | { readonly type: "object.create"; readonly object: ObjectDraft } + | { readonly type: "object.duplicate"; readonly objectIds: ReadonlyArray; readonly placement?: ObjectPastePlacement } + | { readonly type: "object.remove"; readonly objectIds: ReadonlyArray } + | { readonly type: "object.text"; readonly objectId: string; readonly text: string } + | { readonly type: "document.replace"; readonly document: ObjectDocument } | { readonly type: "selection.set"; readonly objectIds: ReadonlyArray; readonly mode?: ObjectSelectionMode; + readonly primaryKey?: string; } | { readonly type: "selection.remove" } | { readonly type: "selection.fill"; readonly color: string } + | { readonly type: "selection.style"; readonly style: Partial } | { readonly type: "object.translate"; readonly objectIds: ReadonlyArray; @@ -1265,11 +1328,28 @@ type ObjectIntent = ```ts interface ObjectPastePlacement { - readonly type: "offset"; + readonly type: "offset" | "cascade"; readonly dx: number; readonly dy: number; } ``` +## `ObjectPastePreparation` + +```ts +type ObjectPastePreparation = + | { readonly ok: true; readonly clipboard: ObjectClipboard } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `ObjectPasteSession` + +```ts +interface ObjectPasteSession { + readonly pending: boolean; + enqueue(prepare: () => ObjectPastePreparation | Promise, cancelPreparation?: () => void): Promise>; + /** Cancels queued work and releases subscriptions. The session can be reused. */ + cancel(): void; +} +``` ## `ObjectSelection` ```ts @@ -1521,6 +1601,11 @@ interface SheetSelection extends Record { ```ts type SheetTopology = GridTopology; ``` +## `transformAnnotationSelector` + +```ts +transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null +``` ## `TreeClipboard` ```ts diff --git a/docs/api-reference/file-intake.md b/docs/api-reference/file-intake.md index 19b5174d8..4f717e8af 100644 --- a/docs/api-reference/file-intake.md +++ b/docs/api-reference/file-intake.md @@ -1,11 +1,21 @@ # @interactive-os/json-document-file-intake API -**Owner:** Artifact +**탐색 분류:** Artifact -플랫폼 독립 파일 후보와 수용 정책의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +플랫폼 독립 파일 후보와 수용 정책의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-file-intake/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. +## `assertRasterImageContent` + +```ts +assertRasterImageContent(value: unknown): asserts value is RasterImageContent +``` +## `assertRasterImageSource` + +```ts +assertRasterImageSource(source: unknown): asserts source is string +``` ## `FileAcceptancePolicy` ```ts @@ -36,6 +46,15 @@ type FileIntakeResult = ```ts formatFileSize(bytes: number): string ``` +## `RasterImageContent` + +```ts +interface RasterImageContent extends Record { + readonly source: string; + readonly width: number; + readonly height: number; +} +``` ## `validateFileCandidates` ```ts diff --git a/docs/api-reference/json-document.md b/docs/api-reference/json-document.md index 12eb5e48c..64b44fa0d 100644 --- a/docs/api-reference/json-document.md +++ b/docs/api-reference/json-document.md @@ -1,8 +1,8 @@ # @interactive-os/json-document API -**Owner:** JSON Document +**탐색 분류:** JSON Document -Core document 값·주소·patch 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Core document 값·주소·patch 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document/src/application/document/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -26,6 +26,11 @@ buildPointer(segments: ReadonlyArray, options?: { readonly uriF ```ts createJSONDocument(initial: unknown, options?: JSONDocumentOptions): JSONDocument ``` +## `isJSONValue` + +```ts +isJSONValue(value: unknown): value is JSONValue +``` ## `JSONAppliedChange` ```ts @@ -167,6 +172,11 @@ type QueryResult = readonly reason?: string; }; ``` +## `readPointer` + +```ts +readPointer(value: JSONValue, pointer: Pointer): ReadResult +``` ## `ReadResult` ```ts diff --git a/docs/api-reference/markdown-react.md b/docs/api-reference/markdown-react.md index 40fb96261..19f68f06b 100644 --- a/docs/api-reference/markdown-react.md +++ b/docs/api-reference/markdown-react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-markdown-react API -**Owner:** Artifact +**탐색 분류:** Artifact -스트리밍 Markdown 투영과 렌더링의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +스트리밍 Markdown 투영과 렌더링의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-markdown-react/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/object-document.md b/docs/api-reference/object-document.md new file mode 100644 index 000000000..580539d9a --- /dev/null +++ b/docs/api-reference/object-document.md @@ -0,0 +1,218 @@ +# @interactive-os/json-document-object-document API + +**탐색 분류:** Document Types + +Object 문서와 Canvas 프로파일의 모델·검증·연산·projection의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-object-document/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `assertCanvasDocument` + +```ts +assertCanvasDocument(value: unknown): asserts value is CanvasDocument +``` +## `assertCanvasImageSource` + +```ts +assertCanvasImageSource(source: unknown): asserts source is string +``` +## `assertObjectDocument` + +```ts +assertObjectDocument(value: unknown): void +``` +## `assertObjectStyle` + +```ts +assertObjectStyle(value: unknown): asserts value is Partial +``` +## `CanvasDocument` + +```ts +interface CanvasDocument extends ObjectDocument { + readonly profile: "canvas/1"; + readonly width: number; + readonly height: number; + readonly objects: ReadonlyArray; +} +``` +## `CanvasObject` + +```ts +type CanvasObject = CanvasObjectDraft & { readonly id: string }; +``` +## `CanvasObjectDraft` + +```ts +type CanvasObjectDraft = ObjectDraft & ( + | (CanvasTextFormat & { readonly kind: "text"; readonly fontSize: number }) + | (CanvasTextFormat & { readonly kind: "rectangle" | "ellipse" | "sticky-note"; readonly textColor?: string; readonly strokeColor?: string; readonly strokeWidth?: number }) + | { readonly kind: "path"; readonly points: ReadonlyArray; readonly strokeWidth: number } + | { readonly kind: "image"; readonly source: string } +); +``` +## `CanvasObjectKind` + +```ts +type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image"; +``` +## `CanvasTextFormat` + +```ts +interface CanvasTextFormat { + readonly fontSize?: number; + readonly fontWeight?: 400 | 700; + readonly textAlign?: "left" | "center" | "right"; +} +``` +## `createCanvasImage` + +```ts +createCanvasImage(image: { readonly source: string; readonly width: number; readonly height: number; readonly label: string; }, bounds: ObjectBounds): Extract +``` +## `createCanvasObject` + +```ts +createCanvasObject(kind: Exclude, bounds: ObjectBounds, style: { readonly color: string; readonly label: string; readonly fontSize?: number; readonly textColor?: string; }): CanvasObjectDraft +``` +## `createCanvasPath` + +```ts +createCanvasPath(points: ReadonlyArray, style: { readonly color: string; readonly label: string; readonly strokeWidth: number; }): Extract +``` +## `DocumentObject` + +```ts +interface DocumentObject extends ObjectDraft { + readonly id: string; +} +``` +## `getObjectStyle` + +```ts +getObjectStyle(object: DocumentObject): Partial +``` +## `ObjectBounds` + +```ts +interface ObjectBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} +``` +## `ObjectDocument` + +```ts +interface ObjectDocument extends Record { + readonly objects: ReadonlyArray; +} +``` +## `ObjectDraft` + +```ts +interface ObjectDraft extends ObjectBounds, Record { + readonly label: string; + readonly color: string; +} +``` +## `ObjectOperation` + +```ts +type ObjectOperation = + | { readonly type: "insert"; readonly objects: ReadonlyArray } + | { readonly type: "transform"; readonly objectIds: ReadonlyArray; readonly transform: ObjectTransform } + | { readonly type: "fill"; readonly objectIds: ReadonlyArray; readonly color: string } + | { readonly type: "style"; readonly objectIds: ReadonlyArray; readonly style: Partial } + | { readonly type: "remove"; readonly objectIds: ReadonlyArray } + | { readonly type: "text"; readonly objectId: string; readonly text: string } + | { readonly type: "replace"; readonly document: ObjectDocument }; +``` +## `ObjectOperationPlan` + +```ts +type ObjectOperationPlan = + | { readonly ok: true; readonly operations: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `ObjectPoint` + +```ts +interface ObjectPoint extends Record { + readonly x: number; + readonly y: number; +} +``` +## `ObjectStyle` + +```ts +interface ObjectStyle { + readonly color: string; + /** Body text paint for filled objects; standalone text keeps its existing color field. */ + readonly textColor: string; + readonly fontSize: number; + readonly fontWeight: 400 | 700; + readonly textAlign: "left" | "center" | "right"; + readonly strokeColor: string; + readonly strokeWidth: number; +} +``` +## `ObjectStyleSelection` + +```ts +type ObjectStyleSelection = { readonly [Key in keyof ObjectStyle]?: ObjectStyle[Key] | null }; +``` +## `ObjectTextProjection` + +```ts +interface ObjectTextProjection extends ObjectBounds, Pick { + readonly text: string; + readonly verticalAlign: "top" | "center"; +} +``` +## `ObjectTransform` + +```ts +interface ObjectTransform { + readonly dx: number; + readonly dy: number; + readonly dw?: number; + readonly dh?: number; +} +``` +## `parseCanvasDocument` + +```ts +parseCanvasDocument(json: string): CanvasDocument +``` +## `planObjectOperation` + +```ts +planObjectOperation(document: ObjectDocument, operation: ObjectOperation): ObjectOperationPlan +``` +## `projectObject` + +```ts +projectObject(object: DocumentObject): CanvasObject +``` +## `projectObjectText` + +```ts +projectObjectText(object: DocumentObject): ObjectTextProjection | null +``` +## `readObjectStyle` + +```ts +readObjectStyle(objects: ReadonlyArray): ObjectStyleSelection +``` +## `serializeCanvasDocument` + +```ts +serializeCanvasDocument(document: CanvasDocument): string +``` +## `transformObject` + +```ts +transformObject(object: Object, transform: ObjectTransform): Object +``` diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index 0e02331d9..8d7896465 100644 --- a/docs/api-reference/packages.mjs +++ b/docs/api-reference/packages.mjs @@ -1,4 +1,7 @@ export const apiReferencePackages = [ + ["object-document", "@interactive-os/json-document-object-document", "packages/json-document-object-document/src/index.ts", "Document Types", "Object 문서와 Canvas 프로파일의 모델·검증·연산·projection"], + ["canvas", "@interactive-os/json-document-canvas", "packages/json-document-canvas/src/index.ts", "Hands", "한 장짜리 Canvas의 입력·preview·UI 조합"], + ["calendar-document", "@interactive-os/json-document-calendar-document", "packages/json-document-calendar-document/src/index.ts", "Document Types", "Calendar 문서 모델·검증·의미 연산·projection 계약"], ["a2ui", "@interactive-os/json-document-a2ui", "packages/json-document-a2ui/src/index.ts", "Connector", "A2UI streaming document connector"], ["json-document", "@interactive-os/json-document", "packages/json-document/src/application/document/index.ts", "JSON Document", "Core document 값·주소·patch 계약"], ["selection", "@interactive-os/json-document-selection", "packages/json-document-selection/src/index.ts", "Editing", "구조적 selection과 topology 계약"], @@ -12,11 +15,12 @@ export const apiReferencePackages = [ ["ui-primitives-react", "@interactive-os/json-document-ui-primitives-react", "packages/json-document-ui-primitives-react/src/index.ts", "UI Primitives", "표준 React UI primitive"], ["animation-react", "@interactive-os/json-document-animation-react", "packages/json-document-animation-react/src/index.ts", "UI Primitives", "생성 대기 시각 언어"], ["markdown-react", "@interactive-os/json-document-markdown-react", "packages/json-document-markdown-react/src/index.ts", "Artifact", "스트리밍 Markdown 투영과 렌더링"], - ["database", "@interactive-os/json-document-database", "packages/json-document-database/src/index.ts", "Hands", "Database Hand domain 계약"], - ["calendar", "@interactive-os/json-document-calendar", "packages/json-document-calendar/src/index.ts", "Hands", "Calendar React lifecycle와 occurrence interaction 계약"], + ["database", "@interactive-os/json-document-database", "packages/json-document-database/src/index.ts", "Hands", "Database 문서 모델·연산·saved-view projection"], + ["annotation", "@interactive-os/json-document-annotation", "packages/json-document-annotation/src/index.ts", "Hands", "Annotation interaction과 SVG projection"], + ["calendar", "@interactive-os/json-document-calendar", "packages/json-document-calendar/src/index.ts", "Hands", "Calendar React lifecycle와 occurrence interaction 계약 ([시간·반복·거절 계약: Editing의 Calendar protocol profile](/docs/api/editing#calendar-protocol-profile-rc))"], ["web", "@interactive-os/json-document-web", "packages/json-document-web/src/index.ts", "Adapter", "Web platform adapter"], ["contenteditable", "@interactive-os/json-document-contenteditable", "packages/json-document-contenteditable/src/index.ts", "Adapter", "contenteditable platform adapter"], - ["rich-text", "@interactive-os/json-document-rich-text", "packages/json-document-rich-text/src/index.ts", "Editing", "Rich Text domain과 editing 계약"], + ["rich-text", "@interactive-os/json-document-rich-text", "packages/json-document-rich-text/src/index.ts", "Editing", "Rich Text 문서 의미와 editing 계약"], ["file-intake", "@interactive-os/json-document-file-intake", "packages/json-document-file-intake/src/index.ts", "Artifact", "플랫폼 독립 파일 후보와 수용 정책"], ["rich-text-suggestion", "@interactive-os/json-document-rich-text-suggestion", "packages/json-document-rich-text-suggestion/src/index.ts", "Hands", "Rich Text suggestion trigger와 상태 계약"], ["rich-text-suggestion-react", "@interactive-os/json-document-rich-text-suggestion-react", "packages/json-document-rich-text-suggestion-react/src/index.ts", "Hands", "Rich Text suggestion React interaction binding"], @@ -28,13 +32,34 @@ export const apiReferencePackages = [ ["rich-text-react", "@interactive-os/json-document-rich-text-react", "packages/json-document-rich-text-react/src/index.tsx", "Connector", "Rich Text React connector"], ["collaboration", "@interactive-os/json-document-collaboration", "packages/json-document-collaboration/src/index.ts", "Collaboration", "replica, history, text collaboration runtime"], ["contenteditable-collaboration", "@interactive-os/json-document-contenteditable-collaboration", "packages/contenteditable-collaboration/src/index.ts", "Collaboration", "collaborative contenteditable lease"], -].map(([slug, packageName, entrypoint, owner, responsibility]) => ({ - slug, packageName, entrypoint, owner, responsibility, +].map(([slug, packageName, entrypoint, navigationGroup, responsibility]) => ({ + slug, packageName, entrypoint, navigationGroup, responsibility, subpaths: slug === "collaboration" ? [{ packageName: "@interactive-os/json-document-collaboration/history", entrypoint: "packages/json-document-collaboration/src/history-index.ts", + }, { + packageName: "@interactive-os/json-document-collaboration/text", + entrypoint: "packages/json-document-collaboration/src/text-index.ts", }, { packageName: "@interactive-os/json-document-collaboration/editing", entrypoint: "packages/json-document-collaboration/src/editing-index.ts", }] : [], })); + +export function apiReferenceCoverageErrors(manifests, references = apiReferencePackages) { + const expected = manifests.filter((manifest) => !manifest.private).flatMap((manifest) => + Object.entries(manifest.exports).filter(([, target]) => hasTypes(target)) + .map(([subpath]) => subpath === "." ? manifest.name : `${manifest.name}/${subpath.slice(2)}`)); + const registered = references.flatMap(({ packageName, subpaths }) => + [packageName, ...subpaths.map((subpath) => subpath.packageName)]); + return [ + ...expected.filter((name) => !registered.includes(name)).map((name) => `API reference missing: ${name}`), + ...registered.filter((name) => !expected.includes(name)).map((name) => `API reference is not public: ${name}`), + ...registered.filter((name, index) => registered.indexOf(name) !== index).map((name) => `Duplicate API reference: ${name}`), + ]; +} + +function hasTypes(target) { + return target !== null && typeof target === "object" + && (typeof target.types === "string" || Object.values(target).some(hasTypes)); +} diff --git a/docs/api-reference/packages.test.mjs b/docs/api-reference/packages.test.mjs new file mode 100644 index 000000000..667998ee6 --- /dev/null +++ b/docs/api-reference/packages.test.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { apiReferenceCoverageErrors, apiReferencePackages } from "./packages.mjs"; + +const root = new URL("../../", import.meta.url); +const read = (path) => JSON.parse(readFileSync(new URL(path, root), "utf8")); +const manifests = read("package.json").workspaces.map((workspace) => read(`${workspace}/package.json`)); + +test("every published TypeScript entrypoint has its owner reference", () => { + assert.deepEqual(apiReferenceCoverageErrors(manifests), []); +}); + +test("detects the omitted collaboration text subpath even when every package root is registered", () => { + const missingText = apiReferencePackages.map((entry) => ({ + ...entry, + subpaths: entry.subpaths.filter((subpath) => !subpath.packageName.endsWith("/text")), + })); + assert.deepEqual(apiReferenceCoverageErrors(manifests, missingText), [ + "API reference missing: @interactive-os/json-document-collaboration/text", + ]); +}); + +test("does not mistake CSS exports for TypeScript contracts and rejects stale entries", () => { + const entry = { packageName: "example", subpaths: [] }; + const published = [{ name: "example", exports: { ".": { types: "./index.d.ts" }, "./styles.css": "./styles.css" } }]; + assert.deepEqual(apiReferenceCoverageErrors(published, [entry]), []); + assert.deepEqual(apiReferenceCoverageErrors(published, [{ ...entry, subpaths: [{ packageName: "example/removed" }] }]), [ + "API reference is not public: example/removed", + ]); +}); diff --git a/docs/api-reference/react-hook-form.md b/docs/api-reference/react-hook-form.md index aff21a230..b7db9efd7 100644 --- a/docs/api-reference/react-hook-form.md +++ b/docs/api-reference/react-hook-form.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-react-hook-form API -**Owner:** Connector +**탐색 분류:** Connector -React Hook Form connector의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +React Hook Form connector의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-react-hook-form/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/react.md b/docs/api-reference/react.md index 36a8d8aba..d84fb355a 100644 --- a/docs/api-reference/react.md +++ b/docs/api-reference/react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-react API -**Owner:** Connector +**탐색 분류:** Connector -React lifecycle connector의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +React lifecycle connector의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-react/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/rich-text-mention-react.md b/docs/api-reference/rich-text-mention-react.md index 8ca8f59a2..62754f1fe 100644 --- a/docs/api-reference/rich-text-mention-react.md +++ b/docs/api-reference/rich-text-mention-react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-rich-text-mention-react API -**Owner:** Hands +**탐색 분류:** Hands -Rich Text mention React projection의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Rich Text mention React projection의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-rich-text-mention-react/src/index.tsx`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/rich-text-mention.md b/docs/api-reference/rich-text-mention.md index ac0c6b287..349a46524 100644 --- a/docs/api-reference/rich-text-mention.md +++ b/docs/api-reference/rich-text-mention.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-rich-text-mention API -**Owner:** Hands +**탐색 분류:** Hands -Rich Text entity mention schema와 삽입 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Rich Text entity mention schema와 삽입 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-rich-text-mention/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/rich-text-react.md b/docs/api-reference/rich-text-react.md index 6c9acf7ba..9f06ea9a6 100644 --- a/docs/api-reference/rich-text-react.md +++ b/docs/api-reference/rich-text-react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-rich-text-react API -**Owner:** Connector +**탐색 분류:** Connector -Rich Text React connector의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Rich Text React connector의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-rich-text-react/src/index.tsx`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/rich-text-suggestion-react.md b/docs/api-reference/rich-text-suggestion-react.md index 061ca76b2..6f40279a9 100644 --- a/docs/api-reference/rich-text-suggestion-react.md +++ b/docs/api-reference/rich-text-suggestion-react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-rich-text-suggestion-react API -**Owner:** Hands +**탐색 분류:** Hands -Rich Text suggestion React interaction binding의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Rich Text suggestion React interaction binding의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-rich-text-suggestion-react/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/rich-text-suggestion.md b/docs/api-reference/rich-text-suggestion.md index d771a4fb9..b70154e5d 100644 --- a/docs/api-reference/rich-text-suggestion.md +++ b/docs/api-reference/rich-text-suggestion.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-rich-text-suggestion API -**Owner:** Hands +**탐색 분류:** Hands -Rich Text suggestion trigger와 상태 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Rich Text suggestion trigger와 상태 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-rich-text-suggestion/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/rich-text-web.md b/docs/api-reference/rich-text-web.md index 37f35bb08..9311c44f0 100644 --- a/docs/api-reference/rich-text-web.md +++ b/docs/api-reference/rich-text-web.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-rich-text-web API -**Owner:** Adapter +**탐색 분류:** Adapter -Rich Text DOM adapter의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Rich Text DOM adapter의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-rich-text-web/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/rich-text.md b/docs/api-reference/rich-text.md index 48247c549..6f648e3a6 100644 --- a/docs/api-reference/rich-text.md +++ b/docs/api-reference/rich-text.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-rich-text API -**Owner:** Editing +**탐색 분류:** Editing -Rich Text domain과 editing 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Rich Text 문서 의미와 editing 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-rich-text/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/selection.md b/docs/api-reference/selection.md index 8e2a7e112..05dad5d39 100644 --- a/docs/api-reference/selection.md +++ b/docs/api-reference/selection.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-selection API -**Owner:** Editing +**탐색 분류:** Editing -구조적 selection과 topology 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +구조적 selection과 topology 계약의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-selection/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/tanstack-table.md b/docs/api-reference/tanstack-table.md index a13a487ab..ee676c51c 100644 --- a/docs/api-reference/tanstack-table.md +++ b/docs/api-reference/tanstack-table.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-tanstack-table API -**Owner:** Connector +**탐색 분류:** Connector -TanStack Table connector의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +TanStack Table connector의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-tanstack-table/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/api-reference/ui-primitives-react.md b/docs/api-reference/ui-primitives-react.md index ceb8a738a..7b2079b8f 100644 --- a/docs/api-reference/ui-primitives-react.md +++ b/docs/api-reference/ui-primitives-react.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-ui-primitives-react API -**Owner:** UI Primitives +**탐색 분류:** UI Primitives -표준 React UI primitive의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +표준 React UI primitive의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-ui-primitives-react/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -223,7 +223,7 @@ type MenuItem = { ## `Popover` ```ts -Popover(props: { readonly label: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly trigger: ReactNode; readonly children: ReactNode; readonly className?: string; readonly panelClassName?: string; }): ReactNode +Popover(props: { readonly label: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly trigger: ReactNode; readonly triggerPresentation?: "label" | "icon"; readonly children: ReactNode; readonly className?: string; readonly panelClassName?: string; }): ReactNode ``` ## `ProductCanvas` @@ -296,7 +296,7 @@ Tabs(props: { readonly label: string; readonly value: ## `Toggle` ```ts -Toggle(props: Omit, "aria-pressed"> & FocusPreservingControl & ControlAffordanceProps & { readonly pressed: boolean; readonly presentation?: "button" | "chip"; readonly label?: string; readonly tooltip?: string; }): ReactNode +Toggle(props: Omit, "aria-pressed"> & FocusPreservingControl & ControlAffordanceProps & { readonly pressed: boolean; readonly presentation?: "button" | "chip" | "icon"; readonly label?: string; readonly tooltip?: string; }): ReactNode ``` ## `Toolbar` diff --git a/docs/api-reference/web.md b/docs/api-reference/web.md index b39d5cb49..556b3cc59 100644 --- a/docs/api-reference/web.md +++ b/docs/api-reference/web.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-web API -**Owner:** Adapter +**탐색 분류:** Adapter -Web platform adapter의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Web platform adapter의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-web/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. @@ -36,6 +36,12 @@ calendarKeyFromWebRow(clientX: number, bounds: { readonly left: number; rea ```ts calendarMinutesFromWebGrid(clientY: number, bounds: { readonly top: number; readonly height: number; }, options: { readonly hourStart: number; readonly hourEnd: number; readonly stepMinutes: number; }): number ``` +## `captureWebClipboardPaste` + +```ts +captureWebClipboardPaste(event: WebClipboardEvent, options: { readonly codec?: WebClipboardCodec; readonly files?: boolean; readonly text?: boolean; readonly html?: never; readonly delegatedMimeTypes?: ReadonlyArray; }): WebClipboardPaste +captureWebClipboardPaste(event: WebClipboardEvent, options: { readonly codec?: WebClipboardCodec; readonly files?: boolean; readonly text?: boolean; readonly html?: "images"; readonly delegatedMimeTypes?: ReadonlyArray; }): WebHTMLClipboardPaste +``` ## `chordFromStroke` ```ts @@ -198,6 +204,16 @@ const objectClipboardCodec: WebClipboardCodec ```ts const orderClipboardCodec: WebClipboardCodec ``` +## `parseWebClipboardHTML` + +```ts +parseWebClipboardHTML(html: string): WebHTMLClipboardContent | null +``` +## `parseWebHTMLFragment` + +```ts +parseWebHTMLFragment(html: string): WebHTMLFragment | null +``` ## `pressInteractionFromWeb` ```ts @@ -213,10 +229,20 @@ projectWebClientPointToSVG(point: WebClientPoint, viewport: WebSVGViewport): Web ```ts projectWebWidgetState(state: WebWidgetState): WebWidgetARIA ``` +## `readWebHTMLClipboard` + +```ts +readWebHTMLClipboard(content: WebHTMLClipboardContent, options: Parameters[1] & { readonly currentCount?: number; }): Promise +``` ## `readWebRasterFile` ```ts -readWebRasterFile(file: WebRasterFile): Promise +readWebRasterFile(file: WebRasterFile, options?: { readonly signal?: WebRasterReadSignal; }): Promise +``` +## `readWebRasterFiles` + +```ts +readWebRasterFiles(files: ReadonlyArray, options: { readonly policy: FileAcceptancePolicy; readonly maxImagePixels: number; readonly signal?: WebRasterReadSignal; readonly readRaster?: typeof readWebRasterFile; }): Promise ``` ## `registerWebVirtualSelectionScope` @@ -228,6 +254,11 @@ registerWebVirtualSelectionScope(document: object, options: WebVirtualSelectionS ```ts renderWebAnnotationRaster(options: { readonly document: AnnotationDocument; readonly sourceId: string; readonly sourceURL: string; readonly style: WebAnnotationRasterStyle; }): Promise ``` +## `routeWebClipboardEvent` + +```ts +routeWebClipboardEvent(root: object, event: { readonly target?: object | null; readonly defaultPrevented?: boolean; preventDefault(): void; }, operation: "copy" | "cut" | "paste", handle: () => Result): Result | null +``` ## `rovingFocusItemProps` ```ts @@ -406,6 +437,7 @@ interface WebClipboardCodec { ```ts interface WebClipboardData { readonly types: ReadonlyArray; + readonly files?: WebFileCandidateList; getData(format: string): string; setData(format: string, data: string): void; } @@ -414,10 +446,22 @@ interface WebClipboardData { ```ts interface WebClipboardEvent { + readonly target?: object | null; + readonly currentTarget?: object | null; + readonly defaultPrevented?: boolean; readonly clipboardData: WebClipboardData | null; preventDefault(): void; } ``` +## `WebClipboardPaste` + +```ts +type WebClipboardPaste = + | { readonly ok: true; readonly type: "structured"; readonly payload: Payload } + | { readonly ok: true; readonly type: "files"; readonly files: ReadonlyArray } + | { readonly ok: true; readonly type: "text"; readonly text: string } + | Extract, { readonly ok: false }>; +``` ## `WebClipboardPayload` ```ts @@ -447,9 +491,9 @@ type WebClipboardResult = ```ts interface WebClipboardSurface { - readonly onCopy: (event: WebClipboardEvent) => WebClipboardResult; - readonly onCut: (event: WebClipboardEvent) => WebClipboardResult; - readonly onPaste: (event: WebClipboardEvent) => WebClipboardResult; + readonly onCopy: (event: WebClipboardEvent) => WebClipboardResult | null; + readonly onCut: (event: WebClipboardEvent) => WebClipboardResult | null; + readonly onPaste: (event: WebClipboardEvent) => WebClipboardResult | null; } ``` ## `WebClipboardTextPort` @@ -606,6 +650,45 @@ interface WebGridCellAddressRoot { querySelectorAll(selectors: string): ArrayLike; } ``` +## `WebHTMLClipboardContent` + +```ts +interface WebHTMLClipboardContent { readonly parts: ReadonlyArray } +``` +## `WebHTMLClipboardPart` + +```ts +type WebHTMLClipboardPart = + | { readonly type: "text"; readonly text: string } + | { readonly type: "image"; readonly source: string; readonly label: string }; +``` +## `WebHTMLClipboardPaste` + +```ts +type WebHTMLClipboardPaste = WebClipboardPaste + | { readonly ok: true; readonly type: "html"; readonly content: WebHTMLClipboardContent }; +``` +## `WebHTMLClipboardResult` + +```ts +type WebHTMLClipboardResult = + | { readonly ok: true; readonly parts: ReadonlyArray | ({ readonly type: "image" } & WebRasterFileContent)> } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `WebHTMLFragment` + +```ts +interface WebHTMLFragment { readonly childNodes: ArrayLike } +``` +## `WebHTMLNode` + +```ts +interface WebHTMLNode { + readonly nodeType: number; + readonly textContent: string | null; + readonly childNodes: ArrayLike; +} +``` ## `WebJSONClipboardFormat` ```ts @@ -769,12 +852,36 @@ interface WebRasterFile { readonly type: string; } ``` +## `WebRasterFileContent` + +```ts +interface WebRasterFileContent { + readonly candidate: FileCandidate; + readonly image: RasterImageContent; +} +``` +## `WebRasterFilesResult` + +```ts +type WebRasterFilesResult = + | { readonly ok: true; readonly files: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `WebRasterReadSignal` + +```ts +interface WebRasterReadSignal { + readonly aborted: boolean; + addEventListener(type: "abort", listener: () => void, options?: { readonly once?: boolean }): void; + removeEventListener(type: "abort", listener: () => void): void; +} +``` ## `WebRasterSourceResult` ```ts type WebRasterSourceResult = | { readonly ok: true; readonly dataURL: string; readonly width: number; readonly height: number } - | { readonly ok: false; readonly code: "raster.read-failed" | "raster.decode-failed"; readonly reason?: string }; + | { readonly ok: false; readonly code: "raster.read-failed" | "raster.decode-failed" | "raster.cancelled"; readonly reason?: string }; ``` ## `WebSVGElement` diff --git a/docs/api-reference/zod.md b/docs/api-reference/zod.md index 9a10e315e..efa6b5603 100644 --- a/docs/api-reference/zod.md +++ b/docs/api-reference/zod.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-zod API -**Owner:** Connector +**탐색 분류:** Connector -Zod schema connector의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +Zod schema connector의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다. > 이 문서는 `packages/json-document-zod/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 1bac21b25..bb7c57615 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -1,15 +1,20 @@ -import { readFileSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { validateLlmsContract, validatePublicPackageReferences } from "./public-contract-checks.mjs"; const root = dirname(dirname(fileURLToPath(import.meta.url))); -const apiReferenceCheck = spawnSync(process.execPath, ["scripts/generate-api-reference.mjs", "--check"], { cwd: root, encoding: "utf8" }); -if (apiReferenceCheck.status !== 0) { - process.stderr.write(apiReferenceCheck.stderr || apiReferenceCheck.stdout); - process.exit(apiReferenceCheck.status ?? 1); +for (const args of [ + ["scripts/generate-api-reference.mjs", "--check"], + ["--test", "docs/api-reference/packages.test.mjs", "site/scripts/route-checks.test.mjs"], +]) { + const result = spawnSync(process.execPath, args, { cwd: root, encoding: "utf8" }); + if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status ?? 1); + } } function read(path) { @@ -52,6 +57,8 @@ const publicDocs = { applications: read("docs/public/applications.md"), concepts: read("docs/public/concepts.md"), foundation: read("docs/public/foundation.md"), + buildingBlocks: read("docs/public/building-blocks.md"), + editing: read("docs/public/editing.md"), howWeBuild: read("docs/public/how-we-build.md"), documentTypes: read("docs/public/document-types.md"), selection: read("docs/public/selection.md"), @@ -123,6 +130,7 @@ const surfaces = { ajvReadme: read("packages/json-document-ajv/README.md"), zodReadme: read("packages/json-document-zod/README.md"), databaseReadme: read("packages/json-document-database/README.md"), + annotationReadme: read("packages/json-document-annotation/README.md"), tanstackTableReadme: read("packages/json-document-tanstack-table/README.md"), webReadme: read("packages/json-document-web/README.md"), contenteditableReadme: read("packages/json-document-contenteditable/README.md"), @@ -138,93 +146,73 @@ const publicContract = readJson("packages/json-document/public-contract.json"); const rootPackage = readJson("package.json"); const implementationShape = read("standards/repository-implementation-shape.md"); const domEditingLifecycle = read("standards/dom-editing-lifecycle.md"); +const editingSession = read("standards/editing-session.md"); -if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ - "adapter-clipboard.md", - "adapter-contenteditable.md", - "adapter-grid-cell.md", - "adapter-interaction.md", - "adapter-keyboard.md", - "adapter-virtual-selection.md", - "adapters.md", - "affordance-activate.md", - "affordance-cancel.md", - "affordance-caret.md", - "affordance-context-menu.md", - "affordance-contextual.md", - "affordance-copy-drag.md", - "affordance-delete.md", - "affordance-double-click.md", - "affordance-drag.md", - "affordance-drop.md", - "affordance-focus.md", - "affordance-fold.md", - "affordance-forbid.md", - "affordance-handles.md", - "affordance-history.md", - "affordance-hover.md", - "affordance-marquee.md", - "affordance-nudge.md", - "affordance-pan.md", - "affordance-rename.md", - "affordance-resize.md", - "affordance-scroll.md", - "affordance-select.md", - "affordance-snap.md", - "affordance-triple-click.md", - "affordance-typeahead.md", - "affordance-zoom.md", - "affordance.md", - "animation.md", - "api.md", - "applications.md", - "clipboard.md", - "collaboration-history.md", - "collaboration-lease.md", - "collaboration-lifecycle.md", - "collaboration-replica.md", - "collaboration-text.md", - "collaboration.md", - "composer.md", - "concepts.md", - "connector-a2ui.md", - "connector-ajv.md", - "connector-react-hook-form.md", - "connector-react.md", - "connector-tanstack-table.md", - "connector-zod-validate.md", - "connector-zod.md", - "connectors.md", - "database.md", - "document-types.md", - "foundation.md", - "hands.md", - "history.md", - "how-we-build.md", - "intent-guide.md", - "intent.md", - "llms.txt", - "mention.md", - "object.md", - "official-hands.md", - "order.md", - "overview.md", - "react-editing.md", - "selection.md", - "topology.md", - "tree.md", - "ui-primitives.md", -])) { - fail("docs/public: only the active v3 guides and llms.txt may remain."); +const documentRoutes = readJson("site/site-routes.json").filter((route) => route.documentSource !== undefined); +for (const route of documentRoutes) { + for (const source of route.documentIncludes ?? []) { + if (!/^packages\/[^/]+\/docs\/[^/]+\.md$/.test(source) || !existsSync(join(root, source))) fail(`Invalid owner documentation inclusion: ${source}`); + } +} +const registeredSources = documentRoutes.map((route) => route.documentSource); +for (const source of registeredSources) { + if (!/^docs\/(?:public|api-reference)\/[^/]+\.md$/.test(source) || !existsSync(join(root, source))) { + fail(`Invalid documentation source registration: ${source}`); + } +} +if (new Set(registeredSources).size !== registeredSources.length) fail("Documentation sources must have one canonical route."); +for (const directory of ["docs/public", "docs/api-reference"]) { + const actual = fileNames(directory).filter((name) => directory === "docs/public" || name.endsWith(".md")); + const registered = registeredSources.filter((source) => dirname(source) === directory).map((source) => basename(source)); + if (directory === "docs/public") registered.push("llms.txt"); + registered.sort(); + if (JSON.stringify(actual) !== JSON.stringify(registered)) fail(`${directory}: Markdown files must match registered pages.`); +} + +// File links are repository-relative; site Markdown uses the same registered source path. +const linkedDocuments = [ + "README.md", "docs/README.md", "docs/changelog.md", + ...registeredSources, + ...rootPackage.workspaces.filter((workspace) => workspace !== "site").map((workspace) => `${workspace}/README.md`), + ...filesUnder("standards").filter((path) => path.endsWith(".md")), +]; +for (const path of linkedDocuments) { + for (const [, href] of read(path).matchAll(/(? match[1])); +const sessionEvidence = [...editingSession.matchAll(/^\| (ES-[A-Z-]+) \| \[[^\]]+\]\(([^)]+)\) \| `([^`]+)` \|$/gm)]; +if (sessionRules.size === 0) fail("EditingSession: missing normative rules."); +for (const rule of sessionRules) { + if (!sessionEvidence.some((match) => match[1] === rule)) fail(`EditingSession: ${rule} has no behavior evidence.`); +} +for (const [, rule, path, name] of sessionEvidence) { + if (!sessionRules.has(rule)) fail(`EditingSession: evidence refers to unknown rule ${rule}.`); + const target = join("standards", path); + if (!existsSync(join(root, target)) || !read(target).includes(JSON.stringify(name))) { + fail(`EditingSession: ${rule} lost behavior evidence ${path}: ${name}.`); + } +} +for (const [, path] of editingSession.matchAll(/\]\(([^)]+)\)/g)) { + if (!existsSync(join(root, "standards", path))) fail(`EditingSession: missing local reference ${path}.`); } for (const token of [ @@ -265,11 +253,12 @@ const misplacedMarkdown = filesUnder("").filter((path) => { return path.endsWith(".md") && !path.startsWith("docs/") && !path.startsWith("standards/") + && !rootPackage.workspaces.some((workspace) => workspace.startsWith("packages/") && path.startsWith(`${workspace}/docs/`)) && name !== "README.md" && name !== "AGENTS.md"; }); if (misplacedMarkdown.length > 0) { - fail(`docs layout: non-README markdown must live under docs/: ${misplacedMarkdown.join(", ")}.`); + fail(`docs layout: non-README markdown must live under docs/, standards/, or a registered package's docs/: ${misplacedMarkdown.join(", ")}.`); } for (const [name, source] of Object.entries(surfaces)) { diff --git a/docs/public/adapter-clipboard.md b/docs/public/adapter-clipboard.md index 7b617d8b3..a4073fc86 100644 --- a/docs/public/adapter-clipboard.md +++ b/docs/public/adapter-clipboard.md @@ -43,8 +43,26 @@ interface WebClipboardSurface { ``` 모든 handler는 같은 binding lifecycle을 사용하고 결과를 `onResult`에 한 번 -전달한 뒤 그 결과를 반환합니다. payload 의미, paste policy, 사용자 메시지와 -관찰 상태는 Host 책임입니다. +전달한 뒤 그 결과를 반환합니다. payload 의미와 문서 반영은 정본 Editing·Hand가 +소유하며 Host는 제품 policy 값, 사용자 메시지와 관찰 표현을 주입합니다. + +## 외부 이미지 입력 준비 + +`captureWebClipboardPaste(event, { codec?, files?, html?, text?, delegatedMimeTypes? })`는 event가 끝나기 전에 +활성화한 표현을 한 번만 선택합니다. `html: "images"`를 켜면 구조화 → 파일 → 이미지가 +포함된 HTML → 일반 텍스트 순입니다. `delegatedMimeTypes`는 기존 중첩 binding의 MIME을 +캡처 전에 위임합니다. Codec과 text를 생략한 `{ files: true, html: "images" }`는 이미지 +입력을 처리하고 이미지 없는 text·HTML은 기존 Rich Text binding에 남깁니다. +`readWebRasterFiles`는 선택한 파일 batch의 정책·PNG/JPEG/WebP decode를 검증하고 +실제 내용과 크기를 반환합니다. 이 단계는 문서나 History를 변경하지 않습니다. +`parseWebClipboardHTML`은 inert fragment에서 글·이미지 순서를 읽고, +`readWebHTMLClipboard`는 포함된 raster data URL을 같은 정책·decode 경로로 준비합니다. +외부 URL은 요청하지 않으며 읽을 수 없는 source는 전체 실패입니다. + +API 계약은 소유 패키지의 [Web API](/docs/api/web)에 있으며 +[Canvas](/demo/canvas)와 [Composer](/demo/composer)가 같은 준비 경로를 사용합니다. +HTML의 남은 source·혼합 입력과 외부 앱 왕복은 [Paste × Image TBD](clipboard.md#paste--image-기본기--tbd)에 +구현 범위와 구분해 공개합니다. ## `createWebClipboardTextWriter` @@ -68,6 +86,9 @@ mutation, announcement나 실패 시 rollback을 소유하지 않습니다. DOM listener를 직접 설치하거나 개별 operation을 호출해야 할 때 사용하는 저수준 API입니다. +cut callback이 있으면 write 전에 native event를 취소합니다. 쓰기 실패에도 브라우저의 +기본 삭제로 넘어가지 않으며, 전부 쓴 payload의 캡처 대상만 callback으로 제거합니다. +상세 실패·native editable 계약은 소유 패키지의 [Web Clipboard API](/docs/api/web)에 있습니다. ```ts function createWebClipboardBinding( @@ -78,11 +99,32 @@ interface WebClipboardBindingOptions { readonly codec: WebClipboardCodec; readonly representations?: ReadonlyArray>; readonly read: () => Payload | null; - readonly cut?: (payload: Payload) => EditingResult; + readonly cut?: (payload: Payload) => EditingResult | null; readonly paste: (payload: Payload) => EditingResult; } ``` +### 이벤트 소유권과 실패 + +| 동작 | `preventDefault()` 시점 | +| --- | --- | +| Copy | 모든 표현을 성공적으로 쓴 뒤 | +| Cut | DOM 입력 소유권을 확인한 뒤, payload 준비 전 | +| Paste | 지원하는 payload를 해석한 뒤, 편집 callback 호출 전 | + +Cut의 표현 인코딩/쓰기가 실패하면 `clipboard.unavailable`을 반환하고 제거 +callback을 호출하지 않습니다. 이미 취소한 native Cut을 다시 위임하지 않으므로 +브라우저의 후속 삭제로 원본·선택·History가 바뀌지 않습니다. 모든 표현을 쓴 +뒤에만 제거를 호출하며, 제거가 거절되면 `editing.rejected`를 반환합니다. +지원하는 Paste의 편집 거절도 취소한 이벤트를 다시 위임하지 않습니다. + +`createWebClipboardSurface`와 `routeWebClipboardEvent`는 다른 편집영역의 Cut을 준비 없이 위임합니다. +이 경계에서 앱 소유로 판정한 Cut의 미지원·clipboardData/payload 부재는 취소한 실패로 남습니다. +저수준 binding만 직접 호출할 때는 호출자가 소유권을 판정해야 하며, cut callback이 없으면 취소하지 않고 `clipboard.unsupported`를 반환합니다. Copy 쓰기 실패와 지원하지 않거나 유효하지 않은 Paste는 기존 위임을 유지합니다. 실패 전에 일부 clipboard 표현을 +썼다면 그 데이터는 남을 수 있습니다. 이 API는 OS clipboard와 문서의 원자성을 +보장하지 않습니다. [정본 owner의 계약과 검증](https://github.com/developer-1px/json-document/blob/main/packages/json-document-web/README.md#clipboard-소유권-라우팅)에 +단위·실제 브라우저 검증 경계를 함께 설명합니다. + ## Live Demo ```live-demo diff --git a/docs/public/affordance-copy-drag.md b/docs/public/affordance-copy-drag.md index 2b7accb64..886b8813f 100644 --- a/docs/public/affordance-copy-drag.md +++ b/docs/public/affordance-copy-drag.md @@ -1,67 +1,36 @@ # Duplicate -Duplicate는 수정 키를 누른 채 드래그하면 원본을 복제하는 손입니다. -`copy` / `alias` 커서가 이 손을 가리킵니다. +Duplicate는 원본을 남기고 새 ID를 가진 사본 집합을 만드는 손입니다. +평면 선택에서는 [`createPlaneSelectProfile`](/docs/api/affordance)이 기존 +`dragAffordance`·`dragOperation`을 조합하여 `operation: "copy"`를 반환합니다. +ID·문서·Selection·History는 Object Editing이 소유합니다. ```ts -import { - applyAffordance, - commitAffordance, - dragAffordance, - dragOperation, - dropAffordance, -} from "@interactive-os/json-document-affordance"; +import { createPlaneSelectProfile } from "@interactive-os/json-document-affordance"; -function onPointerMove(event: PointerEvent) { - applyAffordance(dragOperation(event), { - cursor: (cursor) => { - event.currentTarget.style.cursor = cursor; - }, - }); -} - -function onPointerUp(event: PointerEvent) { - const committed = commitAffordance( - dragAffordance(origin, { x: event.clientX, y: event.clientY }), - ); - if (!committed) return; - applyAffordance(committed, { - commit: (translate) => { - if (translate.type !== "translate") return; - let copied = false; - applyAffordance(dragOperation(event), { - hand: (operation) => { - if (operation.type !== "copy") return; - copied = true; - hostDuplicate(objectIds, translate); - }, - }); - if (copied) return; - const drop = commitAffordance(dropAffordance({ canDrop: true })); - if (!drop) return; - applyAffordance(drop, { - commit: (hand) => { - if (hand.type !== "move-drop") return; - editor.dispatch({ - type: "object.translate", - objectIds, - dx: translate.dx, - dy: translate.dy, - }); - }, - }); - }, - }); +const profile = createPlaneSelectProfile(); +profile.begin({ items: document.objects, selection: editor.snapshot.selection }, { + point: origin, hitKey: objectId, altKey: true, +}); +const preview = profile.preview(point, modifiers); // 원본과 변환된 사본 렌더링; 문서/ID 불변 +const result = profile.commit(point, modifiers); +if (result) { + editor.dispatch({ type: "selection.set", objectIds: result.selection.keys, + ...(result.selection.primaryKey === null ? {} : { primaryKey: result.selection.primaryKey }) }); + const delta = result.translation; + if (delta) editor.dispatch(delta.operation === "copy" + ? { type: "object.duplicate", objectIds: delta.keys, placement: { type: "offset", dx: delta.dx, dy: delta.dy } } + : { type: "object.translate", objectIds: delta.keys, dx: delta.dx, dy: delta.dy }); } ``` -복제 생성은 장르 Intent(호스트 또는 editor)이고, 옮기기는 json-document로 -갑니다. 값의 클립보드 복사/붙이기는 Hands입니다. +Alt/Option+drag는 copy, release 전에 Alt를 놓으면 move입니다. Shift는 큰 delta 축을 +고정합니다. 원본은 문서 순서 그대로 남고 사본 preview는 위에 그립니다. 취소·Alt-click· +최종 zero delta는 복제하지 않습니다. 사본 생성과 위치 변경은 하나의 Intent/Undo이며 +사본 집합·대응 primary를 선택합니다. Host에 별도 복제 구현을 두지 않습니다. -닫는 손: -- Alt/Option + 드래그 → copy -- 커서 `copy` 또는 `alias` -- 원본은 자리에 남고, 복제본이 포인터를 따라감 -- 드롭 후 복제본 집합이 선택된 채로 남음 +Mod+D와 공통 아이콘 툴바의 복제는 같은 `object.duplicate`를 사용합니다. 기본 offset은 +x/y 각각 24단위이고 반복 복제는 방금 생성한 선택을 대상으로 합니다. Clipboard는 건드리지 +않습니다. 값의 복사·잘라내기·붙여넣기는 별도로 Web Clipboard와 Object Editing이 연결합니다. -근거: [Apple HIG pointing devices](https://developer.apple.com/design/human-interface-guidelines/pointing-devices), [CSS UI cursor `copy` / `alias`](https://www.w3.org/TR/css-ui-4/#cursor), Figma Option-drag, Illustrator Option, tldraw Alt +실제 [Canvas Usage와 Source](/demo/canvas)에서 이 정본 경로를 확인할 수 있습니다. diff --git a/docs/public/affordance-drag.md b/docs/public/affordance-drag.md index 2d78e414b..d4964eae0 100644 --- a/docs/public/affordance-drag.md +++ b/docs/public/affordance-drag.md @@ -79,7 +79,8 @@ Canvas에 한정되지 않은 create/draw/move/resize lifecycle은 `createGestureSession()`을 사용합니다. `GestureState`는 string `type`만 요구하며 begin/preview/commit/cancel과 supersede 의미를 소유합니다. -좌표 변환, hit test, 잠금 정책, renderer, tool/viewport 정책은 Host 책임입니다. +좌표 변환은 Web Adapter, hit target·renderer·tool 조합은 Hand, 문서 기하는 +Document Type이 소유합니다. Host에는 권한·잠금 정책 값과 레이아웃만 남습니다. ## TBD @@ -88,9 +89,7 @@ Canvas에 한정되지 않은 create/draw/move/resize lifecycle은 ## Live Demo -```live-demo -/widgets/canvas -``` +[Canvas Hand의 Usage 및 Source](/docs/api/canvas)에서 같은 drag 문법을 확인할 수 있습니다. ```live-demo /widgets/board diff --git a/docs/public/affordance-nudge.md b/docs/public/affordance-nudge.md index e2fee957d..7975d95ab 100644 --- a/docs/public/affordance-nudge.md +++ b/docs/public/affordance-nudge.md @@ -4,6 +4,11 @@ Nudge는 고른 대상을 키보드로 조금 옮기는 손입니다. 화살표 Shift+화살표는 큰 단위입니다. 항목 이웃으로 초점을 옮기는 [Select](affordance-select.md)와 다릅니다. +평면 편집기는 [`createPlaneSelectProfile`](/docs/api/affordance)의 `keyDown`이 +이 Affordance를 조합한 집합 `translate` 결과를 소비합니다. [Canvas Usage](/demo/canvas)는 +native editable/IME 및 Ctrl/Meta/Alt 조합을 제외하고 Object Editing에 연결합니다. +이 최소 프로파일은 선택이 없을 때 nudge를 처리하지 않으며 pan으로 전환하지 않습니다. + ```ts import { applyAffordance, nudgeAffordance } from "@interactive-os/json-document-affordance"; @@ -24,7 +29,8 @@ function onKeyDown(event: KeyboardEvent) { 손은 1과 10을 닫습니다. 이동은 json-document로 갑니다. -닫는 손: +단일 Affordance의 조합 규칙: + - Arrow: 1 단위 - Shift+Arrow: 10 단위 - 키 반복은 호스트 플랫폼 기본 diff --git a/docs/public/affordance-resize.md b/docs/public/affordance-resize.md index eacf7a43f..a2653fefe 100644 --- a/docs/public/affordance-resize.md +++ b/docs/public/affordance-resize.md @@ -36,7 +36,7 @@ import { import { applyAffordance, commitAffordance, resizeAffordance } from "@interactive-os/json-document-affordance"; function onPointerMove(event: PointerEvent, edge: "se") { - applyAffordance(resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event), { + applyAffordance(resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event, initialSize), { cursor: (cursor) => { event.currentTarget.style.cursor = cursor; }, @@ -48,7 +48,7 @@ function onPointerMove(event: PointerEvent, edge: "se") { function onPointerUp(event: PointerEvent, objectId: string, edge: "se") { const committed = commitAffordance( - resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event), + resizeAffordance(origin, { x: event.clientX, y: event.clientY }, edge, event, initialSize), ); if (!committed) return; applyAffordance(committed, { @@ -67,6 +67,12 @@ function onPointerUp(event: PointerEvent, objectId: string, edge: "se") { } ``` +`initialSize`는 press 시점의 `{ width, height }`입니다. `origin`·`point`와 크기는 +같은 좌표계를 사용합니다. 초기 크기를 제공해야 Shift가 실제 객체 비율을 유지하고 +최소 크기에서도 반대편 고정점이 보존됩니다. 네 변은 한 축을 조절하고, Shift와 함께 +잡으면 다른 축은 중심 기준으로 조절합니다. 자세한 API와 실제 Canvas Usage는 +[소유 패키지의 Resize 계약](/docs/api/affordance)에서 확인할 수 있습니다. + 커서는 호스트 화면 상태이고, 확정된 크기만 json-document로 갑니다. 분할선 화살표는 APG Window Splitter와 같고, Shift는 비율, Alt는 가운데 기준입니다. diff --git a/docs/public/affordance-select.md b/docs/public/affordance-select.md index 664aa7a1a..6e879dc4e 100644 --- a/docs/public/affordance-select.md +++ b/docs/public/affordance-select.md @@ -4,6 +4,13 @@ Select는 대상을 집는 손입니다. 클릭은 그 대상으로 바꾸고, S 범위를 늘리며, Mod는 토글합니다. 화살표는 이웃으로 옮기고, Shift+화살표는 범위를 늘립니다. +위 문법은 순서가 있는 선택입니다. **평면**에서는 Shift가 범위가 아닌 집합 toggle입니다. +`createPlaneSelectProfile`이 click/drag 구분, marquee, 집합 이동 preview, Mod+A, Delete, +Escape, primary 편집까지 연결합니다. Canvas는 이 public profile을 그대로 소비합니다. +입력·출력·취소·범위의 정본은 [Affordance API · 평면 Select](/docs/api/affordance)에 있습니다. + +[실제 Canvas Usage와 Source](/demo/canvas)에서 프로파일을 실행할 수 있습니다. + ```ts import { applyAffordance, @@ -55,8 +62,8 @@ function onSelectAll(event: KeyboardEvent) { ``` 호스트는 보이는 키와 장르 Intent만 넘깁니다. keymap을 덮어쓰지 않습니다. -이미 고른 상자를 수정 키 없이 누르면 집합을 유지합니다. 안 고른 상자는 -그 상자만으로 바꿉니다. +`planeHitAffordance`는 press 시점의 집합 유지를 해석하는 단일 연산입니다. +완성된 프로파일은 drag면 그 집합을 이동하고, drag 없이 release하면 그 상자 하나로 선택합니다. ## API Reference @@ -92,7 +99,8 @@ Usage와 Source: [Order](/demo/order), [Tree](/demo/tree), [Sheet](/demo/sheet), - selection follows focus vs focus-only move는 [Focus](affordance-focus.md) - 글 단어·줄 범위는 [Double-click](affordance-double-click.md)· [Triple-click](affordance-triple-click.md)·[Caret](affordance-caret.md) -- 빈 평면의 여러 대상은 [Marquee](affordance-marquee.md) +- 독립적인 marquee 연산은 [Marquee](affordance-marquee.md), 완성된 평면 문법은 + `createPlaneSelectProfile`이 제공합니다. ## Live Demo diff --git a/docs/public/affordance.md b/docs/public/affordance.md index f3ebaf847..8c173c549 100644 --- a/docs/public/affordance.md +++ b/docs/public/affordance.md @@ -94,6 +94,12 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command 평면 위 객체에서 손은 선택 도구가 기본입니다. 빈 곳과 객체 히트가 다르고, 고른 집합이 곧 옮길 대상입니다. +최소 평면 문법은 [`createPlaneSelectProfile`](/docs/api/affordance)로 조합되어 +[Canvas Usage](/demo/canvas)에서 실행됩니다. 아래 표에는 단일 Affordance로만 제공되는 +더 넓은 기능도 포함됩니다. 축 고정·Alt 복제·Mod+D·nudge는 프로파일에 연결되어 있습니다. +그룹·중첩 선택·snap·zoom/pan은 이 최소 프로파일의 +지원 범위가 아닙니다. + 근거 축: [Figma 레이어 선택](https://help.figma.com/hc/en-us/articles/360040449873-Select-layers-and-objects), [FigJam 선택·이동](https://help.figma.com/hc/en-us/articles/1500004292221-Select-move-and-order-objects-in-FigJam), [Illustrator 기본 단축키](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), @@ -108,7 +114,7 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command | 손 | 함의 | 수렴 | 상태 | | --- | --- | --- | --- | | 객체 click | 그 객체만 replace | 안정 | [닫힘](affordance-select.md) | -| 이미 고른 객체 click (수정 키 없음) | 집합 유지. 다음 드래그의 대상은 집합 전부 | 안정 | [닫힘](affordance-select.md) | +| 이미 고른 객체 press (수정 키 없음) | 집합 유지. drag는 집합 전부, drag 없이 release하면 단일 선택 | 안정 | [닫힘](affordance-select.md) | | 안 고른 객체 click | 집합을 그 객체 하나로 바꿈. 드래그 대상도 그 하나 | 안정 | [닫힘](affordance-select.md) | | Shift+click | 집합에 더하거나, 이미 있으면 뺌 | 안정 | [닫힘](affordance-select.md) | | Mod+click (⌘/Ctrl) | 호스트가 `nestedId`를 줄 때만 자식. 없으면 일반 click. 리스트박스 토글이 아님 | 갈림 | [닫힘](affordance-select.md) | diff --git a/docs/public/api.md b/docs/public/api.md index 81b750313..ec899b97d 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -1,7 +1,9 @@ -# API Reference +# JSON Document Protocol -앞 문서에서 사용한 `@interactive-os/json-document`의 공개 API를 작업별로 -정리합니다. +`JSONDocument`는 값·주소·검증·원자적 변경·관찰의 여섯 member를 갖는 공통 +계약입니다. 로컬 구현과 [Collaboration](collaboration.md)이 같은 계약을 +제공합니다. Core v3의 Stable 계약과 현재 TypeScript API를 작업별로 정리합니다. +package 전체 시그니처는 [JSON Document API](../api-reference/json-document.md)에 있습니다. ## 문서 만들기 @@ -158,6 +160,14 @@ import { jsonEqual } from "@interactive-os/json-document"; jsonEqual({ title: "Draft", tags: [] }, { tags: [], title: "Draft" }); // true ``` +## 문서 없이 JSON 값 검증·조회하기 + +Snapshot 조회와 일반 JSON 값 검증에는 `readPointer(value, pointer)`와 +`isJSONValue(value)`를 사용합니다. 두 함수는 값을 복제하거나 정규화하지 +않습니다. 주소 조회는 `document.at`과 같은 문법·실패 결과를 사용하고 원본 +참조를 반환합니다. 상세 제약과 예제는 [Core package 문서](https://github.com/developer-1px/json-document/blob/main/packages/json-document/README.md)의 +순수 core 항목에서 확인할 수 있습니다. + ## 문서 없이 patch 적용하기 `applyPatch(value, operations)`는 document 상태를 만들지 않고 RFC 6902 @@ -287,6 +297,8 @@ type Failure = { | --- | --- | --- | | 현재 값 | `document.value` | `JSONValue` | | 한 위치 읽기 | `document.at(pointer)` | `ReadResult` | +| snapshot에서 한 위치 읽기 | `readPointer(value, pointer)` | `ReadResult` | +| JSON 값 검사 | `isJSONValue(value)` | boolean/type guard | | 여러 위치 찾기 | `document.query(jsonPath)` | `QueryResult` | | patch 검사 | `document.validatePatch(operations)` | `JSONPatchValidationResult` | | 상태 변경 | `document.commit(operations, options?)` | `JSONDocumentCommitResult` | @@ -298,13 +310,13 @@ type Failure = { ## 공개 export -Package root는 다음 23개 symbol을 공개합니다. +Package root는 다음 25개 symbol을 공개합니다. ```txt values applyPatch, createJSONDocument appendSegment, buildPointer, parentPointer, parsePointer - jsonEqual, parseArrayIndex, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parseArrayIndex, readPointer, trackPointer, tryParsePointer types JSONValue, Pointer, JSONPatchOperation diff --git a/docs/public/building-blocks.md b/docs/public/building-blocks.md new file mode 100644 index 000000000..089378cee --- /dev/null +++ b/docs/public/building-blocks.md @@ -0,0 +1,37 @@ +# Building Blocks + +Building Blocks는 Foundation의 계약을 실제 입력·외부 생태계·UI에 연결하는 +독립적인 책임입니다. 필요한 조각을 골라 Hands를 조합합니다. + +## 네 가지 책임 + +| 위치 | 건너는 경계 | 안내 | +| --- | --- | --- | +| Adapter | keyboard, pointer, clipboard, native input 같은 플랫폼 사실 → 기존 편집 계약 | [Adapter](adapters.md) | +| Connector | React, React Hook Form, Ajv, Zod, TanStack Table, A2UI의 계약 ↔ 문서·편집 계약 | [Connector](connectors.md) | +| Affordance | 선택·drag·resize·취소 같은 조작 의미와 수명주기 → 장르별 Intent | [Affordance](affordance.md) | +| UI Primitives | 표준 control·focus·overlay·반복 UI 행동 → 제품의 시각 조합 | [UI Primitives](ui-primitives.md) | + +Adapter와 Connector는 앞뒤 계층이 아닙니다. React 없이 Web Adapter를 쓰거나, +DOM 입력 없이 Connector로 문서를 관찰할 수 있습니다. Affordance가 플랫폼 입력을 +받는 편의 API도 실제 플랫폼 판정은 Adapter의 계약을 소비합니다. + +## 조합과 소유권 + +JSON 값과 원자적 변경은 [JSON Document](api.md), 문서 고유 의미는 +[Document Type](document-types.md), 작업 경계와 선택·History는 +[Editing](editing.md)에 남습니다. Building Blocks는 이 의미를 다시 구현하지 않고 +자신이 연결하는 계약을 소비합니다. + +Host는 사용할 모듈, 권한·기본값, 구체 인스턴스와 layout을 선택합니다. +modifier 해석, gesture 수명주기, native selection 복구와 재사용 UI는 해당 +Adapter·Affordance·Connector·UI Primitives의 책임입니다. + +## 현재 제공 범위와 목표 + +각 안내의 API·Usage·Source는 현재 제공하는 연결을 보여 줍니다. 특정 연결의 +존재가 모든 플랫폼이나 모든 장르의 편집을 보장하지는 않습니다. + +[Official Hands · TBD](official-hands.md)는 이 조각들을 조합했을 때 지원 입력, +선택 복원, Clipboard, 취소와 Undo/Redo가 함께 동작하는 기본 Profile을 목표로 +합니다. 지원 범위와 적합성 증거가 닫히기 전에는 완성된 SDK로 간주하지 않습니다. diff --git a/docs/public/clipboard.md b/docs/public/clipboard.md index dcab4545f..c18ed4bd3 100644 --- a/docs/public/clipboard.md +++ b/docs/public/clipboard.md @@ -49,6 +49,48 @@ if (cut?.result.ok) { 행과 열 순서를 유지합니다. [History](history.md)는 이렇게 기록된 문서 값과 Selection을 함께 복원합니다. +## Paste × Image 기본기 — TBD + +Clipboard의 기본기는 다른 앱에서 가져온 내용을 편집 가능한 문서로 받아들이고, +다시 다른 앱에 전달하는 과정까지 포함합니다. 아래 TBD는 지원 약속의 목표이며, +현재 API가 모두 구현했다는 뜻은 아닙니다. + +| 기본기 | 현재 범위 / TBD | 완료를 판단할 동작 | 정본 | +| --- | --- | --- | --- | +| 이미지 파일 입력 | Canvas·Composer 공통 경로 구현 | PNG/JPEG/WebP를 실제로 읽고 표시하며, 실패 batch는 삽입하지 않음 | File Intake·Web·각 Hand | +| 이미지 내용 보존 | Canvas 객체·Composer 첨부에 포함 | Undo/Redo·JSON 왕복·Composer submit, Canvas 구조 복사 후에도 내용과 치수 유지 | 각 문서 모델·Editing | +| 비동기 편집 | 공통 순서·취소 queue 구현 | 연속 요청 순서 유지, 취소 후 늦은 삽입 없음; Composer는 준비 중 타이핑 가능 | Editing·각 Hand | +| HTML 이미지·글과 이미지 | 일부 구현: 포함된 raster와 Canvas 혼합 입력, Composer 이미지-only | HTML 안의 이미지와 글의 순서를 지원하는 문서 의미로 변환; 읽을 수 없는 이미지는 전체 실패 | Web·Rich Text Web·각 문서 모델 | +| 서식 없이 붙여넣기 | 명시적인 공통 입력 계약 TBD | 서식 붙여넣기와 plain text 선택을 구별하고 native 입력을 침범하지 않음 | Web·Affordance·각 Hand | +| 이미지로 복사 | Canvas TBD | 선택한 글·도형·이미지를 PNG로 복사해 외부 앱에 붙임; 실패가 문서를 바꾸지 않음 | Canvas·Web | +| 실제 플랫폼 왕복 | OS-native 검증 TBD | 스크린샷·브라우저 이미지·Docs/Slides에서 실제 복사하여 붙이고, 외부 앱으로 다시 전달 | Web·제품 경로 검증 | + +한 항목의 HTML·텍스트·PNG는 같은 내용의 대체 표현일 수 있습니다. 이를 모두 +별개 내용으로 삽입하지 않습니다. 반면 선택한 HTML 표현 안의 글·이미지는 함께 +보존해야 할 내용일 수 있습니다. 표현 선택과 문서 내용 변환은 다른 결정입니다. +[Clipboard 표현 모델](https://www.w3.org/TR/clipboard-apis/#clipboard-interface)은 +이 구분을 설명합니다. [Docs·Slides의 이미지 복사](https://support.google.com/docs/answer/161768?hl=en)도 +외부 앱에는 HTML로 전달되므로 HTML 이미지를 고급 문서 import만의 문제로 보지 않습니다. + +현재 Canvas는 구조화 Object → native 이미지 파일 → 이미지가 포함된 HTML → 일반 텍스트 +순으로 표현을 고릅니다. 선택한 HTML의 글과 PNG/JPEG/WebP data URL은 입력 순서의 +편집 가능한 객체가 되며 한 번에 Undo합니다. 배치는 간격을 둔 세로 흐름이며 넘치는 높이를 +함께 축소합니다. HTML 서식·원래 CSS 배치는 재현하지 않습니다. + +Composer는 내부 Rich Text 구조화 MIME을 먼저 기존 binding에 위임합니다. 그 외 파일과 +이미지-only HTML은 실제 첨부로 준비하고, 이미지가 없는 HTML은 기존 Rich Text로 처리합니다. +글+이미지 HTML은 inline 의미를 표현할 수 없어 draft를 바꾸지 않고 미지원 오류를 알립니다. + +HTML 이미지의 외부·상대·blob·cid URL, 비어 있거나 깨진 source는 일부 내용만 남기는 대신 +전체 입력을 거절합니다. 임의 다운로드는 하지 않습니다. native 파일과 HTML이 함께 있을 때는 +파일 표현이 우선하며, 둘의 대응과 혼합 의미 보존은 TBD입니다. 이 지원 범위는 브라우저· +Docs/Slides의 실제 복사 입력을 모두 지원한다는 뜻이 아닙니다. + +Canvas는 평면 객체, Composer는 instruction과 첨부 목록을 결과로 만듭니다. +같은 입력을 받아도 Canvas 좌표를 Composer에 넣거나, 첨부 목록을 Rich Text의 +inline 이미지처럼 설명하지 않습니다. 서버 업로드, 임의 외부 URL의 가져오기, +Office 전체 레이아웃 재현은 아직 지원하지 않습니다. + ## Live Demo ```live-demo diff --git a/docs/public/composer.md b/docs/public/composer.md index 98c7551e3..300608988 100644 --- a/docs/public/composer.md +++ b/docs/public/composer.md @@ -24,6 +24,9 @@ Composer의 instruction은 Rich Text extension profile을 사용하는 canonical 사람·agent와 skill은 안정 ID를 가진 inline atom입니다. 첨부 자료도 보이는 파일명과 별개인 ID를 가진 context로 같은 draft에 남습니다. native contenteditable은 입력, Selection, IME, structured Clipboard와 history를 기존 Rich Text editor에 위임합니다. +PNG·JPEG·WebP 첨부는 파일 정보에 실제 이미지 내용과 원본 크기를 함께 보존합니다. +이미지 내용이 없는 기존 attachment는 metadata-only이며, 두 상태를 같은 것으로 +표시하지 않습니다. ## Public contract @@ -38,6 +41,7 @@ Selection, IME, structured Clipboard와 history를 기존 Rich Text editor에 `@interactive-os/json-document-file-intake`의 `validateFileCandidates`로 공통 파일 후보와 수용 정책을 검증한 뒤 Host가 주입한 ID로 Composer attachment를 만들며, 검증 실패를 명시적인 Composer command 결과로 번역합니다. +이미지가 있으면 같은 File Intake의 `RasterImageContent` 계약도 검증합니다. 호환용 `findComposerTrigger`와 `resolveComposerSuggestions`도 공용 Rich Text Suggestion 계약에 위임합니다. @@ -61,6 +65,18 @@ Web `File`과 `ClipboardEvent`에서 이름·크기·media type을 읽는 일은 `@interactive-os/json-document-file-intake`의 `FileCandidate`입니다. Composer domain은 DOM과 Web object를 알지 않으며, Web adapter는 ID·허용 정책·attachment kind를 결정하지 않습니다. +파일 선택·drop·paste의 이미지 준비는 Web `readWebRasterFiles`를 함께 사용합니다. +Editing `createEditingPreparationQueue`가 완료 순서와 무관하게 입력 순서대로 반영하며, +한 요청의 실패는 일부 첨부나 Undo 기록을 남기지 않습니다. 준비 중에도 instruction을 +계속 고칠 수 있고, 완료 시점의 최신 첨부 목록에 붙입니다. 이는 inline 문서의 paste +anchor를 추적하는 기능과는 다릅니다. + +`useComposer`의 `isPreparingAttachments`, `attachmentError`, `canSubmit`, +`cancelAttachments()`로 준비·실패·취소 상태를 표현합니다. 준비 중에는 submit을 막고, +Escape·binding의 Undo/Redo·unmount는 늦은 결과의 삽입을 취소합니다. 외부에서 draft를 +교체하거나 editor의 History API를 직접 호출할 때도 먼저 `cancelAttachments()`를 호출합니다. +첨부 완료는 현재 caret이나 focus를 옮기지 않습니다. + Host는 config 값과 runtime port를 주입하고 composer의 배치, copy, CSS와 첨부 preview를 그립니다. 파일 metadata 표시는 File Intake 정본의 `formatFileSize`를 사용합니다. 후보 자료·제품 copy·권한은 Host가 정하지만 suggestion open/dismiss, keyboard focus, @@ -68,6 +84,19 @@ pointer active state와 mention 삽입 lifecycle은 정본 Hands가 소유합니 검색 source, 파일 저장소, Agent runtime, transcript, think·stream·tool 상태는 Composer가 소유하지 않습니다. +## 이미지와 Clipboard의 남은 기본기 + +이미지-only HTML은 Web의 inert parser와 준비 API를 통해 포함된 PNG/JPEG/WebP를 +실제 첨부로 읽습니다. 내부 Rich Text 구조화 복사는 기존 binding에 우선 위임하며, +파일이 함께 있으면 파일 표현만 처리합니다. 글+이미지 HTML은 일부 내용을 버리는 대신 +전체를 미지원 오류로 알립니다. 외부·상대·blob·cid 이미지 주소는 다운로드하지 않습니다. + +[Paste × Image TBD](clipboard.md#paste--image-기본기--tbd)에 HTML의 남은 source·혼합 입력, +명시적인 plain paste, 이미지로 복사와 OS-native round-trip의 소유자·기대 결과를 +미리 공개합니다. 현재 Composer 이미지 slice는 별도 첨부 목록에 내용을 보존하는 +단계이며 inline 혼합 입력을 구현한 것은 아닙니다. 서버 업로드와 자산 저장소는 +별도 계약이고, 이미지 외 파일은 현재 파일 정보만 보존합니다. + ```live-demo /demo/composer ``` diff --git a/docs/public/concepts.md b/docs/public/concepts.md index ac0a41c62..1c8cf9b32 100644 --- a/docs/public/concepts.md +++ b/docs/public/concepts.md @@ -1,163 +1,114 @@ # Concept Map -이 사이트의 권장 읽기 순서와 package 의존 방향은 같은 것이 아닙니다. -먼저 Core를 배우고 실제 편집 경험까지 읽어 가지만, 필요한 책임은 아래처럼 -Core 주위에 선택적으로 붙습니다. - -```txt - ┌─ Editing ─ Selection · Intent · History -local JSON Document ─────┤ - ├─ Document Types ─ profile · model · schema · operations - ├─ Adapter ─ platform contract -collaborative Document ──┤ - ├─ Connector ─ named ecosystem - └─ optional domain / UI composition - -Affordance ─ input grammar ─┐ -UI Primitives ─ standard UI ├─ Host가 장르별 Hands를 조합 -Rich Text 등 domain ────────┘ - -Hands를 surface에 조합한 결과가 사람이 다루는 Artifact가 됩니다. Artifact는 -navigation이나 workflow를 소유하지 않는 콘텐츠입니다. Application은 Artifact와 -다른 콘텐츠를 runtime과 제품 정책에 놓아 실제 제품 경험으로 제공합니다. -``` - -이 그림의 선은 허용된 의존·조합 방향입니다. 모든 노드를 순서대로 설치하라는 -뜻이 아닙니다. Adapter와 Connector는 서로의 선행 계층이 아니며, 각각 플랫폼과 -외부 라이브러리가 필요할 때 고릅니다. Collaboration은 다음 계층이 아니라 -같은 `JSONDocument` 계약의 다른 구현입니다. - -권장 읽기 순서는 `Foundation → Building Blocks → Hands → Artifact → Application`입니다. -Foundation 안에서는 JSON Document, Document Types, Editing과 Collaboration을, -Building Blocks에서는 Adapter, Connector, Affordance와 UI Primitives를 읽습니다. -이 순서는 학습을 위한 서사일 뿐 package dependency를 주장하지 않습니다. -Collaboration은 Core의 대체 구현과 profile 포함 관계로 Foundation 안에서 읽습니다. - -프로젝트가 책임을 발견하는 방향은 이 읽기·구현 방향과 반대입니다. +목표는 같은 역할과 책임이 하나의 정본 모듈을 갖고, Application이 그 공개 API를 +조합하는 구조입니다. 아래는 읽기 순서와 책임 지도입니다. 모든 package가 차례로 +의존하는 직렬 계층은 아닙니다. ```text -구현 의존: Foundation → Building Blocks → Hands → Artifact → Application -책임 발견: Application → 책임 발견 → Canonical Module → Application +Foundation +├─ JSON Document ─ value / at / query / validatePatch / commit / subscribe +│ └─ Local 또는 Collaboration 구현 +├─ Document Types ─ model / schema / invariants / operations / projections +│ └─ 후보별 owner 수렴과 계약 확정 · TBD +└─ Editing ─ Selection / Topology / Intent / Clipboard / History + +Building Blocks +├─ Adapter ─ 플랫폼 입력·출력 +├─ Connector ─ 이름 있는 외부 생태계 +├─ Affordance ─ 조작 의미·수명주기 +└─ UI Primitives ─ 재사용 UI + +Hands ─ 장르별로 함께 검증된 편집 조합 +└─ Official Hands Profile의 완성 조건 · TBD + +Artifact ─ Application 안에서 사람이 다루는 콘텐츠 +└─ 현재 visual prototype의 문서·Hands 계약 연결 · TBD + +Applications ─ 조합·실행 순서·제품 정책·layout·외부 인스턴스 주입 ``` -먼저 제품을 만들고 실제 사용 흐름에서 반복되는 책임을 찾습니다. 추출된 책임은 -canonical owner와 public API를 얻고, Application은 임시 구현 대신 그 API를 다시 -소비합니다. 자세한 순환은 [How We Build](how-we-build.md)에서 설명합니다. - -## JSON Document - -JSON Document는 현재 값을 보관하고, JSON Pointer와 JSONPath로 위치를 찾고, -JSON Patch를 검사해 적용합니다. 적용된 변경은 구독자에게 전달합니다. +Adapter와 Connector는 서로의 선행 계층이 아닙니다. 필요한 플랫폼과 생태계를 +독립적으로 선택합니다. Collaboration도 Editing 앞이나 뒤의 필수 단계가 아니라 +같은 `JSONDocument` 계약의 다른 구현입니다. -이 계약에는 화면이나 편집 장르가 들어가지 않습니다. 문서, 표, 보드의 -생김새가 달라도 값의 주소와 변경 형식은 여기서 같습니다. 공개 호출은 -[API](api.md)에 정리되어 있습니다. +## Foundation의 프로토콜 -## Editing +[Foundation](foundation.md)은 값·의미·작업·관찰의 경계를 설명합니다. -Editing은 문서 값 옆에 편집 중에만 필요한 상태를 둡니다. 화면에서 들어온 -요청은 Intent가 되고, Selection은 대상을, Topology는 보이는 순서를, -Clipboard는 옮길 내용을 기억합니다. History는 값과 선택을 함께 되돌립니다. +| 경계 | 현재 계약 | 목표와 남은 일 | +| --- | --- | --- | +| JSON Document | 여섯 member와 JSON 표준 연산; Core v3 Stable | UI·장르별 edit verb를 Core에 추가하지 않음 | +| Document Type | 의미·모델·유효성·연산·Projection이라는 책임 경계 | 후보별 canonical owner와 공개 계약의 수렴 · TBD | +| Editing | Intent → EditingPlan → Session.apply → commit → EditingSnapshot | Hands별 필수 행동과 조합 적합성의 동결 · TBD | +| Collaboration | 같은 document 계약과 base → History → Text profile | 각 profile의 지원 범위로 사용; 모든 Hands의 협업 보장과 구별 | -Editing은 화면을 그리지 않습니다. 화면이 보낸 Intent를 현재 문서와 편집 -상태에 적용합니다. 시작점은 [Intent guide](intent-guide.md)입니다. +[JSON Document Protocol](api.md)과 [Editing Protocol](editing.md)에서 실제 경계를 +건너는 값을 봅니다. Copy는 읽기이고 선택만 바꾸는 작업에는 document commit이 +필요하지 않습니다. 로컬 inverse History와 actor-local 협업 History는 이름이 +같아도 복원 의미와 소유자가 다릅니다. -## Document Types +## Document Types · TBD Document Type은 특정 JSON Document가 무엇을 의미하고 어떤 상태와 변경이 유효한지를 정의합니다. Profile, Document Model, Schema와 invariant, Document Operation, Projection이 이 책임에 속합니다. -Document Type은 selection, History 같은 편집 lifecycle이나 화면 표현을 -소유하지 않습니다. 현재 후보와 아직 결정하지 않은 소유권은 -[Document Types · TBD](document-types.md)에 정리되어 있습니다. - -## Adapter +현재 Rich Text·Order·Object·Tree·Database·Calendar·Sheet·Kanban·Annotation은 +분류 후보입니다. [Document Types](document-types.md)에서 현재 관찰된 schema, +소유권 감사와 완료 조건을 봅니다. 이름이 등록됐다고 package 재배치가 완료된 +것은 아닙니다. 같은 Calendar라도 Document Type, Hand와 Application은 다른 책임입니다. -Adapter는 keyboard, clipboard, contenteditable 같은 플랫폼 계약을 공개 -API에 맞춰 번역합니다. 예를 들어 key chord는 의미 command가 되고, -브라우저의 clipboard event는 Editing의 copy, cut, paste로 이어집니다. +## Building Blocks -Adapter는 책임 종류입니다. Web Adapter는 Editing을 소비하지만 -Contenteditable Adapter는 JSON Document와 DOM/React lifecycle을 직접 잇습니다. -따라서 모든 Adapter가 Editing 다음 dependency라는 뜻은 아닙니다. +[Building Blocks](building-blocks.md)는 서로 독립적인 네 책임을 제공합니다. -플랫폼마다 다른 event와 lifecycle은 [Adapter](adapters.md)가 맡습니다. +| 위치 | 소유하는 것 | 안내 | +| --- | --- | --- | +| Adapter | keyboard·clipboard·native input 같은 플랫폼 계약의 번역 | [Adapter](adapters.md) | +| Connector | React·Zod·Ajv·TanStack Table·A2UI 같은 외부 계약의 연결 | [Connector](connectors.md) | +| Affordance | 선택·drag·resize·취소 같은 조작 의미와 수명주기 | [Affordance](affordance.md) | +| UI Primitives | 표준 control·focus·overlay와 반복 UI 행동 | [UI Primitives](ui-primitives.md) | -## Connector - -Connector는 React, Zod, Ajv, TanStack Table처럼 이름 있는 라이브러리의 -입출력을 기존 계약에 연결합니다. 문서 변경을 React 구독으로 전달하거나, -화면에 보이는 행과 열을 Sheet의 Topology로 바꾸는 식입니다. - -라이브러리를 교체해도 문서와 편집 계약은 바뀌지 않습니다. 지원 범위는 -[Connector](connectors.md)에 있습니다. Connector는 JSON Document, Editing, -Hands capability 중 자신이 연결하는 계약에 직접 붙으며 Adapter를 전제로 하지 -않습니다. - -## Affordance - -Affordance는 고르기, 입력하기, 접기, drag, undo처럼 사람이 이미 알고 있는 -조작을 정의합니다. 일부 API는 Adapter가 만든 command를 받고, 일부 Web 편의 -API는 event-shaped input을 내부 Adapter와 함께 해석합니다. 각 reference의 -입력 type이 어느 경계인지 정본입니다. - -화면의 모양은 host가 정합니다. 입력의 의미와 조합은 -[Affordance](affordance.md)에서 다룹니다. +예를 들어 Contenteditable Adapter는 JSON Document와 DOM lifecycle을 직접 +연결할 수 있습니다. 모든 Adapter가 Editing을 거쳐야 한다는 뜻은 아닙니다. +외부 생태계를 교체해도 문서 고유 의미를 Connector에서 다시 정의하지 않습니다. ## Hands -Hands는 Core와 필요한 선택 책임을 조합해 사람과 agent가 artifact를 다루게 -하는 장르별 완료 기준입니다. -Order는 한 줄 목록을 집어 옮기고, Object는 key를 고치며, Tree는 가지를 -접습니다. Composer는 agent에게 지시와 맥락을 건네는 손이고, Mention은 -안정적인 대상을 글 안에 넣는 손입니다. 둘은 아직 TBD입니다. - -Hands는 하나의 공통 package나 화면 component 이름이 아닙니다. 장르 document와 -Intent, Selection/Clipboard/History, 대표 Affordance, platform lifecycle이 실제 -Host 조합에서 함께 동작해야 닫힙니다. 재사용 책임은 owner package API로, -제품 고유 정책은 이름 붙은 Host module로 남습니다. 현재 증거와 목록은 -[Hands](hands.md)에 있습니다. - -## Artifact - -Artifact는 독립 App이 아니라 앞의 책임을 조합해 사람이 보고 고칠 수 있게 만든 -Application 내부 콘텐츠입니다. navigation, workflow와 제품 정책은 소유하지 않습니다. -MD, PPT, Sheet는 서로 다른 화면과 Hands를 사용해도 같은 문서와 편집 계약을 -공유할 수 있습니다. +Hands는 장르의 문서와 Intent, Selection/Clipboard/History, 대표 Affordance, +platform lifecycle을 실제 편집 경험으로 닫은 조합입니다. 하나의 공통 superclass나 +만능 package 이름이 아닙니다. -현재 Artifact 페이지는 file compatibility나 Core/Hands interoperability를 -증명하지 않는 visual prototype입니다. 여러 artifact surface를 한 Host chrome에 -놓는 정보 구조와 시각 가설만 확인하며, 실제 계약 증거는 각 Hands Live Demo와 -package test에서 봅니다. +[Hands](hands.md)의 Live Demo와 owner API는 현재 구현의 증거입니다. +[Official Hands · TBD](official-hands.md)는 디자인과 제품 정책은 열어 두고 +기본 편집을 완성된 SDK로 제공하려는 목표입니다. 구현이 있는 것과 Profile의 +지원 입력·실패·선택 복원·호환성 조건이 모두 닫힌 것은 구별합니다. -## Application +## Artifact · TBD -Application은 Artifact와 Hands를 실제 제품 경험으로 제공하는 최종 composition -root입니다. 주요 화면 영역과 실행 순서, URL과 navigation, 제품 copy와 fixture, -concrete runtime 연결은 Application에 남습니다. 문서의 의미, editing lifecycle, -platform translation과 반복 UI처럼 같은 역할과 책임을 갖는 코드는 canonical -module로 추출됩니다. +Artifact는 navigation과 workflow를 소유하지 않는 Application 내부 콘텐츠입니다. +서로 다른 Hands를 사용해도 같은 문서·편집 계약으로 사람이 보고 고칠 수 있어야 합니다. -[Calendar와 AI Agent](/applications)는 제품에서 발견한 책임과 App에 남은 정책을 -함께 보여 줍니다. Calendar Document Type, Calendar Hand와 Calendar Application은 -같은 이름을 공유하지만 서로 다른 owner입니다. +현재 [Artifact](/viewer)는 MD·PPT·Sheet surface를 한 Host chrome에 놓는 visual +prototype입니다. JSON Document/Hands 연결, 편집 후 복원과 파일 호환성의 증거는 +아직 없습니다. 모양을 전환할 수 있다는 사실을 상호운용이나 완성된 편집의 +증거로 세지 않습니다. -## Collaboration +## Applications -Collaboration은 JSON Document 계약을 여러 참여자의 인과 변경으로 구현합니다. -로컬 구현과 마찬가지로 값을 읽고, 변경을 적용하고, 결과를 구독하지만 내부 -기록은 참여자의 변경 순서와 수렴을 다룹니다. +Application은 Artifact와 Hands를 실제 제품 경험으로 조합합니다. 실행 순서, +URL과 navigation, 권한·copy·fixture·layout과 concrete runtime 연결을 소유합니다. +모델·의미 연산·selection·history·gesture·플랫폼 번역·projection·재사용 UI는 +각 canonical module의 책임입니다. 한 제품에서만 쓰여도 이 경계는 같습니다. -Collaboration은 Foundation 안에서 JSON Document와 같은 계약의 대체 구현으로 읽습니다. -협업 document를 Editing에 주입할 수 있지만 History command는 editor-local -History 대신 actor-local `runtime.history`로 연결해야 합니다. base → History → -Text profile의 포함 관계는 [Collaboration](collaboration.md)에 있습니다. +[Calendar와 AI Agent](applications.md)는 현재 제품에서 드러난 조합을 보여 줍니다. +읽기 순서는 Foundation에서 Application으로 가지만, 책임을 발견하는 작업은 +Application에서 시작해 정본 API를 만들고 제품이 다시 소비하는 순환입니다. +이 과정은 [How We Build](how-we-build.md)에 있습니다. ## Reference vertical: Rich Text -Rich Text는 새 최상위 계층이 아니라 이 책임 graph를 끝까지 적용한 대표 -vertical입니다. JSON Document와 Selection/Editing 위에 versioned domain schema를 -두고, Web와 React integration을 분리한 뒤 Host UI가 조합합니다. profile, -conformance vector와 browser evidence는 다른 Hands가 경계를 판단할 때 참고하는 -구현 증거입니다. +Rich Text는 새 최상위 계층이 아니라 이 책임 지도를 적용한 대표 vertical입니다. +문서 의미와 Editing 위에 Web Adapter, React Connector, 장르별 UI를 조합합니다. +현재 profile·적합성·browser 증거는 다른 Hands가 경계를 판단할 때 참고할 수 있지만 +모든 Document Type과 Hands의 완료를 대신하지는 않습니다. diff --git a/docs/public/database.md b/docs/public/database.md index 43950ab5f..37b782651 100644 --- a/docs/public/database.md +++ b/docs/public/database.md @@ -1,8 +1,8 @@ # Database Database Hands는 이미 존재하는 resource schema와 CRUD API 위에 완성된 database -UX를 놓습니다. 데이터·권한 정책·업무 규칙은 host가 소유하고, Hands는 query, -projection, editing, async failure와 접근성 품질을 소유합니다. +UX를 놓습니다. 데이터·권한 정책·업무 규칙은 host가 소유하고, Editing은 +saved-view projection을, Hands는 query wiring, React editing, async failure와 접근성 품질을 소유합니다. ```text Host @@ -101,8 +101,12 @@ import { } from "@interactive-os/json-document-editing"; const editor = createDatabaseEditor(document); -const sort = nextDatabasePropertySort(view.sort, propertyId); -editor.dispatch({ type: "view.configure", viewId: view.id, sort }); +const sort = nextDatabasePropertySort(view.projection.sorts[0] ?? null, propertyId); +editor.dispatch({ + type: "view.configure", + viewId: view.id, + projection: { ...view.projection, sorts: sort === null ? [] : [sort] }, +}); const initialValue = defaultDatabaseValue(property); const value = databaseValueFromText(property, input.value); if (acceptsDatabaseValue(property, value)) { diff --git a/docs/public/document-types.md b/docs/public/document-types.md index 390223c46..8ce2fe6f9 100644 --- a/docs/public/document-types.md +++ b/docs/public/document-types.md @@ -2,7 +2,8 @@ Document Type은 특정 JSON Document가 무엇을 의미하고 어떤 상태와 변경이 유효한지를 정의하는 생태계 위치입니다. 이 페이지는 책임 이름과 경계만 -확정하며, 기존 package와 Hands의 실제 소유권 재배치는 아직 결정하지 않습니다. +확정합니다. Calendar와 Object는 공개 소유자와 소비 경계를 확정했고, 나머지 후보의 실제 +소유권 재배치는 아직 결정하지 않았습니다. ```text Document Type @@ -57,28 +58,52 @@ Document Type은 DOM event, pointer gesture, React lifecycle, 화면 layout과 `Domain`은 business bounded context와 혼동되고, `Genre`는 제품 설명과 기술 계약의 경계를 드러내지 않으므로 이 생태계 위치의 정본 이름으로 사용하지 않습니다. -## 후보 · TBD +## 현재 소유자와 후보 -현재 사이트에서 다음 항목이 Document Type 후보입니다. +Calendar의 정본 소유자는 `@interactive-os/json-document-calendar-document`입니다. +모델·검증·의미 연산·projection은 이 package에, 선택·Clipboard·History는 Editing에, +입력과 UI 조합은 Calendar Hands에 둡니다. Editing의 기존 문서 관련 export는 +동일 구현을 가리키는 호환 경로입니다. + +[Calendar Document Type](/docs/document-types/calendar)에서 공개 API, Usage/Source와 +책임 감사 증거를 확인할 수 있습니다. 이 소유권 확정은 RC 계약을 Stable wire +프로파일로 승격하거나 나머지 후보의 완료를 선언하지 않습니다. + +Object와 단일 슬라이드 Canvas 프로파일의 정본 소유자는 +`@interactive-os/json-document-object-document`입니다. 기존 `createObjectEditor`가 이를 +소비하며 `@interactive-os/json-document-canvas` Hand가 두 Canvas Host의 입력·UI를 +닫습니다. [Object 소유권 감사](/docs/document-types/object)와 [Canvas Usage/Source](/docs/api/canvas)를 참고하세요. + +현재 사이트에서 다음 항목을 추적합니다. ```text Document Types · TBD ├── Rich Text ├── Order -├── Object +├── Object · RC 공개 소유자 확정 ├── Tree ├── Database -├── Calendar +├── Calendar · RC 공개 소유자 확정 ├── Sheet ├── Kanban └── Annotation ``` -이 목록은 분류 후보이지 완료 선언이 아닙니다. 각 후보는 모델, invariant, +Calendar·Object 이외의 목록은 분류 후보이지 완료 선언이 아닙니다. 각 후보는 모델, invariant, operation과 projection의 실제 owner를 감사한 뒤에만 이 위치로 이동할 수 있습니다. 그때까지 기존 package/API 이름, 모듈 배치와 Hands 내비게이션은 유지합니다. +## 현재와 목표 사이 + +현재 구현의 schema와 API는 후보를 검토할 근거입니다. 목표는 model·invariant· +operation·projection이 한 Document Type owner에서 나오고, Editing과 UI가 그 +공개 계약을 소비하는 것입니다. 편집 lifecycle·DOM geometry 관찰·React 구독은 +각자의 이웃 책임에 남습니다. + +Calendar와 Object는 공개 소유자와 소스 기반 책임 감사를 연결했습니다. +다른 후보도 이름이나 schema 표만으로 소유권 검토를 완료했다고 간주하지 않습니다. + ## 완료 조건 · TBD 각 Document Type의 분류를 확정할 때는 다음 증거가 모두 필요합니다. diff --git a/docs/public/editing.md b/docs/public/editing.md new file mode 100644 index 000000000..aa224fce1 --- /dev/null +++ b/docs/public/editing.md @@ -0,0 +1,76 @@ +# Editing Protocol + +Editing은 문서의 변경과 다음 선택을 하나의 작업으로 확정하고 관찰하는 계약입니다. +현재 TypeScript API의 공통 흐름을 설명하며, 모든 Hands를 하나의 editor +interface로 통합하거나 새로운 wire protocol을 정의하지 않습니다. + +## 입력에서 관찰까지 + +```text +플랫폼 입력 + → Adapter: 플랫폼 사실을 해석 + → Affordance: 조작 의미와 진행 상태 + → 장르 editor의 Intent + + Document Type의 의미 연산 + + Selection / Topology + → EditingPlan + → EditingSession.apply + → JSONDocument.commit + → EditingResult / EditingSnapshot + → Connector / UI +``` + +이 그림은 편집을 실행하는 대표 경로입니다. headless 호출은 입력 Adapter가 +필요하지 않고, Connector가 반드시 마지막 단계에만 있는 것도 아닙니다. +문서의 유효한 연산은 Document Type의 책임이며, 현재 각 editor에 놓인 책임의 +재배치는 [Document Types · TBD](document-types.md)에서 구분합니다. + +## 경계를 건너는 계약 + +| 계약 | 담는 것 | 소유자 | +| --- | --- | --- | +| Intent | 사용자가 요청한 편집의 의미와 대상 | 장르별 Editing API | +| `EditingPlan` | `operations`, `selectionAfter`, `origin`, 선택적인 `history`·`historyGroup` | Editing | +| `JSONDocument.commit` | JSON Patch의 검증과 원자적 적용 | JSON Document의 로컬 또는 협업 구현 | +| `EditingResult` | 성공한 자기 작업의 snapshot/change 또는 `ok: false`·`code` | Editing | +| `EditingSnapshot` | `value`, `selection`, `revision`, `canUndo`, `canRedo` | Editing | + +작업 거절은 해당 요청의 문서·선택·History를 바꾸지 않습니다. 구독 중 재진입해 +다음 작업이 실행돼도 반환 결과는 자신의 전이에 속합니다. `revision`은 문서 +commit 횟수가 아니라 편집 상태의 전이입니다. + +구체 시그니처는 [Editing API](../api-reference/editing.md), 사용법은 +[Intent guide](intent-guide.md)와 [Intent](intent.md)에서 봅니다. + +## 값을 바꾸지 않는 경로 + +- Selection만 바꾸면 document commit과 local Undo 항목이 생기지 않습니다. +- Copy는 읽기입니다. 원본·선택·History를 바꾸지 않습니다. +- Cut은 표현 쓰기와 제거를, Paste는 표현 해석과 의미 연산을 연결합니다. + 지원 표현과 실패 조건은 해당 [Clipboard](clipboard.md)와 editor의 계약을 따릅니다. +- 외부 변경은 그 문서에 맞는 선택 복구를 거칩니다. 복구 실패를 이미 완료된 + document commit의 취소로 바꾸지 않습니다. + +## History의 소유자 + +| 구성 | 복원 의미 | 안내 | +| --- | --- | --- | +| 로컬 inverse History | 기록한 문서 변경과 전후 선택을 복원하며, 실제 외부 변경 뒤 오래된 기록을 무효화 | [Local History](history.md) | +| actor-local 협업 History | 다른 참여자의 변경을 보존하고 현재 참여자의 기여를 선택적으로 되돌림 | [Collaborative History](collaboration-history.md) | + +협업 document를 주입했다고 로컬 inverse History가 협업 History가 되지는 않습니다. +협업 History owner를 연결하고 그 owner의 기록·그룹·복원 정책을 따릅니다. +현재 외부 History API가 지원하지 않는 `history: "ignore"`는 비어 있지 않은 +operations에 대해 mutation 전에 거절합니다. + +## Hands Profile · TBD + +EditingSession의 공통 의미는 현재 계약입니다. 반면 각 Hands의 필수 작업, +여러 선택 범위의 처리, 기본 입력 정책, 중첩 편집의 수신자와 공유 History 단위는 +전체 Profile로 아직 동결하지 않았습니다. + +목표는 같은 지원 입력과 Intent에서 같은 문서·선택·실패·복원 의미를 얻는 것입니다. +이를 위해 각 Profile에 지원/의도적 미지원/미구현을 나누고, 입력부터 Undo/Redo까지의 +적합성 증거를 연결해야 합니다. 현재 API를 호출할 수 있다는 사실만으로 이 목표가 +완료되지는 않습니다. [Official Hands · TBD](official-hands.md)에 남은 경계와 +완료 조건을 정리합니다. diff --git a/docs/public/foundation.md b/docs/public/foundation.md index f53683ca9..1b84bdacd 100644 --- a/docs/public/foundation.md +++ b/docs/public/foundation.md @@ -1,32 +1,42 @@ # Foundation -Foundation은 Application, Artifact와 Hands가 공유하는 기반 계약입니다. 화면이나 -제품 장르보다 먼저 값의 의미, 변경, 편집 상태와 협업 방식을 정의합니다. +Foundation은 Application, Artifact와 Hands가 공유하는 기반 계약입니다. +값의 변경, 문서의 의미, 편집 작업과 협업을 서로 다른 책임으로 구분합니다. -## JSON Document +## JSON Document Protocol -표의 셀과 문서의 블록은 생김새가 달라도 JSON 안에서 주소를 가집니다. 한 위치는 -JSON Pointer로 가리키고 여러 위치는 JSONPath로 찾으며, 변경은 JSON Patch로 -표현합니다. `JSONDocument`는 현재 값을 읽고, 찾고, 검증하고, 원자적으로 적용하고, -실제로 달라진 결과를 구독자에게 전달하는 공통 계약입니다. +`JSONDocument`는 `value`, `at`, `query`, `validatePatch`, `commit`, +`subscribe`의 여섯 member를 제공합니다. 주소는 JSON Pointer, 검색은 JSONPath, +변경은 JSON Patch로 표현합니다. Core v3의 Stable 계약이며 화면과 장르별 +편집 기능을 포함하지 않습니다. [JSON Document Protocol](api.md)에서 호출과 +실패·관찰 의미를 봅니다. -## Document Types +## Document Types · TBD -Rich Text, Calendar, Database 같은 Document Type은 Foundation 위에서 데이터의 -의미와 유효한 구조를 정의합니다. 같은 이름을 쓰는 Hand나 Application과는 별도 -책임이며, 제품 화면이나 navigation을 소유하지 않습니다. +Document Type은 문서 고유의 모델·schema·invariant·의미 연산·Projection을 +소유합니다. 예를 들어 Calendar의 recurrence와 occurrence는 제품의 layout이나 +일시적인 selection과 다른 책임입니다. -## Editing +[Document Types · TBD](document-types.md)는 목표 위치와 후보를 드러냅니다. +현재 package에서 이 책임을 어디가 소유하는지 확인하고 API·Usage·Source와 +소비자를 닫기 전에는 재배치 완료를 선언하지 않습니다. -선택, 보이는 순서, clipboard와 history처럼 편집하는 동안만 필요한 상태는 문서 값 -옆에 둡니다. 화면 사건은 Intent가 되고 Editing은 현재 문서와 편집 상태를 읽어 -처리합니다. +## Editing Protocol + +[Editing Protocol](editing.md)은 Intent에서 변경 계획과 다음 선택을 만들고 +`EditingSession.apply`로 적용한 뒤 `EditingSnapshot`을 관찰하는 경계입니다. +Selection, Topology, Clipboard와 History는 문서의 의미 모델과 구별합니다. + +현재 EditingSession의 공통 의미와 모든 Hands Profile의 완성은 다릅니다. +지원 행동·기본 입력·조합 적합성이 아직 닫히지 않은 범위는 +[Official Hands · TBD](official-hands.md)로 남습니다. ## Collaboration -협업은 다음 UI 계층이 아니라 같은 `JSONDocument` 계약의 다른 구현입니다. 여러 -참여자의 변경을 인과 순서로 수렴시키면서도 Foundation의 읽기·변경·구독 진입점을 -유지합니다. +[Collaboration](collaboration.md)은 같은 `JSONDocument` 계약의 다른 구현입니다. +base, History, Text는 선택적인 profile 포함 관계이며 새로운 UI 계층이 아닙니다. +협업 document를 Editing에 주입할 때도 actor-local History의 소유권과 복원 의미를 +유지해야 합니다. -다음으로 플랫폼과 생태계 연결을 고르려면 [Building Blocks](adapters.md)를, -전체 개념 관계를 먼저 보려면 [Concept Map](concepts.md)을 읽습니다. +플랫폼과 생태계 연결은 [Building Blocks](building-blocks.md), 전체 목표와 현재 +상태의 차이는 [Concept Map](concepts.md)에서 이어집니다. diff --git a/docs/public/hands.md b/docs/public/hands.md index c62d177a0..40840ddcc 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -14,14 +14,19 @@ editor.undo(); `AnnotationDocument`는 source와 selector geometry, presentation을 직렬화하고, selection과 undo/redo는 editor snapshot에 둡니다. Point, rectangle, path와 arrow selector는 geometry의 유일한 정본이며 presentation은 geometry를 반복하지 -않습니다. SVG 좌표 변환, pointer gesture, Canvas rasterization과 comment UI는 -Editing owner 밖에서 조합합니다. - -```ts -const gesture = createGestureSession(); -const point = projectWebClientPointToSVG(clientPoint, viewport); -const raster = await readWebRasterFile(file); -const output = await renderWebAnnotationRaster({ document, sourceId, sourceURL, style }); +않습니다. `@interactive-os/json-document-annotation`의 `AnnotationHand`가 +도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 +하나의 공개 surface로 제공합니다. + +```tsx + crypto.randomUUID()} + rasterStyle={style} +/> ``` Gesture는 Affordance가 input-independent lifecycle로 소유하고 Pointer capture는 @@ -35,95 +40,74 @@ presentation을 번역합니다. ## Calendar editor -시간 구간 이벤트의 persistent model과 editing session은 -`@interactive-os/json-document-editing`의 `createCalendarEditor`가 소유합니다. +Calendar의 문서 모델·검증·의미 연산·projection은 +`@interactive-os/json-document-calendar-document`, 편집 lifecycle은 +`@interactive-os/json-document-editing`, 입력과 UI 조합은 +`@interactive-os/json-document-calendar`가 소유합니다. ```ts +import { validateCalendarDocument } from "@interactive-os/json-document-calendar-document"; +import { createCalendarEditor } from "@interactive-os/json-document-editing"; + +const validation = validateCalendarDocument(calendarDocument); +if (!validation.ok) throw new Error(validation.reason); const editor = createCalendarEditor(calendarDocument); editor.dispatch({ type: "event.move", eventId, start: "2026-08-03T10:00" }); -editor.dispatch({ type: "event.move-day", eventId, day: "2026-08-05" }); -editor.dispatch({ type: "event.create", start: "2026-08-03", end: "2026-08-04", allDay: true }); editor.undo(); ``` -`CalendarDocument`는 캘린더 `{ id, title, hidden, color }`와 이벤트 -`{ id, title, start, end, allDay, calendarId }`를 직렬화합니다. `color`는 -Host가 fill로 옮기는 짧은 토큰입니다. 시간 이벤트는 datetime-local, 종일 -이벤트는 exclusive-end 날짜입니다. 일·주 보기의 빈 구간 drag는 그 -start/end로 만들고, 빈 칸 클릭은 선택을 지웁니다. 빈 칸 더블클릭은 기본 -길이로 만들고 제목을 묻습니다. 블록 이동은 duration을 유지하며 가장자리는 -`event.resize`입니다. 종일 밴드는 날짜 단위로 드래그해 만들고 옮기고 -늘립니다. 월 보기 같은 날 빈 칸 클릭은 선택 해제, 더블클릭은 그날 종일 생성, -빈 칸을 다른 날로 끌면 그 날들을 덮는 종일 구간을 만들고 제목을 묻습니다. -여러 날을 덮는 종일 이벤트는 주 행을 가로지르는 막대이고, 주 경계에서 잘립니다. -Exclusive end를 마지막 점유 날짜로 바꾸는 interval projection은 Editing -`calendarIntervalLastDate`가 소유합니다. occurrence day 열거, all-day resize end, -month week clipping이 모두 같은 정본 규칙을 사용합니다. -날짜 또는 날짜·시간 문자열에서 `YYYY-MM-DD` 날짜 부분을 얻는 projection은 -Editing `calendarDatePart`가 소유합니다. 현재 시각의 오늘 날짜와 새 이벤트의 -생성 날짜도 Host의 문자열 자르기 없이 이 공개 API를 사용합니다. -Host는 `Temporal.Now`로 concrete clock을 읽고 Editing `formatCalendarInstant`로 -Calendar datetime-local minute 문자열을 만듭니다. clock source와 저장 형식의 -책임을 섞는 route-local formatter는 두지 않습니다. -Calendar collection과 id lookup은 Editing `calendarDocumentCalendars`, -`calendarDocumentCalendar`가 소유합니다. Host는 sidebar와 inspector를 조합하고 -calendar color를 UI variant로 바꾸는 시각 정책만 유지합니다. -Inspector의 repeat frequency·interval·until 변경은 Editing -`calendarRecurrenceWithFrequency`, `calendarRecurrenceWithInterval`, -`calendarRecurrenceWithUntil`이 `CalendarRecurrence` model을 만들고 보존합니다. -Host는 option copy와 recurrence 비활성화 선택만 조합합니다. -월간 42개 날짜 cell을 6개의 ISO 주 행으로 투영하는 일은 UI Primitives 날짜 값 -정본의 `calendarMonthWeeks`가 소유하며, Host는 각 행의 event layout과 DOM을 -조합합니다. -표시 cell collection의 첫 날짜부터 마지막 날짜 다음 날까지의 half-open query -범위는 UI Primitives `calendarCellInterval`이 소유합니다. 연간 12개 month grid와 -sidebar navigator가 같은 interval을 Editing occurrence query에 전달합니다. -Day와 week time grid의 ordered 날짜 cell도 UI Primitives `calendarCells`가 -소유합니다. Day는 정확한 ISO weekday를 포함한 단일 cell, week는 ISO 주의 -7개 cell을 반환합니다. 각 `CalendarCell`은 canonical date에서 투영한 `day`와 -ISO weekday를 제공하며 Host는 문자열을 해석하지 않고 이 metadata로 날짜 숫자, -header와 event grid를 조합합니다. -Inclusive UI 날짜 endpoint를 all-day event의 exclusive storage interval로 바꾸는 -projection은 Editing `calendarAllDaySpan`이 소유합니다. 빈 drag, end resize, -timed→all-day 전환, 단일 생성과 Inspector 수정이 모두 같은 정본 규칙을 사용합니다. -막대 가장자리는 종일 밴드와 같이 `event.resize`입니다. -점유 칸은 origin 이벤트 선택, 다른 날로 끌 때만 `event.move-day`입니다. -월간 span에서 누른 Web `clientX`는 `calendarKeyFromWebRow`가 주 행 bounds와 -정렬된 날짜를 사용해 origin 날짜로 투영합니다. Calendar React의 -`useCalendarPointerInteractions`가 DOM 측정과 pointer session 시작을 소유하므로 -Host는 이벤트와 날짜 목록만 연결합니다. -`+N more`는 그 날의 이벤트 목록을 열고 월 보기에 남습니다. 이 매핑은 -`interpretCalendarTimeGridPointer`, `interpretCalendarAllDayPointer`, -`interpretCalendarMonthPointer`가 소유하며 현재 선택은 입력이 아닙니다. - -선택한 occurrence의 body drag는 이 단건 pointer intent를 반복하지 않습니다. -Selection의 `resolveMaterializedSelectionDragSource`가 source snapshot을 확정하고, -Editing의 `planCalendarSelectionMove`가 anchor에서 target까지의 temporal delta를 -모든 occurrence에 동일하게 적용합니다. React Calendar binding은 Web pointer -session과 Affordance `createGestureSession`을 합성해 같은 plan으로 preview한 뒤 -`selection.move`를 한 번 dispatch합니다. 따라서 document 변경, `selectionAfter`, -undo/redo는 한 Editing transaction으로 함께 이동합니다. ResizeHandle은 이 -selection drag와 별개의 edge geometry lifecycle을 유지합니다. -연 보기는 12개 미니 월입니다. 월 이름은 월 보기로, 날짜는 일 보기로 -들어갑니다. 연간 12개 월 시작일은 UI Primitives 날짜 값 정본의 -`calendarYearMonths`가 만들고, Host는 월 이름과 grid layout 및 navigation만 -조합합니다. 보기와 날짜는 Host URL (`?view=&date=`)입니다. 픽셀 격자와 -보기 전환은 Host가 조합합니다. 정본 view membership은 Editing -`parseCalendarView`가 판별하고 URL의 invalid -값을 어떤 view로 대체할지는 Host 정책으로 남습니다. -toolbar의 현재 기간 문구는 UI Primitives -`visiblePeriodLabel`이 view 분기와 날짜 경계를 투영하고, Host가 월 이름 copy와 -week separator policy를 주입합니다. -Previous/Next 및 keyboard period 이동은 UI Primitives `shiftVisibleDate`가 -day/week/month/year의 단위와 calendar arithmetic을 소유하며, Host는 현재 view와 -direction을 전달하고 결과를 URL state에 반영합니다. -Timed event의 datetime-local에서 `HH:mm` 문구를 투영하는 일은 UI Primitives -`calendarTimeLabel`이 소유합니다. Host는 그 결과를 visual copy에 조합하고, -UI Primitives의 event-label projection도 같은 정본 값을 accessible name에 -사용합니다. Date-only와 유효하지 않은 값은 빈 문구입니다. -Month event의 accessible name은 UI Primitives `calendarEventLabel`이 all-day에는 -title, timed event에는 가능한 `HH:mm title`을 투영합니다. 이 모듈은 구조적 -event 값만 받아 UI Primitives가 Editing package에 의존하지 않도록 합니다. +`CalendarDocument`는 calendar와 interval event·recurrence를 정의합니다. +datetime-local minute과 exclusive-end all-day, calendar 참조, 반복의 +this/following/all 의미는 [Document Type 계약](/docs/api/calendar-document)을 +따릅니다. `validateCalendarDocument`와 생성자는 같은 검증을 사용합니다. +생략된 legacy 필드와 잘못된 타입은 다르게 처리합니다. + +Editing은 Document Type의 `planCalendarEventEdit`, `planCalendarEventRemoval`, +`planCalendarOccurrenceRemoval`, `planCalendarVisibility`를 실행하고 Selection과 +History를 연결합니다. occurrence 선택은 `{ eventId, occurrenceStart }`로 식별하며 +Hand는 `editor.primaryOccurrence`를 읽습니다. 직접 dispatch, 외부에서 바꾼 선택, +mount 전 선택도 Inspector·수정·삭제의 같은 대상이 됩니다. +`editor.paste(clipboard)`의 기본 목적지는 선택 회차이고, 빈 슬롯을 찍은 위치는 +Hand의 명시적 임시 paste target입니다. [Editing 프로파일](/docs/api/editing#calendar-protocol-profile-rc)에 +선택·복사·붙여넣기·History 결과와 공통 검사 근거를 연결합니다. + +문서 조회와 시간 변환은 Document Type의 공개 API를 사용합니다. + +- `calendarDocumentCalendars` / `calendarDocumentCalendar`: collection과 id lookup +- `calendarDatePart` / `calendarIntervalLastDate`: 날짜 부분과 exclusive end의 마지막 점유일 +- `calendarAllDaySpan`: inclusive UI 날짜 endpoint를 exclusive 저장 구간으로 변환 +- `formatCalendarInstant`: concrete `Temporal.Now` 값을 저장 형식으로 변환 +- `calendarRecurrenceWithFrequency` / `calendarRecurrenceWithInterval` / + `calendarRecurrenceWithUntil`: 반복 모델 변경 +- `projectCalendarOccurrences` / `calendarBusyDates`: 발생분과 점유 날짜 조회 +- `calendarTimedLayout` / `calendarAllDayLayout` / `calendarMonthWeekLayout`: + event 구간과 lane projection + +Host는 clock 인스턴스, color를 UI variant로 바꾸는 정책, copy와 layout을 조합합니다. +`CalendarMonthGrid`와 `CalendarTimeGrid`는 표시와 접근성·overflow·interaction을 +소유합니다. 월의 42개 날짜 cell과 6개 주 행은 Calendar Hands의 `calendarMonthWeeks`, +query 범위는 `calendarCellInterval`, day/week의 ordered cell은 `calendarCells`가 +만듭니다. cell의 ISO weekday metadata와 화면의 주 시작 정책을 같은 개념으로 +설명하지 않습니다. 연간 월 목록은 `calendarYearMonths`가 소유합니다. + +Toolbar의 `visiblePeriodLabel`, 날짜 이동의 `shiftVisibleDate`, 시간 문구의 +`calendarTimeLabel`, 접근 가능한 event 이름의 `calendarEventLabel`도 Calendar +Hands 책임입니다. generic UI Primitives에 Calendar 모델이나 날짜 선택 동작을 +넣지 않습니다. View와 날짜의 URL은 Host가 조합하며 view membership은 기존 +Editing `parseCalendarView`가 판별합니다. + +Calendar별 `interpretCalendarTimeGridPointer`, `interpretCalendarAllDayPointer`, +`interpretCalendarMonthPointer`와 bind 함수는 정규화된 release 값을 Calendar Intent로 +연결하는 Editing 책임입니다. generic gesture의 begin/preview/commit/cancel은 +Affordance, DOM pointer capture와 `calendarKeyFromWebRow` 같은 좌표 변환은 Web에 둡니다. +`useCalendarPointerInteractions`가 이 API들을 조합하며 자체 root 안에서 hit-test합니다. + +선택한 occurrence의 body drag는 Selection의 +`resolveMaterializedSelectionDragSource`로 대상을 캡처하고 Editing의 +`planCalendarSelectionMove`로 preview와 commit을 계획합니다. 같은 Document Type +연산을 공유하며 전체 document·selection·undo/redo가 한 Editing transaction으로 +이동합니다. Hand는 Web pointer session과 Affordance `createGestureSession`을 +조합하고, resize edge와 그룹 이동의 lifecycle은 구별합니다. ```live-demo /demo/calendar diff --git a/docs/public/history.md b/docs/public/history.md index b4540fd52..83f75f5c7 100644 --- a/docs/public/history.md +++ b/docs/public/history.md @@ -29,7 +29,10 @@ History 항목은 JSON 값이 실제로 바뀐 편집에서 생깁니다. Select 현재 편집 대상만 바꾸므로 기록을 추가하지 않습니다. 검사를 통과하지 못한 요청과 문서 값이 그대로인 요청도 되돌릴 값이 없어 기록되지 않습니다. -기본 local history는 외부 문서 변경을 받으면 비워집니다. 다른 참여자의 변경을 +기본 local history는 실제 외부 문서 변경이 있으면 비워집니다. UI 구독자가 없거나 +구독을 해제한 뒤에도 같은 정책을 따릅니다. 외부 변경 후 값이 원래 값으로 +돌아와도 이전 Undo/Redo 기록은 되살아나지 않습니다. 동일 값의 새 snapshot +reference나 문서 no-op은 기록을 지우지 않습니다. 다른 참여자의 변경을 보존하며 내 기여만 취소하려면 [Collaborative History](collaboration-history.md)의 공식 연결 API를 사용합니다. document만 바꾸는 것으로 history 의미까지 바뀌지는 않습니다. diff --git a/docs/public/how-we-build.md b/docs/public/how-we-build.md index 618523758..26935c902 100644 --- a/docs/public/how-we-build.md +++ b/docs/public/how-we-build.md @@ -17,13 +17,16 @@ canonical module과 public API로 정본화한다 Application이 정본 API를 다시 소비한다 ``` -구현 의존 방향과 책임을 발견하는 방향은 서로 반대입니다. +읽기 순서와 책임을 발견하는 방향을 구별합니다. ```text -구현 의존: Foundation → Building Blocks → Hands → Artifact → Application +읽기 순서: Foundation → Building Blocks → Hands → Artifact → Application 책임 발견: Application → 책임 발견 → Canonical Module → Application ``` +이 읽기 순서는 package의 직렬 의존 관계가 아닙니다. Adapter와 Connector는 +독립적으로 선택하고, Collaboration은 같은 JSONDocument 계약을 구현합니다. + 여기서 Artifact는 독립 App이 아니라 Application이 만들고 편집하는 콘텐츠입니다. Navigation, workflow, runtime과 제품 정책은 Application에 남습니다. diff --git a/docs/public/intent.md b/docs/public/intent.md index 7fda4da16..9cfd24bf7 100644 --- a/docs/public/intent.md +++ b/docs/public/intent.md @@ -55,10 +55,14 @@ type EditingResult = `EditingSnapshot`은 처리 뒤의 값과 Selection, revision, 실행 취소 상태를 묶습니다. `type`은 editor가 수행할 동작을 나타내고, 각 동작에 필요한 필드는 editor별 Intent union에서 정합니다. 성공 결과에는 snapshot이 들어 있으며 -JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. 실패하면 -문서와 Selection은 요청 전 상태를 유지합니다. +JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. -값이 바뀐 요청은 History 항목을 만들고 +외부 변경의 동기화를 마친 뒤 요청 자체가 검증·commit에서 거절되면 +문서와 Selection, History는 그 요청의 시작 상태를 유지합니다. 이미 완료된 외부 +commit은 되돌아가지 않습니다. Selection mapping/reconciliation callback의 예외와 +재시도는 [History의 동기화·복구 계약](history.md)에서 설명합니다. + +기본 local History에서 기록 대상인 값 변경 요청은 History 항목을 만들고 `change.metadata.editing.origin`에 `intent.type`을 남깁니다. Selection만 바뀐 요청은 성공 snapshot을 돌려주지만 History 항목은 만들지 않습니다. @@ -112,6 +116,8 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `selection.set` | `objectIds`, `mode?` | 객체 선택 변경 | | `selection.remove` | | 선택한 객체 제거 | | `selection.fill` | `color` | 선택한 객체 색 변경 | +| `selection.style` | `style` | 지원하는 선택 객체의 색·글자 서식·테두리를 한 번 변경 | +| `object.text` | `objectId`, `text` | 글자·도형·스티커 노트의 label 본문 변경 | | `object.translate` | `objectIds`, `dx`, `dy` | 선택한 객체 위치 이동 | | `object.resize` | `objectIds`, `dx`, `dy`, `dw`, `dh` | 선택한 객체 크기 | | `clipboard.paste` | `clipboard` | 붙여넣기 | diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 497b6a361..970f89585 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -1,6 +1,6 @@ # json-document v3 -json-document는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 사용할 수 +json-document의 Core는 문서, 표, 슬라이드, 캔버스, 노트 편집기가 공통으로 사용할 수 있는 implementation-neutral JSON 편집 Kernel이다. UI component library가 아니다. v3 표준 상태는 Stable이다. 현재 release version은 `3.0.0`이다. Reference와 독립 @@ -9,6 +9,35 @@ reference·독립·collaboration binding에서 검증한다. 공식 사이트: https://developer-1px.github.io/json-document/ +## 목표 구조와 현재 상태 + +읽기 순서는 Foundation → Building Blocks → Hands → Artifact → Applications다. +필수 package dependency chain이 아니다. Foundation은 JSON Document, Document +Types, Editing, Collaboration을, Building Blocks는 Adapter, Connector, +Affordance, UI Primitives를 묶는다. Adapter와 Connector는 독립적으로 선택한다. +Collaboration은 같은 JSONDocument 계약의 다른 구현이다. + +Document Type은 model·schema·invariant·Document Operation·Projection을 소유한다. +Rich Text, Order, Object, Tree, Database, Calendar, Sheet, Kanban, Annotation은 +아직 후보(TBD)다. 관찰된 schema와 API는 증거이지 owner 재배치 완료 선언이 아니다. +목표 owner, public API·reference, Usage·Source와 소비자 수렴을 닫아야 한다. + +Hands는 정본 책임을 장르별 편집 경험으로 닫은 조합이다. Official Hands Profile의 +필수 행동·지원 입력·선택 복원·실패·History·호환성 조건은 TBD다. Artifact는 +Application 내부 콘텐츠이며 현재 viewer는 visual prototype(TBD)다. 실제 +document/Hands 연결과 파일 호환성은 검증하지 않았다. + +Application/Host는 조합·실행 순서·제품 정책 값·copy·fixture·layout과 구체 외부 +인스턴스 주입을 소유한다. 같은 역할과 책임은 소비자 수와 무관하게 정본 모듈로 +둔다. 사이트 탐색 분류는 package의 실제 책임 종류나 API owner의 이름이 아니다. + +현재 계약 안내: +- https://developer-1px.github.io/json-document/docs/concepts/ +- https://developer-1px.github.io/json-document/docs/api/ +- https://developer-1px.github.io/json-document/docs/editing/ +- https://developer-1px.github.io/json-document/docs/document-types/ +- https://developer-1px.github.io/json-document/docs/official-hands/ + ## 정본 경계 Portable consumer는 Root만 import한다. @@ -23,12 +52,13 @@ import { ``` Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다. -공개 Root는 정확히 다음 23개 symbol이다. +공개 Root는 정확히 다음 25개 symbol이다. ```txt values appendSegment, applyPatch, buildPointer, createJSONDocument - jsonEqual, parentPointer, parseArrayIndex, parsePointer, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parentPointer, parseArrayIndex, parsePointer + readPointer, trackPointer, tryParsePointer types JSONAppliedChange, JSONPatchValidationResult @@ -52,8 +82,8 @@ stateless JSON Patch `-> optional native-input DOM lease ``` -Stateless JSON Patch는 JSON value, RFC 6901 JSON Pointer, RFC 9535 JSONPath, -RFC 6902 JSON Patch, Result 의미를 소유한다. JSON Document는 현재 document +Core의 표준 JSON 책임은 JSON value, RFC 6901 JSON Pointer, RFC 9535 JSONPath, +RFC 6902 JSON Patch와 Result 의미를 다룬다. JSON Document는 현재 document value와 change notification을 연결한다. Canonical concept와 identifier 문법은 @@ -200,12 +230,14 @@ Core package는 root entrypoint만 공개한다. Selection, clipboard, history optional editing companion이 여섯-member `JSONDocument` 위에서 조합한다. DOM-free selection state와 semantic interaction은 `@interactive-os/json-document-selection` companion이 key, range, mask family로 -제공하며 topology, geometry와 physical input 해석은 host port 밖에 둔다. +제공한다. Topology는 문서·표시 순서의 owner가, geometry 관찰과 physical input +해석은 플랫폼 Adapter가 제공하며 제품 layout과 구체 입력의 연결은 Host에 둔다. 브라우저 플랫폼 계약은 independently versioned Adapter가 변환한다. 반복되는 외부 생태계 integration은 independently versioned Connector가 제공한다. Adapter와 Connector는 공통 runtime interface가 아니라 package category다. 외부 peer는 해당 package의 peer dependency다. Schema introspection, DOM과 -제품별 UI 의미는 해당 Adapter, Connector 또는 host의 명시적인 책임으로 남는다. +재사용 UI 행동은 해당 Adapter, Connector 또는 UI Primitives의 책임으로 남는다. +Host는 제품의 정책 값과 시각 조합을 선택한다. v3 Kernel release는 dependency-free Core package 하나다. Local-only consumer는 Core만 설치한다. 이 저장소의 `@interactive-os/json-document-editing`은 atomic @@ -214,28 +246,34 @@ Order, Sheet, Object 및 Tree domain slice를 제공하는 browser-independent companion이다. Document, Order, Sheet와 Tree는 range 상태 전이를 공유하고 Object는 key 상태 전이를 사용한다. 화면 줄은 `LineTopology`와 `GridTopology`로 넘긴다. Sheet는 `SheetTopology`, Database는 저장된 -뷰의 `recordIds`×`propertyIds`, Tree는 host `visibleIds`다. JSON Patch -계획은 각 slice 또는 host에 둔다. +뷰의 `recordIds`×`propertyIds`, Tree는 `projectTreeVisibility`가 계산한 +`visibleIds`다. JSON Patch 계획과 의미 연산은 장르 editor와 문서 의미의 +owner에 둔다. Sheet slice는 stable row·column identity, 복수의 anchor/focus rectangular range, primary-range JSON/TSV clipboard, selection fill, cell commit과 selection-restoring undo/redo를 제공한다. -Tree expand/collapse와 visible order, Object pointer geometry와 hit-test는 host -책임이다. 값 변경 history는 selection을 함께 복구한다. Native text selection은 +Tree visible projection은 Editing의 `projectTreeVisibility`, React의 접힘 상태와 +입력 연결은 `useTreeEditing`이 소유한다. Host는 초기 expanded IDs 등 정책 값을 +선택한다. Object geometry의 의미 연산과 플랫폼 hit-test도 각각의 정본 owner에 +둔다. 값 변경 history는 selection을 함께 복구한다. Native text selection은 이 structural selection family에 포함하지 않는다. `@interactive-os/json-document-web`은 KeyboardEvent chord, ClipboardEvent structured MIME, text-control value/caret과 Web modifier state를 public -editing·selection contract로 번역하는 공식 Adapter다. Event target, shortcut, -focus, geometry와 native text selection은 Host가 계속 소유한다. +editing·selection contract로 번역하는 공식 Adapter다. Event target 경계, +modifier·기본 chord 해석과 DOM focus 실현은 Web의 정본 API를 소비한다. +Host는 활성화 조건과 제품별 keymap 정책 값을 선택한다. `@interactive-os/json-document-contenteditable`은 local JSONDocument 문자열 포인터를 leased contenteditable React root에 붙이는 공식 Adapter다. -툴바, atom, marks와 product chrome은 Host가 소유한다. +Host는 toolbar와 product chrome을 조합한다. Atom·mark의 문서 의미와 재사용 +rendering·native selection·IME lifecycle은 각 문서·Adapter·UI owner에 둔다. `@interactive-os/json-document-react`는 React external-store subscription, Document editor component lifecycle, 그리고 선택 범위·focus 커서·text offset 질의(`useEditing`)를 제공하는 공식 Connector다. `@interactive-os/json-document-react-hook-form`은 React Hook Form이 draft, dirty, touched와 field error를 소유하게 두고 유효한 submit만 하나의 canonical editing -transaction으로 적용한다. Undo, redo와 외부 canonical 변경은 `reset`으로 form에 -동기화하며 field UI와 product schema는 Host가 소유한다. +transaction으로 적용한다. Undo, redo와 외부 canonical 변경은 Connector의 +leaf 동기화 또는 `reset` 정책으로 form에 반영한다. Host는 field UI를 조합하고 +문서 의미의 owner가 제공하는 schema와 제품 정책 값을 주입한다. `@interactive-os/json-document-ajv`는 호출자가 컴파일한 동기 Ajv validator의 첫 error message와 `instancePath`를 validation diagnostic으로 번역한다. Mutable clone을 검사하므로 Ajv option이 만든 변형 결과는 canonical JSON에 채택하지 않는다. @@ -248,7 +286,7 @@ row/column model을 Sheet topology로 번역해 정렬·필터·column ordering cell edit, rectangular multi-range selection, selection fill과 clipboard가 화면 순서를 따르게 하는 공식 Connector다. `@interactive-os/json-document-collaboration`은 같은 canonical JSON Document을 -제공하는 independently versioned, transport-free provider이고, +제공하는 independently versioned, transport-free collaboration engine이고, `@interactive-os/json-document-contenteditable-collaboration`은 collaborative string을 위한 optional native-input DOM lease다. 두 companion을 사용해도 Core Root API와 editor가 받는 `JSONDocument` port는 바뀌지 않는다. @@ -272,12 +310,27 @@ catalog의 일부가 아니다. ## Host 책임 -Rendering, DOM focus, geometry, keyboard policy, system clipboard, filesystem, -network, formula engine, CRDT와 OT는 host 또는 adapter가 소유한다. Selection의 -공통 lifecycle은 editing companion에 있지만 grid range나 spatial object 같은 -구체적인 topology는 제품이 소유한다. Tree -indent/outdent, visible-row focus, slide selection box, grid coordinate와 같은 제품 -의도는 host가 Pointer와 Patch operation으로 번역한다. +Host는 모듈 조합·실행 순서·제품 정책 값·권한·copy·fixture·layout·구체 외부 +인스턴스 주입만 소유한다. 모델·schema·Intent·command·selection·history·gesture· +플랫폼 번역·직렬화·projection·재사용 UI의 최종 owner가 아니다. +Tree indent/outdent나 grid coordinate 해석도 해당 정본 모듈의 공개 API를 소비한다. +정본이 유효한 사례를 지원하지 못하면 API를 확장하거나 책임을 등록해야 하며 +Host local 구현을 최종 상태로 인정하지 않는다. + +## Editing Protocol + +장르 editor는 Intent와 Selection/Topology에서 의미 연산을 계획한다. +EditingPlan은 operations·selectionAfter·origin과 history 정책을 담고, +EditingSession.apply가 JSONDocument.commit을 사용한다. EditingSnapshot은 +value·selection·revision·canUndo·canRedo를 일관되게 관찰하게 한다. +거절은 해당 요청의 문서·선택·History를 바꾸지 않는다. Copy는 읽기이며 +selection-only 전이는 document commit과 local History 항목을 만들지 않는다. + +EditingSession의 공통 의미는 현재 계약이다. Hands Profile 전체의 동결과 +독립 구현 간 상호운용 인증은 아니다. 로컬 inverse History와 actor-local 협업 +History는 복원 의미와 owner가 다르다. 협업 document를 주입할 때도 협업 History +owner를 연결해야 하며 현재 외부 History가 지원하지 않는 ignore 정책을 조용히 +다른 의미로 적용하지 않는다. ## 표준과 검증 diff --git a/docs/public/object.md b/docs/public/object.md index 12055fa32..118b752c7 100644 --- a/docs/public/object.md +++ b/docs/public/object.md @@ -1,11 +1,15 @@ # Object Object는 안정된 ID를 가진 객체를 집는 편집기입니다. 줄 번호가 아니라 키 -가족(key family)을 씁니다. 화면에서 어디를 눌렀는지는 제품이 계산하고, -editor에는 객체 ID만 넘깁니다. +가족(key family)을 씁니다. 문서 모델·검증·연산은 +[`Object Document Type`](/docs/api/object-document), 입력·기하의 UI 조합은 +[`Canvas Hand`](/docs/api/canvas)가 소유하고 editor에는 객체 ID와 Intent를 넘깁니다. -색을 채우거나 지우는 요청은 Intent로 들어갑니다. 기하와 히트 테스트는 -editor 밖에 남습니다. +생성·글자 편집·색 채우기·이동·resize·삭제는 Intent로 들어갑니다. 문서의 기하 +규칙은 Document Type이, 화면 좌표와 hit target은 Adapter와 Hand가 소유합니다. +Canvas의 스티커 노트와 도형 내부 글도 별도 편집기 없이 같은 `object.text`로 +label을 바꿉니다. 채워진 객체의 본문 글자색은 `selection.style`의 `style.textColor`로, +채우기는 기존 `color`로 구분합니다. ## API Reference @@ -20,8 +24,8 @@ ID 정책을 Host가 주입하는 자리입니다. ### `ObjectIntent` `dispatch`가 받는 Object domain command입니다. 공개 variant는 -`selection.set`, `selection.remove`, `selection.fill`, `object.translate`, -`object.resize`, `clipboard.paste`입니다. DOM event, pointer 좌표, clipboard +`selection.set`, `selection.remove`, `selection.fill`, `selection.style`, `object.create`, `object.text`, +`object.translate`, `object.resize`, `object.duplicate`, `object.remove`, `document.replace`, `clipboard.paste`입니다. DOM event, pointer 좌표, clipboard event를 Intent에 넣지 않습니다. ### `ObjectSelectionMode` @@ -41,6 +45,10 @@ event를 Intent에 넣지 않습니다. `clipboard.paste.placement`의 `{ type: "offset", dx, dy }`로 전달합니다. Editor가 unique ID clone 뒤 placement를 정확히 한 번 적용하므로 clipboard payload에는 배치 결과를 미리 저장하지 않습니다. placement 생략은 zero offset입니다. +복제·paste의 primary remap, 새 ID와 원자적 History 계약은 소유 패키지의 +[Object Editing API](/docs/api/editing)에 있습니다. native cut은 성공적으로 쓴 payload의 +ID를 `object.remove`에 전달합니다. Object Demo의 복제 버튼은 OS Clipboard와 별개이며 +copy/cut/paste는 native 이벤트만 사용합니다. ## 상태의 주인 @@ -48,7 +56,7 @@ Object Hands는 서로 다른 수명의 상태를 한 덩어리로 만들지 않 - document state는 객체의 값과 기하를 보존합니다. - editing session은 선택과 undo/redo를 보존합니다. -- host interaction state는 active tool, hover, drag, resize처럼 현재 조작만 +- Hand interaction state는 active tool, text draft, drag, resize처럼 현재 조작만 보존합니다. 객체를 만드는 작업이 끝나면 그 결과를 현재 선택으로 만듭니다. 예를 들어 @@ -68,6 +76,6 @@ Object Hands는 서로 다른 수명의 상태를 한 덩어리로 만들지 않 /demo/object ``` -```live-demo -/demo/canvas -``` +[한 장짜리 Canvas의 Usage와 Source](/docs/api/canvas)는 별도 Canvas editor 없이 +이 Object Editing을 사용합니다. Canvas UI는 같은 다중 선택·Clipboard API와 +Affordance의 평면 Select 프로파일을 연결합니다. diff --git a/docs/public/official-hands.md b/docs/public/official-hands.md index 2ca611d2f..1fa9b24f9 100644 --- a/docs/public/official-hands.md +++ b/docs/public/official-hands.md @@ -1,8 +1,7 @@ # Official Hands · TBD -> **TBD** — 이 페이지는 Official Hands의 제품 방향을 설명하는 초안입니다. -> 새로운 public contract, package boundary, kit admission 기준 또는 -> compatibility 약속을 확정하지 않습니다. +Official Hands의 목표와 남은 경계를 설명하는 초안입니다. 새로운 public contract, +package boundary, kit admission 기준 또는 compatibility 약속을 확정하지 않습니다. Official Hands는 디자인과 제품 데이터는 자유롭게 바꿀 수 있지만, 사람이 편집을 끝내는 데 필요한 기능은 이미 구현되어 있는 SDK를 지향합니다. @@ -40,15 +39,14 @@ Official은 제품 취향을 임의로 정한다는 뜻이 아닙니다. 여러 대부분의 사용자는 Official Hands만으로 편집기를 완성할 수 있어야 합니다. Custom Hands는 기본 경로가 아니라 제품에만 있는 차이를 위한 escape hatch입니다. -## 하나의 Hands Profile +## Hands Profile · TBD 완성된 Hands는 행동 함수만 모은 package가 아닙니다. 그 행동이 항상 같은 뜻을 갖게 하는 최소 profile을 함께 제공합니다. ```text Official Hands Profile -├─ minimum schema와 canonical shape -├─ stable identity와 structural invariant +├─ Document Type Profile 참조: schema · identity · invariant ├─ Selection specialization ├─ Topology interpretation ├─ Intent vocabulary @@ -64,6 +62,11 @@ row identity, column identity와 cell addressability를 먼저 정해야 합니 Sheet다운 편집 행동이 무엇을 대상으로 하는지 안정적으로 정하기 위해 필요합니다. +최소 schema와 의미 연산의 owner는 [Document Type](document-types.md)입니다. +Hands Profile은 이 계약을 참조하고 Editing·Adapter·Affordance·Connector·UI를 +함께 선택합니다. Hands가 각 책임을 다시 구현하거나 Host가 빈칸을 메우는 구조가 +아닙니다. + ### 공통 규칙과 profile의 선택 공통 편집 규칙은 Selection과 Editing이 소유합니다. Profile은 그 규칙이 자신의 @@ -90,17 +93,17 @@ Selection family, EditingSession을 사용하면서 입력부터 편집 결과 Official Hands가 최소 profile을 제공해도 완성 제품을 대신 소유하지는 않습니다. ```text -Official Hands가 소유 -├─ 장르다운 편집을 성립시키는 최소 shape -├─ identity와 structural invariant -├─ 수렴한 편집 행동 -└─ 함께 검증된 기본 조합 +Official Hands Profile이 연결 +├─ Document Type의 shape·identity·invariant +├─ Editing의 Selection·Intent·Clipboard·History +├─ Adapter·Affordance·Connector·UI의 편집 경로 +└─ 함께 검증할 지원 범위와 기본 조합 Host가 소유 -├─ 업무 field와 business rule -├─ permission과 workflow -├─ persistence와 collaboration policy -├─ rendering과 layout +├─ 제품별 정책 값·권한·copy·fixture +├─ workflow와 실행 순서 +├─ persistence·collaboration의 구체 인스턴스 주입 +├─ UI composition과 layout └─ visual design ``` @@ -109,6 +112,9 @@ automation에서 전혀 다르게 보일 수 있습니다. Hands는 object ident Selection, translate와 resize의 의미를 유지하고 Host는 표현과 제품 정책을 결정합니다. +재사용 가능한 업무 모델·규칙과 rendering 행동은 각각 문서 의미와 UI의 정본 +모듈에 둡니다. 제품에서 선택하는 정책 값과 모듈 자체의 의미를 구별합니다. + ## Affordance까지 닫기 Editing capability만으로는 사람이 작업을 끝낼 수 없습니다. Official Hands는 @@ -186,3 +192,17 @@ Official profile의 지향점은 구현이 바뀌어도 같은 지원 입력에 구체적인 profile별 필수 작업, Host field 연결, 여러 Hand가 공유하는 History 단위는 아직 확정하지 않았습니다. 현재 후보 목록과 위 동작 예시는 완성된 SDK의 호환성 보장이 아닙니다. + +## 현재 증거와 완료 조건 · TBD + +| 경계 | 현재 있는 것 | 완료에 필요한 것 | +| --- | --- | --- | +| 문서 의미 | 각 editor와 package의 모델·연산 | Document Type owner와 Profile 참조의 수렴 | +| 편집 작업 | 기존 Intent와 EditingSession 공통 의미 | Profile별 지원/의도적 미지원/미구현 및 결과·실패 조건 | +| 실제 입력 | Hands Live Demo와 platform binding | keyboard·pointer·Clipboard·취소·Undo/Redo가 이어지는 적합성 증거 | +| 공개 사용 | package API와 Usage·Source | Host의 같은 책임 우회 구현 없이 조합되는 완료 경로 | +| 호환성 | 개별 구현과 Profile의 증거 | 기본값·중첩 맥락·공유 History와 변경 정책의 명시 | + +이 조건을 닫기 전에는 Official Hands를 완성된 SDK나 모든 장르가 상호운용하는 +Stable 계약으로 표시하지 않습니다. 목표를 미리 드러내되 현재 동작의 증거와 +미확정 설계를 섞지 않습니다. diff --git a/docs/public/overview.md b/docs/public/overview.md index dd1629057..f87214568 100644 --- a/docs/public/overview.md +++ b/docs/public/overview.md @@ -45,7 +45,7 @@ json-document는 그 공통 층을 화면과 분리된 문서 커널로 둡니 통과한 변경만 원자적으로 적용되고, 실제로 값이 달라진 변경만 구독자에게 전달됩니다. 현재 값을 읽고, 한 위치와 여러 위치를 찾고, 검사하고, 적용하고, 구독하는 일이 이 계약의 전부입니다. 호출 모양은 -[API](api.md)에 있습니다. +[JSON Document Protocol](api.md)에 있습니다. ## 같은 문을 여는 협업 @@ -76,8 +76,17 @@ Editing은 이 상태를 JSON Document 옆에 둡니다. 화면은 클릭과 키 보이는 순서를 알려 줍니다. Clipboard는 JSON과 사람이 읽을 텍스트를 함께 나릅니다. History는 값과 선택을 같이 되돌립니다. -같은 문서 위에 선택과 작업을 더하는 일이 Editing입니다. 따라 가려면 -[Intent guide](intent-guide.md)에서 시작합니다. +같은 문서 위에 선택과 작업을 더하는 일이 Editing입니다. +[Editing Protocol](editing.md)에서 계획·적용·관찰의 경계를 보고, +[Intent guide](intent-guide.md)에서 직접 호출해 봅니다. + +## 문서의 의미와 목표 owner + +Calendar의 recurrence나 Tree의 parent/child 관계는 편집 중의 선택과 다른 +책임입니다. Document Type이 model·schema·invariant·의미 연산·Projection을 +소유하고 Editing이 그 계약을 소비하는 구조를 지향합니다. +[Document Types · TBD](document-types.md)에 현재 후보와 남은 소유권 수렴을 +미리 드러냅니다. 기존 package/API의 존재만으로 이 목표가 완료되지는 않습니다. ## Artifact에 손을 붙이기 @@ -85,11 +94,16 @@ Editing은 이 상태를 JSON Document 옆에 둡니다. 화면은 클릭과 키 제품처럼 보입니다. 그 아래에서는 같은 문서와 같은 편집 상태를 씁니다. 다른 것은 그 장르가 손을 얹는 방식입니다. -Hands는 사람이 artifact와 agent를 다루는 편집 도구의 최소 완성본입니다. +Hands는 사람이 artifact와 agent를 다루는 장르별 편집 조합입니다. 한 줄 목록을 집어 옮기는 손, 칸을 채우는 손, 가지를 접는 손이 선반에 있습니다. Agent에게 지시와 맥락을 건네는 Composer와, 안정적인 대상을 글에 넣는 Mention도 Rich Text와 구조화된 context로 동작합니다. 고르려면 [Hands](hands.md)로 갑니다. +[Official Hands · TBD](official-hands.md)는 이 조합을 기본 편집이 완성된 SDK로 +제공하려는 목표입니다. 전체 지원 입력·실패·선택 복원·호환성 조건은 아직 닫히지 +않았습니다. [Artifact](/viewer)도 현재는 visual prototype이며 문서·Hands 연결과 +파일 호환성을 증명하지 않습니다. + ## 플랫폼, 라이브러리, Affordance 브라우저에서 쓰려면 키보드와 clipboard, contenteditable 같은 플랫폼 @@ -102,12 +116,13 @@ React로 그리거나 Zod로 검사하려면 이름 있는 라이브러리의 React 구독으로 흐르고, 표의 보이는 행과 열은 Sheet의 Topology가 됩니다. 고르기, 접기, 드래그, 되돌리기는 제품이 json-document를 만지는 손입니다. -화면은 호스트가 그리고, 단축키와 마우스 문법은 Affordance가 닫습니다. +제품 화면은 Host가 조합하고, 플랫폼 사실은 Adapter가 해석하며, +입력 장치와 독립적인 조작 의미·수명주기는 Affordance가 소유합니다. Adapter와 Connector는 서로 직렬인 계층이 아니라 환경에 따라 독립적으로 고르는 책임입니다. Affordance도 필요한 입력 문법을 선택해 Host에 조합합니다. -[Adapters](adapters.md)와 [Connectors](connectors.md), -[Affordance](affordance.md)에서 이어서 읽습니다. +[Building Blocks](building-blocks.md)에서 Adapter·Connector·Affordance· +UI Primitives의 경계를 함께 봅니다. ## Artifact editing의 Core @@ -118,5 +133,5 @@ Connector, Affordance와 UI Primitive는 필요한 환경과 입력 문법에 Hands는 그 조합이 장르별 최소 편집 loop를 완성했는지 판정합니다. 여러 artifact가 같은 주소와 실행 취소와 협업을 쓰게 하려는 자리가 이 -Core입니다. 각 책임의 경계와 의존 순서는 [Concept Map](concepts.md)에서 +Core입니다. 각 책임의 경계와 선택적인 의존 관계는 [Concept Map](concepts.md)에서 이어집니다. diff --git a/docs/public/ui-primitives.md b/docs/public/ui-primitives.md index a205b4a04..dcd119388 100644 --- a/docs/public/ui-primitives.md +++ b/docs/public/ui-primitives.md @@ -75,7 +75,7 @@ Escape/Tab의 popup close와 focus restore, filtering과 option content는 Host | 역할 | 정본 primitive | 허용 presentation | | --- | --- | --- | | 명령 실행 | `Command` | label, icon | -| 이진 상태 | `Toggle` | button, chip | +| 이진 상태 | `Toggle` | button, chip, icon | | 단일 값 선택 | `Choice` | inline, popup | | 다중 포함 여부 | `Check` | checkbox | | surface 이동 | `Tabs` | tab list | @@ -118,7 +118,11 @@ icon button은 독립 역할이 아니므로 공개 primitive가 아닙니다. 보존합니다. `Command`는 명령 실행 역할 하나를 소유하며 label과 icon은 presentation입니다. `Toggle`은 `pressed`를 `aria-pressed`에 투영하며 icon-only인 경우 `label`을 visible tooltip과 accessible name으로 사용합니다. `Command`도 -`label`을 visible tooltip과 accessible name에 투영합니다. `Toggle`은 binary state를, +`label`을 visible tooltip과 accessible name에 투영합니다. `Toggle`은 label이 있으면 +기본 presentation이 `icon`이며 명시적인 `button`/`chip` 지정은 보존합니다. +텍스트 버튼에 설명만 추가하려면 `tooltip`을 사용합니다. 아이콘 전용 Command와 +Toggle은 같은 32px hit target, 무테, hover/focus/disabled 문법을 공유하고 +Toggle의 선택 상태는 배경으로 표시합니다. `Toggle`은 binary state를, `Choice`는 single choice를, `Tabs`는 navigation surface 전환을 소유합니다. `Choice`와 `Tabs`는 option ID generic을 callback까지 보존하므로 Host는 선택 값을 다시 cast하지 않습니다. `DisclosureButton`은 diff --git a/fixtures/external-kit/src/main.tsx b/fixtures/external-kit/src/main.tsx index 106cfcc0f..402adb39f 100644 --- a/fixtures/external-kit/src/main.tsx +++ b/fixtures/external-kit/src/main.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { createRoot } from "react-dom/client"; -import { Database, DatabaseOperationError, createDatabaseResource, createDatabaseView, type DatabaseFilterGroup, type DatabaseOperations, type DatabaseViewDocument } from "@interactive-os/json-document-database"; +import { Database, DatabaseOperationError, createDatabaseResource, createDatabaseView, type DatabaseFilter, type DatabaseFilterGroup, type DatabaseOperations, type DatabaseTableView } from "@interactive-os/json-document-database"; import "@interactive-os/json-document-database/styles.css"; import * as z from "zod/v4"; import "./styles.css"; @@ -10,7 +10,7 @@ type Task = z.infer; const resource = createDatabaseResource({ id: "delivery-tasks", schema: taskSchema, getRowId: (row) => row.id, createDraft: () => ({ title: "", owner: "", points: 0, status: "backlog", shipped: false }) }); const allTasks = createDatabaseView("all", "All delivery", ["title", "owner", "points", "status", "shipped"], "shared"); const triageBase = createDatabaseView("triage", "My triage", ["status", "title", "owner", "points", "shipped"]); -const triageView: DatabaseViewDocument = { ...triageBase, projection: { ...triageBase.projection, filter: { id: "triage:root", conjunction: "and", items: [{ id: "open", propertyId: "status", operator: "not-equals", value: "done" }] }, columns: [{ propertyId: "status", visible: true, width: 150, pinned: "start" }, { propertyId: "title", visible: true, width: 300 }, { propertyId: "owner", visible: true, width: 150 }, { propertyId: "points", visible: true, width: 110 }, { propertyId: "shipped", visible: true, width: 120 }] } }; +const triageView: DatabaseTableView = { ...triageBase, projection: { ...triageBase.projection, filter: { id: "triage:root", conjunction: "and", items: [{ id: "open", propertyId: "status", operator: "not-equals", value: "done" }] }, columns: [{ propertyId: "status", visible: true, width: 150, pinned: "start" }, { propertyId: "title", visible: true, width: 300, pinned: null }, { propertyId: "owner", visible: true, width: 150, pinned: null }, { propertyId: "points", visible: true, width: 110, pinned: null }, { propertyId: "shipped", visible: true, width: 120, pinned: null }] } }; let serverRows = Array.from({ length: 240 }, (_, index): Task => ({ id: `task-${index + 1}`, title: ["Triage customer feedback", "Polish billing settings", "Publish changelog", "Archive legacy exports"][index % 4]! + (index < 4 ? "" : ` ${index + 1}`), owner: ["Ada", "Lin", "Mina", "Theo"][index % 4]!, points: (index % 8) + 1, status: ["backlog", "progress", "done"][index % 3] as Task["status"], shipped: index % 3 === 2 })); let failureMode: "none" | "network" | "conflict" = "none"; @@ -69,9 +69,10 @@ function DatabaseApp() { function matchesGroup(row: Task, group: DatabaseFilterGroup): boolean { if (group.items.length === 0) return true; - const results = group.items.map((item) => "propertyId" in item ? matchesRule(row, item.propertyId, item.operator, item.value) : matchesGroup(row, item)); + const results = group.items.map((item) => isFilter(item) ? matchesRule(row, item.propertyId, item.operator, item.value) : matchesGroup(row, item)); return group.conjunction === "and" ? results.every(Boolean) : results.some(Boolean); } +function isFilter(item: DatabaseFilter | DatabaseFilterGroup): item is DatabaseFilter { return typeof item.propertyId === "string"; } function matchesRule(row: Task, propertyId: string, operator: string, expected: unknown): boolean { const actual = row[propertyId as keyof Task]; if (operator === "equals") return String(actual) === String(expected); diff --git a/package-lock.json b/package-lock.json index 0f02d8174..fd11dfbf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "workspaces": [ "packages/json-document", "packages/json-document-selection", + "packages/json-document-object-document", + "packages/json-document-calendar-document", "packages/json-document-editing", "packages/json-document-react", "packages/json-document-react-hook-form", @@ -19,7 +21,9 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", + "packages/json-document-canvas", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", @@ -1124,10 +1128,22 @@ "resolved": "packages/json-document-animation-react", "link": true }, + "node_modules/@interactive-os/json-document-annotation": { + "resolved": "packages/json-document-annotation", + "link": true + }, "node_modules/@interactive-os/json-document-calendar": { "resolved": "packages/json-document-calendar", "link": true }, + "node_modules/@interactive-os/json-document-calendar-document": { + "resolved": "packages/json-document-calendar-document", + "link": true + }, + "node_modules/@interactive-os/json-document-canvas": { + "resolved": "packages/json-document-canvas", + "link": true + }, "node_modules/@interactive-os/json-document-collaboration": { "resolved": "packages/json-document-collaboration", "link": true @@ -1164,6 +1180,10 @@ "resolved": "packages/json-document-markdown-react", "link": true }, + "node_modules/@interactive-os/json-document-object-document": { + "resolved": "packages/json-document-object-document", + "link": true + }, "node_modules/@interactive-os/json-document-react": { "resolved": "packages/json-document-react", "link": true @@ -6460,12 +6480,14 @@ "version": "0.1.0-rc.0", "license": "MIT", "devDependencies": { + "@interactive-os/json-document-selection": "*", "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" }, "peerDependencies": { + "@interactive-os/json-document-selection": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1" } }, @@ -6504,12 +6526,47 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-annotation": { + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "@interactive-os/json-document": ">=3.0.0-rc.0 <4", + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-calendar": { "name": "@interactive-os/json-document-calendar", "version": "0.1.0-rc.0", "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, "devDependencies": { "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-calendar-document": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-ui-primitives-react": "*", @@ -6524,6 +6581,7 @@ }, "peerDependencies": { "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-calendar-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", @@ -6531,6 +6589,58 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-calendar-document": { + "name": "@interactive-os/json-document-calendar-document", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document": "^3.0.0" + } + }, + "packages/json-document-canvas": { + "name": "@interactive-os/json-document-canvas", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "lucide-react": "^1.33.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-file-intake": "*", + "@interactive-os/json-document-object-document": "*", + "@interactive-os/json-document-react": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-file-intake": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-collaboration": { "name": "@interactive-os/json-document-collaboration", "version": "0.2.0-rc.1", @@ -6562,6 +6672,7 @@ "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-mention": "*", "@interactive-os/json-document-rich-text-suggestion": "*", + "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" @@ -6571,7 +6682,8 @@ "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", - "@interactive-os/json-document-rich-text-suggestion": "^0.1.0-rc.0" + "@interactive-os/json-document-rich-text-suggestion": "^0.1.0-rc.0", + "@interactive-os/json-document-web": "^0.1.0-rc.0" } }, "packages/json-document-composer-react": { @@ -6581,6 +6693,8 @@ "devDependencies": { "@interactive-os/json-document": "*", "@interactive-os/json-document-composer": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-mention": "*", "@interactive-os/json-document-rich-text-mention-react": "*", @@ -6601,6 +6715,8 @@ "peerDependencies": { "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-composer": "^0.1.0-rc.0", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention-react": "^0.1.0-rc.0", @@ -6677,11 +6793,10 @@ "name": "@interactive-os/json-document-editing", "version": "0.1.0-rc.0", "license": "MIT", - "dependencies": { - "@js-temporal/polyfill": "^0.5.1" - }, "devDependencies": { "@interactive-os/json-document": "*", + "@interactive-os/json-document-calendar-document": "*", + "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", @@ -6689,6 +6804,8 @@ }, "peerDependencies": { "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-calendar-document": "^0.1.0-rc.0", + "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-selection": "^0.1.0-rc.0" } }, @@ -6728,6 +6845,22 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-object-document": { + "name": "@interactive-os/json-document-object-document", + "version": "0.1.0-rc.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-file-intake": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-file-intake": "^0.1.0-rc.0" + } + }, "packages/json-document-react": { "name": "@interactive-os/json-document-react", "version": "0.1.0-rc.0", @@ -6851,6 +6984,7 @@ "version": "0.1.0-rc.0", "license": "MIT", "devDependencies": { + "@interactive-os/json-document": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-web": "*", @@ -6863,6 +6997,7 @@ "vitest": "^4.1.7" }, "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-react": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-web": "^0.1.0-rc.0", @@ -6985,6 +7120,7 @@ "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", + "jsdom": "^29.1.1", "typescript": "^5.0.0", "vitest": "^4.1.7" }, @@ -7022,7 +7158,10 @@ "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-ajv": "*", "@interactive-os/json-document-animation-react": "*", + "@interactive-os/json-document-annotation": "*", "@interactive-os/json-document-calendar": "*", + "@interactive-os/json-document-calendar-document": "*", + "@interactive-os/json-document-canvas": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-composer": "*", "@interactive-os/json-document-composer-react": "*", @@ -7032,6 +7171,7 @@ "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-markdown-react": "*", + "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-react-hook-form": "*", "@interactive-os/json-document-rich-text": "*", diff --git a/package.json b/package.json index 81f3367ae..ef935a9b3 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "workspaces": [ "packages/json-document", "packages/json-document-selection", + "packages/json-document-object-document", + "packages/json-document-calendar-document", "packages/json-document-editing", "packages/json-document-react", "packages/json-document-react-hook-form", @@ -16,7 +18,9 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", + "packages/json-document-canvas", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 607d3159e..b97c12c06 100644 --- a/packages/json-document-affordance/README.md +++ b/packages/json-document-affordance/README.md @@ -48,6 +48,11 @@ useEditing({ `historyAffordance(snapshot).hand` exposes the typed Undo/Redo availability map directly. The editing runtime still owns history state and execution. +`resizeAffordance(origin, point, edge, modifiers?, size?)` owns eight-direction +anchored resizing. Supply the initial size for true Shift aspect-ratio locking, +Alt center resizing and anchor-preserving minimum bounds. See the +[Resize API contract](docs/resize.md) and its Canvas Usage/Source. + `contentInteractionAffordance` is the canonical product-content state model. It distinguishes persistent selection, transient active feedback, movement, drop targets, and insertion positions without owning DOM or product color. @@ -93,9 +98,15 @@ open/focus semantics remain outside this geometry contract. Usage: [Affordance](https://developer-1px.github.io/json-document/docs/affordance) +`createPlaneSelectProfile` composes key selection, click/drag arbitration, marquee, +set translation previews and keyboard outcomes without Canvas, React, DOM or a +document dependency. See the owning [Plane Select API contract](docs/plane-select.md). +The [Canvas Usage](https://developer-1px.github.io/json-document/demo/canvas) +imports the public profile and injects it into the Hand. + `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: +for Mod+A without Alt or Shift, 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. @@ -105,3 +116,10 @@ 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. + +`deleteAffordance(stroke)` consumes the Web default structural keymap: bare +Delete/Backspace delete; modified variants return no hand. Omitted modifiers +remain false for existing partial-input calls. Pass the full event to preserve +modifier facts. `selectAllAffordance` uses Web `chordFromStroke` for the same +normalization, then applies its own select-all repetition policy. Text word or +line deletion belongs to the text input adapter. diff --git a/packages/json-document-affordance/docs/plane-select.md b/packages/json-document-affordance/docs/plane-select.md new file mode 100644 index 000000000..0db520f55 --- /dev/null +++ b/packages/json-document-affordance/docs/plane-select.md @@ -0,0 +1,99 @@ +## 평면 Select 프로파일 · RC + +`createPlaneSelectProfile`은 다중 선택까지 닫힌 최소 평면 선택 문법입니다. +Canvas 모델, React, DOM, 문서 저장소 없이 ID·bounds·현재 선택과 정규화한 입력을 +받습니다. Selection의 `createKeySelectionFamily`로 집합과 primary를 전이하고, +기존 hit·marquee·drag·copy·nudge Affordance와 `createGestureSession`을 조합합니다. + +```text +Web: hit ID, 같은 좌표계의 point, keyboard stroke, pointer capture + └─ Affordance: createPlaneSelectProfile + ├─ Selection: key 집합·primaryKey 전이/reconcile + └─ preview / commit 결과 + ├─ Hand: 선택 윤곽·marquee·이동/복제 preview 렌더링 + └─ Editing: 선택 반영, 집합 이동·복제·삭제, primary 편집, History +``` + +### API + +- `begin(context, { point, hitKey, shiftKey?, altKey? })`: `items: { id, x, y, width, height }[]`와 + `selection: { kind: "explicit", keys, primaryKey }`를 캡처합니다. `hitKey: null`은 빈 곳입니다. + items의 순서가 선택 순서·primary fallback 순서이며, 사라진 key는 Selection이 제거합니다. +- `preview(point, modifiers?)` / `getPreview()`: `{ selection, marquee, translation } | null`. + `translation`은 `{ operation: "move" | "copy", keys, dx, dy }` 하나입니다. 문서와 committed selection은 바꾸지 않습니다. +- `updateModifiers({ shiftKey?, altKey? })`: 포인터가 정지해 있어도 modifier 변경을 다시 투영합니다. + `preview`/`commit`에서 modifiers를 생략하면 마지막 상태를 유지하고, 전달하면 현재 상태로 교체합니다. +- `commit(point, modifiers?)`: 최종 point로 `{ selection, translation } | null`을 한 번 반환하고 + gesture를 비웁니다. Hand가 Selection을 반영한 뒤 translation 전체를 한 Intent로 실행합니다. +- `cancel(reason?)`: preview를 버립니다. 이후 release는 결과를 만들지 않습니다. +- `select(context, key, shiftKey?)`: Space 같은 discrete activation의 선택 결과. + **focus만으로 호출하지 않습니다.** Focus와 selection은 독립입니다. +- `keyDown(stroke, context, grabbing?)`: selection / delete(keys) / duplicate(keys) / + translate(keys, dx, dy) / edit(key) / cancel 또는 + null. `grabbing`은 resize 등 다른 gesture의 활성 상태입니다. 처리한 command는 + 진행 중 Select preview도 취소합니다. 플랫폼 binding은 native editable/IME를 먼저 제외합니다. + +한 mounted Hand마다 하나의 profile instance를 사용합니다. instance 간 상태는 독립입니다. +외부 문서·대상 geometry·committed selection 변경, 도구 전환, capture loss, unmount에서는 binding이 `cancel`을 +호출합니다. immutable begin snapshot을 사용하므로 오래된 gesture를 새 문서에 적용하지 않습니다. + +### 닫힌 문법 + +| 입력 | 결과 | +| --- | --- | +| 객체 click | release에서 그 객체 하나로 replace | +| 선택된 객체 press → drag | press에서는 집합 유지, drag는 집합 전체에 같은 delta | +| 선택되지 않은 객체 press → drag | 그 객체 하나로 replace 후 이동 | +| Shift+click | 포함하면 제거, 아니면 추가; 새 대상이 primary | +| Shift+객체 drag | toggle이 아닌 집합 이동; 큰 delta 축으로 고정 | +| Alt/Option+객체 drag | 같은 선택 집합의 복제 요청; Shift와 조합 가능 | +| 빈 곳 click | Shift 여부와 관계없이 clear | +| 빈 곳 drag / Shift+drag | marquee replace / 기존 선택에 add | +| Mod+A 반복 | 전체 선택 유지, 유효한 기존 primary 유지 | +| Delete / Backspace | 선택 집합 삭제 요청 | +| Mod+D | 선택 집합 복제 요청; ID·배치는 Editing 소유 | +| 방향키 / Shift+방향키 | 선택 집합 1 / 10단위 이동 요청 | +| Enter / F2 | primary 하나의 편집 요청 | +| Escape | gesture만 cancel, idle에서는 선택 clear | + +click/drag 임계값은 같은 좌표계의 3단위(`dragThreshold`), marquee는 bounds 교차 +(`contain: "intersect"`)가 기본입니다. `contain: "inside"`를 명시할 수 있습니다. +임계값을 넘으면 이동을 되돌려도 drag로 유지하며, 최종 delta가 0이면 translation은 null입니다. +primary가 아닌 객체도 선택 윤곽을 그리지만 resize handles와 텍스트 입력은 primary에만 붙입니다. + +Selection 변경은 문서 JSON과 History를 건드리지 않습니다. 문서 commit/Undo는 Editing이 +소유합니다. Alt-click과 최종 0 delta는 복제를 만들지 않습니다. 복제 preview에서는 원본을 +유지하고 변환된 사본을 위에 렌더링하지만, commit 전에 ID를 할당하지 않습니다. +Mod+C/X/V는 처리하지 않습니다. Web의 native clipboard event가 직렬화와 이벤트 소유권을 +맡으며 text 입력·IME는 binding에서 먼저 제외합니다. +이 프로파일에는 그룹·다중 resize·중첩 선택·Mod drill-down·snap·zoom/pan·레이어 문법이 없습니다. + +### Usage와 Source + +실제 [Canvas Usage](/demo/canvas)는 public constructor를 import하여 `CanvasHand`에 +주입합니다. 생략하면 Hand가 같은 프로파일을 생성합니다. Source는 Affordance 프로파일, +Selection key family, Object Editing, Canvas binding까지 연결됩니다. + +```tsx +import { createPlaneSelectProfile } from "@interactive-os/json-document-affordance"; +import { CanvasHand } from "@interactive-os/json-document-canvas"; + +const [selectProfile] = useState(() => createPlaneSelectProfile()); +return ; +``` + +다른 평면 Hand는 CanvasHand 없이 같은 프로파일을 소비합니다. + +```ts +const select = createPlaneSelectProfile(); +const context = { + items: [{ id: "node", x: 10, y: 20, width: 80, height: 40 }], + selection: { kind: "explicit" as const, keys: ["node"], primaryKey: "node" }, +}; +select.begin(context, { hitKey: "node", point: { x: 20, y: 30 } }); +const preview = select.preview({ x: 40, y: 50 }); // renderer에만 전달 +const result = select.commit({ x: 40, y: 50 }); // operation="move", keys=["node"], dx=20, dy=20 +``` + +`tests/plane-select.test.ts`는 Object·Canvas·React를 import하지 않는 diagram 소비자로 +동일 계약을 검증합니다. 실제 문서 commit/취소/Undo는 Canvas 통합 테스트가 검증합니다. diff --git a/packages/json-document-affordance/docs/resize.md b/packages/json-document-affordance/docs/resize.md new file mode 100644 index 000000000..54262f0fa --- /dev/null +++ b/packages/json-document-affordance/docs/resize.md @@ -0,0 +1,55 @@ +## Resize · 고정 기준과 초기 크기 + +`resizeAffordance(origin, point, edge, modifiers?, size?)`는 8방향 resize의 정본입니다. +같은 좌표계의 시작점·현재 점, `ResizeEdge`, Shift/Alt 상태와 **시작할 때의** +`{ width, height }`를 받아 `{ hand: { type: "resize", dx, dy, dw, dh, edge }, cursor }`를 +반환합니다. 문서, DOM, pointer capture와 History를 소유하지 않습니다. + +| 입력 | 고정 기준과 크기 | +| --- | --- | +| `n` / `s` | 반대편 변 고정, 높이 조절 | +| `e` / `w` | 반대편 변 고정, 너비 조절 | +| `ne` / `se` / `sw` / `nw` | 반대 모서리 고정, 너비·높이 조절 | +| Shift | 초기 너비/높이 비율 유지. 변에서는 나머지 축의 중심을 고정 | +| Alt / Option | 객체 중심 고정, 잡은 방향의 반대편도 대칭 조절 | +| Shift + Alt | 초기 비율과 중심 모두 고정 | + +`size`를 주면 결과 크기를 최소 1좌표 단위로 제한하고, 그 결과로 위치 delta를 +계산하므로 최소 크기를 지나쳐도 고정점이 밀리거나 뒤집히지 않습니다. 비율 유지 시 +두 축 모두 최소 크기를 만족합니다. 시작 크기는 유한한 양수여야 하며 아니면 +`RangeError`입니다. 1보다 작은 유효한 입력도 정지한 grab에서는 변하지 않고, +실제 resize가 발생했을 때만 최소 크기를 적용합니다. + +모서리 Shift는 두 축의 상대 크기 변화 중 절댓값이 큰 값을 사용합니다. +각 preview와 release는 같은 초기 크기에서 다시 계산해야 합니다. 이미 바뀐 preview +크기를 다음 입력의 `size`로 쓰지 않습니다. 포인터가 정지해도 modifier가 바뀌면 +마지막 point로 재계산하고, release의 최종 point와 modifier로 결과를 확정합니다. +`commitAffordance`는 0 delta를 null로 거르며 자체적으로 문서를 변경하지 않습니다. + +```ts +import { commitAffordance, resizeAffordance } from "@interactive-os/json-document-affordance"; + +const origin = { x: 300, y: 200 }; +const size = { width: 200, height: 100 }; +const preview = resizeAffordance(origin, { x: 340, y: 210 }, "se", { shiftKey: true }, size); +// dx=0, dy=0, dw=40, dh=20: 반대 모서리와 2:1 비율을 유지 +const result = commitAffordance(preview); +// Hand가 preview를 렌더링하고, release에서만 result를 Object Editing Intent로 전달 +``` + +`size`를 생략한 기존 호출은 기존 delta-only 의미를 유지합니다. 크기를 알 수 없으므로 +최소 크기 제한이나 객체 비율을 보장하지 않으며, Shift+모서리는 x/y 이동량을 같게 +맞추는 기존 규칙입니다. 객체의 비율을 유지하려는 소비자는 반드시 초기 `size`를 넘깁니다. + +### Usage와 Source + +[Canvas Usage](/demo/canvas)와 [Canvas Widget](/widgets/canvas)는 같은 `CanvasHand`를 +사용합니다. `useCanvasHand`가 이 공개 API에 초기 객체 크기와 현재 modifier를 전달하며, +Source에서 Hand → `resizeAffordance` → Object projection/Editing을 확인할 수 있습니다. +네 변의 연속 hit 영역과 네 모서리는 React의 `useInteractionHandle`을 공유하고, +pointer capture·장치별 이벤트 수명은 Web binding에 남깁니다. 선택 집합 중 primary만 +resize하며, 그룹 resize·회전·flip·snap·crop은 이 계약에 포함하지 않습니다. + +```live-demo +/demo/canvas +``` diff --git a/packages/json-document-affordance/package.json b/packages/json-document-affordance/package.json index fbf340d09..1be117c10 100644 --- a/packages/json-document-affordance/package.json +++ b/packages/json-document-affordance/package.json @@ -17,7 +17,7 @@ "provenance": true, "tag": "next" }, - "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], "exports": { ".": { "types": "./dist/index.d.ts", @@ -33,9 +33,11 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document-selection": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1" }, "devDependencies": { + "@interactive-os/json-document-selection": "*", "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", diff --git a/packages/json-document-affordance/src/drag.ts b/packages/json-document-affordance/src/drag.ts index 207030c31..5b120b199 100644 --- a/packages/json-document-affordance/src/drag.ts +++ b/packages/json-document-affordance/src/drag.ts @@ -147,36 +147,42 @@ export function resizeAffordance( point: Point, edge: ResizeEdge, modifiers?: { readonly shiftKey?: boolean; readonly altKey?: boolean }, + size?: Pick, ): AffordancePreview { let dx = point.x - origin.x; let dy = point.y - origin.y; const corner = edge.length === 2; - if (modifiers?.shiftKey && corner) { + if (modifiers?.shiftKey && corner && !size) { const mag = Math.max(Math.abs(dx), Math.abs(dy)); dx = (dx === 0 ? 1 : Math.sign(dx)) * mag; dy = (dy === 0 ? 1 : Math.sign(dy)) * mag; } - let left = 0; - let top = 0; - let right = 0; - let bottom = 0; - if (edge.includes("e")) right = dx; - if (edge.includes("w")) left = dx; - if (edge.includes("s")) bottom = dy; - if (edge.includes("n")) top = dy; - if (modifiers?.altKey) { - if (edge.includes("e")) left = -dx; - if (edge.includes("w")) right = -dx; - if (edge.includes("s")) top = -dy; - if (edge.includes("n")) bottom = -dy; + const west = edge.includes("w"), north = edge.includes("n"); + const horizontal = west || edge.includes("e"), vertical = north || edge.includes("s"); + const centered = modifiers?.altKey ?? false, factor = centered ? 2 : 1; + let dw = (west ? -dx : horizontal ? dx : 0) * factor; + let dh = (north ? -dy : vertical ? dy : 0) * factor; + if (size) { + const { width, height } = size; + if (![width, height].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Resize dimensions must be finite and positive."); + // A stationary grab is a no-op, including valid imported sub-unit objects. + if ((dw !== 0 || dh !== 0) && modifiers?.shiftKey) { + const sx = dw / width, sy = dh / height; + const growth = horizontal && (!vertical || Math.abs(sx) >= Math.abs(sy)) ? sx : sy; + const scaleDelta = Math.max(1 / width - 1, 1 / height - 1, growth); + dw = width * scaleDelta; dh = height * scaleDelta; + } else if (dw !== 0 || dh !== 0) { + dw = Math.max(1 - width, dw); dh = Math.max(1 - height, dh); + } } + // Derive translation from the constrained size so the opposite edge or center stays fixed. return { hand: { type: "resize", - dx: left, - dy: top, - dw: right - left, - dh: bottom - top, + dx: dw === 0 ? 0 : centered || !horizontal ? -dw / 2 : west ? -dw : 0, + dy: dh === 0 ? 0 : centered || !vertical ? -dh / 2 : north ? -dh : 0, + dw, + dh, edge, }, cursor: interactionHandleCursor({ kind: "resize", edge }), diff --git a/packages/json-document-affordance/src/index.ts b/packages/json-document-affordance/src/index.ts index 78881e675..52b0fcd97 100644 --- a/packages/json-document-affordance/src/index.ts +++ b/packages/json-document-affordance/src/index.ts @@ -16,6 +16,12 @@ export { export { createBoardDragSession } from "./board-drag-session.js"; export { createCanvasGestureSession } from "./canvas-gesture-session.js"; export { createGestureSession } from "./gesture-session.js"; +export { createPlaneSelectProfile } from "./plane-select.js"; +export type { + PlaneSelectCommit, PlaneSelectContext, PlaneSelectInput, PlaneSelectKeyResult, + PlaneSelectPreview, PlaneSelectProfile, PlaneSelectProfileOptions, PlaneSelectSelection, + PlaneSelectTranslation, PlaneSelectModifiers, +} from "./plane-select.js"; export { createInteractionHandleSession, interactionHandleCursor, diff --git a/packages/json-document-affordance/src/plane-select.ts b/packages/json-document-affordance/src/plane-select.ts new file mode 100644 index 000000000..1fd45ed4a --- /dev/null +++ b/packages/json-document-affordance/src/plane-select.ts @@ -0,0 +1,192 @@ +import { createKeySelectionFamily, type KeySelection, type KeySelectionCommand } from "@interactive-os/json-document-selection"; +import { createWebKeyboardAdapter, type WebKeyboardStroke } from "@interactive-os/json-document-web"; +import { dragAffordance, dragOperation, marqueeAffordance, marqueeHitsAffordance, nudgeAffordance, type Point, type Rect } from "./drag.js"; +import { createGestureSession, type GestureCancelReason } from "./gesture-session.js"; +import { escapeAffordance, planeHitAffordance, selectAllAffordance } from "./select.js"; + +export type PlaneSelectSelection = Extract; + +/** Items and pointer points must use the same coordinate space; order determines primary fallback. */ +export interface PlaneSelectContext { + readonly items: ReadonlyArray; + readonly selection: PlaneSelectSelection; +} + +export interface PlaneSelectModifiers { + readonly shiftKey?: boolean; + readonly altKey?: boolean; +} + +export interface PlaneSelectInput extends PlaneSelectModifiers { + readonly point: Point; + readonly hitKey: string | null; +} + +export interface PlaneSelectTranslation { + readonly operation: "move" | "copy"; + readonly keys: readonly string[]; + readonly dx: number; + readonly dy: number; +} + +export interface PlaneSelectPreview { + readonly selection: PlaneSelectSelection; + readonly marquee: Rect | null; + readonly translation: PlaneSelectTranslation | null; +} + +export interface PlaneSelectCommit { + readonly selection: PlaneSelectSelection; + readonly translation: PlaneSelectTranslation | null; +} + +export type PlaneSelectKeyResult = + | { readonly type: "selection"; readonly selection: PlaneSelectSelection } + | { readonly type: "delete"; readonly keys: readonly string[] } + | { readonly type: "duplicate"; readonly keys: readonly string[] } + | { readonly type: "translate"; readonly keys: readonly string[]; readonly dx: number; readonly dy: number } + | { readonly type: "edit"; readonly key: string } + | { readonly type: "cancel" }; + +export interface PlaneSelectProfileOptions { + /** In the input coordinate space. Defaults to 3; movement is latched once crossed. */ + readonly dragThreshold?: number; + readonly contain?: "intersect" | "inside"; +} + +export interface PlaneSelectProfile { + begin(context: PlaneSelectContext, input: PlaneSelectInput): PlaneSelectPreview; + preview(point: Point, modifiers?: PlaneSelectModifiers): PlaneSelectPreview | null; + commit(point: Point, modifiers?: PlaneSelectModifiers): PlaneSelectCommit | null; + /** Reproject a stationary drag when a modifier changes; omitted preview modifiers retain this state. */ + updateModifiers(modifiers: PlaneSelectModifiers): PlaneSelectPreview | null; + cancel(reason?: GestureCancelReason): void; + getPreview(): PlaneSelectPreview | null; + /** Discrete activation (e.g. Space), not focus. Shift toggles, plain activation replaces. */ + select(context: PlaneSelectContext, key: string | null, shiftKey?: boolean): PlaneSelectSelection; + /** Native editable/IME ownership is checked by the platform binding before calling. */ + keyDown(stroke: WebKeyboardStroke, context: PlaneSelectContext, grabbing?: boolean): PlaneSelectKeyResult | null; +} + +type Gesture = { + readonly type: "plane-select"; + readonly context: PlaneSelectContext; + readonly input: PlaneSelectInput; + readonly selection: PlaneSelectSelection; + readonly point: Point; + readonly moved: boolean; + readonly modifiers: PlaneSelectModifiers; +}; + +const family = createKeySelectionFamily(); +const keyboard = createWebKeyboardAdapter(); +const editKeyboard = createWebKeyboardAdapter<"edit">({ defaults: false, keymap: { Enter: "edit", F2: "edit" } }); +const duplicateKeyboard = createWebKeyboardAdapter<"duplicate">({ defaults: false, keymap: { "Mod-d": "duplicate" } }); + +function selectionContext(context: PlaneSelectContext) { + return { keys: context.items.map((item) => item.id), universe: "plane", universeMismatch: "clear" as const }; +} + +function transition(context: PlaneSelectContext, command: KeySelectionCommand): PlaneSelectSelection { + const topology = selectionContext(context); + const state = family.transition(context.selection, command, topology).state; + return { kind: "explicit", keys: family.targets(state, topology), primaryKey: state.primaryKey }; +} + +/** Minimal flat selection grammar. Owns previews and outcomes, never a document, DOM or History. */ +export function createPlaneSelectProfile(options: PlaneSelectProfileOptions = {}): PlaneSelectProfile { + const threshold = options.dragThreshold ?? 3; + if (!Number.isFinite(threshold) || threshold < 0) throw new RangeError("dragThreshold must be finite and non-negative"); + const gestures = createGestureSession(); + + function select(context: PlaneSelectContext, key: string | null, shiftKey = false) { + return transition(context, key === null ? { type: "clear" } : { type: shiftKey ? "toggle" : "replace", keys: [key], primaryKey: key }); + } + + function project(gesture: Gesture): PlaneSelectPreview { + const { context, input, point, moved, modifiers } = gesture; + let selection = gesture.selection; + if (input.hitKey === null && moved) { + const band = marqueeAffordance(input.point, point, input); + if (band.hand?.type === "select" && band.hand.rect) { + const hit = marqueeHitsAffordance({ rect: band.hand.rect, items: context.items, contain: options.contain ?? "intersect" }); + if (hit.hand?.type === "select") return { + selection: transition(context, { type: input.shiftKey ? "add" : "replace", keys: hit.hand.objectIds ?? [] }), + marquee: band.hand.rect, translation: null, + }; + } + return { selection: transition(context, { type: input.shiftKey ? "add" : "replace", keys: [] }), marquee: null, translation: null }; + } + if (moved && input.hitKey !== null) { + // Shift-click toggles; after the threshold Shift constrains the drag, never subtracts its source. + const hit = planeHitAffordance({ hitId: input.hitKey, selectedIds: context.selection.keys }).hand; + if (hit?.type === "select") selection = transition(context, { type: "replace", keys: hit.objectIds ?? [], primaryKey: input.hitKey }); + } + const delta = moved && input.hitKey !== null ? dragAffordance(input.point, point, modifiers).hand : null; + const operation = dragOperation({ ...modifiers, shiftKey: modifiers.shiftKey ?? false, metaKey: false, ctrlKey: false }).hand?.type === "copy" ? "copy" : "move"; + return { + selection, marquee: null, + translation: delta?.type === "translate" && (delta.dx !== 0 || delta.dy !== 0) + ? { operation, keys: selection.keys, dx: delta.dx, dy: delta.dy } : null, + }; + } + + function preview(point: Point, modifiers?: PlaneSelectModifiers) { + const active = gestures.preview((gesture) => ({ ...gesture, point: { ...point }, + modifiers: modifiers === undefined ? gesture.modifiers : { shiftKey: modifiers.shiftKey ?? false, altKey: modifiers.altKey ?? false }, + moved: gesture.moved || Math.hypot(point.x - gesture.input.point.x, point.y - gesture.input.point.y) > threshold, + })); + return active ? project(active) : null; + } + + return { + begin(context, input) { + const selection = transition(context, { type: "set-primary", key: context.selection.primaryKey }); + const captured = { items: context.items.map((item) => ({ ...item })), selection }; + const hitKey = input.hitKey !== null && captured.items.some((item) => item.id === input.hitKey) ? input.hitKey : null; + const press = hitKey === null ? null : planeHitAffordance({ hitId: hitKey, selectedIds: selection.keys, shiftKey: input.shiftKey ?? false }).hand; + const active = gestures.begin({ type: "plane-select", context: captured, input: { ...input, point: { ...input.point }, hitKey }, + point: { ...input.point }, moved: false, modifiers: { shiftKey: input.shiftKey ?? false, altKey: input.altKey ?? false }, + selection: press?.type === "select" ? transition(captured, { type: "replace", keys: press.objectIds ?? [], primaryKey: hitKey! }) : selection, + }); + return project(active); + }, + preview, + commit(point, modifiers) { + preview(point, modifiers); + const active = gestures.commit(); + if (!active) return null; + const result = project(active); + return { + selection: !active.moved ? select(active.context, active.input.hitKey, active.input.shiftKey) : result.selection, + translation: result.translation, + }; + }, + updateModifiers(modifiers) { + const active = gestures.getActive(); + return active ? preview(active.point, modifiers) : null; + }, + cancel(reason) { gestures.cancel(reason); }, + getPreview() { const active = gestures.getActive(); return active ? project(active) : null; }, + select, + keyDown(stroke, context, grabbing = false) { + const selection = transition(context, { type: "set-primary", key: context.selection.primaryKey }); + const escape = escapeAffordance({ key: stroke.key, grabbing: grabbing || gestures.getActive() !== null, selected: selection.keys.length > 0 }).hand; + let result: PlaneSelectKeyResult | null = null; + if (escape?.type === "cancel") result = { type: "cancel" }; + else if (escape?.type === "clear") result = { type: "selection", selection: select(context, null) }; + else if (!stroke.altKey && selectAllAffordance(stroke, { allSelected: false }, { repeat: "preserve" }).hand) { + result = { type: "selection", selection: transition(context, { type: "replace", keys: context.items.map((item) => item.id), ...(selection.primaryKey === null ? {} : { primaryKey: selection.primaryKey }) }) }; + } else { + const command = keyboard.resolve(stroke); + const nudge = !stroke.metaKey && !stroke.ctrlKey && !stroke.altKey ? nudgeAffordance(stroke).hand : null; + if (editKeyboard.resolve(stroke) === "edit" && selection.primaryKey !== null) result = { type: "edit", key: selection.primaryKey }; + else if (command?.type === "delete" && selection.keys.length) result = { type: "delete", keys: selection.keys }; + else if (duplicateKeyboard.resolve(stroke) && selection.keys.length) result = { type: "duplicate", keys: selection.keys }; + else if (nudge?.type === "nudge" && selection.keys.length) result = { type: "translate", keys: selection.keys, dx: nudge.dx, dy: nudge.dy }; + } + if (result) gestures.cancel(); + return result; + }, + }; +} diff --git a/packages/json-document-affordance/src/select.ts b/packages/json-document-affordance/src/select.ts index 879a7e39b..89f817251 100644 --- a/packages/json-document-affordance/src/select.ts +++ b/packages/json-document-affordance/src/select.ts @@ -1,4 +1,5 @@ import { + chordFromStroke, createWebKeyboardAdapter, selectionOperationFromModifiers, type WebKeyboardCommand, @@ -88,9 +89,16 @@ export function planeHitAffordance(input: { }; } -export function deleteAffordance(input: { readonly key?: string }): AffordancePreview { - if (input.key === "Delete" || input.key === "Backspace") return { hand: { type: "delete" } }; - return { hand: null }; +/** Uses the default structural delete chord; omitted modifiers are false. */ +export function deleteAffordance(input: Partial): AffordancePreview { + const command = keyboard.resolve({ + key: input.key ?? "", + shiftKey: input.shiftKey ?? false, + metaKey: input.metaKey ?? false, + ctrlKey: input.ctrlKey ?? false, + altKey: input.altKey ?? false, + }); + return { hand: command?.type === "delete" ? command : null }; } export function contextMenuAffordance(input: { @@ -111,14 +119,20 @@ 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. */ +/** Mod+A without Alt/Shift selects all. Choose preserve for repetition; omission retains the legacy toggle. */ export function selectAllAffordance( - stroke: Pick, + stroke: Pick & Partial>, 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 }; + const chord = chordFromStroke({ + key: stroke.key, + shiftKey: stroke.shiftKey ?? false, + metaKey: stroke.metaKey, + ctrlKey: stroke.ctrlKey, + altKey: stroke.altKey ?? false, + }); + if (chord !== "Mod-a") return { hand: null }; return { hand: { type: state.allSelected && options.repeat !== "preserve" ? "clear" : "select-all" } }; } diff --git a/packages/json-document-affordance/tests/affordance.test.ts b/packages/json-document-affordance/tests/affordance.test.ts index c87a454f2..c626f1265 100644 --- a/packages/json-document-affordance/tests/affordance.test.ts +++ b/packages/json-document-affordance/tests/affordance.test.ts @@ -29,6 +29,7 @@ import { panAffordance, resizeAffordance, resolveAffordanceKey, + selectAllAffordance, snapAffordance, treeAffordance, createBoardDragSession, @@ -41,7 +42,7 @@ import { wheelAffordance, zoomAffordance, } from "../src/index.js"; -import { pressInteractionFromWeb } from "@interactive-os/json-document-web"; +import { createWebKeyboardAdapter, pressInteractionFromWeb } from "@interactive-os/json-document-web"; describe("Affordance sessions", () => { test("owns drag, resize, and control handle descriptor, cursor, delta, and lifecycle semantics", () => { @@ -780,3 +781,38 @@ describe("snapAffordance disable key", () => { .toEqual({ type: "translate", dx: 47, dy: 51 }); }); }); + + +describe("default keyboard modifier contract", () => { + const keyboard = createWebKeyboardAdapter(); + for (let mask = 0; mask < 16; mask++) { + const modifiers = { metaKey: !!(mask & 1), ctrlKey: !!(mask & 2), shiftKey: !!(mask & 4), altKey: !!(mask & 8) }; + test(`preserves all modifier facts (${mask})`, () => { + for (const key of ["Delete", "Backspace"]) { + const stroke = { key, ...modifiers }; + expect(deleteAffordance(stroke).hand).toEqual(mask === 0 ? { type: "delete" } : null); + expect(deleteAffordance(stroke).hand).toEqual(keyboard.resolve(stroke)); + } + for (const key of ["z", "Z"]) { + const stroke = { key, ...modifiers }; + expect(resolveAffordanceKey(stroke).hand).toEqual(keyboard.resolve(stroke)); + expect(editingCommandFromWebKeyboardStroke(stroke)).toEqual(keyboard.resolve(stroke)); + } + for (const key of ["a", "A"]) { + const stroke = { key, ...modifiers }; + const selected = (modifiers.metaKey || modifiers.ctrlKey) && !modifiers.shiftKey && !modifiers.altKey; + expect(selectAllAffordance(stroke, { allSelected: true }, { repeat: "preserve" }).hand) + .toEqual(selected ? { type: "select-all" } : null); + expect(selectAllAffordance(stroke, { allSelected: true }).hand) + .toEqual(selected ? { type: "clear" } : null); + } + }); + } + + test("keeps legacy partial inputs without inventing modifiers", () => { + expect(deleteAffordance({ key: "Backspace" }).hand).toEqual({ type: "delete" }); + expect(deleteAffordance({}).hand).toBeNull(); + expect(selectAllAffordance({ key: "a", metaKey: true, ctrlKey: false }, { allSelected: false }).hand) + .toEqual({ type: "select-all" }); + }); +}); diff --git a/packages/json-document-affordance/tests/plane-select.test.ts b/packages/json-document-affordance/tests/plane-select.test.ts new file mode 100644 index 000000000..2e2272c5f --- /dev/null +++ b/packages/json-document-affordance/tests/plane-select.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "vitest"; +import { createPlaneSelectProfile, type PlaneSelectContext, type PlaneSelectSelection } from "../src/index.js"; + +// A diagram consumer: no Object document, Editing, Canvas, DOM or React dependency. +const items = ["node:a", "node:b", "node:c"].map((id, index) => ({ id, x: index * 100, y: 20, width: 40, height: 40 })); +const point = { x: 10, y: 30 }; +const selected = (keys: readonly string[], primaryKey = keys.at(-1) ?? null): PlaneSelectSelection => ({ kind: "explicit", keys, primaryKey }); +const context = (keys: readonly string[] = ["node:a", "node:b"]): PlaneSelectContext => ({ items, selection: selected(keys) }); +const stroke = (key: string, mod = false) => ({ key, shiftKey: false, metaKey: mod, ctrlKey: false }); + +test("press retains a selected set, release collapses a click, and drag produces one set delta", () => { + const profile = createPlaneSelectProfile(); + expect(profile.begin(context(), { point, hitKey: "node:a" }).selection).toEqual(selected(["node:a", "node:b"], "node:a")); + expect(profile.commit({ x: 12, y: 31 })).toEqual({ selection: selected(["node:a"]), translation: null }); + profile.begin(context(), { point, hitKey: "node:a" }); + expect(profile.preview({ x: 40, y: 50 })?.translation).toEqual({ operation: "move", keys: ["node:a", "node:b"], dx: 30, dy: 20 }); + expect(profile.commit({ x: 50, y: 60 })?.translation).toEqual({ operation: "move", keys: ["node:a", "node:b"], dx: 40, dy: 30 }); + expect(profile.commit(point)).toBeNull(); +}); + +test("an unselected hit replaces the drag source; Shift activation toggles without range semantics", () => { + const profile = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: "node:c" }); + expect(profile.commit({ x: 20, y: 40 })?.translation?.keys).toEqual(["node:c"]); + expect(profile.select(context(["node:a"]), "node:c", true)).toEqual(selected(["node:a", "node:c"])); + expect(profile.select(context(), "node:b", true)).toEqual(selected(["node:a"])); + profile.begin(context(), { point, hitKey: "node:b", shiftKey: true }); + expect(profile.commit({ x: 40, y: 40 })).toEqual({ selection: selected(["node:a", "node:b"]), translation: { operation: "move", keys: ["node:a", "node:b"], dx: 30, dy: 0 } }); +}); + +test.each([false, true])("marquee is transient and uses base selection for every preview (Shift=%s)", (shiftKey) => { + const profile = createPlaneSelectProfile(); + const base = context(["node:c"]); + profile.begin(base, { point: { x: -10, y: 10 }, hitKey: null, shiftKey }); + expect(profile.preview({ x: 150, y: 70 })?.selection.keys).toEqual(shiftKey ? ["node:a", "node:b", "node:c"] : ["node:a", "node:b"]); + const narrowed = profile.preview({ x: 50, y: 70 }); + expect(narrowed?.selection.keys).toEqual(shiftKey ? ["node:a", "node:c"] : ["node:a"]); + expect(narrowed?.marquee).toEqual({ x: -10, y: 10, width: 60, height: 60 }); + expect(base.selection).toEqual(selected(["node:c"])); + expect(profile.commit({ x: 50, y: 70 })?.selection).toEqual(narrowed?.selection); + expect(profile.getPreview()).toBeNull(); +}); + +test("empty click clears even with Shift; reverse marquee intersects bounds and inside is explicit policy", () => { + const profile = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: null, shiftKey: true }); + expect(profile.commit(point)?.selection).toEqual(selected([])); + const inside = createPlaneSelectProfile({ contain: "inside" }); + for (const consumer of [profile, inside]) consumer.begin(context([]), { point: { x: 120, y: 80 }, hitKey: null }); + expect(profile.commit({ x: -10, y: 10 })?.selection.keys).toEqual(["node:a", "node:b"]); + expect(inside.commit({ x: -10, y: 10 })?.selection.keys).toEqual(["node:a"]); +}); + +test.each(["cancel", "pointer-cancel", "lost-capture", "superseded"] as const)("%s discards preview without mutating base or producing a commit", (reason) => { + const profile = createPlaneSelectProfile(); + const base = context(); + profile.begin(base, { point, hitKey: null }); profile.preview({ x: 200, y: 100 }); + profile.cancel(reason); + expect(profile.getPreview()).toBeNull(); expect(profile.commit(point)).toBeNull(); + expect(base.selection).toEqual(selected(["node:a", "node:b"])); +}); + +test("Escape cancels the inner gesture first, then clears idle selection; repeated Mod+A preserves primary", () => { + const profile = createPlaneSelectProfile(); + const base = context(); + profile.begin(base, { point, hitKey: "node:a" }); + expect(profile.keyDown(stroke("Escape"), base)).toEqual({ type: "cancel" }); + expect(profile.commit(point)).toBeNull(); + expect(profile.keyDown(stroke("Escape"), base)).toEqual({ type: "selection", selection: selected([]) }); + expect(profile.keyDown(stroke("Escape"), context([]))).toBeNull(); + expect(profile.keyDown(stroke("Escape"), context([]), true)).toEqual({ type: "cancel" }); + const all = profile.keyDown(stroke("a", true), base); + expect(all).toEqual({ type: "selection", selection: selected(items.map((item) => item.id), "node:b") }); + if (all?.type !== "selection") throw new Error("selection expected"); + expect(profile.keyDown({ ...stroke("A"), ctrlKey: true }, { items, selection: all.selection })).toEqual(all); +}); + +test("Delete targets the set, edit targets primary only, unrelated/modified keys remain unhandled", () => { + const profile = createPlaneSelectProfile(); + expect(profile.keyDown(stroke("Delete"), context())).toEqual({ type: "delete", keys: ["node:a", "node:b"] }); + for (const key of ["Enter", "F2"]) expect(profile.keyDown(stroke(key), context())).toEqual({ type: "edit", key: "node:b" }); + for (const key of ["Delete", "Enter", "F2"]) expect(profile.keyDown(stroke(key), context([]))).toBeNull(); + for (const key of ["Home", "z"]) expect(profile.keyDown(stroke(key), context())).toBeNull(); + expect(profile.keyDown(stroke("Enter", true), context())).toBeNull(); +}); + +test("Alt-copy and Shift-axis lock reproject the same source, including stationary modifier changes", () => { + const profile = createPlaneSelectProfile(); + const base = context(); + profile.begin(base, { point, hitKey: "node:a", altKey: true, shiftKey: true }); + const modifiers = Object.create({ get shiftKey() { return true; }, get altKey() { return true; } }); + expect(profile.preview({ x: 40, y: 50 }, modifiers)?.translation).toEqual({ operation: "copy", keys: ["node:a", "node:b"], dx: 30, dy: 0 }); + expect(profile.updateModifiers({ altKey: false })?.translation).toEqual({ operation: "move", keys: ["node:a", "node:b"], dx: 30, dy: 20 }); + expect(profile.updateModifiers({ altKey: true, shiftKey: true })?.translation?.operation).toBe("copy"); + expect(profile.commit({ x: 40, y: 90 })?.translation).toEqual({ operation: "copy", keys: ["node:a", "node:b"], dx: 0, dy: 60 }); + expect(base.selection).toEqual(selected(["node:a", "node:b"])); + expect(profile.updateModifiers({})).toBeNull(); +}); + +test("Alt-click, zero-distance return and cancellation never request duplication", () => { + const profile = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: "node:a", altKey: true }); + expect(profile.commit(point)?.translation).toBeNull(); + profile.begin(context(), { point, hitKey: "node:a", altKey: true }); + profile.preview({ x: 40, y: 50 }); + expect(profile.commit(point)?.translation).toBeNull(); + profile.begin(context(), { point, hitKey: "node:c", altKey: true }); + expect(profile.preview({ x: 40, y: 50 })?.translation?.keys).toEqual(["node:c"]); + profile.cancel(); expect(profile.commit({ x: 40, y: 50 })).toBeNull(); +}); + +test("nudge and duplicate target the set, and native clipboard chords remain available", () => { + const profile = createPlaneSelectProfile(); + expect(profile.keyDown(stroke("ArrowRight"), context())).toEqual({ type: "translate", keys: ["node:a", "node:b"], dx: 1, dy: 0 }); + expect(profile.keyDown({ ...stroke("ArrowUp"), shiftKey: true }, context())).toEqual({ type: "translate", keys: ["node:a", "node:b"], dx: 0, dy: -10 }); + for (const modifiers of [{ metaKey: true }, { ctrlKey: true }]) { + expect(profile.keyDown({ ...stroke("d"), ...modifiers }, context())).toEqual({ type: "duplicate", keys: ["node:a", "node:b"] }); + for (const key of ["c", "x", "v", "ArrowLeft"]) expect(profile.keyDown({ ...stroke(key), ...modifiers }, context())).toBeNull(); + } + for (const key of ["d", "ArrowLeft"]) { + expect(profile.keyDown({ ...stroke(key), altKey: true }, context())).toBeNull(); + expect(profile.keyDown(stroke(key, key === "d"), context([]))).toBeNull(); + } +}); + +test("keys and primary reconcile through Selection; geometry and input are captured, and instances are independent", () => { + const profile = createPlaneSelectProfile(); + const mutable = { items: items.map((item) => ({ ...item })), selection: selected(["missing", "node:a"], "missing") }; + const input = { point: { x: -10, y: 0 }, hitKey: null }; + profile.begin(mutable, input); + mutable.items[0]!.x = 999; input.point.x = 999; + expect(profile.commit({ x: 60, y: 80 })?.selection).toEqual(selected(["node:a"])); + const second = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: "node:a" }); + expect(second.getPreview()).toBeNull(); + expect(second.select(context(), "missing")).toEqual(selected([])); +}); + +test("crossing threshold is latched; returning to the origin has no document delta", () => { + const profile = createPlaneSelectProfile({ dragThreshold: 5 }); + profile.begin(context(), { point, hitKey: "node:a" }); + expect(profile.preview({ x: 13, y: 34 })?.translation).toBeNull(); + profile.preview({ x: 30, y: 40 }); + expect(profile.commit(point)).toEqual({ selection: selected(["node:a", "node:b"], "node:a"), translation: null }); + profile.begin(context(), { point, hitKey: null }); profile.preview({ x: 100, y: 100 }); + expect(profile.commit(point)?.selection).toEqual(selected([])); + for (const dragThreshold of [-1, NaN, Infinity]) expect(() => createPlaneSelectProfile({ dragThreshold })).toThrow(RangeError); +}); diff --git a/packages/json-document-affordance/tests/resize.test.ts b/packages/json-document-affordance/tests/resize.test.ts new file mode 100644 index 000000000..3df08f673 --- /dev/null +++ b/packages/json-document-affordance/tests/resize.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import { commitAffordance, resizeAffordance, type ResizeEdge } from "../src/index.js"; + +const size = { width: 200, height: 100 }; +const origin = { x: 0, y: 0 }; +const edges = ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const; +const modes = [ + { shiftKey: false, altKey: false }, { shiftKey: true, altKey: false }, + { shiftKey: false, altKey: true }, { shiftKey: true, altKey: true }, +]; + +function bounds(edge: ResizeEdge, point: typeof origin, modifiers = modes[0]!, initial = size) { + const result = resizeAffordance(origin, point, edge, modifiers, initial); + expect(result.cursor).toBe(`${edge}-resize`); + const hand = result.hand; + if (hand?.type !== "resize") throw new Error("Expected a resize result"); + return { x: hand.dx, y: hand.dy, width: initial.width + hand.dw, height: initial.height + hand.dh }; +} + +describe("size-aware Resize", () => { + test.each([ + ["n", 0, -10, 200, 110], ["ne", 40, -10, 240, 110], + ["e", 40, 0, 240, 100], ["se", 40, 10, 240, 110], + ["s", 0, 10, 200, 110], ["sw", -40, 10, 240, 110], + ["w", -40, 0, 240, 100], ["nw", -40, -10, 240, 110], + ] as const)("%s resizes only its axes and fixes the opposite edge/corner", (edge, dx, dy, width, height) => { + expect(bounds(edge, { x: dx || 75, y: dy || 75 })).toEqual({ + x: edge.includes("w") ? 200 - width : 0, y: edge.includes("n") ? 100 - height : 0, width, height, + }); + }); + + test.each(edges)("%s uses the initial non-square ratio and Alt fixes the center", (edge) => { + const x = edge.includes("w") ? -40 : edge.includes("e") ? 40 : 0; + const y = edge.includes("n") ? -10 : edge.includes("s") ? 10 : 0; + const width = x === 0 ? 220 : 240, height = width / 2; + const resized = bounds(edge, { x, y }, { shiftKey: true, altKey: false }); + expect(resized.width).toBeCloseTo(width); expect(resized.height).toBeCloseTo(height); + expect(resized.x).toBeCloseTo(x === 0 ? (200 - width) / 2 : x < 0 ? 200 - width : 0); + expect(resized.y).toBeCloseTo(y === 0 ? (100 - height) / 2 : y < 0 ? 100 - height : 0); + for (const shiftKey of [false, true]) { + const centered = bounds(edge, { x, y }, { shiftKey, altKey: true }); + expect(centered.x + centered.width / 2).toBeCloseTo(100); + expect(centered.y + centered.height / 2).toBeCloseTo(50); + expect(centered.width).toBeCloseTo(shiftKey ? 200 + (width - 200) * 2 : 200 + Math.abs(x) * 2); + expect(centered.height).toBeCloseTo(shiftKey ? centered.width / 2 : 100 + Math.abs(y) * 2); + } + }); + + test.each(edges.flatMap((edge) => modes.map((mode) => ({ edge, ...mode }))))("$edge clamps without moving its anchor (Shift=$shiftKey Alt=$altKey)", ({ edge, shiftKey, altKey }) => { + const horizontal = edge.includes("e") || edge.includes("w"), vertical = edge.includes("n") || edge.includes("s"); + const result = bounds(edge, { x: edge.includes("w") ? 1000 : -1000, y: edge.includes("n") ? 1000 : -1000 }, { shiftKey, altKey }); + expect(result.width).toBeCloseTo(shiftKey ? 2 : horizontal ? 1 : 200); + expect(result.height).toBeCloseTo(shiftKey || vertical ? 1 : 100); + expect(result.x + result.width * (altKey || !horizontal ? 0.5 : edge.includes("w") ? 1 : 0)).toBeCloseTo(altKey || !horizontal ? 100 : edge.includes("w") ? 200 : 0); + expect(result.y + result.height * (altKey || !vertical ? 0.5 : edge.includes("n") ? 1 : 0)).toBeCloseTo(altKey || !vertical ? 50 : edge.includes("n") ? 100 : 0); + }); + + test.each(edges)("%s keeps a stationary input a no-op in every modifier mode", (edge) => { + for (const mode of modes) expect(commitAffordance(resizeAffordance(origin, origin, edge, mode, size))).toBeNull(); + }); + + test("a tall object's ratio, shrinking and initially sub-unit dimensions remain well-defined", () => { + expect(bounds("se", { x: -10, y: -80 }, { shiftKey: true, altKey: false }, { width: 100, height: 400 })).toEqual({ x: 0, y: 0, width: 80, height: 320 }); + const small = { width: 0.5, height: 0.25 }; + expect(bounds("nw", origin, modes[0], small)).toEqual({ x: 0, y: 0, ...small }); + expect(bounds("nw", { x: 10, y: 10 }, modes[0], small)).toEqual({ x: -0.5, y: -0.75, width: 1, height: 1 }); + }); + + test.each([0, -1, Infinity, NaN])("rejects invalid initial dimensions (%s)", (width) => { + expect(() => resizeAffordance(origin, origin, "e", undefined, { width, height: 100 })).toThrow(RangeError); + }); +}); diff --git a/packages/json-document-affordance/tsconfig.json b/packages/json-document-affordance/tsconfig.json index ee5938125..0fd47ac1b 100644 --- a/packages/json-document-affordance/tsconfig.json +++ b/packages/json-document-affordance/tsconfig.json @@ -5,6 +5,6 @@ "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, - "references": [{ "path": "../json-document-web" }], + "references": [{ "path": "../json-document-web" }, { "path": "../json-document-selection" }], "include": ["src/**/*.ts"] } diff --git a/packages/json-document-annotation/LICENSE b/packages/json-document-annotation/LICENSE new file mode 100644 index 000000000..b66a4819a --- /dev/null +++ b/packages/json-document-annotation/LICENSE @@ -0,0 +1,3 @@ +MIT License + +Copyright (c) Interactive OS contributors diff --git a/packages/json-document-annotation/README.md b/packages/json-document-annotation/README.md new file mode 100644 index 000000000..2f593fd23 --- /dev/null +++ b/packages/json-document-annotation/README.md @@ -0,0 +1,41 @@ +# @interactive-os/json-document-annotation + +`AnnotationHand` is the canonical React interaction surface for raster +annotations. Editing owns the persistent document and selector transforms; +the Hand owns tools, gesture-to-Intent orchestration, SVG projection, +transient previews, resize handles, and comment UI. + +```tsx +import { AnnotationHand } from "@interactive-os/json-document-annotation"; + + crypto.randomUUID()} + rasterStyle={rasterStyle} +/> +``` + +The Host owns the active `tool` and injects `onToolChange`, IDs, enabled tools, +copy, class names, `reactionShadow`, raster style, and the +concrete source URL. The serialized output remains an `AnnotationDocument`; +selection and history stay in the editor snapshot. + + +`useAnnotationOutput({ document, editor, sourceUrl, rasterStyle, renderImage })` +provides `structured`, `structuredDownloadUrl`, `renderedImage`, `imageError`, +`canRestore`, `save()` and `restore()`. Pass the same Core `document` instance +used to create `editor`. `save()` retains an immutable document snapshot; +`restore()` uses a Core commit and clears selection, returning whether it +succeeded. This is external document replacement, so the editor's external +history policy applies. A saved snapshot cannot be restored into a different +Core document instance. Image rendering is lazy and ignores stale completions. +The Host composes its own output tabs, copyable code display and download links. + +The Hand uses Key Selection through Editing, `useInteractionHandle` for move +and resize, Web pointer capture for creation, and the Web keyboard resolver for +Undo/Redo/Delete. Tool shortcuts are plain V/C/D/A/L/K; modified shortcuts and +IME composition do not choose a tool. Preview and commit both consume Editing's +`transformAnnotationSelector`. diff --git a/packages/json-document-annotation/package.json b/packages/json-document-annotation/package.json new file mode 100644 index 000000000..7bba7fc71 --- /dev/null +++ b/packages/json-document-annotation/package.json @@ -0,0 +1,66 @@ +{ + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "description": "Official React Annotation Hand for json-document.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/json-document-annotation" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "tag": "next" + }, + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -b tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0", + "@interactive-os/json-document": ">=3.0.0-rc.0 <4" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "@interactive-os/json-document": "*" + } +} diff --git a/packages/json-document-annotation/src/annotation-hand.tsx b/packages/json-document-annotation/src/annotation-hand.tsx new file mode 100644 index 000000000..9759107a6 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-hand.tsx @@ -0,0 +1,326 @@ +import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; +import { createGestureSession, type InteractionHandleEvent, type InteractionHandleDescriptor } from "@interactive-os/json-document-affordance"; +import { + annotationResizeHandle, + annotationSelectorBounds, + transformAnnotationSelector, + type Annotation, + type AnnotationDocument, + type AnnotationEditor, + type AnnotationPoint, + type AnnotationSource, +} from "@interactive-os/json-document-editing"; +import { createWebKeyboardAdapter, createWebPointerSession, projectWebClientPointToSVG, renderWebAnnotationRaster, webSVGViewportFromElement, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; +import { Command, Field, Toggle, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; +import { ArrowUpRight, Download, MessageSquare, MousePointer2, Pencil, SendHorizontal, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; + +export type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +type Gesture = + | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } + | { readonly type: "draw"; readonly points: ReadonlyArray } + | { readonly type: "move" | "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; + +export const annotationTools = [ + { id: "select", label: "Select", shortcut: "V", icon: MousePointer2 }, + { id: "comment", label: "Comment", shortcut: "C", icon: MessageSquare }, + { id: "draw", label: "Draw", shortcut: "D", icon: Pencil }, + { id: "arrow", label: "Arrow", shortcut: "A", icon: ArrowUpRight }, + { id: "like", label: "Like", shortcut: "L", icon: ThumbsUp }, + { id: "dislike", label: "Dislike", shortcut: "K", icon: ThumbsDown }, +] as const; + +export interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} + +export interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} + +export interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} + +const defaultLabels = { + canvas: "Raster annotation canvas", tools: "Annotation tools", instruction: "Annotation instruction", + instructionPlaceholder: "수정 요청을 입력하세요…", sendComment: "Send comment", + deleteAnnotation: "Delete annotation", downloadImage: "Download annotated image", +}; +const accent = "var(--annotation-accent)"; +const keyboard = createWebKeyboardAdapter(); +const toolKeyboard = createWebKeyboardAdapter({ defaults: false, keymap: Object.fromEntries(annotationTools.map(({ id, shortcut }) => [shortcut.toLowerCase(), id])) }); + +export function AnnotationHand(props: AnnotationHandProps) { + useSyncExternalStore(props.editor.subscribe, () => props.editor.snapshot.revision, () => props.editor.snapshot.revision); + const labels = { ...defaultLabels, ...props.labels }; const classes = props.classNames ?? {}; + const enabled = props.enabledTools ?? annotationTools.map(({ id }) => id); + const { tool, onToolChange: setTool } = props; + const [editingId, setEditingId] = useState(null); const [previewId, setPreviewId] = useState(null); + const [, redraw] = useState(0); + const [gestures] = useState(() => createGestureSession({ onBegin: rerender, onPreview: rerender, onCommit: rerender, onCancel: rerender })); + const [pointer] = useState(() => createWebPointerSession<{ readonly active: true }>()); + const document = props.editor.snapshot.value as AnnotationDocument; const selectedId = props.editor.snapshot.selection.primaryId; + const selected = document.annotations.find(({ id }) => id === selectedId) ?? null; const source = document.sources[0]!; const gesture = gestures.getActive(); + function rerender() { redraw((value) => value + 1); } + function announce(message: string) { props.onAnnouncement?.(message); } + function select(id: string | null) { props.editor.dispatch({ type: "selection.set", annotationId: id, mode: "replace" }); } + function choose(next: AnnotationTool) { setTool(next); setEditingId(null); if (selectedId !== null) select(null); } + function remove() { if (selectedId === null) return; props.editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); setEditingId(null); announce("선택한 annotation을 삭제했습니다."); } + + function canvasDown(event: PointerEvent) { + if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; + const point = eventPoint(event); if (point === null) return; if (tool === "select") return select(null); + pointer.begin(event.currentTarget, event.pointerId, { active: true }); + gestures.begin(tool === "draw" ? { type: "draw", points: [point] } : { type: "create", tool, start: point, current: point }); + } + function handleInteraction(interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") { + if (interaction.phase === "start") { + if (type === "move") { setEditingId(null); setPreviewId(null); select(annotation.id); if (tool !== "select") return; } + const start = eventPoint(event); + if (start !== null) gestures.begin({ type, id: annotation.id, start, current: start }); + return; + } + if (interaction.phase === "cancel") { gestures.cancel("pointer-cancel"); announce("진행 중인 조작을 취소했습니다."); return; } + const active = gestures.getActive(); + if (active?.type !== type || active.id !== annotation.id) return; + const current = eventPoint(event); if (current === null) return; + gestures.preview({ ...active, current }); + if (interaction.phase === "commit") commitActiveGesture(); + } + function pointerMove(event: PointerEvent) { + const gesture = gestures.getActive(); + if (gesture === null || pointer.getSnapshot()?.pointerId !== event.pointerId) return; const point = eventPoint(event); if (point === null) return; + if (gesture.type === "draw") { const last = gesture.points[gesture.points.length - 1]; if (last && distance(last, point) >= 4) gestures.preview({ ...gesture, points: [...gesture.points, point] }); } + else gestures.preview({ ...gesture, current: point }); + } + function pointerUp(event: PointerEvent) { + pointerMove(event); + if (pointer.commit(event.pointerId) !== null) commitActiveGesture(); + } + function commitActiveGesture() { + const committed = gestures.commit(); if (committed === null) return; + if (committed.type === "draw" || committed.type === "create") { + const annotation = committed.type === "draw" ? drawAnnotation(source.id, committed.points, props.createId) : createAnnotation(source.id, committed, props.createId); + if (annotation === null || !props.editor.dispatch({ type: "annotation.create", annotation }).ok) return; setTool("select"); + setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); announce(createdMessage(annotation)); return; + } + const dx = committed.current.x - committed.start.x; const dy = committed.current.y - committed.start.y; + if (committed.type === "move" && Math.hypot(dx, dy) < 4) { + const annotation = document.annotations.find(({ id }) => id === committed.id); if (annotation?.presentation.type !== "reaction") setEditingId(committed.id); return; + } + const annotation = document.annotations.find(({ id }) => id === committed.id); if (!annotation) return; + const handle = annotationResizeHandle(annotation.target.selector); + const result = committed.type === "move" ? props.editor.dispatch({ type: "annotation.move", annotationId: committed.id, dx, dy }) + : handle === null ? null : props.editor.dispatch({ type: "annotation.resize", annotationId: committed.id, handle, dx, dy }); + if (!result?.ok) return; + announce(committed.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); + } + function cancel(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { + if (pointer.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel") === null) return; + gestures.cancel(reason); announce("진행 중인 조작을 취소했습니다."); + } + function keyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing) return; + const command = keyboard.resolve(event); + if (command?.type === "undo" || command?.type === "redo") { event.preventDefault(); props.editor[command.type](); return; } + if (command?.type === "delete") { event.preventDefault(); remove(); return; } + const next = toolKeyboard.resolve(event); + if (next && enabled.includes(next)) { event.preventDefault(); choose(next); return; } + if (event.key === "Escape") { event.preventDefault(); const active = pointer.getSnapshot(); if (active) pointer.cancel(active.pointerId); gestures.cancel("cancel"); choose("select"); } + } + async function download() { + const result = await renderWebAnnotationRaster({ document, sourceId: source.id, sourceURL: props.sourceUrl, style: props.rasterStyle }); + if (!result.ok) return announce("Annotation 이미지를 만들지 못했습니다."); + const link = window.document.createElement("a"); link.href = result.dataURL; link.download = "annotation-request.png"; link.click(); announce("Annotation이 적용된 이미지를 다운로드했습니다."); + } + return
+
+ cancel(event, "lost-capture")} onPointerCancel={(event) => cancel(event, "pointer-cancel")} onPointerMove={pointerMove} onPointerUp={pointerUp} role="application" tabIndex={0} viewBox={`0 0 ${source.width} ${source.height}`}> + + {document.annotations.map((annotation, index) => setPreviewId(visible ? annotation.id : null)} />)} + {gesture?.type === "create" ? : null}{gesture?.type === "draw" ? : null} + + {document.annotations.map((annotation, index) => gesture === null && previewId === annotation.id && annotation.body.instruction.trim() && editingId !== annotation.id ? : null)} + {selected && editingId === selected.id ? cancelComment(selected)} onSave={(instruction) => saveComment(selected, instruction)} onSubmit={(instruction) => submitComment(selected, instruction)} /> : null} +
+ +
; + + function saveComment(annotation: Annotation, instruction: string) { const value = instruction.trim(); if (annotation.body.instruction !== value) props.editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: value }); setTool("select"); announce("수정 요청을 추가했습니다."); } + function submitComment(annotation: Annotation, instruction: string) { saveComment(annotation, instruction); setEditingId(null); } + function cancelComment(annotation: Annotation) { if (!annotation.body.instruction) props.editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); else select(null); setEditingId(null); } +} + +function CommentComposer(props: { annotation: Annotation; index: number; source: AnnotationSource; classNames: AnnotationHandClassNames; labels: typeof defaultLabels; onCancel: () => void; onSave: (value: string) => void; onSubmit: (value: string) => void }) { + const [draft, setDraft] = useState(props.annotation.body.instruction); const input = useRef(null); const dock = composerDock(props.annotation, props.source); + useEffect(() => setDraft(props.annotation.body.instruction), [props.annotation.id, props.annotation.body.instruction]); + useEffect(() => { const frame = requestAnimationFrame(() => input.current?.focus()); return () => cancelAnimationFrame(frame); }, [props.annotation.id]); + return
+ { if (draft.trim()) props.onSave(draft); }} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); if (draft.trim()) props.onSubmit(draft); } else if (event.key === "Escape") props.onCancel(); }} /> + props.onSubmit(draft)} onMouseDown={(event) => event.preventDefault()}> +
; +} +function CommentPreview({ annotation, index, source, className }: { annotation: Annotation; index: number; source: AnnotationSource; className?: string | undefined }) { const dock = composerDock(annotation, source); return
{annotation.body.instruction}
; } +function AnnotationShape(props: { + readonly annotation: Annotation; + readonly index: number; + readonly selected: boolean; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") => void; + readonly onPreview: (visible: boolean) => void; +}) { + const { annotation } = props; + const drag = useInteractionHandle({ + descriptor: { kind: "drag", cursor: { idle: "move", active: "grabbing" } }, + onHandle: (interaction, event) => props.onHandle(interaction, event, annotation, "move"), + }); + const selector = annotation.target.selector; + const bounds = annotationSelectorBounds(annotation.target.selector); + const common = { + fill: "none", + stroke: accent, + strokeWidth: props.selected ? 6 : 4, + vectorEffect: "non-scaling-stroke" as const, + }; + return ( + props.onPreview(false)} + onFocus={() => props.onPreview(true)} + onPointerEnter={() => props.onPreview(true)} + onPointerLeave={() => props.onPreview(false)} + {...drag.handleProps} + role="button" + tabIndex={0} + style={{ cursor: drag.cursor }} + > + {annotation.presentation.type === "marker" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "reaction" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "outline" && selector.type === "rectangle" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type === "stroke" && selector.type === "path" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} /> + ) : null} + + ) : null} + {annotation.presentation.type === "arrow" && selector.type === "arrow" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type !== "marker" && annotation.presentation.type !== "reaction" ? ( + + ) : null} + + ); +} + +function AnnotationPointHandle(props: { + readonly "aria-label": string; + readonly cx: number; + readonly cy: number; + readonly descriptor: InteractionHandleDescriptor; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: props.descriptor, onHandle: props.onHandle }); + return ; +} + +function Badge(props: { readonly index: number; readonly point: AnnotationPoint; readonly selected: boolean }) { + return ( + + + {props.index} + + ); +} + +function commentBubblePath(point: AnnotationPoint): string { + const { x, y } = point; + return `M ${x} ${y - 24} C ${x + 13.25} ${y - 24} ${x + 24} ${y - 13.25} ${x + 24} ${y} C ${x + 24} ${y + 13.25} ${x + 13.25} ${y + 24} ${x} ${y + 24} L ${x - 24} ${y + 24} L ${x - 24} ${y} C ${x - 24} ${y - 13.25} ${x - 13.25} ${y - 24} ${x} ${y - 24} Z`; +} + + +function Stroke({ points, selected, draft }: { points: ReadonlyArray; selected?: boolean; draft?: boolean }) { return ; } +function Arrow({ from, to, selected }: { from: AnnotationPoint; to: AnnotationPoint; selected: boolean }) { const a = Math.atan2(to.y - from.y, to.x - from.x); const point = (delta: number) => ({ x: to.x - 34 * Math.cos(a + delta), y: to.y - 34 * Math.sin(a + delta) }); const l = point(-Math.PI / 6), r = point(Math.PI / 6); return ; } +function Reaction(props: { readonly point: AnnotationPoint; readonly reaction: "like" | "dislike"; readonly selected: boolean; readonly draft?: boolean }) { + const Icon = props.reaction === "like" ? ThumbsUp : ThumbsDown; + return ( + + + + + + ); +} +function DraftShape({ gesture }: { gesture: Extract }) { if (gesture.tool === "like" || gesture.tool === "dislike") return ; if (gesture.tool === "arrow") return ; if (distance(gesture.start, gesture.current) < 16) return ; return ; } +function project(annotation: Annotation, gesture: Gesture | null): Annotation { if (!gesture || (gesture.type !== "move" && gesture.type !== "resize") || gesture.id !== annotation.id) return annotation; const selector = transformAnnotationSelector(annotation.target.selector, gesture.type === "move" ? { type: "move", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y } : { type: "resize", handle: annotationResizeHandle(annotation.target.selector) ?? "south-east", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y }); return selector ? { ...annotation, target: { ...annotation.target, selector } } : annotation; } +function createAnnotation(sourceId: string, gesture: Extract, id: () => string): Annotation | null { const { tool, start, current } = gesture; if (tool === "like" || tool === "dislike") return { id: id(), target: { sourceId, selector: { type: "point", ...start } }, body: { instruction: "" }, presentation: { type: "reaction", reaction: tool } }; if (tool === "comment") return { id: id(), target: { sourceId, selector: distance(start, current) < 16 ? { type: "point", ...start } : { type: "rectangle", ...rectangle(start, current) } }, body: { instruction: "" }, presentation: { type: distance(start, current) < 16 ? "marker" : "outline" } }; return distance(start, current) < 8 ? null : { id: id(), target: { sourceId, selector: { type: "arrow", from: start, to: current } }, body: { instruction: "" }, presentation: { type: "arrow" } }; } +function drawAnnotation(sourceId: string, points: ReadonlyArray, id: () => string): Annotation | null { return points.length < 2 || pathLength(points) < 16 ? null : { id: id(), target: { sourceId, selector: { type: "path", points } }, body: { instruction: "" }, presentation: { type: "stroke" } }; } +function eventPoint(event: PointerEvent): AnnotationPoint | null { const svg = event.currentTarget.ownerSVGElement ?? event.currentTarget as SVGSVGElement; const point = projectWebClientPointToSVG({ x: event.clientX, y: event.clientY }, webSVGViewportFromElement(svg)); return point && { x: point.x, y: point.y }; } +function rectangle(a: AnnotationPoint, b: AnnotationPoint) { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) }; } +function distance(a: AnnotationPoint, b: AnnotationPoint) { return Math.hypot(b.x - a.x, b.y - a.y); } +function pathLength(points: ReadonlyArray) { return points.slice(1).reduce((total, point, index) => total + distance(points[index] ?? point, point), 0); } +function pathData(points: ReadonlyArray) { const first = points[0]; if (!first) return ""; if (points.length === 2) return `M ${first.x} ${first.y} L ${points[1]!.x} ${points[1]!.y}`; const curves = points.slice(1, -1).map((point, index) => { const next = points[index + 2] ?? point; return `Q ${point.x} ${point.y} ${(point.x + next.x) / 2} ${(point.y + next.y) / 2}`; }); const last = points[points.length - 1] ?? first; return [`M ${first.x} ${first.y}`, ...curves, `L ${last.x} ${last.y}`].join(" "); } +function composerDock(annotation: Annotation, source: AnnotationSource) { const bounds = annotationSelectorBounds(annotation.target.selector); return { horizontal: bounds.x + bounds.width / 2 > source.width * .75 ? "left" : "right", vertical: bounds.y < 48 ? "below" : bounds.y > source.height - 48 ? "above" : "center", bounds }; } +function dockStyle(dock: ReturnType, source: AnnotationSource) { const left = dock.horizontal === "left" ? dock.bounds.x - 36 : dock.bounds.x + 36; const x = dock.horizontal === "left" ? "-100%" : "0"; const y = dock.vertical === "above" ? "-100%" : dock.vertical === "below" ? "0" : "-50%"; return { left: `${left / source.width * 100}%`, top: `${dock.bounds.y / source.height * 100}%`, transform: `translate(${x}, ${y})` }; } +function createdMessage(annotation: Annotation) { if (annotation.presentation.type === "reaction") return annotation.presentation.reaction === "like" ? "좋아요 스티커를 붙였습니다." : "싫어요 스티커를 붙였습니다."; return annotation.presentation.type === "marker" ? "위치 코멘트를 만들었습니다." : annotation.presentation.type === "outline" ? "영역 코멘트를 만들었습니다." : annotation.presentation.type === "stroke" ? "자유선 코멘트를 만들었습니다." : "화살표 코멘트를 만들었습니다."; } diff --git a/packages/json-document-annotation/src/annotation-output.ts b/packages/json-document-annotation/src/annotation-output.ts new file mode 100644 index 000000000..c77a4e562 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-output.ts @@ -0,0 +1,58 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { JSONDocument } from "@interactive-os/json-document"; +import type { AnnotationDocument, AnnotationEditor } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; + +export interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +export interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} + +/** Output lifecycle; the Host owns tabs, copy, links, and panel layout. */ +export function useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput { + const { document, editor, sourceUrl, rasterStyle, renderImage } = options; + useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision); + const value = editor.snapshot.value as AnnotationDocument; + const [saved, setSaved] = useState<{ owner: JSONDocument; value: AnnotationDocument } | null>(null); + const [image, setImage] = useState<{ value: AnnotationDocument; sourceUrl: string; style: WebAnnotationRasterStyle; dataURL: string | null } | null>(null); + const { stroke, fill, lineWidth, labelFont } = rasterStyle; + useEffect(() => { + if (!renderImage) return; + let current = true; + const style = { stroke, fill, lineWidth, labelFont }; + void renderWebAnnotationRaster({ document: value, sourceId: value.sources[0]!.id, sourceURL: sourceUrl, style }) + .then((result) => { if (current) setImage({ value, sourceUrl, style, dataURL: result.ok ? result.dataURL : null }); }) + .catch(() => { if (current) setImage({ value, sourceUrl, style, dataURL: null }); }); + return () => { current = false; }; + }, [value, sourceUrl, stroke, fill, lineWidth, labelFont, renderImage]); + const currentImage = image?.value === value && image.sourceUrl === sourceUrl && image.style.stroke === stroke && image.style.fill === fill && image.style.lineWidth === lineWidth && image.style.labelFont === labelFont ? image : null; + const structured = JSON.stringify(value, null, 2); + return { + structured, + structuredDownloadUrl: `data:application/json;charset=utf-8,${encodeURIComponent(structured)}`, + renderedImage: currentImage?.dataURL ?? null, + imageError: currentImage !== null && currentImage.dataURL === null, + canRestore: saved?.owner === document, + save() { setSaved({ owner: document, value }); }, + restore() { + if (saved?.owner !== document) return false; + const result = document.commit([{ op: "replace", path: "", value: saved.value }]); + if (!result.ok) return false; + editor.dispatch({ type: "selection.set", annotationId: null, mode: "replace" }); + return true; + }, + }; +} diff --git a/packages/json-document-annotation/src/index.ts b/packages/json-document-annotation/src/index.ts new file mode 100644 index 000000000..0c9557b6c --- /dev/null +++ b/packages/json-document-annotation/src/index.ts @@ -0,0 +1,4 @@ +export { AnnotationHand, annotationTools } from "./annotation-hand.js"; +export type { AnnotationHandClassNames, AnnotationHandLabels, AnnotationHandProps, AnnotationTool } from "./annotation-hand.js"; +export { useAnnotationOutput } from "./annotation-output.js"; +export type { AnnotationOutput, AnnotationOutputOptions } from "./annotation-output.js"; diff --git a/packages/json-document-annotation/tests/annotation-hand.test.tsx b/packages/json-document-annotation/tests/annotation-hand.test.tsx new file mode 100644 index 000000000..b7b98d9d8 --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-hand.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { AnnotationHand, annotationTools } from "../src/index.js"; + +const document: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; + +afterEach(cleanup); + +describe("AnnotationHand", () => { + test("publishes one descriptor for every default tool", () => { + expect(annotationTools.map(({ id, shortcut }) => [id, shortcut])).toEqual([["select", "V"], ["comment", "C"], ["draw", "D"], ["arrow", "A"], ["like", "L"], ["dislike", "K"]]); + }); + + test("renders the canonical canvas and configurable tool set", () => { + render( "next"} tool="comment" onToolChange={() => {}} rasterStyle={rasterStyle} enabledTools={["select", "comment"]} />); + expect(screen.getByRole("application", { name: "Raster annotation canvas" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Select" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Draw" })).toBeNull(); + + }); +}); + + +test("the Host controls tools; modified keys and IME do not invoke ordinary commands", () => { + const editor = createAnnotationEditor(document); + const onToolChange = vi.fn(); + const undo = vi.spyOn(editor, "undo"), redo = vi.spyOn(editor, "redo"), dispatch = vi.spyOn(editor, "dispatch"); + const props = { editor, sourceUrl: "/image.png", createId: () => "next", rasterStyle, onToolChange }; + const view = render(); + const canvas = screen.getByRole("application"); + fireEvent.click(screen.getByRole("button", { name: "Draw" })); + expect(onToolChange).toHaveBeenLastCalledWith("draw"); + expect(canvas.getAttribute("data-tool")).toBe("comment"); + view.rerender(); + expect(canvas.getAttribute("data-tool")).toBe("draw"); + onToolChange.mockClear(); dispatch.mockClear(); + for (const modifiers of [{ altKey: true }, { ctrlKey: true }, { shiftKey: true }, { isComposing: true }]) fireEvent.keyDown(canvas, { key: "c", ...modifiers }); + expect(onToolChange).not.toHaveBeenCalled(); + fireEvent.keyDown(canvas, { key: "c" }); + expect(onToolChange).toHaveBeenLastCalledWith("comment"); + fireEvent.keyDown(canvas, { key: "z", metaKey: true }); + fireEvent.keyDown(canvas, { key: "Z", ctrlKey: true, shiftKey: true }); + expect(undo).toHaveBeenCalledTimes(1); expect(redo).toHaveBeenCalledTimes(1); + fireEvent.keyDown(canvas, { key: "z", ctrlKey: true, altKey: true }); + fireEvent.keyDown(canvas, { key: "z", metaKey: true, isComposing: true }); + expect(undo).toHaveBeenCalledTimes(1); +}); diff --git a/packages/json-document-annotation/tests/annotation-output.test.tsx b/packages/json-document-annotation/tests/annotation-output.test.tsx new file mode 100644 index 000000000..ec3598f69 --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-output.test.tsx @@ -0,0 +1,62 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterResult } from "@interactive-os/json-document-web"; +import { useAnnotationOutput } from "../src/index.js"; + +vi.mock("@interactive-os/json-document-web", async (load) => ({ ...await load(), renderWebAnnotationRaster: vi.fn() })); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); +const initial: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; +function setup() { + const document = createJSONDocument(initial), editor = createAnnotationEditor(document); + return { document, editor, sourceUrl: "/image.png", rasterStyle, renderImage: false }; +} + +test("serializes only the document and restores a saved snapshot with cleared selection", () => { + const options = setup(); + const { result } = renderHook(() => useAnnotationOutput(options)); + expect(result.current.restore()).toBe(false); + act(() => result.current.save()); + act(() => { options.editor.dispatch({ type: "annotation.create", annotation: { id: "note", body: { instruction: "Inspect" }, presentation: { type: "marker" }, target: { sourceId: "image", selector: { type: "point", x: 10, y: 20 } } } }); }); + expect(options.editor.snapshot.selection.primaryId).toBe("note"); + expect(JSON.parse(result.current.structured)).toEqual(options.document.value); + expect(JSON.parse(decodeURIComponent(result.current.structuredDownloadUrl.split(",")[1]!))).toEqual(options.document.value); + expect(result.current.structured).not.toContain("primaryId"); + act(() => { expect(result.current.restore()).toBe(true); }); + expect(options.document.value).toEqual(initial); + expect(options.editor.snapshot.selection.ids).toEqual([]); +}); + +test("does not restore a snapshot from a replaced document owner", () => { + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: setup() }); + act(() => result.current.save()); + rerender(setup()); + expect(result.current.canRestore).toBe(false); + expect(result.current.restore()).toBe(false); +}); + +test("renders lazily, ignores stale raster completion, and exposes the current failure", async () => { + const pending: Array<(result: WebAnnotationRasterResult) => void> = []; + vi.mocked(renderWebAnnotationRaster).mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + const options = setup(); + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: options }); + expect(pending).toHaveLength(0); + rerender({ ...options, renderImage: true }); + expect(pending).toHaveLength(1); + rerender({ ...options, renderImage: true, sourceUrl: "/new.png" }); + expect(pending).toHaveLength(2); + await act(async () => pending[0]!({ ok: true, dataURL: "data:old" })); + expect(result.current.renderedImage).toBeNull(); + await act(async () => pending[1]!({ ok: false, code: "raster.decode-failed" })); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); + + +test("reports an unexpected raster rejection without an unhandled promise", async () => { + vi.mocked(renderWebAnnotationRaster).mockRejectedValue(new Error("canvas unavailable")); + const options = { ...setup(), renderImage: true }; + const { result } = renderHook(() => useAnnotationOutput(options)); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); diff --git a/packages/json-document-annotation/tsconfig.json b/packages/json-document-annotation/tsconfig.json new file mode 100644 index 000000000..a90816ee2 --- /dev/null +++ b/packages/json-document-annotation/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig/library-react.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "references": [ + { + "path": "../json-document" + }, + { + "path": "../json-document-affordance" + }, + { + "path": "../json-document-editing" + }, + { + "path": "../json-document-ui-primitives-react" + }, + { + "path": "../json-document-web" + } + ], + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/packages/json-document-annotation/tsconfig.test.json b/packages/json-document-annotation/tsconfig.test.json new file mode 100644 index 000000000..6a4921de1 --- /dev/null +++ b/packages/json-document-annotation/tsconfig.test.json @@ -0,0 +1 @@ +{"extends":"./tsconfig.json","compilerOptions":{"composite":false,"noEmit":true,"rootDir":".","tsBuildInfoFile":null},"include":["src/**/*.ts","src/**/*.tsx","tests/**/*.ts","tests/**/*.tsx"]} diff --git a/packages/json-document-annotation/vitest.config.ts b/packages/json-document-annotation/vitest.config.ts new file mode 100644 index 000000000..04e0964c9 --- /dev/null +++ b/packages/json-document-annotation/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ test: { environment: "jsdom" } }); diff --git a/packages/json-document-calendar-document/LICENSE b/packages/json-document-calendar-document/LICENSE new file mode 100644 index 000000000..6a984193a --- /dev/null +++ b/packages/json-document-calendar-document/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 interactive-os contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/json-document-calendar-document/README.md b/packages/json-document-calendar-document/README.md new file mode 100644 index 000000000..e3750bc15 --- /dev/null +++ b/packages/json-document-calendar-document/README.md @@ -0,0 +1,36 @@ +# @interactive-os/json-document-calendar-document + +Calendar Document Type의 RC 공개 소유자입니다. 문서 모델, JSON/Calendar 검증, +입력 독립 의미 연산과 occurrence/기간 projection을 제공합니다. 의존성은 +JSON Document와 Temporal이며 Editing, Selection, React 또는 DOM을 요구하지 않습니다. + +```ts +import { applyPatch } from "@interactive-os/json-document"; +import { + validateCalendarDocument, planCalendarEventEdit, projectCalendarOccurrences, +} from "@interactive-os/json-document-calendar-document"; + +const validation = validateCalendarDocument(document); +if (!validation.ok) throw new Error(validation.reason); +const plan = planCalendarEventEdit(document.events, { + type: "event.move", eventId: "meeting", start: "2026-08-03T10:00", +}, { allocateId: () => crypto.randomUUID(), calendarIds: new Set(document.calendars.map(calendar => calendar.id)) }); +if (plan.ok) { + const result = applyPatch(document, plan.operations); + const occurrences = projectCalendarOccurrences(plan.events, "2026-08-03", "2026-08-04"); +} +``` + +정본 [API 및 값 계약](docs/api.md)은 이 package에 둡니다. 사이트의 +[Calendar Document API](/docs/api/calendar-document), [Usage / Source](/editors#calendar-editor), +Usage의 Source 탭에서 실제 소유자와 소비 경로를 확인할 수 있습니다. + +`json-document-editing`은 이 package의 연산 결과를 Selection, Clipboard와 +History에 연결합니다. `json-document-calendar`는 Web/Affordance/React와 UI를 +조합합니다. 기존 Editing root의 문서 타입·projection export는 동일 구현의 +호환 경로이며, 새 직접 소비자는 이 package에서 import합니다. + +`tests/calendar-document.test.ts`는 editor 없는 소비를 검증합니다. +Editing의 `tests/conformance/calendar-grammar.test.ts`는 동일 연산을 사용하는 +선택·복사·붙여넣기·삭제·Undo/Redo의 공통 규칙을 검증합니다. Document Type +소유권의 확정은 wire 프로토콜의 Stable 승격이나 독립 구현 간 호환 보장이 아닙니다. diff --git a/packages/json-document-calendar-document/docs/api.md b/packages/json-document-calendar-document/docs/api.md new file mode 100644 index 000000000..1dadbe570 --- /dev/null +++ b/packages/json-document-calendar-document/docs/api.md @@ -0,0 +1,101 @@ +## Calendar Document Type 계약 · RC + +소유자: `@interactive-os/json-document-calendar-document`, 현재 `0.1.0-rc.0`. +이 package는 문서 규칙을 소유합니다. 선택·Clipboard·History는 +[Editing 프로파일](/docs/api/editing#calendar-protocol-profile-rc), 사용자 입력과 +UI는 [Calendar Hands](/docs/api/calendar)가 연결합니다. Core Stable 및 Official +Hands/Editing Grammar의 Draft 지위를 바꾸지 않습니다. + +### 모델과 검증 + +`CalendarDocument`는 `calendars`와 `events`를, `CalendarEvent`는 interval과 +recurrence를 정의합니다. `{ eventId, occurrenceStart }`인 `CalendarOccurrencePoint`는 +문서의 발생분 주소이며 selection 상태가 아닙니다. `CalendarOccurrenceInterval`은 +현재 규칙으로 해석한 `{ eventId, start, end }`입니다. + +- `validateCalendarDocument(unknown)`는 JSON과 Calendar 구조·참조·시간 규칙을 + 검사해 `{ ok: true }` 또는 `{ ok: false, code, reason }`을 반환합니다. +- `assertCalendarDocument(unknown)`는 같은 검사에 실패하면 `TypeError`를 던집니다. + 검사만 수행하며 입력을 정규화하거나 mutation하지 않습니다. legacy 수용을 + 포함하므로 validator 결과는 필수 필드가 모두 채워졌다는 TypeScript type guard가 아닙니다. +- calendar의 `id`는 고유한 비어 있지 않은 문자열, `title`은 문자열, + `hidden`은 boolean, `color`는 비어 있지 않은 문자열입니다. calendars의 생략은 + 허용하지만 null·문자열 같은 비배열 값은 거절합니다. +- event는 고유한 비어 있지 않은 `id`, 문자열 `title`, `start < end`를 갖습니다. + calendar 목록이 비어 있지 않을 때 비어 있지 않은 `calendarId`는 실제 calendar를 참조합니다. +- 기존 간단한 문서의 생략된 calendars / allDay / calendarId / recurrence / + excludeDates는 빈 목록 / timed / 미지정 / 반복 없음 / 제외 없음으로 읽습니다. + 이 호환 경로는 잘못된 타입을 생략으로 바꾸지 않습니다. + +### 시간과 반복 + +timed 값은 정확한 `YYYY-MM-DDTHH:mm` local date-time, all-day 값은 +`YYYY-MM-DD`입니다. 둘 다 종료는 exclusive입니다. 8월 1일 하루는 +`start: "2026-08-01", end: "2026-08-02"`입니다. UTC Instant, offset, timezone과 +초 단위는 현재 프로파일에 포함하지 않습니다. + +recurrence의 `freq`는 daily / weekly / monthly / yearly, `interval`은 양의 safe +integer입니다. `until: ""`은 무기한이며 나머지는 발생 시작일 기준 inclusive 날짜입니다. +`excludeDates`는 발생 시작일을 제외합니다. 월말·윤년의 시작은 Temporal constrain, +종료는 원본의 local duration을 보존하므로 시작·종료를 따로 constrain하지 않습니다. + +### 의미 연산 + +| API | 입력과 결과 | +| --- | --- | +| `planCalendarEventEdit(events, operation, options)` | create/update/move/move-day/resize/occurrence.edit를 events·JSON Patch·affectedOccurrence로 계획 | +| `planCalendarEventRemoval(events, eventIds)` | 지정한 원본 event들을 제거하는 events·Patch 계획 | +| `planCalendarOccurrenceRemoval(events, removal)` | 발생분 scope에 따른 제외·시리즈 절단·제거 계획 | +| `planCalendarVisibility(document, calendarId, hidden)` | 존재하는 calendar의 boolean 가시성 변경 계획 | + +모든 plan은 입력을 변경하거나 commit하지 않습니다. 실패하면 `{ ok: false, code, +reason? }`이며 성공 시 `operations`를 JSONDocument 또는 `applyPatch`에 적용할 수 +있습니다. `affectedOccurrence`는 변경 결과의 문서 주소입니다. 무엇을 선택하고 +어떤 Undo 단위로 묶을지는 Editing이 결정합니다. 기존 Calendar rejection code는 +호환성을 위해 유지하며, `selection.*`라는 code 이름이 Selection 의존성을 뜻하지는 않습니다. + +연산의 `events`는 검증된 현재 문서에서 가져옵니다. `calendarIds`에는 그 문서의 +calendar ID 집합을 전달합니다. 생략하면 event의 시간·반복 구조만 검사하므로 +문서 전체의 membership 검증을 대신하지 않습니다. 생성의 `defaultCalendarId`는 +호출자가 고른 기본값이고, 미지정이면 빈 문자열입니다. `allocateId`는 필요한 새 ID를 +공급하며 비어 있거나 이미 있는 ID는 거절합니다. provider 예외는 호출자에게 전파합니다. +Editing은 기존 bounded ID allocator를 주입합니다. + +| scope | 편집 | 삭제 | +| --- | --- | --- | +| this | 해당 발생분을 제외하고 독립 일정으로 분리 | 해당 발생분 제외 | +| this-and-following | 기준일 전날까지 원본을 자르고 이후 시리즈 분리 | 기준일 이후 발생분 제거 | +| all | 선택한 발생분의 변경량을 원본 시리즈에 적용 | 원본 시리즈 제거 | + +시작만 바꾸면 구간 길이를 보존하고 resize는 지정한 경계만 변경합니다. 시리즈 +이동은 유한한 until과 excludeDates를 함께 옮기며 following은 기존 종료와 이후 +제외 날짜를 보존합니다. 월/년 재기준화로 요청한 발생분을 표현할 수 없으면 +`selection.unrepresentable-series-move`로 거절합니다. allDay / calendarId / +recurrence 자체의 변경은 시리즈 속성 변경입니다. + +### Projection과 날짜 값 + +`projectCalendarOccurrences(events, rangeStart, rangeEnd)`는 `[rangeStart, rangeEnd)`와 +겹치는 발생분을 계산합니다. 범위는 date 문자열입니다. 요청 구간 근처로 seek하며 +400회 같은 lifetime cap은 없습니다. `resolveCalendarOccurrence`는 화면 밖의 주소도 +현재 recurrence와 exclusions로 해석합니다. 잘못된 조회 범위는 빈 결과입니다. + +`calendarVisibleEvents`, `calendarEventsOnDay`, `calendarEventsInMonth`, +`calendarBusyDates`는 문서 조회를, `calendarTimedLayout`, `calendarAllDayLayout`, +`calendarMonthDayLayout`, `calendarMonthWeekLayout`은 event의 구간/lane projection을 +제공합니다. DOM·CSS·표시 요소의 디자인은 포함하지 않습니다. + +`calendarDocumentCalendars` / `calendarDocumentCalendar`는 collection 조회, +`calendarDatePart` / `calendarIntervalLastDate` / `calendarAllDaySpan`은 시간 값의 +정본 변환입니다. parse/format/add/shift와 recurrence 변경 함수도 같은 owner를 +사용합니다. Calendar 화면의 cell/grid, 날짜 선택과 표시 label은 Calendar Hands에 남습니다. + +### 실제 소비와 남은 범위 + +[Calendar Usage 및 Source](/editors#calendar-editor)는 이 package의 조회·projection을 직접 소비하고, +Editing이 공개 연산 계획을 통해 변경합니다. Source에서 모델·검증·연산·projection과 +Editing·Hand의 연결을 추적할 수 있습니다. 이 문서 아래에는 전체 export signature가 이어집니다. + +timezone/DST, 서버 revision/충돌/재시도, AI command wire, 외부 calendar connector, +범용 RRULE, 대량 조회 pagination, 프로파일 버전 협상과 독립 구현 conformance는 +TBD입니다. 현재 소유권 및 로컬 RC 동작의 검증과 구분합니다. diff --git a/packages/json-document-calendar-document/package.json b/packages/json-document-calendar-document/package.json new file mode 100644 index 000000000..fc22921a0 --- /dev/null +++ b/packages/json-document-calendar-document/package.json @@ -0,0 +1,24 @@ +{ + "name": "@interactive-os/json-document-calendar-document", + "version": "0.1.0-rc.0", + "description": "Calendar Document Type: model, validation, semantic operations and projections without an editor or UI.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { "type": "git", "url": "git+https://github.com/developer-1px/json-document.git", "directory": "packages/json-document-calendar-document" }, + "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "scripts": { + "build": "tsc -b tsconfig.json", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "test": "vitest run --config vitest.config.ts", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { "@interactive-os/json-document": "^3.0.0" }, + "dependencies": { "@js-temporal/polyfill": "^0.5.1" }, + "devDependencies": { "@interactive-os/json-document": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" } +} diff --git a/packages/json-document-calendar-document/src/calendar-model.ts b/packages/json-document-calendar-document/src/calendar-model.ts new file mode 100644 index 000000000..d8a8ff265 --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-model.ts @@ -0,0 +1,42 @@ +import type { JSONValue } from "@interactive-os/json-document"; + +export interface CalendarCalendar extends Record { + readonly id: string; + readonly title: string; + readonly hidden: boolean; + readonly color: string; +} + +export interface CalendarRecurrence extends Record { + readonly freq: "daily" | "weekly" | "monthly" | "yearly"; + readonly interval: number; + readonly until: string; +} + +export interface CalendarEvent extends Record { + readonly id: string; + readonly title: string; + readonly start: string; + readonly end: string; + readonly allDay: boolean; + readonly calendarId: string; + readonly recurrence: CalendarRecurrence | null; + readonly excludeDates: ReadonlyArray; +} + +export interface CalendarDocument extends Record { + readonly calendars: ReadonlyArray; + readonly events: ReadonlyArray; +} + +export interface CalendarOccurrencePoint extends Record { + readonly eventId: string; + readonly occurrenceStart: string; +} + +/** A resolved document occurrence, independent of selection or an editor. */ +export interface CalendarOccurrenceInterval { + readonly eventId: string; + readonly start: string; + readonly end: string; +} diff --git a/packages/json-document-editing/src/calendar-occurrence.ts b/packages/json-document-calendar-document/src/calendar-occurrence.ts similarity index 57% rename from packages/json-document-editing/src/calendar-occurrence.ts rename to packages/json-document-calendar-document/src/calendar-occurrence.ts index 80bb8d5df..a2e8920c2 100644 --- a/packages/json-document-editing/src/calendar-occurrence.ts +++ b/packages/json-document-calendar-document/src/calendar-occurrence.ts @@ -1,12 +1,16 @@ import { Temporal } from "@js-temporal/polyfill"; -import type { CalendarEvent, CalendarRecurrence } from "./calendar.js"; +import type { CalendarEvent, CalendarOccurrencePoint, CalendarOccurrenceInterval, CalendarRecurrence } from "./calendar-model.js"; import { addCalendarDate, calendarDatePart, calendarEventBounds, + calendarEventIntervalAt, + calendarDaysBetween, + formatCalendarDate, + formatCalendarInstant, isCalendarAllDay, + isCalendarRecurrence, parseCalendarDate, - parseCalendarInstant, } from "./calendar-validation.js"; export type CalendarOccurrence = { @@ -16,17 +20,7 @@ export type CalendarOccurrence = { }; export function calendarEventRecurrence(event: CalendarEvent): CalendarRecurrence | null { - const value = event.recurrence; - if (value === null || typeof value !== "object" || Array.isArray(value)) return null; - const freq = value.freq; - const interval = value.interval; - if ( - (freq !== "daily" && freq !== "weekly" && freq !== "monthly" && freq !== "yearly") - || typeof interval !== "number" - || interval < 1 - ) return null; - const until = typeof value.until === "string" ? value.until : ""; - return { freq, interval, until }; + return isCalendarRecurrence(event.recurrence) ? event.recurrence : null; } export function calendarRecurrenceWithFrequency( @@ -82,8 +76,21 @@ export function projectCalendarOccurrences( } const excluded = new Set(calendarEventExcludeDates(event)); const until = recurrence.until === "" ? null : parseCalendarDate(recurrence.until); - for (let index = 0; index < 400; index += 1) { - const shifted = shiftOccurrence(event, recurrence.freq, recurrence.interval * index); + const end = parseCalendarDate(calendarDatePart(event.end)); + if (end === null) continue; + // Seek by the interval end, not start, so long occurrences overlapping the + // window are retained. One preceding period covers constrained month/year ends. + const distance = recurrence.freq === "monthly" ? (from.year - end.year) * 12 + from.month - end.month + : recurrence.freq === "yearly" ? from.year - end.year + : calendarDaysBetween(end, from) / (recurrence.freq === "weekly" ? 7 : 1); + const first = Math.max(0, Math.floor(distance / recurrence.interval) - 1); + for (let index = first; ; index += 1) { + let shifted: ReturnType; + try { shifted = shiftOccurrence(event, recurrence.freq, recurrence.interval * index); } + catch (error) { + if (!(error instanceof RangeError)) throw error; + break; // Beyond the finite Temporal date domain, not a truncated result. + } if (shifted === null) break; const bounds = calendarEventBounds({ ...event, start: shifted.start, end: shifted.end }); if (bounds === null) break; @@ -97,37 +104,31 @@ export function projectCalendarOccurrences( return occurrences; } +/** Resolve against the current recurrence/exclusion rules, including off-screen occurrences. */ +export function resolveCalendarOccurrence( + events: ReadonlyArray, + point: CalendarOccurrencePoint, +): CalendarOccurrenceInterval | null { + const event = events.find((candidate) => candidate.id === point.eventId); + if (event === undefined || typeof point.occurrenceStart !== "string") return null; + const day = calendarDatePart(point.occurrenceStart); + const next = addCalendarDate(day, 1); + if (next === null) return null; + const occurrence = projectCalendarOccurrences([event], day, next).find((candidate) => candidate.start === point.occurrenceStart); + return occurrence === undefined ? null : { eventId: event.id, start: occurrence.start, end: occurrence.end }; +} + function shiftOccurrence( event: CalendarEvent, freq: CalendarRecurrence["freq"], steps: number, ): { readonly start: string; readonly end: string } | null { if (steps === 0) return { start: event.start, end: event.end }; - if (isCalendarAllDay(event)) { - const start = shiftDate(event.start, freq, steps); - const end = shiftDate(event.end, freq, steps); - if (start === null || end === null) return null; - return { start, end }; - } - const start = shiftInstant(event.start, freq, steps); - const end = shiftInstant(event.end, freq, steps); - if (start === null || end === null) return null; - return { start, end }; -} - -function shiftDate(value: string, freq: CalendarRecurrence["freq"], steps: number): string | null { - if (freq === "daily") return addCalendarDate(value, steps); - if (freq === "weekly") return addCalendarDate(value, steps * 7); - if (parseCalendarDate(value) === null) return null; - const duration = freq === "monthly" ? { months: steps } : { years: steps }; - return Temporal.PlainDate.from(value).add(duration, { overflow: "constrain" }).toString(); -} - -function shiftInstant(value: string, freq: CalendarRecurrence["freq"], steps: number): string | null { - const dateTime = parseCalendarInstant(value); - if (dateTime === null) return null; - if (freq === "daily") return dateTime.add({ days: steps }).toString({ smallestUnit: "minute" }); - if (freq === "weekly") return dateTime.add({ weeks: steps }).toString({ smallestUnit: "minute" }); - const duration = freq === "monthly" ? { months: steps } : { years: steps }; - return Temporal.PlainDateTime.from(value).add(duration, { overflow: "constrain" }).toString({ smallestUnit: "minute" }); + const bounds = calendarEventBounds(event); + if (bounds === null) return null; + const duration = freq === "daily" ? { days: steps } : freq === "weekly" ? { weeks: steps } + : freq === "monthly" ? { months: steps } : { years: steps }; + const shifted = bounds.from.add(duration, { overflow: "constrain" }); + const start = isCalendarAllDay(event) ? formatCalendarDate(shifted.toPlainDate()) : formatCalendarInstant(shifted); + return calendarEventIntervalAt(event, start); } diff --git a/packages/json-document-calendar-document/src/calendar-operation.ts b/packages/json-document-calendar-document/src/calendar-operation.ts new file mode 100644 index 000000000..bd959002b --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-operation.ts @@ -0,0 +1,245 @@ +import { buildPointer, type JSONPatchOperation } from "@interactive-os/json-document"; +import type { CalendarDocument, CalendarEvent, CalendarOccurrencePoint, CalendarRecurrence } from "./calendar-model.js"; +import { calendarEventExcludeDates, calendarEventRecurrence, resolveCalendarOccurrence } from "./calendar-occurrence.js"; +import { + addCalendarDate, calendarAllDaySpan, calendarDatePart, calendarDaysBetween, + calendarDocumentCalendars, calendarEventIntervalAt, calendarMinutesBetween, formatCalendarInstant, + parseCalendarDate, parseCalendarInstant, validateCalendarEvent, +} from "./calendar-validation.js"; + +export type CalendarEventOperation = + | { + readonly type: "event.create"; + readonly start: string; + readonly end: string; + readonly title?: string; + readonly allDay?: boolean; + readonly calendarId?: string; + readonly recurrence?: CalendarRecurrence | null; + } + | { readonly type: "event.move"; readonly eventId: string; readonly start: string } + | { readonly type: "event.resize"; readonly eventId: string; readonly edge: "start" | "end"; readonly instant: string } + | { readonly type: "event.move-day"; readonly eventId: string; readonly day: string } + | { + readonly type: "event.update"; + readonly eventId: string; + readonly title?: string; + readonly start?: string; + readonly end?: string; + readonly allDay?: boolean; + readonly calendarId?: string; + readonly recurrence?: CalendarRecurrence | null; + } + | { + readonly type: "occurrence.edit"; + readonly eventId: string; + readonly occurrenceStart: string; + readonly scope: "this" | "this-and-following" | "all"; + readonly title?: string; + readonly start?: string; + readonly end?: string; + }; + +export type CalendarOccurrenceRemoval = { + readonly eventId: string; + readonly occurrenceStart: string; + readonly scope: "this" | "this-and-following" | "all"; +}; + +export type CalendarEventPlan = { + readonly ok: true; + readonly events: ReadonlyArray; + readonly operations: ReadonlyArray; + readonly affectedOccurrence: CalendarOccurrencePoint; +} | { readonly ok: false; readonly code: string; readonly reason?: string }; + +/** Calendar's single event/series semantics, shared by commit, preview and group moves. */ +export function planCalendarEventEdit( + events: ReadonlyArray, + intent: CalendarEventOperation, + options: { readonly allocateId: () => string; readonly calendarIds?: ReadonlySet; readonly defaultCalendarId?: string }, +): CalendarEventPlan { + const index = intent.type === "event.create" ? -1 : events.findIndex((event) => event.id === intent.eventId); + const event = events[index]; + + function replace(next: CalendarEvent, selectedStart = next.start, fields?: ReadonlyArray<"start" | "end">): CalendarEventPlan { + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + return { + ok: true, + events: events.map((item, position) => position === index ? next : item), + operations: fields === undefined + ? [{ op: "replace", path: buildPointer(["events", index]), value: next }] + : fields.map((field) => ({ op: "replace", path: buildPointer(["events", index, field]), value: next[field] })), + affectedOccurrence: { eventId: next.id, occurrenceStart: selectedStart }, + }; + } + + function append(next: CalendarEvent, preceding: ReadonlyArray = [], previous?: CalendarEvent): CalendarEventPlan { + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + if (events.some((event) => event.id === next.id)) return failure("event.duplicate-id"); + if (previous !== undefined) { + const previousValidation = validateCalendarEvent(previous, options.calendarIds); + if (!previousValidation.ok) return previousValidation; + } + // Detached records own their JSON subtrees, including extension metadata. + const appended = JSON.parse(JSON.stringify(next)) as CalendarEvent; + return { + ok: true, + events: [...events.map((item, position) => position === index && previous !== undefined ? previous : item), appended], + operations: [...preceding, { op: "add", path: `/events/${events.length}`, value: appended }], + affectedOccurrence: { eventId: next.id, occurrenceStart: next.start }, + }; + } + + if (intent.type === "event.create") { + const candidate: CalendarEvent = { + id: "pending", title: intent.title ?? "Event", start: intent.start, end: intent.end, + allDay: intent.allDay ?? false, calendarId: intent.calendarId ?? options.defaultCalendarId ?? "", + recurrence: intent.recurrence ?? null, excludeDates: [], + }; + const validation = validateCalendarEvent(candidate, options.calendarIds); + if (!validation.ok) return validation; + return append({ ...candidate, id: options.allocateId() }); + } + if (event === undefined) return failure("selection.event-not-found"); + if (intent.type === "event.resize") { + if (intent.edge !== "start" && intent.edge !== "end") return failure("event.invalid-edge"); + return replace({ ...event, [intent.edge]: intent.instant }, intent.edge === "start" ? intent.instant : event.start, [intent.edge]); + } + if (intent.type === "event.move" || intent.type === "event.move-day") { + if (intent.type === "event.move" && event.allDay) return failure("event.all-day-move"); + if (intent.type === "event.move-day" && parseCalendarDate(intent.day) === null) return failure("event.invalid-day"); + const start = intent.type === "event.move" ? intent.start + : event.allDay ? intent.day : `${intent.day}T${event.start.slice(11)}`; + const interval = calendarEventIntervalAt(event, start); + return interval === null ? failure("event.invalid-instant") : replace({ ...event, ...interval }, start, ["start", "end"]); + } + if (intent.type === "event.update") { + let start = intent.start ?? event.start; + let end = intent.end ?? event.end; + if (intent.allDay === true && !event.allDay) { + start = calendarDatePart(event.start); + end = calendarAllDaySpan(start, start)?.end ?? start; + } else if (intent.allDay === false && event.allDay) { + start = `${calendarDatePart(event.start)}T09:00`; + end = `${calendarDatePart(event.start)}T10:00`; + } else if (intent.start !== undefined && intent.end === undefined) { + const interval = calendarEventIntervalAt(event, intent.start); + if (interval === null) return failure("event.invalid-instant"); + ({ start, end } = interval); + } + return replace({ ...event, start, end, allDay: intent.allDay ?? event.allDay, + title: intent.title ?? event.title, calendarId: intent.calendarId ?? event.calendarId, + recurrence: intent.recurrence === undefined ? event.recurrence : intent.recurrence }); + } + + if (intent.type !== "occurrence.edit") return failure("operation.unsupported"); + if (intent.scope !== "this" && intent.scope !== "this-and-following" && intent.scope !== "all") return failure("occurrence.invalid-scope"); + const occurrence = resolveCalendarOccurrence(events, { eventId: event.id, occurrenceStart: intent.occurrenceStart }); + if (occurrence === null) return failure("selection.stale-occurrence"); + const interval = intent.end === undefined + ? calendarEventIntervalAt({ ...event, start: occurrence.start, end: occurrence.end }, intent.start ?? occurrence.start) + : { start: intent.start ?? occurrence.start, end: intent.end }; + if (interval === null) return failure("event.invalid-instant"); + const next = { ...event, ...interval, title: intent.title ?? event.title }; + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + const recurrence = calendarEventRecurrence(event); + if (recurrence === null) return replace(next); + + if (intent.scope === "all") { + const start = shiftValue(event.start, occurrence.start, interval.start); + const end = shiftValue(event.end, occurrence.end, interval.end); + if (start === null || end === null) return failure("event.invalid-instant"); + const shifted = shiftRecurrence(event, occurrence.start, interval.start); + const plan = replace({ ...next, start, end, ...shifted }, interval.start); + if (!plan.ok) return plan; + return resolveCalendarOccurrence(plan.events, plan.affectedOccurrence)?.end === interval.end + ? plan : failure("selection.unrepresentable-series-move"); + } + const day = calendarDatePart(occurrence.start); + const id = options.allocateId(); + if (intent.scope === "this") { + const excludeDates = [...new Set([...calendarEventExcludeDates(event), day])]; + return append({ ...next, id, recurrence: null, excludeDates: [] }, [ + { op: "add", path: buildPointer(["events", index, "excludeDates"]), value: excludeDates }, + ], { ...event, excludeDates }); + } + const previous = { ...event, recurrence: { ...recurrence, until: addCalendarDate(day, -1)! } }; + const shifted = shiftRecurrence({ ...event, excludeDates: calendarEventExcludeDates(event).filter((date) => date >= day) }, occurrence.start, interval.start); + return append({ ...next, id, ...shifted }, [ + { op: "replace", path: buildPointer(["events", index, "recurrence"]), value: previous.recurrence }, + ], previous); +} + +function shiftValue(value: string, origin: string, next: string): string | null { + if (value.length === 10) { + const from = parseCalendarDate(origin), to = parseCalendarDate(next); + return from === null || to === null ? null : addCalendarDate(value, calendarDaysBetween(from, to)); + } + const start = parseCalendarInstant(value), from = parseCalendarInstant(origin), to = parseCalendarInstant(next); + return start === null || from === null || to === null ? null + : formatCalendarInstant(start.add({ minutes: calendarMinutesBetween(from, to) })); +} + +function shiftRecurrence(event: CalendarEvent, origin: string, next: string): Pick { + const recurrence = calendarEventRecurrence(event)!; + const delta = calendarDaysBetween(parseCalendarDate(calendarDatePart(origin))!, parseCalendarDate(calendarDatePart(next))!); + return { + recurrence: { ...recurrence, until: recurrence.until === "" ? "" : addCalendarDate(recurrence.until, delta)! }, + excludeDates: calendarEventExcludeDates(event).map((date) => addCalendarDate(date, delta)!), + }; +} + +export type CalendarPatchPlan = { readonly ok: true; readonly operations: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +export type CalendarEventsPlan = (Extract & { readonly events: ReadonlyArray }) + | Extract; + +/** Remove document records; choosing the next selection belongs to Editing. */ +export function planCalendarEventRemoval(events: ReadonlyArray, eventIds: ReadonlyArray): CalendarEventsPlan { + const removing = new Set(eventIds); + const knownIds = new Set(events.map((event) => event.id)); + if (removing.size === 0 || eventIds.some((id) => !knownIds.has(id))) return failure("selection.event-not-found"); + return { + ok: true, + events: events.filter((event) => !removing.has(event.id)), + operations: events.flatMap((event, index): JSONPatchOperation[] => removing.has(event.id) + ? [{ op: "remove", path: buildPointer(["events", index]) }] : []).reverse(), + }; +} + +/** Exclusion and recurrence truncation have the same meaning without an editor. */ +export function planCalendarOccurrenceRemoval(events: ReadonlyArray, removal: CalendarOccurrenceRemoval): CalendarEventsPlan { + const index = events.findIndex((event) => event.id === removal.eventId); + const event = events[index]; + if (event === undefined) return failure("selection.event-not-found"); + if (removal.scope !== "this" && removal.scope !== "this-and-following" && removal.scope !== "all") return failure("occurrence.invalid-scope"); + if (resolveCalendarOccurrence(events, removal) === null) return failure("selection.stale-occurrence"); + const recurrence = calendarEventRecurrence(event); + if (recurrence === null || removal.scope === "all") return planCalendarEventRemoval(events, [event.id]); + const day = calendarDatePart(removal.occurrenceStart); + const until = addCalendarDate(day, -1); + if (removal.scope === "this-and-following" && (until === null || until < calendarDatePart(event.start))) { + return planCalendarEventRemoval(events, [event.id]); + } + const field = removal.scope === "this" ? "excludeDates" : "recurrence"; + const value = removal.scope === "this" ? [...calendarEventExcludeDates(event), day] : { ...recurrence, until: until! }; + return { + ok: true, + events: events.map((item, position) => position === index ? { ...item, [field]: value } : item), + operations: [{ op: field === "excludeDates" ? "add" : "replace", path: buildPointer(["events", index, field]), value }], + }; +} + +export function planCalendarVisibility(document: CalendarDocument, calendarId: string, hidden: boolean): CalendarPatchPlan { + if (typeof hidden !== "boolean") return failure("calendar.invalid-hidden"); + const index = calendarDocumentCalendars(document).findIndex((calendar) => calendar.id === calendarId); + return index < 0 ? failure("calendar.not-found") : { + ok: true, operations: [{ op: "replace", path: buildPointer(["calendars", index, "hidden"]), value: hidden }], + }; +} + +function failure(code: string): { readonly ok: false; readonly code: string } { return { ok: false, code }; } diff --git a/packages/json-document-calendar-document/src/calendar-projection.ts b/packages/json-document-calendar-document/src/calendar-projection.ts new file mode 100644 index 000000000..fe3f81830 --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-projection.ts @@ -0,0 +1,284 @@ +import { Temporal } from "@js-temporal/polyfill"; +import type { CalendarDocument, CalendarEvent } from "./calendar-model.js"; +import { projectCalendarOccurrences } from "./calendar-occurrence.js"; +import { + addCalendarDate, calendarDatePart, calendarDocumentCalendars, calendarDocumentEvents, + calendarEventBounds, calendarIntervalLastDate, calendarMinutesBetween, + isCalendarAllDay, parseCalendarDate, parseCalendarInstant, +} from "./calendar-validation.js"; + +export function calendarVisibleEvents(document: CalendarDocument): ReadonlyArray { + const events = calendarDocumentEvents(document); + const hidden = new Set(calendarDocumentCalendars(document).filter((item) => item.hidden).map((item) => item.id)); + if (hidden.size === 0) return events; + return events.filter((event) => !hidden.has(event.calendarId)); +} + +export function calendarNowMarker(nowInstant: string, day: string): { readonly minutes: number } | null { + if (calendarDatePart(nowInstant) !== day) return null; + const start = parseCalendarInstant(`${day}T00:00`); + const now = parseCalendarInstant(nowInstant); + if (start === null || now === null) return null; + return { minutes: calendarMinutesBetween(start, now) }; +} + +export function calendarEventsOnDay( + events: ReadonlyArray, + day: string, +): ReadonlyArray { + const next = addCalendarDate(day, 1); + if (next === null) return []; + return projectCalendarOccurrences(events, day, next).map((item) => ({ + ...item.event, + start: item.start, + end: item.end, + })); +} + +export function calendarMonthDayLayout( + events: ReadonlyArray, + day: string, + rowLimit: number, +): { + readonly events: ReadonlyArray; + readonly hiddenCount: number; +} { + const onDay = [...calendarEventsOnDay(events, day)].sort((left, right) => { + const leftAllDay = isCalendarAllDay(left); + const rightAllDay = isCalendarAllDay(right); + if (leftAllDay !== rightAllDay) return leftAllDay ? -1 : 1; + return left.start.localeCompare(right.start); + }); + if (rowLimit < 1) return { events: [], hiddenCount: onDay.length }; + if (onDay.length <= rowLimit) return { events: onDay, hiddenCount: 0 }; + const shown = Math.max(0, rowLimit - 1); + return { events: onDay.slice(0, shown), hiddenCount: onDay.length - shown }; +} + +export function calendarBusyDates( + events: ReadonlyArray, + rangeStart: string, + rangeEnd: string, +): ReadonlySet { + const dates = new Set(); + for (const item of projectCalendarOccurrences(events, rangeStart, rangeEnd)) { + for (const day of calendarOccurrenceDays(item.start, item.end, isCalendarAllDay(item.event))) { + if (day >= rangeStart && day < rangeEnd) dates.add(day); + } + } + return dates; +} + +function calendarOccurrenceDays(start: string, end: string, allDay: boolean): ReadonlyArray { + const first = calendarDatePart(start); + const last = calendarIntervalLastDate(start, end, allDay); + const days: string[] = []; + for (let day = first; day <= last; ) { + days.push(day); + const next = addCalendarDate(day, 1); + if (next === null) break; + day = next; + } + return days; +} + +export function calendarTimedLayout( + events: ReadonlyArray, + day: string, +): ReadonlyArray<{ + readonly event: CalendarEvent; + readonly startMinutes: number; + readonly endMinutes: number; + readonly lane: number; + readonly laneCount: number; +}> { + const dayStart = parseCalendarInstant(`${day}T00:00`); + if (dayStart === null) return []; + const dayEnd = dayStart.add({ days: 1 }); + const next = addCalendarDate(day, 1); + if (next === null) return []; + const layout: Array<{ event: CalendarEvent; startMinutes: number; endMinutes: number }> = []; + for (const item of projectCalendarOccurrences(events, day, next)) { + if (isCalendarAllDay(item.event)) continue; + const bounds = calendarEventBounds({ ...item.event, start: item.start, end: item.end }); + if (bounds === null || Temporal.PlainDateTime.compare(bounds.to, dayStart) <= 0 || Temporal.PlainDateTime.compare(bounds.from, dayEnd) >= 0) continue; + const clippedStart = Temporal.PlainDateTime.compare(bounds.from, dayStart) < 0 ? dayStart : bounds.from; + const clippedEnd = Temporal.PlainDateTime.compare(bounds.to, dayEnd) > 0 ? dayEnd : bounds.to; + layout.push({ + event: { ...item.event, start: item.start, end: item.end }, + startMinutes: calendarMinutesBetween(dayStart, clippedStart), + endMinutes: calendarMinutesBetween(dayStart, clippedEnd), + }); + } + const sorted = layout.sort((left, right) => left.startMinutes - right.startMinutes || left.endMinutes - right.endMinutes); + const positioned: Array = []; + let groupStart = 0; + while (groupStart < sorted.length) { + let groupEnd = groupStart + 1; + let occupiedUntil = sorted[groupStart]!.endMinutes; + while (groupEnd < sorted.length && sorted[groupEnd]!.startMinutes < occupiedUntil) { + occupiedUntil = Math.max(occupiedUntil, sorted[groupEnd]!.endMinutes); + groupEnd += 1; + } + const laneEnds: number[] = []; + const group = sorted.slice(groupStart, groupEnd).map((item) => { + const available = laneEnds.findIndex((end) => end <= item.startMinutes); + const lane = available === -1 ? laneEnds.length : available; + laneEnds[lane] = item.endMinutes; + return { ...item, lane }; + }); + positioned.push(...group.map((item) => ({ ...item, laneCount: laneEnds.length }))); + groupStart = groupEnd; + } + return positioned; +} + +export function calendarAllDayLayout( + events: ReadonlyArray, + days: ReadonlyArray, +): ReadonlyArray<{ + readonly event: CalendarEvent; + readonly startIndex: number; + readonly span: number; + readonly lane: number; + readonly laneCount: number; +}> { + const rangeStart = days[0]; + const rangeLast = days.at(-1); + if (rangeStart === undefined || rangeLast === undefined) return []; + const rangeEnd = addCalendarDate(rangeLast, 1); + if (rangeEnd === null) return []; + const layout: Array<{ event: CalendarEvent; startIndex: number; span: number }> = []; + for (const item of projectCalendarOccurrences(events, rangeStart, rangeEnd)) { + if (!isCalendarAllDay(item.event)) continue; + const clipped = clipAllDayToDays(item.start, item.end, days); + if (clipped === null) continue; + layout.push({ + event: { ...item.event, start: item.start, end: item.end }, + startIndex: clipped.startIndex, + span: clipped.span, + }); + } + const sorted = layout.sort((left, right) => left.startIndex - right.startIndex || right.span - left.span); + const positioned = assignCalendarSpanLanes(sorted); + const laneCount = Math.max(1, positioned[0]?.laneCount ?? 0); + return positioned.map((item) => ({ ...item, laneCount })); +} + +export function calendarMonthWeekLayout( + events: ReadonlyArray, + days: ReadonlyArray, + rowLimit: number, +): { + readonly items: ReadonlyArray<{ + readonly event: CalendarEvent; + readonly startIndex: number; + readonly span: number; + readonly lane: number; + }>; + readonly hiddenCounts: ReadonlyArray; + readonly laneCount: number; +} { + const empty = { items: [], hiddenCounts: days.map(() => 0), laneCount: 0 }; + const rangeStart = days[0]; + const rangeLast = days.at(-1); + if (rangeStart === undefined || rangeLast === undefined) return empty; + const rangeEnd = addCalendarDate(rangeLast, 1); + if (rangeEnd === null) return empty; + const layout: Array<{ event: CalendarEvent; startIndex: number; span: number }> = []; + for (const item of projectCalendarOccurrences(events, rangeStart, rangeEnd)) { + const occurrence = { ...item.event, start: item.start, end: item.end }; + const clipped = isCalendarAllDay(occurrence) + ? clipAllDayToDays(item.start, item.end, days) + : clipTimedToDays(item.start, item.end, days); + if (clipped === null) continue; + layout.push({ event: occurrence, startIndex: clipped.startIndex, span: clipped.span }); + } + layout.sort((left, right) => { + if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex; + const leftAllDay = isCalendarAllDay(left.event) ? 0 : 1; + const rightAllDay = isCalendarAllDay(right.event) ? 0 : 1; + if (leftAllDay !== rightAllDay) return leftAllDay - rightAllDay; + return right.span - left.span || left.event.start.localeCompare(right.event.start); + }); + const positioned = assignCalendarSpanLanes(layout); + const covering = (index: number) => positioned.filter((item) => ( + index >= item.startIndex && index < item.startIndex + item.span + )); + const overflow = days.some((_, index) => covering(index).length > rowLimit); + const visibleLaneCount = overflow + ? Math.max(0, rowLimit - 1) + : positioned.reduce((max, item) => Math.max(max, item.lane + 1), 0); + return { + items: positioned.filter((item) => item.lane < visibleLaneCount), + hiddenCounts: days.map((_, index) => covering(index).filter((item) => item.lane >= visibleLaneCount).length), + laneCount: visibleLaneCount, + }; +} + +function assignCalendarSpanLanes( + layout: ReadonlyArray, +): ReadonlyArray { + const laneEnds: number[] = []; + const positioned = layout.map((item) => { + const available = laneEnds.findIndex((end) => end <= item.startIndex); + const lane = available === -1 ? laneEnds.length : available; + laneEnds[lane] = item.startIndex + item.span; + return { ...item, lane }; + }); + const laneCount = laneEnds.length; + return positioned.map((item) => ({ ...item, laneCount })); +} + +function clipTimedToDays( + start: string, + end: string, + days: ReadonlyArray, +): { readonly startIndex: number; readonly span: number } | null { + let startIndex = -1; + let lastIndex = -1; + for (const day of calendarOccurrenceDays(start, end, false)) { + const index = days.indexOf(day); + if (index < 0) continue; + if (startIndex < 0) startIndex = index; + lastIndex = index; + } + if (startIndex < 0 || lastIndex < startIndex) return null; + return { startIndex, span: lastIndex - startIndex + 1 }; +} + +function clipAllDayToDays( + start: string, + end: string, + days: ReadonlyArray, +): { readonly startIndex: number; readonly span: number } | null { + const first = days[0]; + const last = days.at(-1); + if (first === undefined || last === undefined) return null; + const visibleEnd = addCalendarDate(last, 1); + if (visibleEnd === null) return null; + const startDate = calendarDatePart(start); + const exclusiveEnd = calendarDatePart(end); + if (exclusiveEnd <= first || startDate >= visibleEnd) return null; + const foundStart = days.indexOf(startDate); + const foundEnd = days.indexOf(exclusiveEnd); + const startIndex = foundStart >= 0 ? foundStart : startDate < first ? 0 : -1; + const endIndex = foundEnd >= 0 ? foundEnd : exclusiveEnd >= visibleEnd ? days.length : -1; + if (startIndex < 0 || endIndex < 0 || endIndex <= startIndex) return null; + return { startIndex, span: endIndex - startIndex }; +} + +export function calendarEventsInMonth( + events: ReadonlyArray, + month: string, +): ReadonlyArray { + const start = `${month}-01`; + const startUtc = parseCalendarDate(start); + if (startUtc === null) return []; + const end = Temporal.PlainYearMonth.from(month).add({ months: 1 }).toPlainDate({ day: 1 }).toString(); + return projectCalendarOccurrences(events, start, end).map((item) => ({ + ...item.event, + start: item.start, + end: item.end, + })); +} diff --git a/packages/json-document-calendar-document/src/calendar-validation.ts b/packages/json-document-calendar-document/src/calendar-validation.ts new file mode 100644 index 000000000..268f5713f --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-validation.ts @@ -0,0 +1,219 @@ +import { Temporal } from "@js-temporal/polyfill"; +import { isJSONValue } from "@interactive-os/json-document"; +import type { CalendarCalendar, CalendarDocument, CalendarEvent, CalendarRecurrence } from "./calendar-model.js"; + +const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; +const DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/; +export function calendarDocumentCalendars(document: CalendarDocument): ReadonlyArray { + return Array.isArray(document.calendars) ? document.calendars : []; +} + +export function calendarDocumentCalendar(document: CalendarDocument, calendarId: string): CalendarCalendar | null { + return calendarDocumentCalendars(document).find((calendar) => calendar.id === calendarId) ?? null; +} + +export function calendarDocumentEvents(document: CalendarDocument): ReadonlyArray { + return Array.isArray(document.events) ? document.events : []; +} + +export function assertCalendarDocument(value: unknown): void { + const result = validateCalendarDocument(value); + if (!result.ok) throw new TypeError(result.reason); +} + +/** Validate canonical JSON and Calendar invariants without creating an Editing session. */ +export function validateCalendarDocument(value: unknown): CalendarValidationResult { + if (!isJSONValue(value) || typeof value !== "object" || value === null || Array.isArray(value)) { + return invalidCalendar("Calendar documents must be JSON objects."); + } + const document = value as unknown as CalendarDocument; + if (!Array.isArray(document.events)) return invalidCalendar("Calendar events must be an array."); + if (document.calendars !== undefined && !Array.isArray(document.calendars)) { + return invalidCalendar("Calendar calendars must be an array when present."); + } + const calendarIds = new Set(); + for (const calendar of calendarDocumentCalendars(document)) { + if (typeof calendar !== "object" || calendar === null || Array.isArray(calendar) + || typeof calendar.id !== "string" || calendar.id.length === 0) { + return invalidCalendar("Calendar ids must be nonempty strings."); + } + if (calendarIds.has(calendar.id)) return invalidCalendar(`Calendar id must be unique: ${JSON.stringify(calendar.id)}.`); + if (typeof calendar.title !== "string" || typeof calendar.hidden !== "boolean") { + return invalidCalendar("Calendar title must be a string and hidden must be a boolean."); + } + if (typeof calendar.color !== "string" || calendar.color.length === 0) { + return invalidCalendar(`Calendar color must not be empty: ${JSON.stringify(calendar.id)}.`); + } + calendarIds.add(calendar.id); + } + const ids = new Set(); + for (const event of calendarDocumentEvents(document)) { + const result = validateCalendarEvent(event, calendarIds); + if (!result.ok) return result; + if (ids.has(event.id)) return invalidCalendar(`Calendar event id must be unique: ${JSON.stringify(event.id)}.`); + ids.add(event.id); + } + return { ok: true }; +} + +function invalidCalendar(reason: string): CalendarValidationResult { + return { ok: false, code: "calendar.invalid-document", reason }; +} + +export type CalendarValidationResult = { readonly ok: true } | { + readonly ok: false; readonly code: string; readonly reason: string; +}; + +/** One domain invariant shared by construction, edit planning and clipboard ingress. */ +export function validateCalendarEvent(value: unknown, calendarIds?: ReadonlySet): CalendarValidationResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { ok: false, code: "event.invalid", reason: "Calendar events must be objects." }; + } + const event = value as Record; + if (typeof event.id !== "string" || event.id.length === 0 || typeof event.title !== "string") { + return { ok: false, code: "event.invalid", reason: "Calendar events require a nonempty id and a string title." }; + } + if (typeof event.start !== "string" || typeof event.end !== "string" + || (event.allDay !== undefined && typeof event.allDay !== "boolean")) { + return { ok: false, code: "event.invalid-instant", reason: "Calendar events require canonical temporal values." }; + } + const parse = event.allDay === true ? parseCalendarDate : parseCalendarInstant; + if (parse(event.start) === null || parse(event.end) === null) { + return { ok: false, code: "event.invalid-instant", reason: event.allDay === true + ? `All-day calendar events must use date strings: ${JSON.stringify(event.id)}.` + : `Calendar event times must be datetime-local strings: ${JSON.stringify(event.id)}.` }; + } + if (event.start >= event.end) { + return { ok: false, code: "event.invalid-interval", reason: `Calendar event must end after it starts: ${JSON.stringify(event.id)}.` }; + } + if (event.calendarId !== undefined && typeof event.calendarId !== "string") { + return { ok: false, code: "calendar.not-found", reason: "Calendar references must be strings." }; + } + if (typeof event.calendarId === "string" && event.calendarId.length > 0 + && calendarIds !== undefined && calendarIds.size > 0 && !calendarIds.has(event.calendarId)) { + return { ok: false, code: "calendar.not-found", reason: `Calendar event must belong to a calendar: ${JSON.stringify(event.id)}.` }; + } + if (event.recurrence != null && !isCalendarRecurrence(event.recurrence)) { + return { ok: false, code: "event.invalid-recurrence", reason: "Calendar recurrence requires a supported frequency, positive safe integer interval and canonical until date." }; + } + if (event.excludeDates !== undefined && (!Array.isArray(event.excludeDates) + || !event.excludeDates.every((date) => typeof date === "string" && parseCalendarDate(date) !== null))) { + return { ok: false, code: "event.invalid-exclusions", reason: "Calendar exclusions must be canonical dates." }; + } + return { ok: true }; +} + +export function isCalendarRecurrence(value: unknown): value is CalendarRecurrence { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const rule = value as Record; + return (rule.freq === "daily" || rule.freq === "weekly" || rule.freq === "monthly" || rule.freq === "yearly") + && typeof rule.interval === "number" && Number.isSafeInteger(rule.interval) && rule.interval >= 1 + && typeof rule.until === "string" && (rule.until === "" || parseCalendarDate(rule.until) !== null); +} + +export function isCalendarAllDay(event: Pick): boolean { + return event.allDay === true; +} + +export function parseCalendarInstant(value: string): Temporal.PlainDateTime | null { + if (!DATETIME.test(value)) return null; + try { + return Temporal.PlainDateTime.from(value); + } catch { + return null; + } +} + +export function formatCalendarInstant(value: Temporal.PlainDateTime): string { + return value.toString({ smallestUnit: "minute" }); +} + +export function parseCalendarDate(value: string): Temporal.PlainDate | null { + if (!DATE.test(value)) return null; + try { + return Temporal.PlainDate.from(value); + } catch { + return null; + } +} + +export function formatCalendarDate(value: Temporal.PlainDate): string { + return value.toString(); +} + +export function addCalendarDate(day: string, days: number): string | null { + const date = parseCalendarDate(day); + if (date === null) return null; + return formatCalendarDate(date.add({ days })); +} + +export function calendarAllDaySpan(originDay: string, targetDay: string): { readonly start: string; readonly end: string } | null { + if (parseCalendarDate(originDay) === null || parseCalendarDate(targetDay) === null) return null; + const start = originDay <= targetDay ? originDay : targetDay; + const last = originDay <= targetDay ? targetDay : originDay; + const end = addCalendarDate(last, 1); + if (end === null) return null; + return { start, end }; +} + +export function calendarShiftInstant(instant: string, minutes: number): string | null { + const dateTime = parseCalendarInstant(instant); + if (dateTime === null) return null; + return formatCalendarInstant(dateTime.add({ minutes })); +} + +export function calendarInstantAt(day: string, minutesFromMidnight: number): string | null { + const dateTime = parseCalendarInstant(`${day}T00:00`); + if (dateTime === null) return null; + const minutes = Math.max(0, Math.min(24 * 60, minutesFromMidnight)); + return formatCalendarInstant(dateTime.add({ minutes })); +} + +export function calendarDatePart(value: string): string { + return value.slice(0, 10); +} + +export function calendarIntervalLastDate(start: string, end: string, allDay: boolean): string { + const first = calendarDatePart(start); + let last = calendarDatePart(end); + const endInstant = parseCalendarInstant(end); + const endsAtDateBoundary = !end.includes("T") + || (endInstant !== null && endInstant.hour === 0 && endInstant.minute === 0); + if (allDay || endsAtDateBoundary) last = addCalendarDate(last, -1) ?? first; + return last < first ? first : last; +} + +export function calendarEventBounds( + event: Pick, +): { readonly from: Temporal.PlainDateTime; readonly to: Temporal.PlainDateTime } | null { + if (isCalendarAllDay(event)) { + const from = parseCalendarDate(event.start); + const to = parseCalendarDate(event.end); + if (from === null || to === null) return null; + return { from: from.toPlainDateTime(), to: to.toPlainDateTime() }; + } + const from = parseCalendarInstant(event.start); + const to = parseCalendarInstant(event.end); + if (from === null || to === null) return null; + return { from, to }; +} + +export function calendarDaysBetween(from: Temporal.PlainDate, to: Temporal.PlainDate): number { + return from.until(to, { largestUnit: "days" }).days; +} + +export function calendarMinutesBetween(from: Temporal.PlainDateTime, to: Temporal.PlainDateTime): number { + return from.until(to, { largestUnit: "minutes" }).total("minutes"); +} + +/** Move a Calendar interval without changing its local duration. */ +export function calendarEventIntervalAt( + event: Pick, + start: string, +): { readonly start: string; readonly end: string } | null { + const bounds = calendarEventBounds(event); + const next = isCalendarAllDay(event) ? parseCalendarDate(start)?.toPlainDateTime() : parseCalendarInstant(start); + if (bounds === null || next == null) return null; + const end = next.add({ minutes: calendarMinutesBetween(bounds.from, bounds.to) }); + return { start, end: isCalendarAllDay(event) ? formatCalendarDate(end.toPlainDate()) : formatCalendarInstant(end) }; +} diff --git a/packages/json-document-calendar-document/src/index.ts b/packages/json-document-calendar-document/src/index.ts new file mode 100644 index 000000000..25a270318 --- /dev/null +++ b/packages/json-document-calendar-document/src/index.ts @@ -0,0 +1,52 @@ +export type { + CalendarCalendar, CalendarDocument, CalendarEvent, CalendarRecurrence, + CalendarOccurrencePoint, CalendarOccurrenceInterval, +} from "./calendar-model.js"; +export { planCalendarEventEdit, planCalendarEventRemoval, planCalendarOccurrenceRemoval, planCalendarVisibility } from "./calendar-operation.js"; +export type { CalendarEventOperation, CalendarEventPlan, CalendarOccurrenceRemoval, CalendarPatchPlan, CalendarEventsPlan } from "./calendar-operation.js"; +export type { CalendarValidationResult } from "./calendar-validation.js"; +export { + calendarDocumentCalendars, + calendarDocumentCalendar, + calendarDocumentEvents, + assertCalendarDocument, + validateCalendarDocument, + validateCalendarEvent, + isCalendarRecurrence, + isCalendarAllDay, + parseCalendarInstant, + formatCalendarInstant, + parseCalendarDate, + formatCalendarDate, + addCalendarDate, + calendarAllDaySpan, + calendarShiftInstant, + calendarInstantAt, + calendarDatePart, + calendarIntervalLastDate, + calendarEventBounds, + calendarDaysBetween, + calendarMinutesBetween, + calendarEventIntervalAt, +} from "./calendar-validation.js"; +export { + calendarEventRecurrence, + calendarRecurrenceWithFrequency, + calendarRecurrenceWithInterval, + calendarRecurrenceWithUntil, + calendarEventExcludeDates, + projectCalendarOccurrences, + resolveCalendarOccurrence, +} from "./calendar-occurrence.js"; +export type { CalendarOccurrence } from "./calendar-occurrence.js"; +export { + calendarVisibleEvents, + calendarNowMarker, + calendarEventsOnDay, + calendarMonthDayLayout, + calendarBusyDates, + calendarTimedLayout, + calendarAllDayLayout, + calendarMonthWeekLayout, + calendarEventsInMonth, +} from "./calendar-projection.js"; diff --git a/packages/json-document-calendar-document/tests/calendar-document.test.ts b/packages/json-document-calendar-document/tests/calendar-document.test.ts new file mode 100644 index 000000000..1eaf27f90 --- /dev/null +++ b/packages/json-document-calendar-document/tests/calendar-document.test.ts @@ -0,0 +1,106 @@ +import { applyPatch } from "@interactive-os/json-document"; +import { describe, expect, test } from "vitest"; +import { + assertCalendarDocument, validateCalendarDocument, planCalendarEventEdit, + planCalendarEventRemoval, planCalendarOccurrenceRemoval, planCalendarVisibility, + projectCalendarOccurrences, calendarVisibleEvents, + type CalendarDocument, type CalendarEventOperation, +} from "../src/index.js"; + +const document = (): CalendarDocument => ({ + calendars: [{ id: "work", title: "Work", hidden: false, color: "accent" }], + events: [{ id: "a", title: "A", start: "2026-08-01T09:00", end: "2026-08-01T10:00", allDay: false, + calendarId: "work", recurrence: { freq: "daily", interval: 1, until: "2026-08-08" }, excludeDates: [] }], +}); + +describe("Calendar Document Type public contract", () => { + test("validates without editing state and preserves canonical and legacy inputs", () => { + for (const value of [document(), { events: [{ id: "legacy", title: "L", start: "2026-08-01T09:00", end: "2026-08-01T10:00" }] }]) { + const before = structuredClone(value); + expect(validateCalendarDocument(value)).toEqual({ ok: true }); + expect(() => assertCalendarDocument(value)).not.toThrow(); + expect(value).toEqual(before); + } + }); + + test.each([ + "work", null, 123, {}, [null], [123], [[]], + [{ id: 123, title: "Work", hidden: false, color: "accent" }], + [{ id: "", title: "Work", hidden: false, color: "accent" }], + [{ id: "work", title: 123, hidden: false, color: "accent" }], + [{ id: "work", title: "Work", hidden: "false", color: "accent" }], + [{ id: "work", title: "Work", hidden: false, color: "" }], + ])("rejects malformed calendar containers and records: %j", (calendars) => { + const value = { calendars, events: [] }; + expect(validateCalendarDocument(value)).toMatchObject({ ok: false, code: "calendar.invalid-document" }); + expect(() => assertCalendarDocument(value)).toThrow(TypeError); + }); + + test("validates document identity, membership and JSON extension fields", () => { + const value = document(); + for (const invalid of [ + { ...value, calendars: [...value.calendars, { ...value.calendars[0]! }] }, + { ...value, events: [...value.events, { ...value.events[0]!, recurrence: { ...value.events[0]!.recurrence! }, excludeDates: [] }] }, + { ...value, events: [{ ...value.events[0]!, calendarId: "missing" }] }, + { ...value, metadata: Number.NaN }, + ]) expect(validateCalendarDocument(invalid).ok).toBe(false); + }); + + test.each(["this", "this-and-following", "all"] as const)("plans %s edits as document operations, not selection transitions", (scope) => { + const value = document(); + const before = structuredClone(value); + const plan = planCalendarEventEdit(value.events, { + type: "occurrence.edit", eventId: "a", occurrenceStart: "2026-08-03T09:00", scope, + start: "2026-08-03T11:00", title: "Changed", + }, { allocateId: () => "new", calendarIds: new Set(["work"]) }); + expect(plan.ok).toBe(true); + if (!plan.ok) throw new Error(plan.code); + const applied = applyPatch(value, plan.operations); + expect(applied.ok).toBe(true); + if (!applied.ok) throw new Error("patch failed"); + expect((applied.value as CalendarDocument).events).toEqual(plan.events); + expect(validateCalendarDocument(applied.value).ok).toBe(true); + expect(plan.affectedOccurrence.occurrenceStart).toBe("2026-08-03T11:00"); + expect(plan).not.toHaveProperty("selectionAfter"); + const focused = projectCalendarOccurrences(plan.events, "2026-08-03", "2026-08-04") + .find((occurrence) => occurrence.event.id === plan.affectedOccurrence.eventId); + expect(focused).toMatchObject({ start: "2026-08-03T11:00", end: "2026-08-03T12:00", event: { title: "Changed" } }); + expect(value).toEqual(before); + }); + + test("rejects invalid edits, unknown operations and reused allocation identities", () => { + const value = document(); + for (const operation of [ + { type: "event.create", start: "2026-08-03T11:00", end: "2026-08-03T12:00" }, + { type: "event.update", eventId: "a", end: "2026-08-01T08:00" }, + { type: "event.typo", eventId: "a" }, + ]) expect(planCalendarEventEdit(value.events, operation as CalendarEventOperation, { allocateId: () => "a" }).ok).toBe(false); + expect(value).toEqual(document()); + }); + + test.each(["this", "this-and-following", "all"] as const)("owns %s occurrence removal without selection or history", (scope) => { + const value = document(); + const plan = planCalendarOccurrenceRemoval(value.events, { eventId: "a", occurrenceStart: "2026-08-03T09:00", scope }); + expect(plan.ok).toBe(true); + if (!plan.ok) throw new Error(plan.code); + const applied = applyPatch(value, plan.operations); + expect(applied.ok).toBe(true); + if (!applied.ok) throw new Error("patch failed"); + expect((applied.value as CalendarDocument).events).toEqual(plan.events); + const days = projectCalendarOccurrences(plan.events, "2026-08-01", "2026-08-09").map((item) => item.start.slice(8, 10)); + expect(days).toEqual(scope === "all" ? [] : scope === "this-and-following" ? ["01", "02"] : ["01", "02", "04", "05", "06", "07", "08"]); + }); + + test("owns record removal and calendar visibility", () => { + const value = document(); + expect(planCalendarEventRemoval(value.events, ["missing"]).ok).toBe(false); + expect(planCalendarEventRemoval(value.events, ["a"])).toMatchObject({ ok: true, events: [], operations: [{ op: "remove", path: "/events/0" }] }); + expect(planCalendarVisibility(value, "missing", true).ok).toBe(false); + const plan = planCalendarVisibility(value, "work", true); + if (!plan.ok) throw new Error(plan.code); + const applied = applyPatch(value, plan.operations); + if (!applied.ok) throw new Error("patch failed"); + expect(calendarVisibleEvents(applied.value as CalendarDocument)).toEqual([]); + expect(value).toEqual(document()); + }); +}); diff --git a/packages/json-document-calendar-document/tsconfig.json b/packages/json-document-calendar-document/tsconfig.json new file mode 100644 index 000000000..fa193bf21 --- /dev/null +++ b/packages/json-document-calendar-document/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig/library.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, + "references": [{ "path": "../json-document" }], + "include": ["src/**/*.ts"] +} diff --git a/packages/json-document-calendar-document/tsconfig.test.json b/packages/json-document-calendar-document/tsconfig.test.json new file mode 100644 index 000000000..47af24776 --- /dev/null +++ b/packages/json-document-calendar-document/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "composite": false, "noEmit": true, "rootDir": "../..", "types": ["node", "vitest/globals"] }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/json-document-calendar-document/vitest.config.ts b/packages/json-document-calendar-document/vitest.config.ts new file mode 100644 index 000000000..be8c3f2e7 --- /dev/null +++ b/packages/json-document-calendar-document/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineNodeProject } from "../../test/vitest.shared.js"; + +export default defineNodeProject("json-document-calendar-document"); diff --git a/packages/json-document-calendar/README.md b/packages/json-document-calendar/README.md index 8c961bb8d..77a28c1f6 100644 --- a/packages/json-document-calendar/README.md +++ b/packages/json-document-calendar/README.md @@ -2,8 +2,10 @@ React lifecycle for Calendar Hands over the canonical interval editor. The package owns editor subscription, occurrence focus, normalized gesture preview, -canonical Rename and keyboard composition, and series-scope command binding. The Editing package owns -Calendar document and intent semantics, Affordance owns the input-independent +canonical Rename and keyboard composition, and series-scope command binding. +`@interactive-os/json-document-calendar-document` owns the document model, validation, +operations and projection; Editing owns selection, Intent execution, Clipboard and History. +Affordance owns the input-independent gesture lifecycle, and Web owns Pointer Events capture and coordinate translation. Hosts keep fixtures, URL state, product copy, layout, colors, and time-grid policy. @@ -12,19 +14,47 @@ time-grid policy. const editor = createCalendarEditor(document); const calendar = useCalendarHand(editor); -calendar.dispatch({ type: "selection.set", eventIds: [eventId] }); +calendar.dispatch({ + type: "selection.set", + point: { eventId, occurrenceStart }, + topology: calendarOccurrenceTopology(document, rangeStart, rangeEnd), +}); calendar.applySelectedPatch({ title: "Planning" }); const payload = calendar.copy(); -calendar.paste(payload); +if (payload !== null) calendar.paste(payload); const titleInput = useCalendarRenameInput(calendar); -useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRemove }); +useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRename, onRemove }); ``` -The Hand resolves the currently focused occurrence as the copy/cut source and -paste target. The Host selects Web representations; Calendar schema, +The Hand derives the focused occurrence from `editor.primaryOccurrence`; direct +dispatch, external selection and selection made before mounting use the same target. +`editor.paste(payload)` defaults to that occurrence, not the recurring series origin. +`setOccurrence` supplies an explicit temporal paste cursor (including an empty slot), +scoped to the current editor revision; it never overrides the Inspector/edit selection. +Bind `cut: calendar.cut` directly to the Web clipboard surface: +the Hand accepts the payload already written by Web and removes that captured +target even if selection changes during the write. The Host selects Web representations; Calendar schema, occurrence projection, temporal placement, selection, and history remain in their canonical owners. +Pass `onResult` to `useCalendarHand` to observe domain rejection codes and +present product-owned feedback. `commitIntent` runs occurrence/rename aftercare +only on success. Web clipboard decoding/writing failures remain Web results; +observe the surface's `onResult` as well. Unexpected provider/programmer errors +are not converted into a successful edit. + +`useCalendarPointerInteractions` exposes `rootRef`. `CalendarTimeGrid` and +`CalendarMonthGrid` attach it automatically; a custom Calendar surface must +attach it to its own root. Use one interaction instance per mounted surface. +Hit tests and all-day column measurements never fall back to global document +queries. Keyboard listeners can also use the existing `target` option when a +Host embeds multiple active calendars. + +The [Calendar editing protocol profile](../json-document-editing/docs/calendar-profile.md) +defines temporal values, recurrence scopes, stale-source rejection and clipboard +compatibility. On the site it is visible under [Editing API](/docs/api/editing#calendar-protocol-profile-rc); +the Calendar Hand does not introduce a second domain protocol. + Date and time controls belong to this Calendar owner rather than the generic UI Primitive package: diff --git a/packages/json-document-calendar/package.json b/packages/json-document-calendar/package.json index 01111c04a..9529c3a7a 100644 --- a/packages/json-document-calendar/package.json +++ b/packages/json-document-calendar/package.json @@ -19,7 +19,11 @@ "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, "peerDependencies": { + "@interactive-os/json-document-calendar-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", @@ -28,6 +32,7 @@ "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@interactive-os/json-document-calendar-document": "*", "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-react": "*", diff --git a/packages/json-document-calendar/src/calendar-event-inspector.tsx b/packages/json-document-calendar/src/calendar-event-inspector.tsx index 9d8fc118c..f07cb7153 100644 --- a/packages/json-document-calendar/src/calendar-event-inspector.tsx +++ b/packages/json-document-calendar/src/calendar-event-inspector.tsx @@ -8,7 +8,7 @@ import { calendarRecurrenceWithInterval, calendarRecurrenceWithUntil, type CalendarCalendar, -} from "@interactive-os/json-document-editing"; +} from "@interactive-os/json-document-calendar-document"; import { Choice, Command, diff --git a/packages/json-document-calendar/src/calendar-month-grid.tsx b/packages/json-document-calendar/src/calendar-month-grid.tsx index 9dd398be1..c290a79ce 100644 --- a/packages/json-document-calendar/src/calendar-month-grid.tsx +++ b/packages/json-document-calendar/src/calendar-month-grid.tsx @@ -8,6 +8,9 @@ import { type ReactNode, type Ref, } from "react"; +import { + type CalendarOccurrenceTopologySnapshot, +} from "@interactive-os/json-document-editing"; import { calendarAllDaySpan, calendarEventsOnDay, @@ -16,8 +19,7 @@ import { calendarMonthWeekLayout, isCalendarAllDay, type CalendarEvent, - type CalendarOccurrenceTopologySnapshot, -} from "@interactive-os/json-document-editing"; +} from "@interactive-os/json-document-calendar-document"; import { selectionModeFromModifiers } from "@interactive-os/json-document-react"; import { contentInteractionAttributes, @@ -124,6 +126,7 @@ export const CalendarMonthGrid = forwardRef["scope"]; @@ -38,6 +40,7 @@ export interface CalendarSelectionDragPreview { export type CalendarHandOptions = { readonly initialOccurrence?: CalendarOccurrenceRange; readonly defaultTitle?: string; + readonly onResult?: (result: EditingResult) => void; }; export interface CalendarHand { @@ -88,23 +91,29 @@ export interface CalendarHand { undo(): void; redo(): void; copy(): CalendarClipboard | null; - cut(): EditingResult | null; + cut(clipboard?: CalendarClipboard): EditingResult | null; paste(clipboard: CalendarClipboard): EditingResult; } export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOptions = {}): CalendarHand { const snapshot = useEditingSnapshot(editor); + const optionsRef = useRef(options); + optionsRef.current = options; const selectedEvent = editor.selectedEvents[0] ?? null; const selectedOccurrences = editor.selectedOccurrences; - const [occurrence, setOccurrence] = useState( - options.initialOccurrence ?? calendarOccurrenceFromSelection(selectedEvent), - ); + // A cursor in an empty slot is an explicit paste destination, not a copy of selection. + type PasteTarget = { readonly editor: CalendarEditor; readonly revision: number; readonly range: CalendarOccurrenceRange }; + const [pasteTarget, setPasteTarget] = useState(() => options.initialOccurrence === undefined + ? null : { editor, revision: editor.snapshot.revision, range: options.initialOccurrence }); + const pasteTargetRef = useRef(pasteTarget); + pasteTargetRef.current = pasteTarget; + const primaryOccurrence = editor.primaryOccurrence; + const occurrence = primaryOccurrence !== null ? calendarOccurrenceFromSelection(primaryOccurrence) + : pasteTarget?.editor === editor && pasteTarget.revision === snapshot.revision ? pasteTarget.range : { start: null, end: null }; const [scope, setScope] = useState("this"); const [renameSnapshot, setRenameSnapshot] = useState | null>(null); - const occurrenceRef = useRef(occurrence); const scopeRef = useRef(scope); const createdRenameKeyRef = useRef(null); - occurrenceRef.current = occurrence; scopeRef.current = scope; const [renameSession] = useState(() => createRenameSession({ onCommit(key, draft) { @@ -112,15 +121,13 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt if (current === undefined) return; const next = draft.trim() || (options.defaultTitle ?? "Event"); if (next !== current.title) { - editor.dispatch(calendarUpdateIntent(current, occurrenceRef.current.start, scopeRef.current, { title: next })); - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + if (dispatch(calendarUpdateIntent(current, editor.primaryOccurrence?.start ?? null, scopeRef.current, { title: next }))) rememberSelection(); } }, onCancel(key, draft) { const fallback = options.defaultTitle ?? "Event"; if (createdRenameKeyRef.current === key && (draft.trim() === "" || draft.trim() === fallback)) { - editor.dispatch({ type: "selection.remove" }); - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + if (dispatch({ type: "selection.remove" })) rememberSelection(); } }, onFinish(key) { @@ -156,11 +163,23 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt : selectedEvent?.title ?? ""; function dispatch(intent: CalendarIntent | null): boolean { - return intent !== null && editor.dispatch(intent).ok; + return intent !== null && observe(editor.dispatch(intent)).ok; + } + + function observe(result: EditingResult): EditingResult { + optionsRef.current.onResult?.(result); + return result; } function rememberSelection(): void { - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + pasteTargetRef.current = null; + setPasteTarget(null); + } + + function setOccurrence(range: CalendarOccurrenceRange): void { + const target = { editor, revision: editor.snapshot.revision, range }; + pasteTargetRef.current = target; + setPasteTarget(target); } function commitIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): boolean { @@ -169,9 +188,8 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt return true; } - function rememberIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): void { - const committed = calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null); - setOccurrence(calendarOccurrenceAfterIntent(intent, origin, committed)); + function rememberIntent(intent: CalendarIntent | null, _origin: CalendarOccurrenceRange): void { + rememberSelection(); if (intent?.type === "event.create") { setScope("this"); const created = editor.selectedEvents[0] ?? null; @@ -185,8 +203,9 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt } function applySelectedPatch(patch: CalendarEventPatch): boolean { - if (selectedEvent === null) return false; - if (!dispatch(calendarUpdateIntent(selectedEvent, occurrence.start, scope, patch))) return false; + const current = editor.selectedEvents[0]; + if (current === undefined) return false; + if (!dispatch(calendarUpdateIntent(current, editor.primaryOccurrence?.start ?? null, scopeRef.current, patch))) return false; rememberSelection(); return true; } @@ -218,8 +237,7 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt return point?.eventId === eventId && point.occurrenceStart === occurrenceStart; } const primary = editor.primaryOccurrence ?? editor.selectedOccurrences[0] ?? null; - return (primary?.eventId === eventId && primary.start === occurrenceStart) - || (selectedEvent?.id === eventId && occurrence.start === occurrenceStart); + return primary?.eventId === eventId && primary.start === occurrenceStart; } function selectOccurrence( @@ -235,8 +253,7 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt mode, ...(topology === undefined ? {} : { topology }), })) return false; - const primary = editor.primaryOccurrence ?? editor.selectedOccurrences[0] ?? null; - setOccurrence(primary === null ? { start: null, end: null } : { start: primary.start, end: primary.end }); + rememberSelection(); return true; } @@ -258,9 +275,11 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt } function removeSelected(): boolean { - if (selectedEvent === null) return false; - const intent: CalendarIntent = selectedEvent.recurrence !== null && occurrence.start !== null - ? { type: "occurrence.remove", eventId: selectedEvent.id, occurrenceStart: occurrence.start, scope } + const current = editor.selectedEvents[0]; + if (current === undefined) return false; + const primary = editor.primaryOccurrence; + const intent: CalendarIntent = current.recurrence !== null && primary !== null + ? { type: "occurrence.remove", eventId: current.id, occurrenceStart: primary.start, scope: scopeRef.current } : { type: "selection.remove" }; if (!dispatch(intent)) return false; rememberSelection(); @@ -287,28 +306,30 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt function undo(): void { setSelectionDragPreview(null); - editor.undo(); - rememberSelection(); + if (observe(editor.undo()).ok) rememberSelection(); } function redo(): void { setSelectionDragPreview(null); - editor.redo(); - rememberSelection(); + if (observe(editor.redo()).ok) rememberSelection(); } function copy(): CalendarClipboard | null { return editor.copy(); } - function cut(): EditingResult | null { - const cut = editor.cut(); - if (cut?.result.ok) rememberSelection(); - return cut?.result ?? null; + function cut(clipboard?: CalendarClipboard): EditingResult | null { + if (clipboard !== undefined && calendarClipboardFormat.parse(clipboard) === null) return observe({ ok: false, code: "clipboard.invalid" }); + const cut = editor.cut(clipboard); + if (cut === null) return null; + const result = observe(cut.result); + if (result.ok) rememberSelection(); + return result; } function paste(clipboard: CalendarClipboard): EditingResult { - const result = editor.paste(clipboard, occurrence.start ?? selectedEvent?.start); + const target = pasteTargetRef.current; + const result = observe(editor.paste(clipboard, target?.editor === editor && target.revision === editor.snapshot.revision ? target.range.start ?? undefined : undefined)); if (result.ok) rememberSelection(); return result; } diff --git a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts index 940b88829..81138ff1f 100644 --- a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts +++ b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts @@ -1,13 +1,24 @@ -import { useRef, useState, type PointerEvent } from "react"; +import { useRef, useState, type PointerEvent, type RefObject } from "react"; import { createGestureSession } from "@interactive-os/json-document-affordance"; import { - addCalendarDate, bindCalendarAllDayIntent, bindCalendarMonthIntent, bindCalendarTimeGridIntent, - calendarEventsOnDay, calendarInstantAt, calendarShiftInstant, calendarVisibleEvents, - interpretCalendarAllDayPointer, interpretCalendarMonthPointer, interpretCalendarTimeGridPointer, - type CalendarAllDayPointerRelease, type CalendarIntent, type CalendarTimeGridHandle, + bindCalendarAllDayIntent, + bindCalendarMonthIntent, + bindCalendarTimeGridIntent, + interpretCalendarAllDayPointer, + interpretCalendarMonthPointer, + interpretCalendarTimeGridPointer, + type CalendarAllDayPointerRelease, + type CalendarTimeGridHandle, type CalendarTimeGridPointerRelease, type CalendarSelectionDragSource, } from "@interactive-os/json-document-editing"; +import { + addCalendarDate, + calendarEventsOnDay, + calendarInstantAt, + calendarShiftInstant, + calendarVisibleEvents, +} from "@interactive-os/json-document-calendar-document"; import { calendarDayDeltaFromWebWidth, calendarKeyFromWebRow, calendarMinutesFromWebGrid, createWebPointerSession, findWebPointTarget, @@ -43,6 +54,8 @@ type CalendarSelectionDragGesture = { }; export interface CalendarPointerInteractions { + /** Bind to one Calendar surface; canonical grids attach it automatically. */ + readonly rootRef: RefObject; readonly hoveredTime: { readonly day: string; readonly instant: string; readonly minutes: number } | null; instantAt(day: string, clientY: number, grid: Element): string | null; timePointerDown(event: PointerEvent, day: string, id: string | null, start: string | null, end: string | null, handle: CalendarTimeGridHandle | null): void; @@ -66,6 +79,7 @@ export interface CalendarPointerInteractions { /** Owns Calendar's Web pointer preview, commit, cancel, and resize lifecycle. */ export function useCalendarPointerInteractions(hand: CalendarHand, policy: CalendarPointerPolicy): CalendarPointerInteractions { + const rootRef = useRef(null); const [timePointer] = useState(() => createWebPointerSession()); const [allDayPointer] = useState(() => createWebPointerSession()); const [monthPointer] = useState(() => createWebPointerSession()); @@ -76,9 +90,8 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const document = hand.document; const visibleEvents = calendarVisibleEvents(document); - function remember(intent: CalendarIntent | null, start: string | null, end: string | null): void { - hand.dispatch(intent); - hand.rememberIntent(intent, { start, end }); + function pointTarget(selector: string, event: { clientX: number; clientY: number }): Element | null { + return rootRef.current === null ? null : findWebPointTarget(selector, { x: event.clientX, y: event.clientY }, rootRef.current); } function bindTime(intent: ReturnType, occurrenceStart: string | null) { @@ -97,7 +110,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen if (release.originEventId === null) return; const event = document.events.find((item) => item.id === release.originEventId); const intent = bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), event, release.originEventStart, hand.scope); - remember(intent, release.originEventStart, event?.end ?? null); + hand.commitIntent(intent, { start: release.originEventStart, end: event?.end ?? null }); } function timePointerDown(event: PointerEvent, day: string, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null, originHandle: CalendarTimeGridHandle | null): void { @@ -116,7 +129,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } function timePointerMove(event: PointerEvent): void { - const grid = findWebPointTarget('[data-calendar-grid="time"]', { x: event.clientX, y: event.clientY }); + const grid = pointTarget('[data-calendar-grid="time"]', event); const day = grid?.getAttribute("data-calendar-day"); if (grid == null || day == null) return; if (timePointer.getSnapshot()?.pointerId !== event.pointerId) { @@ -144,7 +157,11 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen if (next?.dragSource !== null && next?.dragSource !== undefined) { const originAnchor = next.dragSource.anchor.occurrenceStart; const move = interpretCalendarTimeGridPointer(next); - if (move?.type !== "event.move") return; + if (move?.type !== "event.move") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "instant", instant: originAnchor } }); const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "instant", instant: move.start } })); hand.previewSelectionDrag(gesture); @@ -168,7 +185,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen hand.setOccurrence({ start: release.targetInstant, end: release.targetInstant }); return; } - remember(bindTime(interpretCalendarTimeGridPointer(release), release.originEventStart), release.originEventStart, release.originEventEnd); + hand.commitIntent(bindTime(interpretCalendarTimeGridPointer(release), release.originEventStart), { start: release.originEventStart, end: release.originEventEnd }); } function allDayPointerDown(event: PointerEvent, originDay: string, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null, originHandle: "body" | "start" | "end" | null): void { @@ -184,7 +201,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen function allDayPointerMove(event: PointerEvent): void { if (allDayPointer.getSnapshot()?.pointerId !== event.pointerId) return; - const targetDay = findWebPointTarget("[data-calendar-allday-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-allday-day"); + const targetDay = pointTarget("[data-calendar-allday-day]", event)?.getAttribute("data-calendar-allday-day"); if (targetDay == null) return; const next = allDayPointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetDay !== state.originDay @@ -193,8 +210,14 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetDay, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { + const move = interpretCalendarAllDayPointer(next); + if (move?.type !== "event.move-day") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "day", day: next.originDay } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: targetDay } })); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: move.day } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setAllDayPreview(next); } @@ -203,13 +226,19 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = allDayPointer.commit(event.pointerId); hand.setAllDayPreview(null); if (release === null) return; - const targetDay = findWebPointTarget("[data-calendar-allday-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-allday-day"); - if (targetDay == null) return; + const targetDay = pointTarget("[data-calendar-allday-day]", event)?.getAttribute("data-calendar-allday-day"); + if (targetDay == null) { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (release.dragSource !== null) { suppressEventClick.current = true; suppressDoubleClickBriefly(); const gesture = selectionDrag.commit(); - if (gesture !== null) hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: targetDay } }); + const move = interpretCalendarAllDayPointer({ ...release, targetDay }); + hand.previewSelectionDrag(null); + if (gesture !== null && move?.type === "event.move-day") hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: move.day } }); return; } if (release.dragCandidate !== null) return; @@ -221,7 +250,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } const id = raw?.type === "event.move-day" || raw?.type === "event.resize" ? raw.eventId : null; const intent = bindCalendarAllDayIntent(raw, id === null ? undefined : document.events.find((item) => item.id === id), release.originEventStart, hand.scope); - remember(intent, release.originEventStart, release.originEventEnd); + hand.commitIntent(intent, { start: release.originEventStart, end: release.originEventEnd }); } function monthPointerDown(event: PointerEvent, fallbackDay: string, rowDays: ReadonlyArray, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null): void { @@ -242,7 +271,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen function monthPointerMove(event: PointerEvent): void { if (monthPointer.getSnapshot()?.pointerId !== event.pointerId) return; - const targetDay = findWebPointTarget("[data-calendar-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-day"); + const targetDay = pointTarget("[data-calendar-day]", event)?.getAttribute("data-calendar-day"); if (targetDay == null) return; const next = monthPointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetDay !== state.originDay @@ -251,8 +280,14 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetDay, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { + const move = interpretCalendarMonthPointer(next); + if (move?.type !== "event.move-day") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "day", day: next.originDay } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: targetDay } })); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: move.day } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setMonthPreview({ ...next, eventsOnTargetDay: [] }); } @@ -261,13 +296,19 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = monthPointer.commit(event.pointerId); hand.setMonthPreview(null); if (release === null) return; - const targetDay = findWebPointTarget("[data-calendar-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-day"); - if (targetDay == null) return; + const targetDay = pointTarget("[data-calendar-day]", event)?.getAttribute("data-calendar-day"); + if (targetDay == null) { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (release.dragSource !== null) { suppressEventClick.current = true; suppressDoubleClickBriefly(); const gesture = selectionDrag.commit(); - if (gesture !== null) hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: targetDay } }); + const move = interpretCalendarMonthPointer({ ...release, targetDay }); + hand.previewSelectionDrag(null); + if (gesture !== null && move?.type === "event.move-day") hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: move.day } }); return; } if (release.dragCandidate !== null) return; @@ -279,7 +320,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } const id = raw?.type === "event.move-day" ? raw.eventId : null; const intent = bindCalendarMonthIntent(raw, id === null ? undefined : document.events.find((item) => item.id === id), release.originEventStart, hand.scope); - remember(intent, release.originEventStart, release.originEventEnd); + hand.commitIntent(intent, { start: release.originEventStart, end: release.originEventEnd }); } function resizeTimed(id: string, edge: "start" | "end", occurrenceStart: string, origin: string, delta: number, phase: Phase): void { @@ -289,12 +330,15 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = { originInstant: origin, originEventId: id, originEventStart: occurrenceStart, originHandle: edge, targetInstant }; if (phase === "preview") return hand.setTimePreview(release); hand.setTimePreview(null); - remember(bindTime(interpretCalendarTimeGridPointer(release), occurrenceStart), occurrenceStart, targetInstant); + hand.commitIntent(bindTime(interpretCalendarTimeGridPointer(release), occurrenceStart), { start: occurrenceStart, end: targetInstant }); } function resizeAllDay(id: string, edge: "start" | "end", originDay: string, occurrenceStart: string, delta: number, phase: Phase): void { - const column = globalThis.document.querySelector("[data-calendar-allday-day]") ?? globalThis.document.querySelector("[data-calendar-week] [data-calendar-day]"); - const targetDay = addCalendarDate(originDay, calendarDayDeltaFromWebWidth(delta, column?.getBoundingClientRect().width ?? 0)); + const column = rootRef.current?.querySelector("[data-calendar-allday-day]") ?? rootRef.current?.querySelector("[data-calendar-week] [data-calendar-day]"); + if (column == null) return; + const width = column.getBoundingClientRect().width; + if (width <= 0) return; + const targetDay = addCalendarDate(originDay, calendarDayDeltaFromWebWidth(delta, width)); if (targetDay === null) return; const release = { originDay, originEventId: id, originEventStart: occurrenceStart, originHandle: edge, targetDay }; if (phase === "preview") return hand.setAllDayPreview(release); @@ -328,6 +372,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } return { + rootRef, hoveredTime, instantAt, timePointerDown, diff --git a/packages/json-document-calendar/tests/calendar-protocol.test.tsx b/packages/json-document-calendar/tests/calendar-protocol.test.tsx new file mode 100644 index 000000000..b6f51c7ca --- /dev/null +++ b/packages/json-document-calendar/tests/calendar-protocol.test.tsx @@ -0,0 +1,189 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { calendarClipboardFormat, createCalendarEditor, type CalendarDocument } from "@interactive-os/json-document-editing"; +import { createWebClipboardBinding, createWebJSONClipboardRepresentation } from "@interactive-os/json-document-web"; +import { useCalendarHand, useCalendarPointerInteractions } from "../src/index.js"; + +afterEach(cleanup); +const initial: CalendarDocument = { + calendars: [{ id: "work", title: "Work", hidden: false, color: "accent" }], + events: [{ id: "a", title: "A", start: "2026-08-01T09:00", end: "2026-08-01T10:00", allDay: false, calendarId: "work", recurrence: null, excludeDates: [] }], +}; +const policy = { hourStart: 0, hourEnd: 24, stepMinutes: 15, pixelsPerHour: 60 }; +const rect = (width: number) => ({ left: 0, right: width, top: 0, bottom: 1440, width, height: 1440, x: 0, y: 0, toJSON: () => ({}) }); +function surface(width: number, day: string) { + const root = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.calendarGrid = "time"; + grid.dataset.calendarDay = day; + grid.dataset.calendarAlldayDay = day; + grid.getBoundingClientRect = () => rect(width); + root.append(grid); + document.body.append(root); + return { root, grid }; +} + +describe("Calendar Hand protocol composition", () => { + test.each(["hand", "editor", "initial"] as const)("uses canonical occurrence after %s selection, including the README patch/paste path", (path) => { + let id = 0; + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, recurrence: { freq: "daily", interval: 1, until: "2026-08-08" } }] }, { createId: () => `new-${++id}` }); + const intent = { type: "selection.set", point: { eventId: "a", occurrenceStart: "2026-08-03T09:00" } } as const; + if (path === "initial") editor.dispatch(intent); + const { result } = renderHook(() => useCalendarHand(editor)); + if (path !== "initial") act(() => path === "hand" ? result.current.dispatch(intent) : editor.dispatch(intent)); + expect(result.current.occurrence).toEqual({ start: "2026-08-03T09:00", end: "2026-08-03T10:00" }); + expect(result.current.inspectedInterval).toEqual(result.current.occurrence); + expect(result.current.isPrimaryOccurrence("a", "2026-08-01T09:00")).toBe(false); + expect(result.current.isPrimaryOccurrence("a", "2026-08-03T09:00")).toBe(true); + act(() => { expect(result.current.applySelectedPatch({ title: "Planning" })).toBe(true); }); + expect(result.current.selectedEvent).toMatchObject({ title: "Planning", start: "2026-08-03T09:00", recurrence: null }); + const payload = result.current.copy()!; + act(() => { expect(result.current.paste(payload).ok).toBe(true); }); + expect(result.current.selectedEvent?.start).toBe("2026-08-03T09:00"); + act(() => result.current.undo()); + act(() => result.current.undo()); + expect(result.current.occurrence.start).toBe("2026-08-03T09:00"); + }); + + test("an explicit empty-slot paste target does not become a stale selected occurrence", () => { + const editor = createCalendarEditor(initial); + const { result } = renderHook(() => useCalendarHand(editor)); + act(() => result.current.setOccurrence({ start: "2026-08-04T11:00", end: "2026-08-04T12:00" })); + act(() => editor.dispatch({ type: "selection.clear" })); + act(() => editor.dispatch({ type: "selection.set", point: { eventId: "a", occurrenceStart: "2026-08-01T09:00" } })); + expect(result.current.occurrence.start).toBe("2026-08-01T09:00"); + act(() => result.current.removeSelected()); + expect(result.current.selectedEvent).toBeNull(); + expect(result.current.occurrence).toEqual({ start: null, end: null }); + }); + + test("selection and patch in one event use the live canonical occurrence", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, recurrence: { freq: "daily", interval: 1, until: "2026-08-08" } }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + act(() => { + result.current.dispatch({ type: "selection.set", point: { eventId: "a", occurrenceStart: "2026-08-03T09:00" } }); + expect(result.current.applySelectedPatch({ title: "Same event" })).toBe(true); + }); + expect(result.current.selectedEvent).toMatchObject({ title: "Same event", start: "2026-08-03T09:00", recurrence: null }); + expect((editor.snapshot.value as CalendarDocument).events[0]?.title).toBe("A"); + }); + + test.each(["time", "allDay", "month"] as const)("returning a %s drag to its origin clears preview and does not commit", (kind) => { + const sourceEvent = kind === "time" ? initial.events[0]! : { ...initial.events[0]!, start: "2026-08-01", end: "2026-08-02", allDay: true }; + const editor = createCalendarEditor({ ...initial, events: [sourceEvent] }); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + const { root, grid } = surface(100, kind === "time" ? "2026-08-01" : "2026-08-02"); + try { + result.current.pointer.rootRef.current = root; + const target = { closest: () => kind === "time" ? grid : null, focus() {}, setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} }; + const down = { button: 0, clientX: 50, clientY: 540, currentTarget: target, pointerId: 1 } as never; + act(() => { + if (kind === "time") result.current.pointer.timePointerDown(down, "2026-08-01", "a", sourceEvent.start, sourceEvent.end, "body"); + else if (kind === "allDay") result.current.pointer.allDayPointerDown(down, "2026-08-01", "a", sourceEvent.start, sourceEvent.end, "body"); + else result.current.pointer.monthPointerDown(down, "2026-08-01", ["2026-08-01"], "a", sourceEvent.start, sourceEvent.end); + }); + act(() => result.current.pointer[`${kind}PointerMove`]({ pointerId: 1, clientX: 50, clientY: 600, target: grid } as never)); + expect(result.current.hand.selectionDragPreview).not.toBeNull(); + grid.dataset.calendarDay = grid.dataset.calendarAlldayDay = "2026-08-01"; + act(() => result.current.pointer[`${kind}PointerMove`]({ pointerId: 1, clientX: 50, clientY: 540, target: grid } as never)); + expect(result.current.hand.selectionDragPreview).toBeNull(); + act(() => result.current.pointer[`${kind}PointerUp`]({ pointerId: 1, clientX: 50, clientY: 540 } as never)); + expect(result.current.hand.document.events).toEqual([sourceEvent]); + expect(editor.snapshot.canUndo).toBe(false); + } finally { root.remove(); } + }); + + test("cuts the written payload even when the writer re-enters selection", () => { + const editor = createCalendarEditor({ ...initial, events: [...initial.events, { ...structuredClone(initial.events[0]!), id: "b", title: "B" }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + const data = new Map(); + const binding = createWebClipboardBinding({ + codec: createWebJSONClipboardRepresentation(calendarClipboardFormat), + read: result.current.copy, cut: result.current.cut, paste: result.current.paste, + }); + act(() => { + expect(binding.cut({ + clipboardData: { + types: [], + getData: (type) => data.get(type) ?? "", + setData(type, value) { + data.set(type, value); + editor.dispatch({ type: "selection.set", point: { eventId: "b", occurrenceStart: "2026-08-01T09:00" } }); + }, + }, + preventDefault() {}, + }).ok).toBe(true); + }); + expect(JSON.parse(data.get(calendarClipboardFormat.mimeType)!).items[0].sourceEventId).toBe("a"); + expect(result.current.document.events.map((event) => event.id)).toEqual(["b"]); + }); + + test("reports a rejected pointer edit without success aftercare", () => { + const editor = createCalendarEditor(initial, { createId: () => "draft" }); + const onResult = vi.fn(); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor, { onResult }); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + act(() => result.current.hand.createInterval("2026-08-01T11:00", "2026-08-01T12:00")); + const before = editor.snapshot; + const occurrence = result.current.hand.occurrence; + act(() => result.current.pointer.resizeTimed("draft", "end", "2026-08-01T11:00", "2026-08-01T12:00", -120, "commit")); + expect(editor.snapshot).toEqual(before); + expect(result.current.hand.occurrence).toEqual(occurrence); + expect(result.current.hand.renaming).toBe(true); + expect(onResult).toHaveBeenLastCalledWith(expect.objectContaining({ ok: false, code: "event.invalid-interval" })); + }); + + test("keeps the edited later occurrence focused for all-scope Inspector follow-up", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, recurrence: { freq: "daily", interval: 1, until: "2026-08-08" } }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + act(() => result.current.selectOccurrence("a", "2026-08-03T09:00", "2026-08-03T10:00")); + act(() => result.current.setScope("all")); + act(() => result.current.applySelectedPatch({ start: "2026-08-03T11:00" })); + expect(result.current.inspectedInterval).toEqual({ start: "2026-08-03T11:00", end: "2026-08-03T12:00" }); + act(() => result.current.applySelectedPatch({ end: "2026-08-03T13:00" })); + expect(result.current.document.events[0]).toMatchObject({ start: "2026-08-01T11:00", end: "2026-08-01T13:00" }); + }); + + test("two overlapping Calendar instances hit-test and resize within their own roots", () => { + const allDay = { ...initial, events: [{ ...initial.events[0]!, start: "2026-08-01", end: "2026-08-02", allDay: true }] }; + const first = createCalendarEditor(allDay), second = createCalendarEditor(allDay); + const { result } = renderHook(() => { + const firstHand = useCalendarHand(first), secondHand = useCalendarHand(second); + return { first: useCalendarPointerInteractions(firstHand, policy), second: useCalendarPointerInteractions(secondHand, policy) }; + }); + const left = surface(100, "2026-08-01"), right = surface(200, "2026-08-02"); + try { + result.current.first.rootRef.current = left.root; + result.current.second.rootRef.current = right.root; + act(() => result.current.second.timePointerMove({ pointerId: 1, clientX: 50, clientY: 540, target: right.grid } as never)); + expect(result.current.second.hoveredTime?.day).toBe("2026-08-02"); + act(() => result.current.first.resizeAllDay("a", "end", "2026-08-01", "2026-08-01", 200, "commit")); + act(() => result.current.second.resizeAllDay("a", "end", "2026-08-01", "2026-08-01", 200, "commit")); + expect((first.snapshot.value as CalendarDocument).events[0]?.end).toBe("2026-08-04"); + expect((second.snapshot.value as CalendarDocument).events[0]?.end).toBe("2026-08-03"); + } finally { left.root.remove(); right.root.remove(); } + }); + + test("all-day body dragging preserves the grab offset inside a multi-day span", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, start: "2026-08-01", end: "2026-08-04", allDay: true }] }); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + const { root, grid } = surface(100, "2026-08-04"); + try { + result.current.pointer.rootRef.current = root; + const target = { focus() {}, setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} }; + act(() => result.current.pointer.allDayPointerDown({ button: 0, currentTarget: target, pointerId: 1 } as never, "2026-08-03", "a", "2026-08-01", "2026-08-04", "body")); + act(() => result.current.pointer.allDayPointerMove({ pointerId: 1, clientX: 50, clientY: 50, target: grid } as never)); + expect(result.current.hand.paintedEvents[0]?.start).toBe("2026-08-02"); + act(() => result.current.pointer.allDayPointerUp({ pointerId: 1, clientX: 50, clientY: 50 } as never)); + expect(result.current.hand.document.events[0]).toMatchObject({ start: "2026-08-02", end: "2026-08-05" }); + } finally { root.remove(); } + }); +}); diff --git a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx index 999ec4bf4..e5505b9e4 100644 --- a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx +++ b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx @@ -201,7 +201,10 @@ describe("useCalendarHand", () => { grid.dataset.calendarGrid = "time"; grid.dataset.calendarDay = "2026-08-03"; grid.getBoundingClientRect = () => ({ left: 0, right: 100, top: 0, bottom: 1440, width: 100, height: 1440, x: 0, y: 0, toJSON: () => ({}) }); - document.body.append(grid); + const root = document.createElement("div"); + root.append(grid); + document.body.append(root); + result.current.pointer.rootRef.current = root; const target = { closest: () => grid, focus: () => undefined, @@ -227,7 +230,7 @@ describe("useCalendarHand", () => { expect(result.current.hand.document.events.map((item) => item.start)).toEqual([ "2026-08-03T09:00", "2026-08-04T11:00", ]); - grid.remove(); + root.remove(); }); test("finishes an outstanding create rename when selection drag commits", () => { diff --git a/packages/json-document-calendar/tsconfig.json b/packages/json-document-calendar/tsconfig.json index fbedf710e..0cd96d404 100644 --- a/packages/json-document-calendar/tsconfig.json +++ b/packages/json-document-calendar/tsconfig.json @@ -6,6 +6,7 @@ "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ + { "path": "../json-document-calendar-document" }, { "path": "../json-document-affordance" }, { "path": "../json-document-editing" }, { "path": "../json-document-react" }, diff --git a/packages/json-document-canvas/LICENSE b/packages/json-document-canvas/LICENSE new file mode 100644 index 000000000..6a984193a --- /dev/null +++ b/packages/json-document-canvas/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 interactive-os contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/json-document-canvas/README.md b/packages/json-document-canvas/README.md new file mode 100644 index 000000000..939643f68 --- /dev/null +++ b/packages/json-document-canvas/README.md @@ -0,0 +1,25 @@ +# Canvas Hand + +`@interactive-os/json-document-canvas`는 한 장의 Object Canvas 프로파일에서 +글자·사각형·타원·스티커 노트·그리기를 완성하는 React Hand입니다. 도형과 노트의 +본문도 같은 plain-text 입력과 스타일 API로 편집합니다. 별도 Canvas editor나 +selection/history 구현을 만들지 않고 기존 `createObjectEditor`를 사용합니다. +재사용 가능한 Plane Select profile로 다중 선택·Alt 복제·Shift 축 고정·방향키 이동을, +Object Editing과 Web Clipboard로 선택 copy/cut/paste·Mod+D·Undo/Redo를 연결합니다. +외부 텍스트와 PNG/JPEG/WebP 붙여넣기, 포함된 이미지·글의 HTML 순서 보존, +반복 paste 배치와 비동기 취소도 정본 API를 사용합니다. 외부 이미지 URL은 가져오지 않습니다. + +```tsx +import { useState } from "react"; +import { createObjectEditor } from "@interactive-os/json-document-editing"; +import { CanvasHand } from "@interactive-os/json-document-canvas"; + +function Slide() { + const [editor] = useState(() => createObjectEditor({ profile: "canvas/1", width: 1280, height: 720, objects: [] })); + return ; +} +``` + +CSS와 레이아웃은 Host가 결정하고 controls는 기존 UI Primitives를 소비합니다. +슬라이드 크기와 색은 문서/제품 값입니다. [API 계약](docs/api.md)과 +[실제 Usage/Source](https://developer-1px.github.io/json-document/docs/api/canvas)를 참고하세요. diff --git a/packages/json-document-canvas/docs/api.md b/packages/json-document-canvas/docs/api.md new file mode 100644 index 000000000..801a71761 --- /dev/null +++ b/packages/json-document-canvas/docs/api.md @@ -0,0 +1,170 @@ +## Canvas Hand 계약 · RC + +`CanvasHand`는 `ObjectEditor`와 `CanvasCreationStyle`을 받아 글자·사각형·타원·스티커 노트·자유 +그리기, 이미지·텍스트 붙여넣기, 다중 선택, 선택 스타일, 집합 이동·복제·삭제, native Clipboard, primary resize, Undo/Redo, JSON 재열기를 연결합니다. +`useCanvasHand`는 같은 입력 조합을 custom UI에서 사용할 수 있게 공개합니다. + +툴바의 모든 도구·명령은 Lucide 아이콘과 공통 `Toggle`/`Command`의 `label`을 +사용합니다. label이 접근성 이름과 hover/focus 툴팁의 정본이며, 별도 툴팁이나 +버튼 구현을 두지 않습니다. 선택 도구는 `aria-pressed`, 실행 불가 명령은 +`disabled`로 상태를 전달합니다. + +```text +Host: 한 장 fixture, 크기·색상 정책, 레이아웃 + └─ Canvas Hand: 도구, 조작 preview, plain-text draft, UI 조합 + ├─ React Connector: Editing snapshot 구독 + ├─ Web Adapter: SVG 좌표, pointer capture, keyboard·Clipboard 해석 + ├─ File Intake: 붙여넣은 파일의 형식·개수·용량 정책 검사 + ├─ Affordance: createPlaneSelectProfile, gesture, resize + │ └─ Selection: key 집합·primaryKey 전이 + ├─ UI Primitives: handle, icon controls·tooltips + └─ Object Editing: Intent, ID 할당, Selection, History, 외부 내용 변환·paste 순서/취소 + ├─ Object Document Type: Canvas profile, 검증, 연산, projection, JSON + └─ Core: immutable 값과 atomic JSON Patch +``` + +이는 책임 관계이며 모든 입력이 통과하는 직렬 pipeline이 아닙니다. + +### 입력과 History + +- 도구를 고르고 클릭하면 기본 크기, 드래그하면 지정한 크기로 생성합니다. + 사각형·타원·글자·노트는 누르거나 작게 흔들리는 동안 기본 크기를 미리 표시하지 않습니다. + 시작점에서 3 문서 단위 이상 움직이면 실제 드래그 상자만 표시하며, 다시 시작점 근처로 + 돌아와도 클릭 크기로 바뀌지 않습니다. 클릭 기본 크기는 놓을 때 press 위치에만 생성합니다. + 드래그 후 시작점에 정확히 돌아와 놓으면 객체나 History를 만들지 않습니다. + 생성 후 Select로 돌아가며 새 객체를 선택합니다. 펜은 최소 두 지점이 필요합니다. +- 객체 click은 단일 선택, Shift+click은 toggle입니다. 빈 곳 click은 clear, + drag는 marquee replace, Shift+marquee는 add입니다. Mod+A를 반복해도 전체 선택을 유지합니다. + 선택된 객체 press는 집합을 유지하고 release까지 drag가 없으면 단일 선택으로 바꿉니다. +- 선택된 객체를 끌면 집합 전체가 같은 delta로 이동합니다. 마지막 객체가 위에 표시됩니다. + 선택 윤곽은 모두 그리지만 네 변·네 모서리 resize targets는 primary 하나에만 붙습니다. + Delete는 집합 전체를 한 번 삭제하며 primary resize/text 편집은 기존 선택 집합을 보존합니다. + Focus만으로 선택하지 않으며, focused 객체에서 Space/Shift+Space로 선택/toggle합니다. + focused 객체의 Enter는 그 객체를 선택하고 본문이 있는 글자·도형·노트라면 편집합니다. 슬라이드 자체의 + Enter/F2는 현재 primary를 편집하며, F2는 객체에 focus가 있어도 primary를 대상으로 합니다. +- 글자·노트는 생성 직후, 사각형·타원은 더블클릭/F2/Enter로 편집합니다. + 기존 글자·노트도 같은 더블클릭/F2/Enter를 사용합니다. 줄바꿈·IME·선택·native + 입력 Undo는 textarea에 남습니다. blur 또는 Mod+Enter가 전체 draft를 한 번 commit하고 + Escape는 draft만 버립니다. 객체의 label이 실제 문자열 값입니다. + 노트 클릭 기본 크기는 200×200이며 드래그로 자유 크기를 지정합니다. 생성과 이후 + 본문 확정은 각각 한 번의 Undo입니다. 새 노트에서 Escape하면 빈 노트는 남습니다. + 도형은 내부 중앙, 노트는 여백을 둔 상단이며 `projectObjectText`를 표시와 입력이 공유합니다. + 편집 중에도 채우기와 테두리는 유지합니다. 상자 밖 본문은 clip하며 입력 중에는 native + textarea 스크롤로 긴 내용을 편집할 수 있습니다. 자동 글자 축소나 상자 자동 확대는 하지 않습니다. +- Alt/Option+drag는 선택 집합을 복제합니다. 원본을 남기고 사본 위치를 preview하며 + release에 새 ID를 할당합니다. Alt를 도중에 누르거나 놓으면 copy/move가 전환됩니다. + Shift+drag는 큰 delta 축을 고정하며 Shift+click toggle과 구분합니다. + Mod+D 또는 아이콘 툴바의 복제는 24단위 offset으로 복제하고 사본 집합·대응 primary를 선택합니다. +- 방향키는 선택 집합을 1단위, Shift+방향키는 10단위 이동합니다. 수정 키 없는 입력만 + 처리하며 text/JSON 입력과 IME의 키보드 소유권은 보존합니다. +- 네 모서리 손잡이는 기존 사각 모양을 유지하며 네 변 전체에도 보이지 않는 resize 영역이 + 있습니다. 방향 커서로 구분하며 모서리가 변보다 우선합니다. 변은 한 축만 조절하고 반대편 + 변을, 모서리는 반대 모서리를 고정합니다. Shift는 초기 객체 비율을 유지하고, 변에서 비율을 + 유지할 때 다른 축은 중심 기준입니다. Alt/Option은 중심 기준, Shift+Alt는 중심·비율을 + 함께 고정합니다. 포인터가 멈춰 있어도 modifier 전환을 반영합니다. 최소 1 문서 단위까지 + 줄여도 고정점은 움직이지 않고 뒤집히지 않습니다. 정지한 grab은 크기·History를 바꾸지 않습니다. + [Resize 정본 계약](/docs/api/affordance)을 소비하며, 글자 크기·path 정규화 좌표·이미지 원본은 + 그대로 두고 객체 상자만 조절합니다. +- 이동·resize·생성 중에는 문서를 변경하지 않습니다. pointerup의 최종 좌표로 한 번 + commit합니다. Escape, pointercancel, capture loss, 외부 문서 변경, unmount는 preview를 + 버립니다. 다른 pointer의 release는 조작을 완료하지 못합니다. marquee 선택 preview도 + commit 전까지 Editing에 반영하지 않습니다. Escape는 gesture만 취소하고 idle에서 선택을 비웁니다. +- 선택만 바꾸거나 0 거리로 움직이면 History가 생기지 않습니다. commit된 편집은 + 한 번의 Undo로 되돌리며 삭제 Undo는 객체와 선택을 함께 복원합니다. Mod+Z/Mod+Shift+Z는 + 입력 필드 밖에서 문서 Undo/Redo를 실행합니다. + +### 선택 스타일 + +Select 도구에서 스타일을 지원하는 객체가 선택되면 팔레트 아이콘 하나가 나타납니다. +공통 Popover와 Command 툴팁을 사용하며, 선택한 종류에 필요한 속성만 엽니다. +색상 팔레트와 굵게·정렬 버튼은 즉시 확정합니다. 직접 입력한 CSS 색·글자 크기·선 굵기는 +Enter 또는 적용 아이콘으로 확정하고, Escape·바깥 클릭으로 닫으면 미확정 입력은 버립니다. +이미지만 선택한 경우에는 스타일 컨트롤이 없습니다. + +도형·노트에는 `색상`(채우기)과 `글자색`이 따로 나타납니다. 글자 크기·굵기·정렬도 +같은 선택 스타일 API로 적용합니다. 독립 글자는 기존 `색상`을 글자색으로 씁니다. + +혼합 선택은 `readObjectStyle`의 `null`을 `혼합`으로 드러냅니다. 색·크기·정렬을 임의의 +primary 값으로 표시하지 않습니다. 속성은 이를 지원하는 선택 객체에만 적용하고 전체 +선택과 primary를 보존합니다. 굵기가 모두 0인 도형에 테두리색을 고르면 2 단위로 함께 +켭니다. 투명한 테두리색이나 도형의 0 굵기로 테두리를 없앨 수 있습니다. path는 양의 +굵기가 필요하므로 path가 포함된 선택에 0을 입력하면 전체를 거절합니다. + +`useCanvasHand`의 `selectedStyle`과 `setStyle(style)`로 같은 기능을 custom UI에 연결할 +수 있습니다. `setStyle`은 `selection.style` Intent의 결과를 반환합니다. 스타일을 열거나 +적용할 때 글자 draft는 먼저 확정하고 진행 중인 gesture·paste는 취소합니다. 글자 편집 +textarea도 표시와 같은 크기·굵기·정렬을 사용합니다. 스타일 확정당 한 번의 Undo이며 +기본값·동일값은 문서와 History를 바꾸지 않습니다. + +스타일은 저장 객체에만 적용하며 `creationStyle`의 제품 생성 기본값을 변경하지 않습니다. +글자 자동 크기, 부분 문자열 서식, 상시 inspector는 이번 범위 밖입니다. + +### Native Clipboard + +선택 객체의 Mod+C/X/V 또는 브라우저 native copy/cut/paste 이벤트를 Web Clipboard +binding에 연결합니다. 구조화 MIME과 label의 `text/plain`을 함께 쓰므로 다른 Canvas +instance로 객체를 복사하거나 다른 앱에 문자열을 붙일 수 있습니다. 앱 내부 가상 +clipboard는 만들지 않습니다. 복제 버튼은 OS clipboard를 바꾸지 않는 별도 명령입니다. + +cut은 쓰기에 성공한 캡처 대상만 제거합니다. 쓰기 실패나 Editing 거절은 오류로 드러내고 +문서 삭제나 브라우저 fallback 삭제를 허용하지 않습니다. paste는 새 ID·대응 primary로 +선택합니다. Editing의 cascade placement로 24/24씩 이동하여 기존 객체와 시작점이 겹치지 않는 +첫 위치를 고릅니다. 객체 간 완전한 충돌 회피나 슬라이드 안 자동 배치는 아닙니다. +text/JSON textarea의 native clipboard는 가로채지 않습니다. + +`createCanvasClipboardBinding(editor, policy, options?)`가 이 연결의 공개 API입니다. +구조화 Object → 이미지 파일 → 이미지가 포함된 HTML → 일반 텍스트 순서로 처리하며 잘못된 Object MIME은 문자열로 +조용히 변환하지 않습니다. 외부 문자열은 한 text 객체가 되며 HTML 서식을 보존하지 않고 +줄바꿈·Unicode를 그대로 보존합니다. PNG/JPEG/WebP는 문서 내부 base64 image 객체로 넣습니다. +기본은 한 paste당 최대 4개, 파일당 10 MiB, decode 후 이미지당 16,000,000픽셀입니다. +이미지는 비율을 유지해 슬라이드 75% 상자에 맞추고 확대하지 않습니다. 후속 resize는 일반 +객체와 같은 상자 변환이며 Shift로 초기 비율을 유지할 수 있습니다. `policy.files`와 `maxImagePixels`로 입력 정책을 지정할 수 +있지만 Object 모델이 지원하지 않는 이미지 표현까지 허용되는 것은 아닙니다. + +HTML은 Web의 inert parser와 이미지 준비 API를 사용합니다. 포함된 PNG/JPEG/WebP data URL과 +글을 HTML 내부 순서대로 text/image 객체로 바꿉니다. Editing의 `createCanvasClipboard`가 +간격을 둔 세로 흐름으로 배치하고, 전체 높이가 넘으면 이미지 비율·글자 크기·간격을 함께 +줄여 상자 안에 맞춥니다. 긴 내용을 원래 글자 크기로 읽거나 CSS·Office 배치를 재현하는 +기능은 아닙니다. source가 없거나 외부·상대·blob·cid URL이면 글만 남기지 않고 전체를 거절합니다. +HTML과 native 파일이 함께 있으면 파일을 우선하고, 두 표현을 합치거나 중복 삽입하지 않습니다. + +같은 batch는 순차 decode로 준비하고 모두 성공한 경우만 한 번 삽입합니다. 연속 paste는 +Editing paste session을 통해 입력 순서대로 각각 commit/Undo를 만듭니다. 진행 상태를 표시하고 +Escape·다른 도구/편집·외부 문서/선택·unmount는 준비를 취소합니다. 늦은 완료는 문서를 바꾸거나 +오류 상태를 덮어쓰지 않습니다. `pending`, `cancel()`, `onResult`, `onPendingChange`를 공개하며 +`readRaster`에는 Web API와 호환되는 구체 환경 인스턴스를 주입할 수 있습니다. + +이미지도 기존 다중 선택·이동·복제·copy/cut/paste·삭제·Undo/Redo와 JSON 재열기를 사용합니다. +이미지용 별도 생성 도구, 외부 URL/SVG·전체 HTML layout import, 이미지 파일 export, asset 서버, +async clipboard 툴바는 아직 지원하지 않습니다. 표준 MIME이 없는 입력이나 실패는 오류로 드러냅니다. + +### JSON + +JSON 버튼은 현재 문서 문자열을 노출합니다. 이 문자열을 저장해 다시 JSON 입력에 +넣고 `JSON 열기`로 복원할 수 있습니다. 재열기는 전체 문서 교체 한 번으로 기록하고 +선택을 비우며 Undo도 가능합니다. 잘못된 JSON은 오류를 표시하고 기존 문서와 History를 +보존합니다. 새 ObjectEditor에 deserialize한 값을 넣으면 새 History 세션으로 시작합니다. +파일 시스템·서버 persistence 정책은 Host 범위입니다. + +### 범위와 Usage + +단일 슬라이드를 컨테이너에 맞춰 표시합니다. 확대/축소·페이지·팬·다중 resize·그룹·회전· +snap·레이어·PPTX·collaboration은 이번 Hand의 지원 범위가 아닙니다. +`creationStyle`은 새 객체에만 적용하는 제품 기본값이며 저장 객체의 스타일을 덮어쓰지 않습니다. +`stickyNoteColor`로 새 노트의 채우기를 지정하며 생략하면 `color`를 사용합니다. +노트·도형의 본문은 `textColor`와 `fontSize` 생성 기본값을 받습니다. + +선택은 Affordance의 [평면 Select 프로파일](/docs/api/affordance)을 소비합니다. +`selectProfile`을 주입하거나 생략하여 기본 instance를 만들 수 있습니다. instance는 Hand마다 +독립적이어야 합니다. `useCanvasHand(editor, style, selectProfile?)`의 `selection`과 `marquee`는 +현재 표시할 preview이며, `snapshot.selection`은 Editing에 확정된 선택입니다. + +```live-demo +/demo/canvas +``` + +샘플이 있는 두 번째 Host도 같은 Hand를 사용합니다. + +```live-demo +/widgets/canvas +``` diff --git a/packages/json-document-canvas/package.json b/packages/json-document-canvas/package.json new file mode 100644 index 000000000..503022652 --- /dev/null +++ b/packages/json-document-canvas/package.json @@ -0,0 +1,35 @@ +{ + "name": "@interactive-os/json-document-canvas", + "version": "0.1.0-rc.0", + "description": "Official React Canvas Hand for a fixed single-slide Object document.", + "type": "module", "license": "MIT", "sideEffects": false, + "main": "./dist/index.js", "types": "./dist/index.d.ts", + "repository": { "type": "git", "url": "git+https://github.com/developer-1px/json-document.git", "directory": "packages/json-document-canvas" }, + "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "scripts": { + "build": "tsc -b tsconfig.json", "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", "test": "vitest run --config vitest.config.ts", + "verify": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { "lucide-react": "^1.33.0" }, + "peerDependencies": { + "@interactive-os/json-document-file-intake": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@interactive-os/json-document-file-intake": "*", + "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-web": "*", + "@interactive-os/json-document-react": "*", "@interactive-os/json-document-ui-primitives-react": "*", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^29.1.1", "react": "^19.2.5", "react-dom": "^19.2.5", "typescript": "^5.0.0", "vitest": "^4.1.7" + } +} diff --git a/packages/json-document-canvas/src/canvas-clipboard.ts b/packages/json-document-canvas/src/canvas-clipboard.ts new file mode 100644 index 000000000..2f8457d8a --- /dev/null +++ b/packages/json-document-canvas/src/canvas-clipboard.ts @@ -0,0 +1,71 @@ +import { createCanvasClipboard, createObjectPasteSession, type CanvasClipboardOptions, type EditingResult, type ObjectEditor, type ObjectPastePreparation, type ObjectSelection } from "@interactive-os/json-document-editing"; +import type { FileAcceptancePolicy } from "@interactive-os/json-document-file-intake"; +import { assertCanvasDocument } from "@interactive-os/json-document-object-document"; +import { captureWebClipboardPaste, createWebClipboardBinding, objectClipboardCodec, readWebHTMLClipboard, readWebRasterFile, readWebRasterFiles, type WebClipboardEvent, type WebFileCandidate, type WebHTMLClipboardContent } from "@interactive-os/json-document-web"; + +export interface CanvasClipboardPolicy { + readonly textColor: string; + readonly fontSize: number; + readonly files?: FileAcceptancePolicy; + readonly maxImagePixels?: number; +} + +/** Canvas input composition. File validation, native capture, content conversion, and ordered adoption retain their canonical owners. */ +export function createCanvasClipboardBinding(editor: ObjectEditor, policy: CanvasClipboardPolicy, options: { + readonly readRaster?: typeof readWebRasterFile; + readonly onResult?: (result: { readonly ok: boolean; readonly code?: string; readonly reason?: string }) => void; + readonly onPendingChange?: (pending: boolean) => void; +} = {}) { + const placement = { type: "cascade", dx: 24, dy: 24 } as const; + const session = createObjectPasteSession(editor, { placement, ...(options.onResult ? { onResult: options.onResult } : {}), ...(options.onPendingChange ? { onPendingChange: options.onPendingChange } : {}) }); + const native = createWebClipboardBinding({ + codec: objectClipboardCodec, + read: () => editor.copy(), + cut: (payload) => editor.dispatch({ type: "object.remove", objectIds: payload.objects.map((object) => object.id) }), + paste: (payload) => editor.dispatch({ type: "clipboard.paste", clipboard: payload, placement }), + }); + const files = policy.files ?? { acceptedMediaTypes: ["image/png", "image/jpeg", "image/webp"], maxFiles: 4, maxBytesPerFile: 10 * 1024 * 1024 }; + const maxPixels = policy.maxImagePixels ?? 16_000_000; + if (!Number.isFinite(maxPixels) || maxPixels <= 0) throw new TypeError("maxImagePixels must be positive and finite."); + + function contentOptions(): CanvasClipboardOptions { + const document = editor.snapshot.value; + assertCanvasDocument(document); + return { bounds: { x: 0, y: 0, width: document.width * 0.75, height: document.height * 0.75 }, textColor: policy.textColor, fontSize: policy.fontSize }; + } + async function images(candidates: readonly WebFileCandidate[], content: CanvasClipboardOptions, signal: AbortSignal): Promise { + const prepared = await readWebRasterFiles(candidates, { policy: files, maxImagePixels: maxPixels, signal, readRaster: options.readRaster ?? readWebRasterFile }); + return prepared.ok + ? { ok: true, clipboard: createCanvasClipboard({ type: "images", images: prepared.files.map(({ candidate, image }) => ({ ...image, label: candidate.name })) }, content) } + : prepared; + } + async function html(input: WebHTMLClipboardContent, content: CanvasClipboardOptions, signal: AbortSignal): Promise { + const prepared = await readWebHTMLClipboard(input, { policy: files, maxImagePixels: maxPixels, signal, readRaster: options.readRaster ?? readWebRasterFile }); + return prepared.ok ? { ok: true, clipboard: createCanvasClipboard({ type: "mixed", items: prepared.parts.map((part) => part.type === "text" ? part : { type: "image", ...part.image, label: part.candidate.name }) }, content) } : prepared; + } + return { + get pending() { return session.pending; }, + cancel: () => session.cancel(), + copy(event: WebClipboardEvent) { + session.cancel(); + const result = native.copy(event); options.onResult?.(result); return result; + }, + cut(event: WebClipboardEvent) { + session.cancel(); + const result = native.cut(event); options.onResult?.(result); return result; + }, + paste(event: WebClipboardEvent): Promise> { + const captured = captureWebClipboardPaste(event, { codec: objectClipboardCodec, files: true, html: "images", text: true }); + const controller = new AbortController(); + return session.enqueue(() => { + if (!captured.ok) return captured; + if (captured.type === "structured") return { ok: true, clipboard: captured.payload }; + const content = contentOptions(); + if (captured.type === "html") return html(captured.content, content, controller.signal); + return captured.type === "text" + ? { ok: true, clipboard: createCanvasClipboard({ type: "text", text: captured.text }, content) } + : images(captured.files, content, controller.signal); + }, () => controller.abort()); + }, + }; +} diff --git a/packages/json-document-canvas/src/canvas-hand.tsx b/packages/json-document-canvas/src/canvas-hand.tsx new file mode 100644 index 000000000..9e7647a58 --- /dev/null +++ b/packages/json-document-canvas/src/canvas-hand.tsx @@ -0,0 +1,75 @@ +import { useState, type CSSProperties } from "react"; +import { Braces, Circle, CopyPlus, MousePointer2, Pencil, RectangleHorizontal, Redo2, StickyNote, Trash2, Type, Undo2, type LucideIcon } from "lucide-react"; +import type { ObjectEditor } from "@interactive-os/json-document-editing"; +import type { PlaneSelectProfile } from "@interactive-os/json-document-affordance"; +import { serializeCanvasDocument } from "@interactive-os/json-document-object-document"; +import { Command, Field, ProductShell, Toggle, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; +import { CanvasObjectTarget, CanvasObjectView, CanvasResizeTarget, CanvasTextInput } from "./canvas-object-view.js"; +import { useCanvasHand, type CanvasCreationStyle, type CanvasTool } from "./use-canvas-hand.js"; +import { CanvasStyleControls } from "./canvas-style-controls.js"; + +export interface CanvasHandProps { + readonly editor: ObjectEditor; + readonly creationStyle: CanvasCreationStyle; + readonly className?: string; + readonly slideStyle?: CSSProperties; + readonly label?: string; + /** Optional policy instance; one profile per mounted Hand. */ + readonly selectProfile?: PlaneSelectProfile; +} + +const tools: ReadonlyArray<{ readonly id: CanvasTool; readonly label: string; readonly icon: LucideIcon }> = [ + { id: "select", label: "선택", icon: MousePointer2 }, { id: "text", label: "글자", icon: Type }, + { id: "sticky-note", label: "스티커 노트", icon: StickyNote }, + { id: "rectangle", label: "사각형", icon: RectangleHorizontal }, { id: "ellipse", label: "타원", icon: Circle }, { id: "path", label: "그리기", icon: Pencil }, +]; + +export function CanvasHand(props: CanvasHandProps) { + const hand = useCanvasHand(props.editor, props.creationStyle, props.selectProfile); + const [json, setJSON] = useState(null); + const selected = hand.objects.find((object) => object.id === hand.selection.primaryKey); + const selectedKeys = new Set(hand.selection.keys); + const copyOriginals = new Map(hand.copyOriginals.map((object) => [object.id, object])); + return ( + + {tools.map((tool) => hand.choose(tool.id)}>)} + + hand.history("undo")}> + hand.history("redo")}> + hand.duplicate()}> + + + {hand.tool === "select" && { hand.commitText(); hand.cancel(); }} />} + { hand.commitText(); hand.cancel(); setJSON(json === null ? serializeCanvasDocument(props.editor.snapshot.value as typeof hand.document) : null); }}> + }> + + {hand.objects.map((object) => + + 0 && selectedKeys.has(object.id)} + onSelect={(shiftKey) => hand.select(object.id, shiftKey)} onEdit={() => hand.editText(object.id)} onHandle={(interaction, event) => hand.interaction(interaction, event, object, "drag")} /> + )} + {copyOriginals.size > 0 && {hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )}} + {hand.preview && } + {hand.tool === "select" && hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => + )} + {selected && hand.tool === "select" && + {!hand.draft && (["n", "e", "s", "w", "nw", "ne", "se", "sw"] as const).map((edge) => hand.interaction(interaction, event, selected, "resize", edge)} />)} + } + {hand.marquee && } + {selected && hand.draft?.id === selected.id && { hand.commitText(); hand.surface.current?.focus(); }} onCancel={() => { hand.cancel(); hand.surface.current?.focus(); }} />} + + {hand.pastePending &&

붙여넣는 중… Escape로 취소

} + {hand.error &&

{hand.error}

} + {json !== null &&
+ + setJSON(serializeCanvasDocument(hand.document))}>현재 문서 읽기 + { if (hand.openJSON(json)) setJSON(null); }}>JSON 열기 +
} +
+ ); +} diff --git a/packages/json-document-canvas/src/canvas-object-view.tsx b/packages/json-document-canvas/src/canvas-object-view.tsx new file mode 100644 index 000000000..b683f20db --- /dev/null +++ b/packages/json-document-canvas/src/canvas-object-view.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, type CSSProperties, type PointerEvent, type ReactNode } from "react"; +import { getObjectStyle, projectObjectText, type CanvasObject, type ObjectTextProjection } from "@interactive-os/json-document-object-document"; +import type { InteractionHandleEvent, ResizeEdge } from "@interactive-os/json-document-affordance"; +import { contentInteractionAttributes, Field, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; + +export function CanvasObjectView({ object, hideText = false }: { readonly object: CanvasObject; readonly hideText?: boolean }): ReactNode { + const style = getObjectStyle(object); + if (object.kind === "image") return ; + if (object.kind === "path") { + return `${object.x + point.x * object.width},${object.y + point.y * object.height}`).join(" ")} fill="none" stroke={object.color} strokeWidth={object.strokeWidth} strokeLinecap="round" strokeLinejoin="round" />; + } + const text = projectObjectText(object); + return <> + {object.kind === "ellipse" + ? + : object.kind !== "text" && } + {!hideText && text &&
{text.text + "\u200b"}
} + ; +} + +function textPresentation(value: ObjectTextProjection): CSSProperties { + return { color: value.color, fontSize: value.fontSize, fontWeight: value.fontWeight, textAlign: value.textAlign, + fontFamily: "inherit", lineHeight: 1.2, whiteSpace: "pre-wrap", overflowWrap: "anywhere", letterSpacing: "normal" }; +} + +/** The same body box and line wrapping serve display and the native textarea. */ +function CanvasTextBox({ value, children }: { readonly value: ObjectTextProjection; readonly children: ReactNode }) { + return +
+
{children}
+
+
; +} + +export function CanvasObjectTarget(props: { + readonly object: CanvasObject; + readonly selected: boolean; + readonly enabled: boolean; + readonly copying?: boolean; + readonly onSelect: (shiftKey: boolean) => void; + readonly onEdit: () => void; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: { kind: "drag" }, onHandle: props.onHandle }); + const { object } = props; + return ( + { + if (event.nativeEvent.isComposing || event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key === " " || (event.key === "Enter" && !event.shiftKey)) { + event.preventDefault(); event.stopPropagation(); props.onSelect(event.shiftKey); + if (event.key === "Enter") props.onEdit(); + } + }} /> + ); +} + +export function CanvasResizeTarget(props: { + readonly object: CanvasObject; + readonly edge: ResizeEdge; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: { kind: "resize", edge: props.edge }, onHandle: props.onHandle }); + const { object, edge } = props; + const x = object.x + (edge.includes("w") ? 0 : edge.includes("e") ? object.width : object.width / 2); + const y = object.y + (edge.includes("n") ? 0 : edge.includes("s") ? object.height : object.height / 2); + if (edge.length === 1) { + const horizontal = edge === "n" || edge === "s"; + return ; + } + return ; +} + +export function CanvasTextInput(props: { + readonly object: CanvasObject; + readonly text: string; + readonly onChange: (text: string) => void; + readonly onCommit: () => void; + readonly onCancel: () => void; +}) { + const input = useRef(null); + useEffect(() => { input.current?.focus(); input.current?.select(); }, [props.object.id]); + const { object } = props; + const text = projectObjectText(object); + if (!text) return null; + return ( + + + { + event.stopPropagation(); + if (event.nativeEvent.isComposing) return; + if (event.key === "Escape") { event.preventDefault(); props.onCancel(); } + else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); props.onCommit(); } + }} /> + + ); +} diff --git a/packages/json-document-canvas/src/canvas-style-controls.tsx b/packages/json-document-canvas/src/canvas-style-controls.tsx new file mode 100644 index 000000000..00fde91b4 --- /dev/null +++ b/packages/json-document-canvas/src/canvas-style-controls.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { AlignCenter, AlignLeft, AlignRight, Bold, Check, Palette, Square, SquareDashed, type LucideIcon } from "lucide-react"; +import type { ObjectStyle, ObjectStyleSelection } from "@interactive-os/json-document-object-document"; +import { Command, Field, Popover, Toggle, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; + +const colors = [ + ["검정", "#253044"], ["흰색", "#ffffff"], ["파랑", "#3b82f6"], ["빨강", "#ef4444"], + ["주황", "#f59e0b"], ["초록", "#22c55e"], ["보라", "#a855f7"], ["투명", "transparent"], +] as const; +const alignments: ReadonlyArray<{ readonly value: ObjectStyle["textAlign"]; readonly label: string; readonly icon: LucideIcon }> = [ + { value: "left", label: "왼쪽 정렬", icon: AlignLeft }, { value: "center", label: "가운데 정렬", icon: AlignCenter }, { value: "right", label: "오른쪽 정렬", icon: AlignRight }, +]; + +/** Selection-only UI; the Document Type owns supported properties and mixed values. */ +export function CanvasStyleControls(props: { + readonly value: ObjectStyleSelection; + readonly onStyle: (style: Partial) => { readonly ok: boolean }; + readonly onOpen: () => void; +}) { + const [open, setOpen] = useState(false); + const value = props.value; + if (Object.keys(value).length === 0) return null; + const apply = (style: Partial) => props.onStyle(style).ok; + return ; +} + +function StyleValue(props: { + readonly label: string; + readonly value: string | number | null; + readonly color?: boolean; + readonly onApply: (value: string) => boolean; +}) { + const [draft, setDraft] = useState(props.value === null ? "" : String(props.value)); + const [error, setError] = useState(null); + function apply() { + const value = draft.trim(); + if (!value || (props.color && !CSS.supports("color", value))) { setError(props.color ? "유효한 색상을 입력하세요." : "값을 입력하세요."); return; } + if (props.onApply(value)) setError(null); + } + return
+ {props.label} + {props.color && + {colors.map(([label, color]) => { props.onApply(color); setDraft(color); setError(null); }}> + {color === "transparent" ? )} + } +
{ event.preventDefault(); apply(); }}> + { if (event.nativeEvent.isComposing) { if (event.key === "Enter") event.preventDefault(); event.stopPropagation(); } }} + aria-invalid={error !== null} onValueChange={(value) => { setDraft(value); setError(null); }} style={{ width: "100%", minWidth: 0 }} /> + + + {error && {error}} +
; +} diff --git a/packages/json-document-canvas/src/index.ts b/packages/json-document-canvas/src/index.ts new file mode 100644 index 000000000..4a7e72cf2 --- /dev/null +++ b/packages/json-document-canvas/src/index.ts @@ -0,0 +1,3 @@ +export { CanvasHand, type CanvasHandProps } from "./canvas-hand.js"; +export { createCanvasClipboardBinding, type CanvasClipboardPolicy } from "./canvas-clipboard.js"; +export { useCanvasHand, type CanvasTool, type CanvasCreationStyle } from "./use-canvas-hand.js"; diff --git a/packages/json-document-canvas/src/use-canvas-hand.ts b/packages/json-document-canvas/src/use-canvas-hand.ts new file mode 100644 index 000000000..48aff1f85 --- /dev/null +++ b/packages/json-document-canvas/src/use-canvas-hand.ts @@ -0,0 +1,323 @@ +import { useEffect, useMemo, useReducer, useRef, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from "react"; +import { commitAffordance, createGestureSession, createPlaneSelectProfile, resizeAffordance, type InteractionHandleEvent, type PlaneSelectProfile, type PlaneSelectSelection, type ResizeEdge } from "@interactive-os/json-document-affordance"; +import { assertCanvasDocument, createCanvasObject, createCanvasPath, parseCanvasDocument, projectObjectText, readObjectStyle, transformObject, type CanvasDocument, type CanvasObject, type CanvasObjectKind, type ObjectPoint, type ObjectStyle } from "@interactive-os/json-document-object-document"; +import type { EditingResult, ObjectEditor, ObjectIntent, ObjectSelection } from "@interactive-os/json-document-editing"; +import { useEditingSnapshot } from "@interactive-os/json-document-react"; +import { routeWebClipboardEvent, createWebKeyboardAdapter, createWebPointerSession, isWebEditableTarget, projectWebClientPointToSVG, webSVGViewportFromElement } from "@interactive-os/json-document-web"; +import { createCanvasClipboardBinding } from "./canvas-clipboard.js"; + +export type CanvasTool = "select" | Exclude; +export interface CanvasCreationStyle { + readonly color: string; + readonly textColor: string; + readonly fontSize: number; + readonly strokeWidth: number; + /** Sticky-note fill; omitted hosts reuse their ordinary object fill. */ + readonly stickyNoteColor?: string; +} + +type Gesture = { readonly base: CanvasDocument } & ( + | { readonly type: "create"; readonly tool: Exclude; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly dragged: boolean } + | { readonly type: "draw"; readonly points: ReadonlyArray } + | { readonly type: "resize"; readonly object: CanvasObject; readonly start: ObjectPoint; readonly point: ObjectPoint; readonly edge: ResizeEdge; + readonly pointerId: number; readonly shiftKey: boolean; readonly altKey: boolean; readonly selection: ObjectSelection } +); +type TextDraft = { readonly id: string; readonly text: string; readonly base: CanvasDocument }; + +const keyboard = createWebKeyboardAdapter(); +const commands = createWebKeyboardAdapter<"cancel">({ defaults: false, keymap: { Escape: "cancel" } }); + +/** Owns Canvas interaction composition, never document or history state. */ +export function useCanvasHand(editor: ObjectEditor, style: CanvasCreationStyle, selectProfile?: PlaneSelectProfile) { + const snapshot = useEditingSnapshot(editor); + const document = useMemo(() => { assertCanvasDocument(snapshot.value); return snapshot.value; }, [snapshot.value]); + const surface = useRef(null); + const [, redraw] = useReducer((value: number) => value + 1, 0); + const [tool, setTool] = useState("select"); + const [error, setError] = useState(null); + const draft = useRef(null); + const selecting = useRef<{ readonly base: CanvasDocument; readonly selection: ObjectSelection; readonly pointerId: number; readonly key: string | null } | null>(null); + const profile = useMemo(() => selectProfile ?? createPlaneSelectProfile(), [editor, selectProfile]); + const clipboard = useMemo(() => createCanvasClipboardBinding(editor, { textColor: style.textColor, fontSize: style.fontSize }, { + onResult(result) { setError(result.ok ? null : result.reason ?? result.code ?? null); if (result.ok) setTool("select"); }, + onPendingChange: redraw, + }), [editor, style.textColor, style.fontSize]); + const gestures = useMemo(() => createGestureSession({ onBegin: redraw, onPreview: redraw, onCommit: redraw, onCancel: redraw }), [editor]); + const pointer = useMemo(() => createWebPointerSession({ onCancel: (_, reason) => { + gestures.cancel(reason === "lost-capture" ? "lost-capture" : "pointer-cancel"); + profile.cancel(reason === "lost-capture" ? "lost-capture" : "pointer-cancel"); selecting.current = null; redraw(); + } }), [gestures, profile]); + + function current(): CanvasDocument { + const value = editor.snapshot.value; + assertCanvasDocument(value); + return value; + } + + function cancelInteraction() { + const active = pointer.getSnapshot(); + if (active) pointer.cancel(active.pointerId); + gestures.cancel(); + profile.cancel(); selecting.current = null; + draft.current = null; + redraw(); + } + + function cancel() { clipboard.cancel(); cancelInteraction(); } + + useEffect(() => { + // A replacement/external edit invalidates previews, even when an ID survives. + const release = editor.subscribe((next) => { + const active = gestures.getActive(); + if ((active && (active.base !== next.value || (active.type === "resize" && active.selection !== next.selection))) + || (selecting.current && (selecting.current.base !== next.value || selecting.current.selection !== next.selection)) + || (draft.current && draft.current.base !== next.value)) cancel(); + }); + return () => { + release(); + clipboard.cancel(); + const active = pointer.getSnapshot(); + if (active) pointer.cancel(active.pointerId); + gestures.cancel(); + profile.cancel(); selecting.current = null; + draft.current = null; + }; + }, [editor, gestures, pointer, profile, clipboard]); + + function report(result: EditingResult) { + setError(result.ok ? null : result.reason ?? result.code); + return result; + } + + function dispatch(intent: ObjectIntent) { return report(editor.dispatch(intent)); } + function selectContext() { return { items: current().objects, selection: editor.snapshot.selection }; } + function applySelection(selection: PlaneSelectSelection) { + return dispatch({ type: "selection.set", objectIds: selection.keys, ...(selection.primaryKey === null ? {} : { primaryKey: selection.primaryKey }) }); + } + function select(id: string | null, shiftKey = false) { cancel(); applySelection(profile.select(selectContext(), id, shiftKey)); } + + function beginSelect(point: ObjectPoint, key: string | null, event: { readonly pointerId: number; readonly shiftKey: boolean; readonly altKey: boolean }) { + selecting.current = { base: current(), selection: editor.snapshot.selection, pointerId: event.pointerId, key }; + profile.begin(selectContext(), { point, hitKey: key, shiftKey: event.shiftKey, altKey: event.altKey }); redraw(); + } + + function commitSelect(point: ObjectPoint, modifiers: { readonly shiftKey: boolean; readonly altKey: boolean }) { + const base = selecting.current?.base; + selecting.current = null; + const result = profile.commit(point, modifiers); redraw(); + if (!result || base !== current()) return; + if (!applySelection(result.selection).ok || !result.translation) return; + const { keys, dx, dy, operation } = result.translation; + dispatch(operation === "copy" + ? { type: "object.duplicate", objectIds: keys, placement: { type: "offset", dx, dy } } + : { type: "object.translate", objectIds: keys, dx, dy }); + } + + function editText(id: string) { + const base = current(); + const object = base.objects.find((item) => item.id === id); + if (!object || !projectObjectText(object)) return; + cancel(); + if (editor.snapshot.selection.primaryKey !== id) applySelection(profile.select(selectContext(), id)); + draft.current = { id, text: object.label, base }; + redraw(); + } + + function commitText() { + const active = draft.current; + if (!active) return; + draft.current = null; + if (active.base === current()) dispatch({ type: "object.text", objectId: active.id, text: active.text }); + redraw(); + } + + function choose(next: CanvasTool) { + commitText(); cancel(); setTool(next); setError(null); + surface.current?.focus(); + } + + function eventPoint(event: { readonly clientX: number; readonly clientY: number }): ObjectPoint | null { + if (!surface.current) return null; + const point = projectWebClientPointToSVG({ x: event.clientX, y: event.clientY }, webSVGViewportFromElement(surface.current)); + return point && Number.isFinite(point.x) && Number.isFinite(point.y) ? { x: point.x, y: point.y } : null; + } + + function createPreview(gesture: Extract, committing = false) { + if (gesture.type === "draw") return gesture.points.length < 2 ? null : createCanvasPath(gesture.points, { color: style.textColor, label: "Drawing", strokeWidth: style.strokeWidth }); + const { start, point } = gesture; + const click = !gesture.dragged; + // A default-sized object belongs to the completed click, never its press preview. + if (click && !committing) return null; + if (!click && start.x === point.x && start.y === point.y) return null; + return createCanvasObject(gesture.tool, { + x: click ? start.x : Math.min(start.x, point.x), y: click ? start.y : Math.min(start.y, point.y), + width: click ? (gesture.tool === "text" ? 280 : gesture.tool === "sticky-note" ? 200 : 160) : Math.abs(point.x - start.x), + height: click ? (gesture.tool === "text" ? 64 : gesture.tool === "sticky-note" ? 200 : 100) : Math.abs(point.y - start.y), + }, { color: gesture.tool === "text" ? style.textColor : gesture.tool === "sticky-note" ? style.stickyNoteColor ?? style.color : style.color, + label: gesture.tool === "text" ? "Text" : "", fontSize: style.fontSize, textColor: style.textColor }); + } + + function transform(gesture: Extract) { + const result = commitAffordance(resizeAffordance(gesture.start, gesture.point, gesture.edge, gesture, gesture.object)); + const hand = result?.hand; + return hand?.type === "resize" ? hand : { dx: 0, dy: 0 }; + } + + function commitGesture() { + const active = gestures.commit(); + if (!active || active.base !== current()) return; + if (active.type === "create" || active.type === "draw") { + const object = createPreview(active, true); + if (!object) return; + const result = dispatch({ type: "object.create", object }); + if (result.ok) { + setTool("select"); + const id = result.snapshot.selection.primaryKey; + if (active.type === "create" && (active.tool === "text" || active.tool === "sticky-note") && id) editText(id); + } + } else { + const delta = transform(active); + if ("dw" in delta) dispatch({ type: "object.resize", objectIds: [active.object.id], dx: delta.dx, dy: delta.dy, dw: delta.dw, dh: delta.dh }); + } + } + + function pointerDown(event: PointerEvent) { + if (event.button !== 0 || isWebEditableTarget(event.target)) return; + if (tool === "select" && (event.target as Element).closest("[data-canvas-object]")) return; + const point = eventPoint(event); + if (!point) return; + event.preventDefault(); + commitText(); cancel(); surface.current?.focus(); + pointer.begin(event.currentTarget, event.pointerId, true); + if (tool === "select") { beginSelect(point, null, event); return; } + const base = current(); + gestures.begin(tool === "path" ? { type: "draw", points: [point], base } : { type: "create", tool, start: point, point, dragged: false, base }); + } + + function pointerMove(event: PointerEvent) { + if (pointer.getSnapshot()?.pointerId !== event.pointerId) return; + const point = eventPoint(event), active = gestures.getActive(); + if (point && selecting.current) { profile.preview(point, event); redraw(); return; } + if (!point || !active) return; + if (active.type === "draw") { + const last = active.points.at(-1)!; + if (last.x !== point.x || last.y !== point.y) gestures.preview({ ...active, points: [...active.points, point] }); + } else if (active.type === "create") { + gestures.preview({ ...active, point, dragged: active.dragged || Math.hypot(point.x - active.start.x, point.y - active.start.y) >= 3 }); + } else gestures.preview({ ...active, point }); + } + + function interaction(interaction: InteractionHandleEvent, event: PointerEvent, object: CanvasObject, type: "drag" | "resize", edge: ResizeEdge = "se") { + if (interaction.phase === "cancel") { + if (type === "drag" && selecting.current?.key === object.id && selecting.current.pointerId === event.pointerId) { profile.cancel("pointer-cancel"); selecting.current = null; redraw(); } + else if (type === "resize") { + const active = gestures.getActive(); + if (active?.type === "resize" && active.pointerId === event.pointerId && active.edge === edge && active.object.id === object.id) gestures.cancel("pointer-cancel"); + } + return; + } + const point = eventPoint(event); + if (!point) return; + if (interaction.phase === "start") { + commitText(); cancel(); surface.current?.focus(); + if (type === "drag") { beginSelect(point, object.id, event); return; } + const latest = current().objects.find((item) => item.id === object.id); + if (latest) gestures.begin({ type, object: latest, start: point, point, edge, base: current(), selection: editor.snapshot.selection, + pointerId: event.pointerId, shiftKey: event.shiftKey, altKey: event.altKey }); + } else { + if (type === "drag") { + if (selecting.current?.key !== object.id || selecting.current.pointerId !== event.pointerId) return; + if (interaction.phase === "commit") commitSelect(point, event); else { profile.preview(point, event); redraw(); } + return; + } + const active = gestures.getActive(); + if (!active || active.type !== type || active.object.id !== object.id || active.pointerId !== event.pointerId || active.edge !== edge) return; + gestures.preview({ ...active, point, shiftKey: event.shiftKey, altKey: event.altKey }); + if (interaction.phase === "commit") commitGesture(); + } + } + + function remove() { commitText(); cancel(); dispatch({ type: "selection.remove" }); surface.current?.focus(); } + function duplicate(keys = editor.snapshot.selection.keys) { commitText(); cancel(); dispatch({ type: "object.duplicate", objectIds: keys }); surface.current?.focus(); } + function history(direction: "undo" | "redo") { commitText(); cancel(); report(editor[direction]()); surface.current?.focus(); } + function setStyle(style: Partial) { commitText(); cancel(); return dispatch({ type: "selection.style", style }); } + + function handleClipboard(operation: "copy" | "cut" | "paste", event: ClipboardEvent) { + routeWebClipboardEvent(event.currentTarget, event, operation, () => { + cancelInteraction(); + setError(null); + void clipboard[operation](event); + }); + } + + function updateModifiers(event: KeyboardEvent) { + if (event.nativeEvent.isComposing || isWebEditableTarget(event.target)) return; + if (profile.updateModifiers(event)) redraw(); + const active = gestures.getActive(); + if (active?.type === "resize" && (active.shiftKey !== event.shiftKey || active.altKey !== event.altKey)) { + gestures.preview({ ...active, shiftKey: event.shiftKey, altKey: event.altKey }); + } + } + + function keyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing || isWebEditableTarget(event.target)) return; + updateModifiers(event); + const selectedAction = profile.keyDown(event, selectContext(), gestures.getActive() !== null || draft.current !== null || clipboard.pending); + if (selectedAction) { + event.preventDefault(); + cancel(); + if (selectedAction.type === "selection") applySelection(selectedAction.selection); + else if (selectedAction.type === "delete") remove(); + else if (selectedAction.type === "duplicate") duplicate(selectedAction.keys); + else if (selectedAction.type === "translate") dispatch({ type: "object.translate", objectIds: selectedAction.keys, dx: selectedAction.dx, dy: selectedAction.dy }); + else if (selectedAction.type === "edit") editText(selectedAction.key); + setTool("select"); return; + } + if (commands.resolve(event) === "cancel") { event.preventDefault(); setTool("select"); return; } + const action = keyboard.resolve(event); + if (action?.type === "undo" || action?.type === "redo") { event.preventDefault(); history(action.type); } + } + + const gesture = gestures.getActive(); + const selectionPreview = profile.getPreview(); + const translation = selectionPreview?.translation; + const translating = new Set(translation?.keys); + const selection = selectionPreview?.selection ?? snapshot.selection; + const objects = document.objects.map((object) => { + if (translation && translating.has(object.id)) return transformObject(object, translation); + return gesture?.type === "resize" && gesture.object.id === object.id ? transformObject(object, transform(gesture)) : object; + }); + const copyOriginals = translation?.operation === "copy" ? document.objects.filter((object) => translating.has(object.id)) : []; + const preview = gesture && (gesture.type === "create" || gesture.type === "draw") ? createPreview(gesture) : null; + + return { + document, snapshot, selection, marquee: selectionPreview?.marquee ?? null, objects, copyOriginals, preview, surface, tool, error, pastePending: clipboard.pending, draft: draft.current, + choose, select, interaction, editText, commitText, cancel, remove, duplicate, history, setStyle, + selectedStyle: readObjectStyle(editor.selectedObjects), + changeText(text: string) { if (draft.current) { draft.current = { ...draft.current, text }; redraw(); } }, + openJSON(json: string) { + try { + const document = parseCanvasDocument(json); + cancel(); + const result = dispatch({ type: "document.replace", document }); + if (result.ok) setTool("select"); + return result.ok; + } catch (error) { setError(error instanceof Error ? error.message : String(error)); return false; } + }, + surfaceProps: { + onPointerDown: pointerDown, onPointerMove: pointerMove, + onPointerUp(event: PointerEvent) { + pointerMove(event); + if (pointer.commit(event.pointerId) === null) return; + const point = eventPoint(event); + if (selecting.current && point) commitSelect(point, event); else commitGesture(); + }, + onPointerCancel(event: PointerEvent) { pointer.cancel(event.pointerId); }, + onLostPointerCapture(event: PointerEvent) { pointer.cancel(event.pointerId, "lost-capture"); }, + onKeyDown: keyDown, onKeyUp: updateModifiers, + onCopy: (event: ClipboardEvent) => handleClipboard("copy", event), + onCut: (event: ClipboardEvent) => handleClipboard("cut", event), + onPaste: (event: ClipboardEvent) => handleClipboard("paste", event), + }, + }; +} diff --git a/packages/json-document-canvas/tests/canvas-clipboard.test.ts b/packages/json-document-canvas/tests/canvas-clipboard.test.ts new file mode 100644 index 000000000..6d96531f3 --- /dev/null +++ b/packages/json-document-canvas/tests/canvas-clipboard.test.ts @@ -0,0 +1,132 @@ +import { expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createObjectEditor } from "@interactive-os/json-document-editing"; +import { parseCanvasDocument, serializeCanvasDocument, type CanvasDocument } from "@interactive-os/json-document-object-document"; +import { objectClipboardCodec, type readWebRasterFile, type WebClipboardData, type WebRasterSourceResult } from "@interactive-os/json-document-web"; +import { createCanvasClipboardBinding } from "../src/index.js"; + +const blank: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [] }; +const style = { textColor: "black", fontSize: 32 }; +const png = "data:image/png;base64,AQID"; +const file = { name: "picture.png", size: 3, type: "image/png" }; +function event(files = [] as typeof file[], values: Record = {}) { + const data: WebClipboardData = { files, get types() { return Object.keys(values); }, getData: (type) => values[type] ?? "", setData: (type, value) => { values[type] = value; } }; + return { clipboardData: data, preventDefault: vi.fn() }; +} +function setup(readRaster: typeof readWebRasterFile = vi.fn(async (): Promise => ({ ok: true, dataURL: png, width: 1600, height: 800 }))) { + let id = 0; + const document = createJSONDocument(blank), editor = createObjectEditor(document, { createId: () => `image-${++id}` }); + const commits = vi.fn(), onResult = vi.fn(); document.subscribe(commits); + const binding = createCanvasClipboardBinding(editor, style, { readRaster, onResult }); + return { editor, binding, readRaster, onResult, commits, value: () => editor.snapshot.value as CanvasDocument }; +} + +test("image batch is one commit with decoded fit, fresh IDs, primary, native copy and JSON round-trip", async () => { + const { binding, editor, value, commits } = setup(); + const pasted = event([file, { ...file, name: "second.png" }], { "text/plain": "image fallback" }); + const pending = binding.paste(pasted); + expect(pasted.preventDefault).toHaveBeenCalledOnce(); expect(binding.pending).toBe(true); expect(commits).not.toHaveBeenCalled(); + expect((await pending).ok).toBe(true); + expect(value().objects.map((object) => [object.kind, object.x, object.y, object.width, object.height, object.source])).toEqual([ + ["image", 24, 24, 960, 480, png], ["image", 48, 48, 960, 480, png], + ]); + expect(editor.snapshot.selection).toMatchObject({ keys: ["image-1", "image-2"], primaryKey: "image-2" }); + expect(parseCanvasDocument(serializeCanvasDocument(value()))).toEqual(value()); + expect(commits).toHaveBeenCalledOnce(); + const copied = event(); binding.copy(copied); + editor.undo(); expect(value()).toEqual(blank); + await binding.paste(copied); expect(value().objects).toHaveLength(2); expect(editor.snapshot.selection.keys).toEqual(["image-3", "image-4"]); + expect(value().objects[0]!.source).toBe(png); + editor.dispatch({ type: "object.translate", objectIds: editor.snapshot.selection.keys, dx: 10, dy: 10 }); + editor.dispatch({ type: "object.resize", objectIds: ["image-4"], dx: 0, dy: 0, dw: 20, dh: 10 }); + editor.dispatch({ type: "object.duplicate", objectIds: editor.snapshot.selection.keys }); + expect(value().objects.at(-1)!.source).toBe(png); + editor.dispatch({ type: "selection.remove" }); expect(value().objects).toHaveLength(2); +}); + +test.each([ + { files: [{ ...file, type: "image/svg+xml" }], code: "file-intake.media-type" }, + { files: [{ ...file, size: 11 * 1024 * 1024 }], code: "file-intake.size" }, + { files: Array.from({ length: 5 }, () => file), code: "file-intake.limit" }, +])("$code rejects before reading any bytes", async ({ files, code }) => { + const { binding, readRaster, value } = setup(); + expect(await binding.paste(event(files))).toMatchObject({ ok: false, code }); + expect(readRaster).not.toHaveBeenCalled(); expect(value()).toEqual(blank); +}); + +test.each(["raster.decode-failed", "raster.pixel-limit"])("%s rejects the entire image batch without IDs/history or partial insertion", async (code) => { + const readRaster = vi.fn<() => Promise>() + .mockResolvedValueOnce({ ok: true, dataURL: png, width: 100, height: 100 }) + .mockResolvedValueOnce(code === "raster.decode-failed" ? { ok: false, code } : { ok: true, dataURL: png, width: 5000, height: 5000 }); + const { binding, value, editor, onResult } = setup(readRaster); + expect(await binding.paste(event([file, file]))).toMatchObject({ ok: false, code }); + expect(value()).toEqual(blank); expect(editor.snapshot.canUndo).toBe(false); expect(onResult).toHaveBeenCalledOnce(); + await binding.paste(event([], { "text/plain": "After failure" })); expect(value().objects[0]!.id).toBe("image-1"); +}); + +test("plain text is literal and synchronous, repeats cascade; malformed structured payload never degrades to text", async () => { + const { binding, value, readRaster } = setup(); + const text = event([], { "text/plain": "안녕\nCanvas" }); + const first = binding.paste(text); expect(value().objects).toHaveLength(1); await first; + await binding.paste(text); expect(value().objects.map((object) => object.x)).toEqual([24, 48]); + expect(value().objects[0]).toMatchObject({ kind: "text", label: "안녕\nCanvas" }); + expect(await binding.paste(event([file], { [objectClipboardCodec.mimeType]: "{", "text/plain": "Fallback" }))).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(value().objects).toHaveLength(2); expect(readRaster).not.toHaveBeenCalled(); +}); + +test("cancellation aborts platform preparation; later resolution cannot commit or notify", async () => { + let resolve!: (result: WebRasterSourceResult) => void; + const readRaster = vi.fn(() => new Promise((done) => { resolve = done; })); + const { binding, value, onResult } = setup(readRaster); + const pending = binding.paste(event([file])); + binding.cancel(); expect(await pending).toMatchObject({ ok: false, code: "clipboard.cancelled" }); + expect(readRaster.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + resolve({ ok: true, dataURL: png, width: 100, height: 100 }); await Promise.resolve(); + expect(value()).toEqual(blank); expect(onResult).not.toHaveBeenCalled(); +}); + +test("HTML mixed paste preserves order and content in one commit, copy, JSON round-trip and Undo", async () => { + const { binding, editor, value, commits } = setup(); + const input = event([], { "text/html": `

BeforeFigureAfter

`, "text/plain": "Must not also paste" }); + expect((await binding.paste(input)).ok).toBe(true); + expect(input.preventDefault).toHaveBeenCalledOnce(); expect(commits).toHaveBeenCalledOnce(); + expect(value().objects.map((object) => [object.kind, object.label])).toEqual([["text", "Before"], ["image", "Figure"], ["text", "After"]]); + expect(value().objects[0]!.y + value().objects[0]!.height).toBeLessThan(value().objects[1]!.y); + expect(value().objects[1]!.y + value().objects[1]!.height).toBeLessThan(value().objects[2]!.y); + expect(value().objects.at(-1)!.y + value().objects.at(-1)!.height).toBeLessThanOrEqual(565); + expect(value().objects[1]!.source).toBe(png); + expect(parseCanvasDocument(serializeCanvasDocument(value()))).toEqual(value()); + const copied = event(); binding.copy(copied); + editor.undo(); expect(value()).toEqual(blank); + await binding.paste(copied); + expect(value().objects.map((object) => object.id)).toEqual(["image-4", "image-5", "image-6"]); + expect(value().objects[1]!.source).toBe(png); +}); + +test("unavailable HTML images reject the whole paste without text fallback or consumed IDs", async () => { + const { binding, value, editor, readRaster } = setup(); + expect(await binding.paste(event([], { "text/html": '

BeforeAfter

', "text/plain": "Fallback" }))).toMatchObject({ ok: false, code: "raster.source-unsupported" }); + expect(value()).toEqual(blank); expect(editor.snapshot.canUndo).toBe(false); expect(readRaster).not.toHaveBeenCalled(); + await binding.paste(event([], { "text/plain": "After failure" })); expect(value().objects[0]!.id).toBe("image-1"); +}); + +test("HTML and subsequent text requests share ordered adoption", async () => { + let resolve!: (value: WebRasterSourceResult) => void; + const { binding, value } = setup(() => new Promise((done) => { resolve = done; })); + const first = binding.paste(event([], { "text/html": `

BeforeFigureAfter

` })); + const next = binding.paste(event([], { "text/plain": "Next paste" })); + expect(value()).toEqual(blank); + resolve({ ok: true, dataURL: png, width: 100, height: 50 }); await Promise.all([first, next]); + expect(value().objects.map((object) => object.label)).toEqual(["Before", "Figure", "After", "Next paste"]); +}); + +test("cancelled HTML preparation cannot revive a late image or its surrounding text", async () => { + let resolve!: (value: WebRasterSourceResult) => void; + const readRaster = vi.fn(() => new Promise((done) => { resolve = done; })); + const { binding, value } = setup(readRaster); + const pending = binding.paste(event([], { "text/html": `

BeforeAfter

` })); + binding.cancel(); expect(await pending).toMatchObject({ code: "clipboard.cancelled" }); + expect(readRaster.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + resolve({ ok: true, dataURL: png, width: 100, height: 50 }); await Promise.resolve(); + expect(value()).toEqual(blank); +}); diff --git a/packages/json-document-canvas/tests/canvas-hand.test.tsx b/packages/json-document-canvas/tests/canvas-hand.test.tsx new file mode 100644 index 000000000..649a5fcc0 --- /dev/null +++ b/packages/json-document-canvas/tests/canvas-hand.test.tsx @@ -0,0 +1,774 @@ +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeAll, expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createObjectEditor } from "@interactive-os/json-document-editing"; +import type { CanvasDocument } from "@interactive-os/json-document-object-document"; +import { CanvasHand } from "../src/index.js"; +import { createPlaneSelectProfile } from "@interactive-os/json-document-affordance"; +import * as web from "@interactive-os/json-document-web"; + +const blank: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [] }; +const style = { color: "#abcdef", textColor: "#123456", fontSize: 32, strokeWidth: 3 }; +beforeAll(() => { + class Pointer extends MouseEvent { readonly pointerId: number; constructor(type: string, init: PointerEventInit = {}) { super(type, init); this.pointerId = init.pointerId ?? 1; } } + vi.stubGlobal("PointerEvent", Pointer); + const captures = new WeakMap(); + Element.prototype.setPointerCapture = function (id) { captures.set(this, id); }; + Element.prototype.hasPointerCapture = function (id) { return captures.get(this) === id; }; + Element.prototype.releasePointerCapture = function () { captures.delete(this); }; +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); + +function setup(document = blank, selectProfile = createPlaneSelectProfile()) { + const source = createJSONDocument(document); + const commits = vi.fn(); source.subscribe(commits); + let id = 0; + const editor = createObjectEditor(source, { createId: () => `object-${++id}` }); + const view = render(); + const svg = screen.getByRole("group", { name: "Canvas slide" }); + Object.defineProperty(svg, "viewBox", { value: { baseVal: { x: 0, y: 0, width: 1280, height: 720 } } }); + svg.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 640, bottom: 360, width: 640, height: 360, toJSON() {} }); + const value = () => editor.snapshot.value as CanvasDocument; + return { ...view, svg, editor, source, commits, value }; +} +function event(x: number, y: number, pointerId = 1) { return { clientX: x, clientY: y, pointerId, button: 0, bubbles: true }; } +function rectangle(svg: Element) { + fireEvent.click(screen.getByRole("button", { name: "사각형" })); + fireEvent.pointerDown(svg, event(40, 50)); fireEvent.pointerMove(svg, event(140, 100)); fireEvent.pointerUp(svg, event(140, 100)); +} + +const populated: CanvasDocument = { ...blank, objects: [ + { id: "a", kind: "text", label: "Title", fontSize: 24, color: "black", x: 100, y: 100, width: 100, height: 100 }, + { id: "b", kind: "rectangle", label: "Box", color: "blue", x: 300, y: 100, width: 100, height: 100 }, + { id: "c", kind: "ellipse", label: "Circle", color: "green", x: 600, y: 100, width: 100, height: 100 }, +] }; +function pick(container: HTMLElement, key: string, shiftKey = false) { + const target = container.querySelector(`[data-canvas-object="${key}"]`)!; + fireEvent.pointerDown(target, { ...event(70, 70), shiftKey }); + fireEvent.pointerUp(window, { ...event(70, 70), shiftKey }); +} + +function clipboardData() { + const data = new Map(); + return { get types() { return [...data.keys()]; }, getData: (format: string) => data.get(format) ?? "", setData: (format: string, value: string) => { data.set(format, value); } }; +} +function clipboardEvent(target: Element, operation: "copy" | "cut" | "paste", data: ReturnType | null) { + const event = new Event(operation, { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { value: data }); + fireEvent(target, event); + return event; +} + +test("Alt+Shift drag previews originals and copies, switches live modifiers, then commits the set once", () => { + const { container, svg, editor, value, commits } = setup(populated); + pick(container, "b", true); + const before = value(), target = container.querySelector('[data-canvas-object="a"]')!; + fireEvent.pointerDown(target, { ...event(70, 70), altKey: true, shiftKey: true }); + fireEvent.pointerMove(window, { ...event(120, 90), altKey: true, shiftKey: true }); + expect(value()).toBe(before); expect(commits).not.toHaveBeenCalled(); + expect(container.querySelectorAll("[data-canvas-copy-original]")).toHaveLength(2); + expect(container.querySelector('[data-canvas-copy-original="a"] foreignObject')?.getAttribute("x")).toBe("100"); + expect(container.querySelector('[data-canvas-copy-preview] foreignObject')?.getAttribute("x")).toBe("200"); + expect(target.getAttribute("y")).toBe("100"); expect((target as SVGElement).style.cursor).toBe("copy"); + fireEvent.keyUp(svg, { key: "Alt", shiftKey: true }); + expect(container.querySelector("[data-canvas-copy-preview]")).toBeNull(); + fireEvent.keyDown(svg, { key: "Alt", altKey: true, shiftKey: true }); + expect(container.querySelector("[data-canvas-copy-preview]")).not.toBeNull(); + fireEvent.pointerUp(window, { ...event(120, 90), altKey: true, shiftKey: true }); + expect(value().objects.slice(0, 3)).toEqual(populated.objects); + expect(value().objects.slice(3).map((object) => [object.id, object.x, object.y])).toEqual([["object-1", 200, 100], ["object-2", 400, 100]]); + expect(editor.snapshot.selection).toMatchObject({ keys: ["object-1", "object-2"], primaryKey: "object-1" }); + expect(commits).toHaveBeenCalledOnce(); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(value()).toEqual(before); + expect(editor.snapshot.selection).toMatchObject({ keys: ["a", "b"], primaryKey: "a" }); +}); + +test("external text paste is literal, repeated placement is visible, and editing retains the native field boundary", () => { + const { svg, container, value } = setup(); + const data = clipboardData(); data.setData("text/plain", "한글\nSecond line"); + expect(clipboardEvent(svg, "paste", data).defaultPrevented).toBe(true); + clipboardEvent(svg, "paste", data); + expect(value().objects.map((object) => [object.kind, object.x, object.y])).toEqual([["text", 24, 24], ["text", 48, 48]]); + expect(container.querySelector("foreignObject b")).toBeNull(); + const text = container.querySelector('[data-canvas-object="object-2"]')!; + fireEvent.doubleClick(text); + const input = screen.getByRole("textbox", { name: "Canvas text" }); + expect(clipboardEvent(input, "paste", data).defaultPrevented).toBe(false); + expect(value().objects).toHaveLength(2); +}); + +test("image paste reaches the SVG renderer and existing duplication, Undo, and JSON reopening", async () => { + const png = "data:image/png;base64,AQID"; + vi.spyOn(web, "readWebRasterFile").mockResolvedValue({ ok: true, dataURL: png, width: 1600, height: 800 }); + const { svg, container, value } = setup(); + const data = { ...clipboardData(), files: [{ name: "picture.png", type: "image/png", size: 3 }] }; + await act(async () => { clipboardEvent(svg, "paste", data); }); + expect(container.querySelector("image")?.getAttribute("href")).toBe(png); + expect(value().objects[0]).toMatchObject({ kind: "image", width: 960, height: 480 }); + fireEvent.keyDown(svg, { key: "d", metaKey: true }); expect(container.querySelectorAll("image")).toHaveLength(2); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(container.querySelectorAll("image")).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "JSON" })); + const json = screen.getByRole("textbox", { name: "Canvas JSON document" }) as HTMLTextAreaElement; + expect(JSON.parse(json.value).objects[0].source).toBe(png); + fireEvent.click(screen.getByRole("button", { name: "JSON 열기" })); + expect(container.querySelector("image")?.getAttribute("href")).toBe(png); +}); + +test.each(["Escape", "tool", "selection", "unmount"])("pending image paste is cancelled by %s and cannot arrive after the action", async (reason) => { + let resolve!: (result: web.WebRasterSourceResult) => void; + const read = vi.spyOn(web, "readWebRasterFile").mockImplementation(() => new Promise((done) => { resolve = done; })); + const { svg, editor, unmount, value, commits } = setup(populated); + const data = { ...clipboardData(), files: [{ name: "picture.png", type: "image/png", size: 3 }] }; + clipboardEvent(svg, "paste", data); + expect(svg.getAttribute("aria-busy")).toBe("true"); expect(screen.getByRole("status").textContent).toContain("Escape"); + if (reason === "Escape") fireEvent.keyDown(svg, { key: "Escape" }); + else if (reason === "tool") fireEvent.click(screen.getByRole("button", { name: "사각형" })); + else if (reason === "selection") act(() => { editor.dispatch({ type: "selection.set", objectIds: ["b"] }); }); + else unmount(); + expect(read.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + await act(async () => { resolve({ ok: true, dataURL: "data:image/png;base64,AQID", width: 100, height: 100 }); }); + expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); + if (reason === "Escape") expect(editor.snapshot.selection.keys).toEqual(["a"]); + if (reason !== "unmount") expect(screen.queryByRole("status")).toBeNull(); +}); + +test.each(["Escape", "pointercancel"])("cancelled Alt duplication (%s) does not allocate IDs or commit", (reason) => { + const { container, svg, editor, value, commits } = setup(populated); + const target = container.querySelector('[data-canvas-object="a"]')!; + fireEvent.pointerDown(target, { ...event(70, 70), altKey: true }); + fireEvent.pointerMove(window, { ...event(120, 90), altKey: true }); + if (reason === "Escape") fireEvent.keyDown(svg, { key: "Escape" }); else fireEvent.pointerCancel(window, event(120, 90)); + fireEvent.pointerUp(window, { ...event(120, 90), altKey: true }); + expect(container.querySelector("[data-canvas-copy-preview]")).toBeNull(); + expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); + fireEvent.keyDown(svg, { key: "d", ctrlKey: true }); + expect(editor.snapshot.selection.keys).toEqual(["object-1"]); +}); + +test("duplicate keyboard/toolbar and nudge reuse Editing with selection-preserving Undo", () => { + const { container, svg, editor, value } = setup(populated); + pick(container, "b", true); + fireEvent.keyDown(svg, { key: "d", metaKey: true }); + expect(editor.snapshot.selection.keys).toEqual(["object-1", "object-2"]); + expect(value().objects[3]!.x).toBe(124); + fireEvent.click(screen.getByRole("button", { name: "복제" })); + expect(editor.snapshot.selection.keys).toEqual(["object-3", "object-4"]); + expect(value().objects[5]!.x).toBe(148); + fireEvent.keyDown(svg, { key: "ArrowRight" }); fireEvent.keyDown(svg, { key: "ArrowUp", shiftKey: true }); + expect(value().objects[5]).toMatchObject({ x: 149, y: 138 }); + expect(value().objects[6]).toMatchObject({ x: 349, y: 138 }); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); + expect(value().objects[5]).toMatchObject({ x: 149, y: 148 }); + expect(editor.snapshot.selection.keys).toEqual(["object-3", "object-4"]); +}); + +test("native structured copy/cut/paste crosses Hand instances with text and primary, and one Undo per edit", () => { + const first = setup(populated); + act(() => { first.editor.dispatch({ type: "selection.set", objectIds: ["a", "c"], primaryKey: "a" }); }); + const data = clipboardData(); + expect(clipboardEvent(first.svg, "copy", data).defaultPrevented).toBe(true); + expect(data.getData("text/plain")).toBe("Title\nCircle"); expect(first.commits).not.toHaveBeenCalled(); + expect(clipboardEvent(first.svg, "cut", data).defaultPrevented).toBe(true); + expect(first.value().objects.map((object) => object.id)).toEqual(["b"]); expect(first.commits).toHaveBeenCalledOnce(); + act(() => { first.editor.undo(); }); expect(first.value()).toEqual(populated); + first.unmount(); + const second = setup(); + expect(clipboardEvent(second.svg, "paste", data).defaultPrevented).toBe(true); + expect(second.value().objects.map((object) => [object.id, object.kind, object.x])).toEqual([["object-1", "text", 124], ["object-2", "ellipse", 624]]); + expect(second.editor.snapshot.selection).toMatchObject({ keys: ["object-1", "object-2"], primaryKey: "object-1" }); + expect(second.commits).toHaveBeenCalledOnce(); + act(() => { second.editor.undo(); }); expect(second.value()).toEqual(blank); +}); + +test("failed cut and invalid paste are observable and cannot delete or partially insert objects", () => { + const { svg, value, commits } = setup(populated); + const refused = { ...clipboardData(), setData() { throw new Error("write refused"); } }; + expect(clipboardEvent(svg, "cut", refused).defaultPrevented).toBe(true); + expect(screen.getByRole("alert").textContent).toBe("write refused"); + const data = clipboardData(); data.setData("application/vnd.interactive-os.objects+json", "{}"); + clipboardEvent(svg, "paste", data); + expect(screen.getByRole("alert").textContent).toBe("clipboard.invalid"); + expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); +}); + +test("native clipboard chords, text input, JSON input and IME are not stolen by object editing", () => { + const { svg, value, commits } = setup(populated); + for (const key of ["c", "x", "v"]) expect(fireEvent.keyDown(svg, { key, metaKey: true })).toBe(true); + fireEvent.keyDown(svg, { key: "d", ctrlKey: true, isComposing: true }); + fireEvent.keyDown(svg, { key: "F2" }); + const text = screen.getByRole("textbox", { name: "Canvas text" }); + for (const operation of ["copy", "cut", "paste"] as const) expect(clipboardEvent(text, operation, clipboardData()).defaultPrevented).toBe(false); + fireEvent.keyDown(text, { key: "d", metaKey: true }); fireEvent.keyDown(text, { key: "ArrowRight" }); + fireEvent.keyDown(text, { key: "Escape" }); + fireEvent.click(screen.getByRole("button", { name: "JSON" })); + const json = screen.getByRole("textbox", { name: "Canvas JSON document" }); + expect(clipboardEvent(json, "cut", clipboardData()).defaultPrevented).toBe(false); + expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); +}); + +test("Canvas consumes an injected profile for click/Shift/keyboard while focus remains independent", () => { + const profile = createPlaneSelectProfile(); + const begin = vi.spyOn(profile, "begin"), keyDown = vi.spyOn(profile, "keyDown"); + const { container, svg, editor, value, commits } = setup(populated, profile); + pick(container, "c", true); expect(editor.snapshot.selection.keys).toEqual(["a", "c"]); + pick(container, "a", true); expect(editor.snapshot.selection.keys).toEqual(["c"]); + pick(container, "b"); expect(editor.snapshot.selection.keys).toEqual(["b"]); + expect(begin).toHaveBeenCalledTimes(3); + const a = container.querySelector('[data-canvas-object="a"]')!; + fireEvent.focus(a); expect(editor.snapshot.selection.keys).toEqual(["b"]); + fireEvent.keyDown(a, { key: " ", shiftKey: true }); expect(editor.snapshot.selection.keys).toEqual(["a", "b"]); + expect(editor.snapshot.selection.primaryKey).toBe("a"); + for (let i = 0; i < 2; i++) fireEvent.keyDown(svg, { key: "a", metaKey: true }); + expect(editor.snapshot.selection.keys).toEqual(["a", "b", "c"]); expect(editor.snapshot.selection.primaryKey).toBe("a"); + expect(keyDown).toHaveBeenCalled(); + expect(container.querySelectorAll('[aria-pressed="true"][data-canvas-object]')).toHaveLength(3); + fireEvent.pointerDown(svg, event(500, 300)); fireEvent.pointerUp(svg, event(500, 300)); + expect(editor.snapshot.selection.keys).toEqual([]); expect(value()).toEqual(populated); + expect(commits).not.toHaveBeenCalled(); expect(editor.snapshot.canUndo).toBe(false); +}); + +test("Enter activates the focused object instead of editing an unrelated primary", () => { + const { container, editor } = setup(populated); + const box = container.querySelector('[data-canvas-object="b"]')!; + fireEvent.focus(box); fireEvent.keyDown(box, { key: "Enter" }); + expect(editor.snapshot.selection.keys).toEqual(["b"]); + expect((screen.getByRole("textbox", { name: "Canvas text" }) as HTMLTextAreaElement).value).toBe("Box"); + const text = container.querySelector('[data-canvas-object="a"]')!; + fireEvent.focus(text); fireEvent.keyDown(text, { key: "Enter" }); + expect(editor.snapshot.selection.keys).toEqual(["a"]); + expect(screen.getByRole("textbox", { name: "Canvas text" })).toBeTruthy(); +}); + +test("marquee previews replace and Shift-add from the base without committing the document", () => { + const { container, svg, editor, value, commits } = setup(populated); + pick(container, "c"); + fireEvent.pointerDown(svg, event(20, 20)); fireEvent.pointerMove(svg, event(210, 110)); + expect(editor.snapshot.selection.keys).toEqual(["c"]); + expect(container.querySelectorAll("[data-selection-outline]")).toHaveLength(2); + expect(container.querySelector("[data-canvas-marquee]")).not.toBeNull(); + fireEvent.pointerUp(svg, event(210, 110)); expect(editor.snapshot.selection.keys).toEqual(["a", "b"]); + expect(container.querySelector("[data-canvas-marquee]")).toBeNull(); + fireEvent.pointerDown(svg, { ...event(280, 20), shiftKey: true }); + fireEvent.pointerMove(svg, event(360, 110)); fireEvent.pointerUp(svg, event(360, 110)); + expect(editor.snapshot.selection.keys).toEqual(["a", "b", "c"]); + expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); +}); + +test("dragging a selected hit moves the set once; one Undo restores the set and Delete removes it atomically", () => { + const { container, svg, editor, value, commits } = setup(populated); + pick(container, "b", true); + const before = value(), target = container.querySelector('[data-canvas-object="a"]')!; + fireEvent.pointerDown(target, event(70, 70)); fireEvent.pointerMove(window, event(100, 100)); + expect(value()).toBe(before); expect(commits).not.toHaveBeenCalled(); + expect(container.querySelector('[data-canvas-object="b"]')?.getAttribute("x")).toBe("360"); + fireEvent.pointerUp(window, event(100, 100)); + expect(value().objects.map((object) => object.x)).toEqual([160, 360, 600]); + expect(editor.snapshot.selection).toMatchObject({ keys: ["a", "b"], primaryKey: "a" }); + expect(commits).toHaveBeenCalledTimes(1); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(value()).toEqual(before); + expect(editor.snapshot.canUndo).toBe(false); expect(editor.snapshot.selection.keys).toEqual(["a", "b"]); + fireEvent.keyDown(svg, { key: "z", metaKey: true, shiftKey: true }); + const moved = value(); commits.mockClear(); + fireEvent.keyDown(svg, { key: "Delete" }); expect(value().objects.map((object) => object.id)).toEqual(["c"]); + expect(commits).toHaveBeenCalledTimes(1); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(value()).toEqual(moved); + expect(editor.snapshot.selection).toMatchObject({ keys: ["a", "b"], primaryKey: "a" }); +}); + +test.each(["Escape", "pointercancel", "lostpointercapture"])("%s cancels marquee before clearing the base selection", (reason) => { + const { container, svg, editor, value, commits } = setup(populated); + fireEvent.pointerDown(svg, event(20, 20)); fireEvent.pointerMove(svg, event(220, 110)); + if (reason === "Escape") fireEvent.keyDown(svg, { key: reason }); + else if (reason === "pointercancel") fireEvent.pointerCancel(svg, event(220, 110)); + else fireEvent.lostPointerCapture(svg, event(220, 110)); + fireEvent.pointerUp(svg, event(220, 110)); + expect(container.querySelector("[data-canvas-marquee]")).toBeNull(); + expect(editor.snapshot.selection.keys).toEqual(["a"]); expect(value()).toEqual(populated); + expect(commits).not.toHaveBeenCalled(); expect(editor.snapshot.canUndo).toBe(false); + fireEvent.keyDown(svg, { key: "Escape" }); expect(editor.snapshot.selection.keys).toEqual([]); +}); + +test("only primary resizes and edits text while retaining the selected set", () => { + const { container, svg, editor, value, commits } = setup(populated); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["a", "b"], primaryKey: "a" }); }); + expect(container.querySelectorAll("[data-resize-edge]")).toHaveLength(8); + const handle = container.querySelector('[data-resize-edge="se"]')!; + fireEvent.pointerDown(handle, event(100, 100)); fireEvent.pointerMove(window, event(120, 115)); fireEvent.pointerUp(window, event(120, 115)); + expect(value().objects[0]!.width).toBeCloseTo(140); expect(value().objects[0]!.height).toBeCloseTo(130); + expect(value().objects[1]).toEqual(populated.objects[1]); + expect(editor.snapshot.selection).toMatchObject({ keys: ["a", "b"], primaryKey: "a" }); + expect(commits).toHaveBeenCalledTimes(1); + fireEvent.keyDown(svg, { key: "F2" }); + const input = screen.getByRole("textbox", { name: "Canvas text" }); + fireEvent.keyDown(input, { key: "a", metaKey: true }); expect(editor.snapshot.selection.keys).toEqual(["a", "b"]); + fireEvent.change(input, { target: { value: "Primary only" } }); + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + expect(value().objects[0]!.label).toBe("Primary only"); expect(value().objects[1]).toEqual(populated.objects[1]); + expect(editor.snapshot.selection).toMatchObject({ keys: ["a", "b"], primaryKey: "a" }); + expect(commits).toHaveBeenCalledTimes(2); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(value().objects[0]!.label).toBe("Title"); +}); + +test("external document replacement cancels marquee and stale release cannot select removed targets", () => { + const { container, svg, source, editor, value } = setup(populated); + fireEvent.pointerDown(svg, event(20, 20)); fireEvent.pointerMove(svg, event(220, 110)); + act(() => { source.commit([{ op: "remove", path: "/objects/1" }]); }); + fireEvent.pointerUp(svg, event(220, 110)); + expect(container.querySelector("[data-canvas-marquee]")).toBeNull(); + expect(editor.snapshot.selection.keys).toEqual(["a"]); + expect(value().objects.map((object) => object.id)).toEqual(["a", "c"]); +}); + +test("external selection changes supersede a pending preview without a stale selection commit", () => { + const { container, svg, editor, commits } = setup(populated); + fireEvent.pointerDown(svg, event(20, 20)); fireEvent.pointerMove(svg, event(220, 110)); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["c"] }); }); + fireEvent.pointerUp(svg, event(220, 110)); + expect(container.querySelector("[data-canvas-marquee]")).toBeNull(); + expect(editor.snapshot.selection.keys).toEqual(["c"]); expect(commits).not.toHaveBeenCalled(); +}); + +test("every toolbar control shares icon, accessible name and canonical tooltip without losing state", () => { + const { svg } = setup(); + const toolbar = within(screen.getByRole("toolbar", { name: "Canvas tools" })); + const labels = ["선택", "글자", "스티커 노트", "사각형", "타원", "그리기", "실행 취소", "다시 실행", "복제", "삭제", "JSON"]; + expect(toolbar.getAllByRole("button")).toHaveLength(labels.length); + for (const label of labels) { + const button = toolbar.getByRole("button", { name: label }); + expect(button.textContent).toBe(""); + expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(button.getAttribute("data-ui-presentation")).toBe("icon"); + expect(button.getAttribute("aria-describedby")).toBe(toolbar.getByRole("tooltip", { name: label }).id); + expect(button.hasAttribute("title")).toBe(false); + } + expect(toolbar.getByRole("button", { name: "선택" }).getAttribute("aria-pressed")).toBe("true"); + expect((toolbar.getByRole("button", { name: "삭제" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(toolbar.getByRole("button", { name: "그리기" })); + expect(svg.getAttribute("data-tool")).toBe("path"); + expect(toolbar.getByRole("button", { name: "그리기" }).getAttribute("aria-pressed")).toBe("true"); + expect(toolbar.getByRole("button", { name: "선택" }).getAttribute("aria-pressed")).toBe("false"); +}); + +const creationTools = [ + { tool: "사각형", kind: "rectangle", width: 160, height: 100 }, + { tool: "타원", kind: "ellipse", width: 160, height: 100 }, + { tool: "글자", kind: "text", width: 280, height: 64 }, + { tool: "스티커 노트", kind: "sticky-note", width: 200, height: 200 }, +] as const; + +function creationPreview(container: HTMLElement) { + const view = container.querySelector("[data-canvas-preview]")?.firstElementChild; + if (!view) return null; + return { + width: Number(view.getAttribute(view.tagName === "ellipse" ? "rx" : "width")) * (view.tagName === "ellipse" ? 2 : 1), + height: Number(view.getAttribute(view.tagName === "ellipse" ? "ry" : "height")) * (view.tagName === "ellipse" ? 2 : 1), + }; +} + +test.each(creationTools)("$kind press stays empty and a drag previews only its current bounds", ({ tool, kind }) => { + const { svg, container, value, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: tool })); + fireEvent.pointerDown(svg, event(40, 50)); + expect(creationPreview(container)).toBeNull(); + fireEvent.pointerMove(svg, event(40.5, 50.5)); + expect(creationPreview(container)).toBeNull(); + fireEvent.pointerMove(svg, event(42, 51.5)); + expect(creationPreview(container)).toEqual({ width: 4, height: 3 }); + fireEvent.pointerMove(svg, event(44, 53)); + expect(creationPreview(container)).toEqual({ width: 8, height: 6 }); + expect(value()).toEqual(blank); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(svg, event(140, 100)); + expect(creationPreview(container)).toBeNull(); + expect(value().objects[0]).toMatchObject({ kind, x: 80, y: 100, width: 200, height: 100 }); + expect(commits).toHaveBeenCalledOnce(); +}); + +test.each(creationTools)("$kind click creates its default size only on release at the press anchor", ({ tool, kind, width, height }) => { + const { svg, container, value, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: tool })); + fireEvent.pointerDown(svg, event(40, 50)); + fireEvent.pointerMove(svg, event(39.5, 49.5)); + expect(creationPreview(container)).toBeNull(); expect(value()).toEqual(blank); + fireEvent.pointerUp(svg, event(39.5, 49.5)); + expect(value().objects[0]).toMatchObject({ kind, x: 80, y: 100, width, height }); + expect(commits).toHaveBeenCalledOnce(); + expect(screen.queryByRole("textbox", { name: "Canvas text" }) !== null).toBe(kind === "text" || kind === "sticky-note"); +}); + +test.each(creationTools)("$kind stays a drag near the origin and an exact return creates nothing", ({ tool, width, height }) => { + const { svg, container, value, editor, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: tool })); + fireEvent.pointerDown(svg, event(40, 50)); fireEvent.pointerMove(svg, event(50, 60)); + fireEvent.pointerMove(svg, event(40.5, 50.5)); + expect(creationPreview(container)).toEqual({ width: 1, height: 1 }); + fireEvent.pointerMove(svg, event(40, 50)); + expect(creationPreview(container)).toBeNull(); + fireEvent.pointerUp(svg, event(40, 50)); + expect(value()).toEqual(blank); expect(editor.snapshot.canUndo).toBe(false); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerDown(svg, event(80, 60)); fireEvent.pointerUp(svg, event(80, 60)); + expect(value().objects[0]).toMatchObject({ id: "object-1", width, height }); +}); + +test("a foreign pointer cannot change a pending click into a drag", () => { + const { svg, container, value } = setup(); + fireEvent.click(screen.getByRole("button", { name: "사각형" })); + fireEvent.pointerDown(svg, event(40, 50)); + fireEvent.pointerMove(svg, event(140, 100, 9)); fireEvent.pointerUp(svg, event(140, 100, 9)); + expect(creationPreview(container)).toBeNull(); expect(value()).toEqual(blank); + fireEvent.pointerUp(svg, event(40, 50)); + expect(value().objects[0]).toMatchObject({ x: 80, y: 100, width: 160, height: 100 }); +}); + +test("release alone can establish a reverse drag using its final coordinates", () => { + const { svg, value, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: "사각형" })); + fireEvent.pointerDown(svg, event(140, 100)); fireEvent.pointerUp(svg, event(40, 50)); + expect(value().objects[0]).toMatchObject({ x: 80, y: 100, width: 200, height: 100 }); + expect(commits).toHaveBeenCalledOnce(); +}); + +test("creation is transient until release, scales coordinates and selects the result with one undo step", () => { + const { svg, value, editor, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: "사각형" })); + fireEvent.pointerDown(svg, event(40, 50)); + fireEvent.pointerMove(svg, event(140, 100)); + expect(value()).toEqual(blank); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(svg, event(140, 100)); + expect(value().objects[0]).toMatchObject({ kind: "rectangle", x: 80, y: 100, width: 200, height: 100 }); + expect(editor.snapshot.selection.keys).toEqual(["object-1"]); + expect(svg.getAttribute("data-tool")).toBe("select"); + expect(commits).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "실행 취소" })); expect(value()).toEqual(blank); + fireEvent.click(screen.getByRole("button", { name: "다시 실행" })); expect(value().objects).toHaveLength(1); +}); + +test.each(["escape", "pointercancel", "lostpointercapture"].flatMap((reason) => [false, true].map((drag) => ({ reason, drag }))))("$reason cancels creation (drag=$drag) without a commit or history", ({ reason, drag }) => { + const { svg, container, value, editor } = setup(); + fireEvent.click(screen.getByRole("button", { name: "타원" })); + fireEvent.pointerDown(svg, event(40, 50)); + if (drag) fireEvent.pointerMove(svg, event(140, 100)); + if (reason === "escape") fireEvent.keyDown(svg, { key: "Escape" }); + else if (reason === "pointercancel") fireEvent.pointerCancel(svg, event(140, 100)); + else fireEvent.lostPointerCapture(svg, event(140, 100)); + fireEvent.pointerUp(svg, event(140, 100)); + expect(creationPreview(container)).toBeNull(); + expect(value()).toEqual(blank); expect(editor.snapshot.canUndo).toBe(false); +}); + +test("move and resize share preview geometry, cancel safely and commit exactly once", () => { + const { svg, value, commits, container } = setup(); rectangle(svg); + const target = container.querySelector("[data-canvas-object]")!; + const before = value(); commits.mockClear(); + fireEvent.pointerDown(target, event(70, 70)); fireEvent.pointerMove(window, event(100, 100)); + expect(value()).toBe(before); + fireEvent.keyDown(svg, { key: "Escape" }); fireEvent.pointerUp(window, event(100, 100)); + expect(value()).toBe(before); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerDown(target, event(70, 70)); fireEvent.pointerMove(window, event(100, 100)); fireEvent.pointerUp(window, event(100, 100)); + expect(value().objects[0]).toMatchObject({ x: 140, y: 160 }); expect(commits).toHaveBeenCalledTimes(1); + const handle = container.querySelector('[data-resize-edge="se"]')!; + fireEvent.pointerDown(handle, event(170, 130)); fireEvent.pointerMove(window, event(200, 160)); + expect(value().objects[0]).toMatchObject({ width: 200, height: 100 }); + fireEvent.pointerUp(window, event(200, 160)); + expect(value().objects[0]).toMatchObject({ width: 260, height: 160 }); expect(commits).toHaveBeenCalledTimes(2); +}); + +test.each([ + ["n", 80, 80, 200, 120], ["e", 80, 100, 240, 100], + ["s", 80, 100, 200, 80], ["w", 120, 100, 160, 100], +] as const)("%s offers an invisible full-edge target and an anchored single-axis resize", (edge, x, y, width, height) => { + const { svg, container, value, commits } = setup(); rectangle(svg); commits.mockClear(); + const handle = container.querySelector(`[data-resize-edge="${edge}"]`)!; + expect(handle).not.toBeNull(); expect(handle.getAttribute("fill")).toBe("transparent"); + expect(handle.getAttribute("stroke")).toBeNull(); expect((handle as SVGElement).style.cursor).toBe(`${edge}-resize`); + expect(Number(handle.getAttribute(edge === "n" || edge === "s" ? "width" : "height"))).toBe(edge === "n" || edge === "s" ? 200 : 100); + expect([...container.querySelectorAll("[data-resize-edge]")].slice(-4).map((node) => node.getAttribute("data-resize-edge"))).toEqual(["nw", "ne", "se", "sw"]); + const before = value(); + fireEvent.pointerDown(handle, event(80, 80)); fireEvent.pointerMove(window, event(100, 70)); + const preview = container.querySelector("[data-canvas-object]")!; + expect(["x", "y", "width", "height"].map((name) => Number(preview.getAttribute(name)))).toEqual([x, y, width, height]); + expect(value()).toBe(before); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(window, event(100, 70)); expect(value().objects[0]).toMatchObject({ x, y, width, height }); + expect(commits).toHaveBeenCalledOnce(); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(value()).toEqual(before); + fireEvent.keyDown(svg, { key: "z", metaKey: true, shiftKey: true }); expect(value().objects[0]).toMatchObject({ x, y, width, height }); +}); + +test("resize switches Shift/Alt live and commits the release coordinates and modifiers once", () => { + const { svg, container, value, commits } = setup(); rectangle(svg); commits.mockClear(); + const handle = container.querySelector('[data-resize-edge="se"]')!; + const target = container.querySelector("[data-canvas-object]")!; + const bounds = () => ["x", "y", "width", "height"].map((key) => Number(target.getAttribute(key))); + fireEvent.pointerDown(handle, event(140, 100)); fireEvent.pointerMove(window, event(160, 105)); + expect(bounds()).toEqual([80, 100, 240, 110]); + fireEvent.keyDown(svg, { key: "Shift", shiftKey: true }); expect(bounds()).toEqual([80, 100, 240, 120]); + fireEvent.keyDown(svg, { key: "Alt", shiftKey: true, altKey: true }); expect(bounds()).toEqual([40, 80, 280, 140]); + fireEvent.keyUp(svg, { key: "Shift", altKey: true }); expect(bounds()).toEqual([40, 90, 280, 120]); + fireEvent.keyUp(svg, { key: "Alt" }); expect(bounds()).toEqual([80, 100, 240, 110]); + expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerUp(window, { ...event(170, 110), shiftKey: true, altKey: true }); + expect(value().objects[0]).toMatchObject({ x: 20, y: 70, width: 320, height: 160 }); + expect(commits).toHaveBeenCalledOnce(); expect(value().objects).toHaveLength(1); +}); + +test.each(["rectangle", "ellipse", "sticky-note", "text", "path", "image"] as const)("%s uses the same west-edge clamp without changing its content", (kind) => { + const object = { id: "a", x: 100, y: 100, width: 200, height: 100, color: "blue", label: "Content", kind, + ...(kind === "text" ? { fontSize: 24 } : kind === "path" ? { points: [{ x: 0, y: 0 }, { x: 1, y: 1 }], strokeWidth: 3 } : kind === "image" ? { source: "data:image/png;base64,AQID" } : {}) } as CanvasDocument["objects"][number]; + const { container, value } = setup({ ...blank, objects: [object] }); + const handle = container.querySelector('[data-resize-edge="w"]')!; + fireEvent.pointerDown(handle, event(50, 70)); fireEvent.pointerMove(window, event(250, 200)); + expect(container.querySelector("[data-canvas-object]")?.getAttribute("x")).toBe("299"); + fireEvent.pointerUp(window, event(250, 200)); + expect(value().objects[0]).toEqual({ ...object, x: 299, width: 1 }); +}); + +test.each(["Escape", "pointercancel", "lostpointercapture", "document", "selection", "unmount"])("resize cancellation (%s) discards preview and ignores stale release", (reason) => { + const { container, svg, editor, source, value, commits, unmount } = setup(populated); + const handle = container.querySelector('[data-resize-edge="e"]')!; + fireEvent.pointerDown(handle, event(100, 75)); fireEvent.pointerMove(window, event(150, 80)); + if (reason === "Escape") fireEvent.keyDown(svg, { key: "Escape" }); + else if (reason === "pointercancel") fireEvent.pointerCancel(window, event(150, 80)); + else if (reason === "lostpointercapture") fireEvent.lostPointerCapture(handle, event(150, 80)); + else if (reason === "document") act(() => { source.commit([{ op: "replace", path: "/objects/0/color", value: "red" }]); }); + else if (reason === "selection") act(() => { editor.dispatch({ type: "selection.set", objectIds: ["b"] }); }); + else unmount(); + commits.mockClear(); fireEvent.pointerUp(window, event(180, 90)); + expect(value().objects[0]!.width).toBe(100); expect(commits).not.toHaveBeenCalled(); + if (reason !== "unmount") expect(container.querySelector('[data-canvas-object="a"]')?.getAttribute("width")).toBe("100"); +}); + +test("resize ignores foreign pointers and a return to the original bounds adds no History", () => { + const { container, editor, value, commits } = setup(populated); + const handle = container.querySelector('[data-resize-edge="n"]')!; + fireEvent.pointerDown(handle, event(75, 50)); + fireEvent.pointerMove(window, event(100, 100, 9)); fireEvent.pointerUp(window, event(100, 100, 9)); + expect(container.querySelector('[data-canvas-object="a"]')?.getAttribute("height")).toBe("100"); + fireEvent.pointerMove(window, event(75, 30)); fireEvent.pointerUp(window, event(75, 50)); + expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); expect(editor.snapshot.canUndo).toBe(false); +}); + +test("an imported sub-unit object does not grow on a stationary resize grab", () => { + const object = { ...populated.objects[1]!, width: 0.5, height: 0.25 }; + const { container, value, commits, editor } = setup({ ...blank, objects: [object] }); + const handle = container.querySelector('[data-resize-edge="nw"]')!; + fireEvent.pointerDown(handle, event(150, 50)); fireEvent.pointerUp(window, event(150, 50)); + expect(value().objects[0]).toEqual(object); expect(editor.snapshot.canUndo).toBe(false); expect(commits).not.toHaveBeenCalled(); + fireEvent.pointerDown(handle, event(150, 50)); fireEvent.pointerUp(window, event(200, 100)); + expect(value().objects[0]).toMatchObject({ x: 299.5, y: 99.25, width: 1, height: 1 }); +}); + +test("text draft supports IME, cancellation and one commit without capturing native editing shortcuts", () => { + const { svg, value, commits, container } = setup(); + fireEvent.click(screen.getByRole("button", { name: "글자" })); + fireEvent.pointerDown(svg, event(40, 50)); fireEvent.pointerUp(svg, event(40, 50)); + const input = screen.getByRole("textbox", { name: "Canvas text" }); + fireEvent.change(input, { target: { value: "안녕하세요\nSlide" } }); + fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, isComposing: true }); + expect(value().objects[0]!.label).toBe("Text"); + fireEvent.keyDown(input, { key: "Enter", ctrlKey: true }); + expect(value().objects[0]!.label).toBe("안녕하세요\nSlide"); expect(commits).toHaveBeenCalledTimes(2); + fireEvent.doubleClick(container.querySelector("[data-canvas-object]")!); + fireEvent.change(screen.getByRole("textbox", { name: "Canvas text" }), { target: { value: "Discard" } }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Canvas text" }), { key: "Escape" }); + expect(value().objects[0]!.label).toBe("안녕하세요\nSlide"); expect(commits).toHaveBeenCalledTimes(2); + fireEvent.keyDown(svg, { key: "z", metaKey: true }); expect(value().objects[0]!.label).toBe("Text"); +}); + +test("drawing samples are transient and a foreign pointer cannot finish the gesture", () => { + const { svg, value, commits } = setup(); + fireEvent.click(screen.getByRole("button", { name: "그리기" })); + fireEvent.pointerDown(svg, event(20, 20)); + fireEvent.pointerMove(svg, event(40, 60)); fireEvent.pointerMove(svg, event(100, 70)); + fireEvent.pointerUp(svg, event(120, 80, 9)); expect(value().objects).toHaveLength(0); + fireEvent.pointerUp(svg, event(120, 80)); + expect(value().objects[0]).toMatchObject({ kind: "path", x: 40, y: 40, width: 200, height: 120 }); expect(commits).toHaveBeenCalledTimes(1); +}); + +test("invalid JSON leaves document/history intact; reopening the saved JSON restores the slide", () => { + const { svg, value, editor } = setup(); rectangle(svg); + fireEvent.click(screen.getByRole("button", { name: "JSON" })); + const field = screen.getByRole("textbox", { name: "Canvas JSON document" }); + const saved = (field as HTMLTextAreaElement).value, before = editor.snapshot; + fireEvent.change(field, { target: { value: '{"profile":"canvas/1","objects":[]}' } }); + fireEvent.click(screen.getByRole("button", { name: "JSON 열기" })); + expect(screen.getByRole("alert")).toBeTruthy(); expect(editor.snapshot).toEqual(before); + fireEvent.click(screen.getByRole("button", { name: "삭제" })); expect(value().objects).toHaveLength(0); + fireEvent.change(field, { target: { value: saved } }); + fireEvent.click(screen.getByRole("button", { name: "JSON 열기" })); + expect(value()).toEqual(JSON.parse(saved)); expect(editor.snapshot.selection.keys).toEqual([]); +}); + +test("external replacement cancels an in-flight transform and unmount releases pointer continuation", () => { + const { svg, container, source, value, unmount, commits } = setup(); rectangle(svg); + fireEvent.pointerDown(container.querySelector("[data-canvas-object]")!, event(70, 70)); + fireEvent.pointerMove(window, event(90, 90)); + act(() => { source.commit([{ op: "replace", path: "/objects/0/x", value: 999 }]); }); + fireEvent.pointerUp(window, event(100, 100)); expect(value().objects[0]!.x).toBe(999); + fireEvent.pointerDown(container.querySelector("[data-canvas-object]")!, event(70, 70)); + commits.mockClear(); unmount(); + const end = new PointerEvent("pointerup", { ...event(100, 100) }); + const observer = vi.fn(); window.addEventListener("pointerup", observer); + window.dispatchEvent(end); window.removeEventListener("pointerup", observer); + expect(observer).toHaveBeenCalled(); expect(commits).not.toHaveBeenCalled(); +}); + +test("selection style uses an icon tooltip and only exposes supported properties", () => { + const { editor } = setup(populated); + const trigger = screen.getByRole("button", { name: "스타일" }); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)?.textContent).toBe("스타일"); + fireEvent.click(trigger); + expect(screen.getByRole("textbox", { name: "글자 크기" })).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: "테두리 색" })).toBeNull(); + act(() => { editor.dispatch({ type: "selection.set", objectIds: [] }); }); + expect(screen.queryByRole("button", { name: "스타일" })).toBeNull(); + expect(screen.queryByRole("dialog", { name: "스타일" })).toBeNull(); +}); + +test("mixed color applies to the whole set once, preserves primary and leaves image data unchanged", () => { + const initial: CanvasDocument = { ...populated, objects: [...populated.objects, { id: "image", kind: "image", source: "data:image/png;base64,AQID", label: "Image", color: "transparent", x: 800, y: 100, width: 100, height: 100 }] }; + const { editor, value, commits } = setup(initial); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["a", "b", "image"], primaryKey: "image" }); }); + const selection = editor.snapshot.selection; + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + expect((screen.getByRole("textbox", { name: "색상" }) as HTMLInputElement).placeholder).toBe("혼합"); + fireEvent.click(screen.getByRole("button", { name: "색상: 빨강" })); + expect(value().objects.slice(0, 2).map((object) => object.color)).toEqual(["#ef4444", "#ef4444"]); + expect(value().objects[3]).toEqual(initial.objects[3]); expect(editor.snapshot.selection).toEqual(selection); + expect(commits).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "색상: 빨강" })); expect(commits).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole("button", { name: "실행 취소" })); expect(value()).toEqual(initial); + expect(editor.snapshot.selection).toEqual(selection); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["image"] }); }); + expect(screen.queryByRole("button", { name: "스타일" })).toBeNull(); +}); + +test("text font size, bold and alignment render identically in display and native editing", () => { + const { container, value, commits } = setup(populated); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + const font = screen.getByRole("textbox", { name: "글자 크기" }); + fireEvent.change(font, { target: { value: "48" } }); + expect(value().objects[0]!.fontSize).toBe(24); expect(commits).not.toHaveBeenCalled(); + fireEvent.submit(font.closest("form")!); + fireEvent.click(screen.getByRole("button", { name: "굵게" })); + fireEvent.click(screen.getByRole("button", { name: "가운데 정렬" })); + expect(value().objects[0]).toMatchObject({ fontSize: 48, fontWeight: 700, textAlign: "center" }); + expect(commits).toHaveBeenCalledTimes(3); + const display = container.querySelector("foreignObject > div") as HTMLElement; + expect(display.style.fontSize).toBe("48px"); expect(display.style.fontWeight).toBe("700"); expect(display.style.textAlign).toBe("center"); + fireEvent.keyDown(screen.getByRole("dialog", { name: "스타일" }), { key: "Escape" }); + fireEvent.doubleClick(container.querySelector('[data-canvas-object="a"]')!); + const input = screen.getByRole("textbox", { name: "Canvas text" }) as HTMLTextAreaElement; + expect(input.style.fontSize).toBe(display.style.fontSize); expect(input.style.fontWeight).toBe("700"); expect(input.style.textAlign).toBe("center"); + fireEvent.change(input, { target: { value: "Updated" } }); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + expect(value().objects[0]!.label).toBe("Updated"); expect(commits).toHaveBeenCalledTimes(4); + fireEvent.click(screen.getByRole("button", { name: "굵게" })); + expect(value().objects[0]).toMatchObject({ label: "Updated", fontWeight: 400 }); +}); + +test("shape outlines and path widths render without replacing geometry or primary selection", () => { + const path = { id: "p", kind: "path", label: "Line", color: "black", strokeWidth: 3, points: [{ x: 0, y: 0 }, { x: 1, y: 1 }], x: 100, y: 400, width: 100, height: 80 } as const; + const { container, editor, value } = setup({ ...populated, objects: [...populated.objects, path] }); + pick(container, "b"); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + fireEvent.click(screen.getByRole("button", { name: "테두리 색: 빨강" })); + expect(value().objects[1]).toMatchObject({ strokeColor: "#ef4444", strokeWidth: 2 }); + expect(container.querySelector('rect[stroke="#ef4444"]')?.getAttribute("stroke-width")).toBe("2"); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["b", "p"], primaryKey: "b" }); }); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + const width = screen.getByRole("textbox", { name: "선 굵기" }); + expect((width as HTMLInputElement).placeholder).toBe("혼합"); + fireEvent.change(width, { target: { value: "8" } }); fireEvent.submit(width.closest("form")!); + expect(value().objects[3]).toEqual({ ...path, strokeWidth: 8 }); + expect(container.querySelector("polyline")?.getAttribute("stroke-width")).toBe("8"); + expect(editor.snapshot.selection).toMatchObject({ keys: ["b", "p"], primaryKey: "b" }); +}); + +test("invalid style and cancelled field drafts do not create partial state or history", () => { + const { value, editor, commits } = setup(populated); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + const field = screen.getByRole("textbox", { name: "글자 크기" }); + fireEvent.change(field, { target: { value: "-1" } }); fireEvent.submit(field.closest("form")!); + expect(screen.getByRole("alert")).toBeTruthy(); expect(value()).toEqual(populated); expect(commits).not.toHaveBeenCalled(); + fireEvent.change(field, { target: { value: "99" } }); fireEvent.keyDown(field, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "스타일" })).toBeNull(); + expect(editor.snapshot.canUndo).toBe(false); expect(value()).toEqual(populated); +}); + +test("opening style cancels a resize preview and stale pointer release cannot overwrite a style", () => { + const { container, value, commits } = setup(populated); + const handle = container.querySelector('[data-resize-edge="se"]')!; + fireEvent.pointerDown(handle, event(100, 100)); fireEvent.pointerMove(window, event(140, 140)); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + fireEvent.click(screen.getByRole("button", { name: "색상: 빨강" })); + fireEvent.pointerUp(window, event(150, 150)); + expect(value().objects[0]).toEqual({ ...populated.objects[0], color: "#ef4444" }); expect(commits).toHaveBeenCalledOnce(); +}); + +test.each(["rectangle", "ellipse", "sticky-note"] as const)("%s edits inside the same body box, retains its fill and shares draft/IME/history behavior", (kind) => { + const object = { ...populated.objects[1]!, kind, label: "기존 본문", textColor: "purple", fontSize: 28, fontWeight: 700 as const, textAlign: "right" as const }; + const { container, svg, editor, value, commits } = setup({ ...populated, objects: [populated.objects[0]!, object] }); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["a", "b"], primaryKey: "b" }); }); + const target = container.querySelector('[data-canvas-object="b"]')!; + const displayed = target.parentElement!.querySelector("[data-canvas-text-box]")!; + const bounds = ["x", "y", "width", "height"].map((key) => displayed.getAttribute(key)); + fireEvent.doubleClick(target); + const input = screen.getByRole("textbox", { name: "Canvas text" }) as HTMLTextAreaElement; + expect(input.value).toBe(object.label); expect(document.activeElement).toBe(input); + expect(["x", "y", "width", "height"].map((key) => input.closest("[data-canvas-text-box]")!.getAttribute(key))).toEqual(bounds); + expect(target.parentElement!.querySelector(`${kind === "ellipse" ? "ellipse" : "rect"}[fill="blue"]`)).not.toBeNull(); + expect(input.style.color).toBe("purple"); expect(input.style.fontSize).toBe("28px"); expect(input.style.fontWeight).toBe("700"); expect(input.style.textAlign).toBe("right"); + fireEvent.change(input, { target: { value: "한글\n💡" } }); + fireEvent.keyDown(input, { key: "Enter", metaKey: true, isComposing: true }); + fireEvent.keyDown(input, { key: "Escape", isComposing: true }); + expect(screen.getByRole("textbox", { name: "Canvas text" })).toBe(input); expect(commits).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + expect(value().objects[1]).toEqual({ ...object, label: "한글\n💡" }); expect(commits).toHaveBeenCalledOnce(); + expect(editor.snapshot.selection).toMatchObject({ keys: ["a", "b"], primaryKey: "b" }); + fireEvent.keyDown(svg, { key: "F2" }); + fireEvent.change(screen.getByRole("textbox", { name: "Canvas text" }), { target: { value: "버릴 내용" } }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Canvas text" }), { key: "Escape" }); + expect(value().objects[1]!.label).toBe("한글\n💡"); expect(commits).toHaveBeenCalledOnce(); + act(() => { editor.undo(); }); expect(value().objects[1]).toEqual(object); + act(() => { editor.redo(); }); expect(value().objects[1]!.label).toBe("한글\n💡"); +}); + +test("new sticky notes accept immediate text, use host fill policy and copy through the existing native binding", () => { + const { svg, container, value, rerender, editor } = setup(); + rerender(); + fireEvent.click(screen.getByRole("button", { name: "스티커 노트" })); + fireEvent.pointerDown(svg, event(40, 50)); fireEvent.pointerUp(svg, event(40, 50)); + const input = screen.getByRole("textbox", { name: "Canvas text" }); + expect(value().objects[0]).toMatchObject({ kind: "sticky-note", label: "", color: "#fff2a8", textColor: style.textColor }); + fireEvent.change(input, { target: { value: "작은 아이디어\n한 장" } }); fireEvent.blur(input); + expect(container.querySelectorAll("[data-resize-edge]")).toHaveLength(8); + const data = clipboardData(); clipboardEvent(svg, "copy", data); + expect(data.getData("text/plain")).toBe("작은 아이디어\n한 장"); + clipboardEvent(svg, "paste", data); + expect(value().objects[1]).toEqual({ ...value().objects[0], id: "object-2", x: 104, y: 124 }); + fireEvent.click(screen.getByRole("button", { name: "실행 취소" })); expect(value().objects).toHaveLength(1); +}); + +test("filled objects expose independent body color without repainting their backgrounds", () => { + const { container, editor, value } = setup({ ...populated, objects: [...populated.objects, { ...populated.objects[1]!, id: "note", kind: "sticky-note", textColor: "red" }] }); + act(() => { editor.dispatch({ type: "selection.set", objectIds: ["b", "note"], primaryKey: "note" }); }); + fireEvent.click(screen.getByRole("button", { name: "스타일" })); + expect((screen.getByRole("textbox", { name: "글자색" }) as HTMLInputElement).placeholder).toBe("혼합"); + fireEvent.click(screen.getByRole("button", { name: "글자색: 빨강" })); + expect(value().objects[1]).toMatchObject({ textColor: "#ef4444", color: "blue" }); + expect(value().objects[3]).toMatchObject({ textColor: "#ef4444", color: "blue" }); + fireEvent.keyDown(screen.getByRole("dialog", { name: "스타일" }), { key: "Escape" }); + fireEvent.doubleClick(container.querySelector('[data-canvas-object="note"]')!); + expect((screen.getByRole("textbox", { name: "Canvas text" }) as HTMLTextAreaElement).style.color).toBe("rgb(239, 68, 68)"); +}); + +test.each(["", "끝의 빈 줄\n", "줄\n\n"])("body display and native input measure the same terminal line without storing a layout marker: %j", (label) => { + const object = { ...populated.objects[1]!, label }; + const { container, value, commits } = setup({ ...blank, objects: [object] }); + const rendered = container.querySelector("[data-canvas-text-box]")!.textContent; + fireEvent.doubleClick(container.querySelector("[data-canvas-object]")!); + const input = screen.getByRole("textbox", { name: "Canvas text" }) as HTMLTextAreaElement; + expect(input.previousElementSibling!.textContent).toBe(rendered); + expect(input.value).toBe(label); + fireEvent.keyDown(input, { key: "Enter", metaKey: true }); + expect(value().objects[0]).toEqual(object); expect(commits).not.toHaveBeenCalled(); +}); diff --git a/packages/json-document-canvas/tsconfig.json b/packages/json-document-canvas/tsconfig.json new file mode 100644 index 000000000..d561a0358 --- /dev/null +++ b/packages/json-document-canvas/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig/library-react.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, + "references": [ + { "path": "../json-document-file-intake" }, + { "path": "../json-document-object-document" }, { "path": "../json-document-editing" }, + { "path": "../json-document-affordance" }, { "path": "../json-document-web" }, + { "path": "../json-document-react" }, { "path": "../json-document-ui-primitives-react" } + ], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/json-document-canvas/tsconfig.test.json b/packages/json-document-canvas/tsconfig.test.json new file mode 100644 index 000000000..7850a9a27 --- /dev/null +++ b/packages/json-document-canvas/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig/test-dom.json"], + "compilerOptions": { "rootDir": "../.." }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx", "vitest.config.ts"] +} diff --git a/packages/json-document-canvas/vitest.config.ts b/packages/json-document-canvas/vitest.config.ts new file mode 100644 index 000000000..ecec11aa5 --- /dev/null +++ b/packages/json-document-canvas/vitest.config.ts @@ -0,0 +1,7 @@ +import react from "@vitejs/plugin-react"; +import { defineDOMReactProject } from "../../test/vitest.shared.js"; +import { jsonDocumentSourceAliases } from "../../site/config/json-document-source-aliases.js"; + +export default defineDOMReactProject("json-document-canvas", { + plugins: [react()], resolve: { alias: jsonDocumentSourceAliases(), dedupe: ["react", "react-dom"] }, +}); diff --git a/packages/json-document-collaboration/benchmarks/runtime.mjs b/packages/json-document-collaboration/benchmarks/runtime.mjs index bd4fabbab..e73a30647 100644 --- a/packages/json-document-collaboration/benchmarks/runtime.mjs +++ b/packages/json-document-collaboration/benchmarks/runtime.mjs @@ -11,6 +11,7 @@ console.log("json-document collaboration benchmark"); console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${config.warmups}`); const ingestRows = []; +const objectRows = []; for (const size of config.sizes) { const initial = { items: Array.from({ length: size }, (_, index) => ({ id: `item-${index}`, done: false })) }; const author = createCollaborationRuntime(initial, { ...runtimeOptions, actorId: "author" }); @@ -29,6 +30,16 @@ for (const size of config.sizes) { }); ingestRows.push({ size, ...ingest }); + const wide = Object.fromEntries(Array.from({ length: size }, (_, index) => [`field${index}`, index])); + const objectAuthor = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "author" }); + objectAuthor.document.commit([{ op: "replace", path: `/field${middle}`, value: -1 }]); + const objectBundle = objectAuthor.replica.exportBundle(); + const objectIngest = measure(config, "remote wide object leaf ingest", () => { + const receiver = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "receiver" }); + return () => receiver.replica.ingest(objectBundle).ok && receiver.document.value[`field${middle}`] === -1; + }); + objectRows.push({ size, ...objectIngest }); + measure(config, "export one-change bundle", () => () => ( author.replica.exportBundle().changes.length === 1 )); @@ -36,6 +47,8 @@ for (const size of config.sizes) { console.log("\nremote leaf ingest"); reportScaling(ingestRows); +console.log("\nremote wide object leaf ingest"); +reportScaling(objectRows); const ledgerSizes = (process.env.PERF_COLLABORATION_CHANGES ?? "100,1000,10000") .split(",") diff --git a/packages/json-document-collaboration/src/checkpoint.ts b/packages/json-document-collaboration/src/checkpoint.ts index 58c399592..de1edb6a8 100644 --- a/packages/json-document-collaboration/src/checkpoint.ts +++ b/packages/json-document-collaboration/src/checkpoint.ts @@ -68,22 +68,26 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { rawPayload.reason ?? "checkpoint payload must contain only JSON values", ); } + const ownedPayload = rawPayload.value as Readonly>; if ( - input.payload.kind !== "json-document-collaboration/checkpoint" - || input.payload.version !== 1 + ownedPayload.kind !== "json-document-collaboration/checkpoint" + || ownedPayload.version !== 1 ) { return invalid("checkpoint payload kind or version is unsupported"); } - const base = applyPatch(input.payload.base, []); - if (!base.ok) { - return invalid(base.reason ?? "checkpoint base must be JSON"); + const base = ownedPayload.base; + // Only missing fields still need Core's non-JSON diagnostic; present fields + // were already validated and detached with the complete payload. + if (base === undefined || ownedPayload.membership === undefined) { + const missing = applyPatch(undefined, []); + if (!missing.ok) return invalid(missing.reason!); } - const membership = prepareMembership(input.payload.membership); + const membership = prepareMembership(ownedPayload.membership!); if (!membership.ok) return membership; const bundle = prepareBundle({ - epoch: input.payload.epoch, - changes: input.payload.changes, + epoch: ownedPayload.epoch, + changes: ownedPayload.changes, }); if (!bundle.ok) return bundle; for (let index = 1; index < bundle.bundle.changes.length; index += 1) { @@ -99,7 +103,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { ); } } - if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base.value)) { + if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base!)) { return invalid("checkpoint base does not match epoch baseDigest"); } if ( @@ -115,7 +119,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { kind: "json-document-collaboration/checkpoint" as const, version: 1 as const, epoch: bundle.bundle.epoch, - base: base.value, + base: base!, membership: membership.membership, changes: bundle.bundle.changes, }); @@ -202,7 +206,7 @@ export function verifyCheckpointProof( } function prepareMembership( - input: unknown, + input: JSONValue, ): | { readonly ok: true; @@ -210,25 +214,19 @@ function prepareMembership( } | { readonly ok: false; readonly reason: string } { if (input === null) return { ok: true, membership: null }; - const validated = applyPatch(input, []); - if (!validated.ok) { - return invalid( - validated.reason ?? "checkpoint membership must contain only JSON values", - ); - } if ( - !isRecord(validated.value) - || validated.value.version !== 1 - || !Array.isArray(validated.value.members) + !isRecord(input) + || input.version !== 1 + || !Array.isArray(input.members) ) { return invalid("checkpoint membership must be null or a version 1 list"); } try { const membership = canonicalMembership( - validated.value as unknown as CollaborationMembership, + input as unknown as CollaborationMembership, ); if ( - canonicalStringify(validated.value) + canonicalStringify(input) !== canonicalStringify(membership as unknown as JSONValue) ) { return invalid("checkpoint membership must be canonical"); diff --git a/packages/json-document-collaboration/src/document-patch.ts b/packages/json-document-collaboration/src/document-patch.ts index 70e27301c..c0bb07b7a 100644 --- a/packages/json-document-collaboration/src/document-patch.ts +++ b/packages/json-document-collaboration/src/document-patch.ts @@ -8,6 +8,7 @@ interface VisibleMember { parent?: VisibleMember; key: string; children: VisibleMember[]; + readonly childrenByKey: Map | undefined; } /** Compile the visible tree transition, retaining member identity in RFC 6902 moves. */ @@ -59,8 +60,8 @@ export function patchBetweenTrees( if (staging === undefined) { let key = "__json_document_transfer__"; if (Array.isArray(root.value)) key = String(root.children.length); - else while ([...root.children, ...target.children].some((child) => child.key === key)) key += "_"; - staging = { id: "", value: [], container: "", key, children: [] }; + else while (root.childrenByKey?.has(key) || target.childrenByKey?.has(key)) key += "_"; + staging = { id: "", value: [], container: "", key, children: [], childrenByKey: undefined }; insert(staging, root, key); operations.push({ op: "add", path: pointer(staging), value: [] }); } @@ -74,12 +75,13 @@ export function patchBetweenTrees( for (const child of [...node.children]) vacate(child); const value = wanted.container === undefined ? wanted.value : Array.isArray(wanted.value) ? [] : {}; operations.push({ op: "replace", path: pointer(node), value }); - const replacement: VisibleMember = { ...wanted, children: [], key: node.key }; + const replacement: VisibleMember = { ...wanted, children: [], childrenByKey: wanted.childrenByKey && new Map(), key: node.key }; if (node.parent !== undefined) { const parent = node.parent; const index = parent.children.indexOf(node); replacement.parent = parent; parent.children[index] = replacement; + parent.childrenByKey?.set(replacement.key, replacement); } current.set(wanted.id, replacement); node = replacement; @@ -91,11 +93,11 @@ export function patchBetweenTrees( if (existing !== undefined && !attached(existing, root)) existing = undefined; const occupant = Array.isArray(node.value) ? node.children[index] - : node.children.find((entry) => entry.key === key); + : node.childrenByKey?.get(key); if (!Array.isArray(node.value) && occupant !== undefined && occupant !== existing) vacate(occupant); if (existing === undefined) { const value = child.container === undefined ? child.value : Array.isArray(child.value) ? [] : {}; - existing = { ...child, value, children: [] }; + existing = { ...child, value, children: [], childrenByKey: child.childrenByKey && new Map() }; insert(existing, node, key); current.set(child.id, existing); operations.push({ op: "add", path: pointer(existing), value }); @@ -123,6 +125,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me container: reference.kind === "container" ? reference.containerId : undefined, key, children: [], + childrenByKey: value !== null && typeof value === "object" && !Array.isArray(value) ? new Map() : undefined, }; members.set(node.id, node); if (value !== null && typeof value === "object") { @@ -131,6 +134,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me const member = snapshot(tree, childId, child, key, members); member.parent = node; node.children.push(member); + node.childrenByKey?.set(key, member); } } return node; @@ -155,6 +159,7 @@ function pointer(node: VisibleMember): string { return buildPointer(segments(nod function detach(node: VisibleMember): void { if (node.parent === undefined) throw new Error("cannot detach the document root"); node.parent.children.splice(node.parent.children.indexOf(node), 1); + node.parent.childrenByKey?.delete(node.key); delete node.parent; } @@ -162,5 +167,8 @@ function insert(node: VisibleMember, parent: VisibleMember, key: string): void { node.parent = parent; node.key = key; if (Array.isArray(parent.value)) parent.children.splice(Number(key), 0, node); - else parent.children.push(node); + else { + parent.children.push(node); + parent.childrenByKey!.set(key, node); + } } diff --git a/packages/json-document-collaboration/tests/unit/checkpoint.test.ts b/packages/json-document-collaboration/tests/unit/checkpoint.test.ts index 7d605c07f..93d2203ba 100644 --- a/packages/json-document-collaboration/tests/unit/checkpoint.test.ts +++ b/packages/json-document-collaboration/tests/unit/checkpoint.test.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; import { describe, expect, test, vi } from "vitest"; +import * as core from "@interactive-os/json-document"; +import { prepareCheckpoint } from "../../src/checkpoint.js"; import { compactCollaborationCheckpoint, @@ -108,6 +110,37 @@ function canonicalJSON(value: unknown): string { } describe("@interactive-os/json-document-collaboration checkpoints", () => { + test("owns the raw payload once and reuses its base and membership", () => { + const source = createCollaborationRuntime( + { rows: Array.from({ length: 1_000 }, (_, id) => ({ id })) }, + options("author", "ownership/v1", membership("author")), + ); + const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint())); + const applyPatch = vi.spyOn(core, "applyPatch"); + try { + const prepared = prepareCheckpoint(input); + expect(prepared.ok).toBe(true); + expect(applyPatch.mock.calls.filter(([value]) => value === input.payload)).toHaveLength(1); + expect(applyPatch.mock.calls.filter(([value]) => value === input.payload.base || value === input.payload.membership)).toHaveLength(0); + input.payload.base.rows[0].id = -1; + input.payload.membership.members[0].actorId = "poison"; + if (!prepared.ok) throw new Error(prepared.reason); + expect(core.readPointer(prepared.checkpoint.payload.base, "/rows/0/id")).toMatchObject({ value: 0 }); + expect(prepared.checkpoint.payload.membership).toEqual(membership("author")); + expect(Object.isFrozen(prepared.checkpoint.payload.base)).toBe(true); + } finally { applyPatch.mockRestore(); } + }); + + test.each(["base", "membership"])("preserves the missing %s JSON diagnostic and validation order", (field) => { + const source = createCollaborationRuntime(null, options("author", "missing/v1", undefined)); + const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint())); + delete input.payload[field]; + const missing = core.applyPatch(undefined, []); + expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: !missing.ok && missing.reason }); + input.payload.version = 2; + expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: "checkpoint payload kind or version is unsupported" }); + }); + test("round-trips the complete same-epoch causal state", () => { const members = membership("actor-a", "actor-b"); const source = createCollaborationRuntime( diff --git a/packages/json-document-collaboration/tests/unit/document-tracking.test.ts b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts index fea4862b3..9e1329020 100644 --- a/packages/json-document-collaboration/tests/unit/document-tracking.test.ts +++ b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts @@ -1,10 +1,28 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { trackPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; import { createCollaborationRuntime } from "../../src/index.js"; +import { createInitialTree } from "../../src/tree.js"; +import { patchBetweenTrees } from "../../src/document-patch.js"; const options = { epochId: "tracking/v1", ruleset: { id: "tracking", digest: "v1" } }; describe("remote structural notification", () => { + test("looks up a wide object's keys without rescanning siblings for each key", () => { + const before = Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, index])); + const after = { ...before, field2500: -1 }; + const beforeTree = createInitialTree(before, "wide"); + const afterTree = createInitialTree(after, "wide"); + const find = vi.spyOn(Array.prototype, "find"); + try { + const operations = patchBetweenTrees(before, after, beforeTree, afterTree); + const siblingSearches = find.mock.contexts.filter((value) => ( + Array.isArray(value) && value.length > 100 && value[0] && "key" in value[0] + )); + expect(siblingSearches).toHaveLength(0); + expect(operations).toEqual([{ op: "replace", path: "/field2500", value: -1 }]); + } finally { find.mockRestore(); } + }); + test.each([ { name: "move then edit", initial: { items: [{ label: "a" }, { label: "b" }] }, pointer: "/items/0/label", patch: [{ op: "move", from: "/items/0", path: "/items/1" }, { op: "replace", path: "/items/1/label", value: "edited" }], expected: "/items/1/label" }, { name: "escaped object member", initial: { "a/b": { label: "a" } }, pointer: "/a~1b/label", patch: [{ op: "move", from: "/a~1b", path: "/~0" }], expected: "/~0/label" }, @@ -18,6 +36,7 @@ describe("remote structural notification", () => { { name: "object swap", initial: { a: { label: "a" }, b: { label: "b" } }, pointer: "/a/label", patch: [{ op: "move", from: "/a", path: "/temp" }, { op: "move", from: "/b", path: "/a" }, { op: "move", from: "/temp", path: "/b" }], expected: "/b/label" }, { name: "move out before replacing its parent", initial: { left: { item: { label: "a" } }, right: {} }, pointer: "/left/item/label", patch: [{ op: "move", from: "/left/item", path: "/right/item" }, { op: "replace", path: "/left", value: {} }], expected: "/right/item/label" }, { name: "root array reorder", initial: [{ label: "a" }, { label: "b" }], pointer: "/0/label", patch: [{ op: "move", from: "/0", path: "/1" }], expected: "/1/label" }, + { name: "successive key swaps and a replaced container", initial: { a: { item: { label: "a" } }, b: { label: "b" }, __json_document_transfer__: 1, __json_document_transfer___: 2 }, pointer: "/a/item/label", patch: [{ op: "move", from: "/a/item", path: "/temp" }, { op: "replace", path: "/a", value: {} }, { op: "move", from: "/b", path: "/a/new" }, { op: "move", from: "/temp", path: "/b" }, { op: "replace", path: "/a/new/label", value: "edited" }], expected: "/b/label" }, ] as const)("$name preserves the address of the same member", ({ initial, pointer, patch, expected }) => { const local = createCollaborationRuntime(initial, { ...options, actorId: "local" }); const remote = createCollaborationRuntime(initial, { ...options, actorId: "remote" }); diff --git a/packages/json-document-composer-react/README.md b/packages/json-document-composer-react/README.md index cbb26934d..eabb3b005 100644 --- a/packages/json-document-composer-react/README.md +++ b/packages/json-document-composer-react/README.md @@ -20,3 +20,52 @@ composed from the canonical suggestion packages. Product copy, styling, layout, suggestions, and concrete ports remain Host-owned. Draft/editor subscription, suggestion integration, keyboard/history execution, Web file intake, focus recovery, and submit lifecycle remain canonical across Host replacements. + +## 이미지 준비와 편집 + +`useComposer`의 `addWebFiles`, `handlePaste`, `handleFileInputChange`는 같은 파일 입력 경로를 +사용합니다. Web `readWebRasterFiles`가 PNG/JPEG/WebP를 실제로 decode하고, +Editing `createEditingPreparationQueue`가 준비 완료와 무관하게 요청 순서를 유지합니다. +일반 파일은 기존 metadata-only 첨부로 남으며 `image`가 있는 첨부만 실제 내용을 보존합니다. + +| API | 계약 | +| --- | --- | +| `isPreparingAttachments` | 아직 완료하지 않은 첨부 준비가 있음 | +| `attachmentError` | 가장 최근 실패의 `code`와 선택적 `reason`, 성공/새 입력/취소 시 초기화 | +| `cancelAttachments()` | 대기 batch를 취소하고 실제 파일 읽기를 중단; 늦은 결과는 반영하지 않음 | +| `canSubmit` | 내용이 있고 대기 중인 첨부가 없음 | +| `submit()` | 최신 draft를 사용하며, 준비 중에는 호출해도 submit port를 실행하지 않음 | +| `maxImagePixels?` | 이미지당 decode 후 픽셀 제한, 기본 16,000,000 | +| `readRaster?` | Web reader의 대체 instance 주입. 테스트·환경 연결용이며 문서 모델을 바꾸지 않음 | + +파일 수·byte 제한과 허용 media type은 기존 `config.attachments`가 결정합니다. +정책은 읽기 전에 검사하고, 실제 추가 직전 최신 첨부 수와 정책으로 다시 검사합니다. +실패한 batch는 첨부와 History를 일부만 남기지 않습니다. 추가는 최신 첨부 목록의 끝에서 +이루어지므로 준비 중 typing/caret 이동은 유지하며 현재 text selection을 덮어쓰지 않습니다. +늦은 완료가 focus를 빼앗지 않습니다. 파일 선택 창을 닫을 때만 editor focus를 복구합니다. + +Escape, `cancelAttachments`, `handleHistoryKeyDown`의 Undo/Redo, unmount는 준비를 취소합니다. +직접 `editor.undo/redo` 또는 외부 문서 교체를 수행하는 소비자는 먼저 `cancelAttachments()`를 +호출합니다. queue 자체는 문서 History나 text 삽입 위치 mapping을 대신하지 않습니다. +React Host는 `handlePaste`와 `handleHistoryKeyDown`을 감싸는 surface의 capture handler에, +`handleKeyDown`은 editor에 연결하고 상태/실패를 표시합니다. + +내부 Rich Text 구조화 MIME은 기존 binding에 먼저 위임합니다. 나머지는 파일 → 이미지가 +포함된 HTML 순으로 Web에서 동기 캡처하고 한 번만 처리합니다. 이미지가 없는 텍스트/HTML은 +기존 Rich Text 경로에 남깁니다. 이미지-only HTML은 `readWebHTMLClipboard`로 포함된 +PNG/JPEG/WebP data URL을 읽고 기존 첨부와 같은 queue·정책·취소·History를 사용합니다. +외부·상대·blob·cid source는 가져오지 않고 `raster.source-unsupported`로 실패합니다. + +글+이미지 HTML은 inline 위치를 표현할 모델이 아직 없으므로 +`composer.clipboard.mixed-unsupported`로 전체를 거절하고 draft/History를 유지합니다. +파일과 HTML 양쪽의 내용을 별개로 추가하거나 대응을 추측하지 않습니다. native 파일과 +HTML의 혼합 의미 보존 및 inline 이미지 profile은 TBD입니다. + +Usage와 Source: [Composer](/demo/composer). PNG/JPEG/WebP의 내용·미리보기·삭제·Undo/Redo와 +submit payload를 확인할 수 있습니다. 서버 upload와 OS-native Clipboard 호환성 완료를 +주장하지 않습니다. + +`useComposer` preserves Alt when translating key events to Composer interaction +meaning. Its history capture handler consumes only the default Undo/Redo chord; +Alt-modified variants leave the event and canonical history untouched. Submit +and command-menu handling retain the configured Composer policy. diff --git a/packages/json-document-composer-react/package.json b/packages/json-document-composer-react/package.json index dc60a801a..636570850 100644 --- a/packages/json-document-composer-react/package.json +++ b/packages/json-document-composer-react/package.json @@ -22,6 +22,8 @@ "peerDependencies": { "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-composer": "^0.1.0-rc.0", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention-react": "^0.1.0-rc.0", @@ -34,6 +36,8 @@ "devDependencies": { "@interactive-os/json-document": "*", "@interactive-os/json-document-composer": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-mention": "*", "@interactive-os/json-document-rich-text-mention-react": "*", diff --git a/packages/json-document-composer-react/src/attachments.ts b/packages/json-document-composer-react/src/attachments.ts new file mode 100644 index 000000000..95b809866 --- /dev/null +++ b/packages/json-document-composer-react/src/attachments.ts @@ -0,0 +1,72 @@ +import type { JSONDocument } from "@interactive-os/json-document"; +import { addComposerAttachments, createComposerAttachments, type ComposerAttachmentCandidate, type ComposerAttachmentPolicy, type ComposerDraft, type ComposerDraftCommandResult } from "@interactive-os/json-document-composer"; +import { createEditingPreparationQueue, type EditingPreparation, type EditingPreparationFailure } from "@interactive-os/json-document-editing"; +import { validateFileCandidates } from "@interactive-os/json-document-file-intake"; +import type { RichTextEditor } from "@interactive-os/json-document-rich-text"; +import { fileCandidatesFromWebFiles, readWebHTMLClipboard, readWebRasterFiles, type readWebRasterFile, type WebFileCandidate, type WebFileCandidateList, type WebHTMLClipboardContent } from "@interactive-os/json-document-web"; +import { useEffect, useRef, useState } from "react"; + +/** Composer appends attachments, so typing and caret changes do not invalidate preparation. */ +export function useComposerAttachments(document: JSONDocument, editor: RichTextEditor, options: { + readonly policy: ComposerAttachmentPolicy; + readonly createId: () => string; + readonly maxImagePixels?: number; + readonly readRaster?: typeof readWebRasterFile; +}) { + const current = useRef(options); + current.current = options; + const [isPending, setPending] = useState(false); + const [error, setError] = useState(null); + const [queue] = useState(() => createEditingPreparationQueue, ComposerDraftCommandResult>({ + apply(candidates) { + const draft = document.value as ComposerDraft; + const created = createComposerAttachments(candidates, { createId: current.current.createId, policy: current.current.policy, currentCount: draft.attachments.length }); + return created.ok ? addComposerAttachments(editor, draft, created.attachments) : created; + }, + onPendingChange: setPending, + onResult: (result) => { setError(result.ok ? null : result); }, + })); + useEffect(() => () => queue.cancel(), [queue]); + + function enqueue(prepare: (signal: AbortSignal) => EditingPreparation> | Promise>>) { + setError(null); + const controller = new AbortController(); + void queue.enqueue(() => prepare(controller.signal), () => controller.abort()); + } + + function addFiles(input: WebFileCandidateList | ReadonlyArray) { + // Snapshot File references while a paste/drop event still owns them. + const files = Array.from(input as ArrayLike); + if (files.length === 0) return; + const policy = current.current.policy; + const maxImagePixels = current.current.maxImagePixels ?? 16_000_000; + const readRaster = current.current.readRaster; + enqueue((signal) => { + const candidates = fileCandidatesFromWebFiles(files); + const accepted = validateFileCandidates(candidates, policy, { currentCount: (document.value as ComposerDraft).attachments.length }); + if (!accepted.ok) return accepted; + const images = files.filter((file) => file.type.startsWith("image/")); + if (images.length === 0) return { ok: true, value: candidates }; + return readWebRasterFiles(images, { policy, maxImagePixels, signal, ...(readRaster ? { readRaster } : {}) }).then((prepared): EditingPreparation> => { + if (!prepared.ok) return prepared; + let imageIndex = 0; + return { ok: true, value: candidates.map((candidate) => candidate.mediaType?.startsWith("image/") + ? { ...candidate, image: prepared.files[imageIndex++]!.image } + : candidate) }; + }); + }); + } + + function addHTML(content: WebHTMLClipboardContent) { + const { policy, maxImagePixels = 16_000_000, readRaster } = current.current; + enqueue((signal) => { + if (content.parts.some((part) => part.type === "text" && part.text.trim())) return { ok: false, code: "composer.clipboard.mixed-unsupported" }; + return readWebHTMLClipboard(content, { policy, maxImagePixels, signal, currentCount: (document.value as ComposerDraft).attachments.length, ...(readRaster ? { readRaster } : {}) }) + .then((prepared): EditingPreparation> => prepared.ok + ? { ok: true, value: prepared.parts.flatMap((part) => part.type === "image" ? [{ ...part.candidate, image: part.image }] : []) } + : prepared); + }); + } + + return { isPending, error, addFiles, addHTML, reportError: setError, cancel: () => { queue.cancel(); setError(null); }, hasPending: () => queue.isPending }; +} diff --git a/packages/json-document-composer-react/src/use-composer.tsx b/packages/json-document-composer-react/src/use-composer.tsx index 733a3fa1e..4b789e661 100644 --- a/packages/json-document-composer-react/src/use-composer.tsx +++ b/packages/json-document-composer-react/src/use-composer.tsx @@ -1,9 +1,7 @@ import { createJSONDocument, type JSONDocument } from "@interactive-os/json-document"; import { - addComposerAttachments, composerInteractionFromKeyStroke, composerSchema, - createComposerAttachments, createComposerDraft, hasComposerContent, insertComposerText, @@ -14,18 +12,22 @@ import { type ComposerHostPorts, type ComposerHostSuggestion, } from "@interactive-os/json-document-composer"; -import { createRichTextEditor, type RichTextEditor, type RichTextNode } from "@interactive-os/json-document-rich-text"; +import { createRichTextEditor, RICH_TEXT_CLIPBOARD_MIME, type RichTextEditor, type RichTextNode } from "@interactive-os/json-document-rich-text"; import type { RichTextSuggestionCandidate } from "@interactive-os/json-document-rich-text-suggestion"; import type { RichTextSuggestionBinding } from "@interactive-os/json-document-rich-text-suggestion-react"; -import { fileCandidatesFromWebClipboard, fileCandidatesFromWebFiles, type WebFileCandidate, type WebFileCandidateList } from "@interactive-os/json-document-web"; +import { captureWebClipboardPaste, type readWebRasterFile, type WebFileCandidate, type WebFileCandidateList } from "@interactive-os/json-document-web"; +import type { EditingPreparationFailure } from "@interactive-os/json-document-editing"; import { useCallback, useRef, useState, useSyncExternalStore, type ChangeEvent, type ClipboardEvent, type KeyboardEvent, type ReactNode } from "react"; import { ComposerReferenceAtom, type ComposerReferenceAtomProps } from "./reference-atom.js"; import { useComposerCommandMenu } from "./command-menu.js"; +import { useComposerAttachments } from "./attachments.js"; export interface UseComposerOptions { readonly id: string; readonly config: ComposerHostConfig & { readonly suggestions: ReadonlyArray }; readonly ports: ComposerHostPorts; + readonly maxImagePixels?: number; + readonly readRaster?: typeof readWebRasterFile; readonly labels: { readonly mentionSuggestions: string; readonly skillSuggestions: string; @@ -41,6 +43,10 @@ export interface ComposerBinding["attachments"]; readonly model: Model; readonly hasContent: boolean; + readonly isPreparingAttachments: boolean; + readonly attachmentError: EditingPreparationFailure | null; + readonly canSubmit: boolean; + cancelAttachments(): void; readonly commandKind: "mention" | "skill" | null; readonly commandMenu: RichTextSuggestionBinding; readonly commandOpen: boolean; @@ -77,38 +83,42 @@ export function useComposer(null); const commandMenu = useComposerCommandMenu({ id: options.id, editor, document: draft.instruction, suggestions: config.suggestions, createId: ports.createId, labels: options.labels }); const content = hasComposerContent(draft); + const intake = useComposerAttachments(document, editor, { + policy: config.attachments, createId: ports.createId, + ...(options.maxImagePixels === undefined ? {} : { maxImagePixels: options.maxImagePixels }), + ...(options.readRaster ? { readRaster: options.readRaster } : {}), + }); function submit() { - if (content) void ports.submit(draft); + const current = document.value as ComposerDraft; + if (!intake.hasPending() && hasComposerContent(current)) void ports.submit(current); } - function addCandidates(candidates: ReturnType) { - if (candidates.length === 0) return; - const created = createComposerAttachments(candidates, { createId: ports.createId, policy: config.attachments, currentCount: draft.attachments.length }); - if (!created.ok) return; - addComposerAttachments(editor, draft, created.attachments); - editorElementRef.current?.focus(); - } - - function addWebFiles(files: Parameters[0]) { - addCandidates(fileCandidatesFromWebFiles(files)); - } + const addWebFiles = intake.addFiles; function handleFileInputChange(event: ChangeEvent) { addWebFiles(event.currentTarget.files ?? []); event.currentTarget.value = ""; + editorElementRef.current?.focus(); } function handlePaste(event: ClipboardEvent) { - const candidates = fileCandidatesFromWebClipboard(event); - if (candidates.length === 0) return; - event.preventDefault(); - event.stopPropagation(); - addCandidates(candidates); + if (event.defaultPrevented) return; + const captured = captureWebClipboardPaste(event, { files: true, html: "images", delegatedMimeTypes: [RICH_TEXT_CLIPBOARD_MIME] }); + if (captured.ok && captured.type === "files") { + event.stopPropagation(); + addWebFiles(captured.files); + } else if (captured.ok && captured.type === "html") { + event.stopPropagation(); + intake.addHTML(captured.content); + } else if (!captured.ok && captured.code !== "clipboard.empty") intake.reportError(captured); } function handleKeyDown(event: KeyboardEvent) { - const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey }, config.interaction); + if (event.key === "Escape" && intake.hasPending() && !event.nativeEvent.isComposing) { + event.preventDefault(); event.stopPropagation(); intake.cancel(); return; + } + const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey, altKey: event.altKey }, config.interaction); if (commandMenu.open) { commandMenu.handleKeyDown(event); if (event.defaultPrevented) { @@ -123,11 +133,12 @@ export function useComposer) { - const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey }, config.interaction); + const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey, altKey: event.altKey }, config.interaction); if (interaction !== "history.undo" && interaction !== "history.redo") return; event.preventDefault(); event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); + intake.cancel(); if (interaction === "history.redo") editor.redo(); else editor.undo(); } @@ -145,6 +156,10 @@ export function useComposer fileInputRef.current?.click(), - removeAttachment: (attachmentId) => { removeComposerAttachment(editor, draft, attachmentId); }, + removeAttachment: (attachmentId) => { removeComposerAttachment(editor, document.value as ComposerDraft, attachmentId); }, selectModel: (model) => { selectComposerModel(editor, model); }, insertText: (text) => { insertComposerText(editor, text); }, chooseTrigger: (value) => { editorElementRef.current?.focus(); insertComposerText(editor, value); }, diff --git a/packages/json-document-composer-react/tests/composer-attachments.test.tsx b/packages/json-document-composer-react/tests/composer-attachments.test.tsx new file mode 100644 index 000000000..7b6128426 --- /dev/null +++ b/packages/json-document-composer-react/tests/composer-attachments.test.tsx @@ -0,0 +1,175 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { COMPOSER_HOST_PROFILE_V1, composerText, type ComposerHostConfig } from "@interactive-os/json-document-composer"; +import type { readWebRasterFile, WebRasterSourceResult } from "@interactive-os/json-document-web"; +import { useComposer } from "../src/index.js"; +import type { ClipboardEvent, KeyboardEvent } from "react"; +import { RICH_TEXT_CLIPBOARD_MIME } from "@interactive-os/json-document-rich-text"; + +afterEach(cleanup); +const file = { name: "image.png", size: 3, type: "image/png" }; +const image = { ok: true as const, dataURL: "data:image/png;base64,AQID", width: 100, height: 50 }; +const config: ComposerHostConfig<"fast"> = { + profile: COMPOSER_HOST_PROFILE_V1, models: [{ id: "fast", value: "fast", label: "Fast", description: "Model" }], suggestions: [], + attachments: { acceptedMediaTypes: ["*/*"], maxFiles: 4, maxBytesPerFile: 100 }, interaction: { submit: "enter", newline: "shift-enter" }, +}; +function waiting() { + let resolve!: (result: WebRasterSourceResult) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function setup(readRaster: typeof readWebRasterFile = vi.fn(async () => image), policy = config.attachments) { + let id = 0; + const submit = vi.fn(async () => undefined); + const hook = renderHook(() => useComposer({ id: "composer", config: { ...config, attachments: policy }, ports: { createId: () => `id-${++id}`, submit }, labels: { mentionSuggestions: "Mentions", skillSuggestions: "Skills" }, readRaster })); + return { ...hook, submit, readRaster }; +} +function key(key: string, metaKey = false) { + return { key, metaKey, preventDefault: vi.fn(), stopPropagation: vi.fn(), nativeEvent: { isComposing: false, stopImmediatePropagation: vi.fn() } } as unknown as KeyboardEvent; +} + +function htmlEvent(html: string) { + const preventDefault = vi.fn(), stopPropagation = vi.fn(); + return { clipboardData: { files: [], types: ["text/html", "text/plain"], getData: (type: string) => type === "text/html" ? html : "Fallback", setData() {} }, preventDefault, stopPropagation } as unknown as ClipboardEvent; +} + +test("PI-CONTENT: decoded images survive draft serialization, submit, removal, and Undo/Redo", async () => { + const { result, submit } = setup(); + await act(async () => { result.current.addWebFiles([file]); }); + const attachment = result.current.attachments[0]!; + expect(attachment).toMatchObject({ id: "id-4", name: file.name, image: { source: image.dataURL, width: 100, height: 50 } }); + expect(JSON.parse(JSON.stringify(result.current.draft)).attachments[0]).toEqual(attachment); + act(() => { result.current.submit(); }); expect(submit).toHaveBeenCalledWith(result.current.draft); + act(() => { result.current.removeAttachment(attachment.id); }); expect(result.current.attachments).toEqual([]); + act(() => { result.current.editor.undo(); }); expect(result.current.attachments).toEqual([attachment]); + act(() => { result.current.editor.undo(); }); expect(result.current.attachments).toEqual([]); + act(() => { result.current.editor.redo(); }); expect(result.current.attachments).toEqual([attachment]); +}); + +test("PI-TYPING/PI-ORDER: typing and caret movement continue; requests append in input order with separate Undo", async () => { + const first = waiting(), second = waiting(); + const reader = vi.fn().mockImplementationOnce(() => first.promise).mockImplementationOnce(() => second.promise); + const { result, submit } = setup(reader); + act(() => { result.current.addWebFiles([file]); result.current.addWebFiles([{ ...file, name: "second.png" }]); result.current.insertText("계속 입력"); }); + expect(result.current.isPreparingAttachments).toBe(true); expect(result.current.canSubmit).toBe(false); + const focus = result.current.editor.snapshot.selection.ranges[0]!.focus; + expect(focus.kind).toBe("text"); + if (focus.kind !== "text") return; + const caret = { ...focus, offset: 0 }; + const selection = { kind: "range" as const, ranges: [{ anchor: caret, focus: caret }], primaryIndex: 0 }; + act(() => { result.current.editor.dispatch({ type: "selection.set", selection }); result.current.submit(); }); + expect(submit).not.toHaveBeenCalled(); + await act(async () => { second.resolve(image); }); expect(result.current.attachments).toEqual([]); + await act(async () => { first.resolve(image); }); + expect(result.current.attachments.map((attachment) => attachment.name)).toEqual([file.name, "second.png"]); + expect(composerText(result.current.draft.instruction)).toBe("계속 입력"); + expect(result.current.editor.snapshot.selection).toEqual(selection); expect(result.current.canSubmit).toBe(true); + act(() => { result.current.handleHistoryKeyDown(key("z", true)); }); expect(result.current.attachments).toHaveLength(1); + act(() => { result.current.handleHistoryKeyDown(key("z", true)); }); expect(result.current.attachments).toHaveLength(0); + expect(composerText(result.current.draft.instruction)).toBe("계속 입력"); +}); + +test.each(["escape", "cancel", "undo", "unmount"])("PI-CANCEL: %s aborts preparation and cannot revive a late image", async (reason) => { + const pending = waiting(); + const reader = vi.fn(() => pending.promise); + const { result, unmount } = setup(reader); + act(() => { result.current.addWebFiles([file]); }); + const document = result.current.document; + const initial = document.value; + act(() => { + if (reason === "escape") result.current.handleKeyDown(key("Escape")); + else if (reason === "cancel") result.current.cancelAttachments(); + else if (reason === "undo") result.current.handleHistoryKeyDown(key("z", true)); + else unmount(); + }); + expect(reader.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(image); }); expect(document.value).toEqual(initial); +}); + +test("PI-FILE: failed image batch leaves neither metadata-only remnants nor History", async () => { + const reader = vi.fn().mockResolvedValueOnce(image).mockResolvedValueOnce({ ok: false, code: "raster.decode-failed" }); + const { result } = setup(reader); + await act(async () => { result.current.addWebFiles([file, file]); }); + expect(result.current.attachments).toEqual([]); expect(result.current.editor.snapshot.canUndo).toBe(false); + expect(result.current.attachmentError?.code).toBe("raster.decode-failed"); expect(result.current.isPreparingAttachments).toBe(false); +}); + +test("queued requests revalidate the latest attachment count before ID allocation", async () => { + const first = waiting(); + const reader = vi.fn().mockImplementationOnce(() => first.promise).mockResolvedValue(image); + const { result } = setup(reader, { ...config.attachments, maxFiles: 1 }); + act(() => { result.current.addWebFiles([file]); result.current.addWebFiles([{ ...file, name: "overflow.png" }]); }); + await act(async () => { first.resolve(image); }); + expect(result.current.attachments).toHaveLength(1); expect(result.current.attachmentError?.code).toBe("composer.attachments.limit"); + act(() => { result.current.removeAttachment(result.current.attachments[0]!.id); }); + await act(async () => { result.current.addWebFiles([file]); }); expect(result.current.attachments[0]!.id).toBe("id-5"); +}); + +test("file-only native paste consumes images once and delegates text/HTML to Rich Text", async () => { + const { result } = setup(); + const preventDefault = vi.fn(), stopPropagation = vi.fn(); + const event = (files: typeof file[], types: string[]) => ({ clipboardData: { files, types, getData: () => "

text

", setData() {} }, preventDefault, stopPropagation }) as unknown as ClipboardEvent; + act(() => { result.current.handlePaste(event([], ["text/html", "text/plain"])); }); + expect(preventDefault).not.toHaveBeenCalled(); expect(stopPropagation).not.toHaveBeenCalled(); + await act(async () => { result.current.handlePaste(event([file], ["Files", "text/html"])); }); + expect(result.current.attachments).toHaveLength(1); expect(preventDefault).toHaveBeenCalledOnce(); expect(stopPropagation).toHaveBeenCalledOnce(); +}); + +test("HTML image-only input shares actual attachment content, History and submit", async () => { + const { result, submit } = setup(); + const input = htmlEvent(`

HTML image

`); + await act(async () => { result.current.handlePaste(input); }); + expect(input.preventDefault).toHaveBeenCalledOnce(); expect(input.stopPropagation).toHaveBeenCalledOnce(); + expect(result.current.attachments[0]).toMatchObject({ name: "HTML image", size: 3, image: { source: image.dataURL, width: 100, height: 50 } }); + expect(composerText(result.current.draft.instruction)).toBe(""); + act(() => { result.current.submit(); }); expect(submit).toHaveBeenCalledWith(result.current.draft); + act(() => { result.current.handleHistoryKeyDown(key("z", true)); }); expect(result.current.attachments).toEqual([]); +}); + +test("Composer leaves structured Rich Text to its original binding before considering HTML", () => { + const { result, readRaster } = setup(); + const input = htmlEvent(``); + Object.defineProperty(input.clipboardData, "types", { value: [RICH_TEXT_CLIPBOARD_MIME, "text/html"] }); + act(() => { result.current.handlePaste(input); }); + expect(input.preventDefault).not.toHaveBeenCalled(); expect(input.stopPropagation).not.toHaveBeenCalled(); + expect(result.current.attachments).toEqual([]); expect(readRaster).not.toHaveBeenCalled(); +}); + +test("unsupported HTML mixed content stays an owned atomic failure without dropping text", async () => { + const { result, readRaster } = setup(); + act(() => { result.current.insertText("Existing draft"); }); + const before = result.current.draft, revision = result.current.editor.snapshot.revision; + const input = htmlEvent(`

BeforeAfter

`); + await act(async () => { result.current.handlePaste(input); }); + expect(input.preventDefault).toHaveBeenCalledOnce(); expect(input.stopPropagation).toHaveBeenCalledOnce(); + expect(result.current.attachmentError?.code).toBe("composer.clipboard.mixed-unsupported"); + expect(result.current.draft).toEqual(before); expect(result.current.editor.snapshot.revision).toBe(revision); + expect(readRaster).not.toHaveBeenCalled(); +}); + +test("HTML and file inputs use the same queue even when later preparation finishes first", async () => { + const first = waiting(), second = waiting(); + const readRaster = vi.fn().mockImplementationOnce(() => first.promise).mockImplementationOnce(() => second.promise); + const { result } = setup(readRaster); + act(() => { result.current.handlePaste(htmlEvent(`First HTML`)); result.current.addWebFiles([file]); result.current.insertText("Typing"); }); + await act(async () => { second.resolve(image); }); expect(result.current.attachments).toEqual([]); + await act(async () => { first.resolve(image); }); + expect(result.current.attachments.map((attachment) => attachment.name)).toEqual(["First HTML", file.name]); + expect(composerText(result.current.draft.instruction)).toBe("Typing"); +}); + +test("HTML observes the current attachment limit before decoding", async () => { + const { result, readRaster } = setup(); + act(() => { result.current.addWebFiles(Array.from({ length: 4 }, (_, index) => ({ name: `note-${index}`, size: 1, type: "text/plain" }))); }); + await act(async () => { result.current.handlePaste(htmlEvent(``)); }); + expect(result.current.attachmentError?.code).toBe("file-intake.limit"); expect(result.current.attachments).toHaveLength(4); + expect(readRaster).not.toHaveBeenCalled(); +}); + +test("HTML cancellation aborts its reader and ignores later completion", async () => { + const pending = waiting(), readRaster = vi.fn(() => pending.promise); + const { result } = setup(readRaster); + act(() => { result.current.handlePaste(htmlEvent(``)); result.current.cancelAttachments(); }); + expect(readRaster.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(image); }); expect(result.current.attachments).toEqual([]); +}); diff --git a/packages/json-document-composer-react/tests/composer-react.test.tsx b/packages/json-document-composer-react/tests/composer-react.test.tsx index cb0c06dab..dadfaad1f 100644 --- a/packages/json-document-composer-react/tests/composer-react.test.tsx +++ b/packages/json-document-composer-react/tests/composer-react.test.tsx @@ -37,6 +37,24 @@ describe("Composer React integration", () => { fireEvent.keyDown(screen.getByTestId("mod-keyboard"), { key: "Enter", ctrlKey: true }); expect(modSubmit).toHaveBeenCalledTimes(1); }); + + test.each(["metaKey", "ctrlKey"] as const)("preserves Alt through the real %s history event path", (modifier) => { + render(); + fireEvent.click(screen.getByRole("button", { name: "history-text" })); + const target = screen.getByTestId("history-keyboard"); + const draft = screen.getByTestId("history-draft"); + const inserted = draft.textContent; + expect(inserted).toContain("hello"); + expect(fireEvent.keyDown(target, { key: "z", [modifier]: true, altKey: true })).toBe(true); + expect(draft.textContent).toBe(inserted); + expect(fireEvent.keyDown(target, { key: "z", [modifier]: true })).toBe(false); + expect(draft.textContent).not.toContain("hello"); + const undone = draft.textContent; + expect(fireEvent.keyDown(target, { key: "Z", [modifier]: true, shiftKey: true, altKey: true })).toBe(true); + expect(draft.textContent).toBe(undone); + expect(fireEvent.keyDown(target, { key: "Z", [modifier]: true, shiftKey: true })).toBe(false); + expect(draft.textContent).toBe(inserted); + }); }); function hostConfig(model: Model, submit: "enter" | "mod-enter"): ComposerHostConfig { @@ -57,7 +75,7 @@ function ComposerHostHarness(props: { readonly host: strin ports: { createId: () => `${props.host}-${++id}`, submit: props.submit }, labels: { mentionSuggestions: `${props.host} mentions`, skillSuggestions: `${props.host} skills` }, }); - return
+ return
{JSON.stringify(composer.document.value)}
; diff --git a/packages/json-document-composer-react/tsconfig.json b/packages/json-document-composer-react/tsconfig.json index 3340baf95..4fd7db963 100644 --- a/packages/json-document-composer-react/tsconfig.json +++ b/packages/json-document-composer-react/tsconfig.json @@ -8,6 +8,8 @@ "references": [ { "path": "../json-document" }, { "path": "../json-document-composer" }, + { "path": "../json-document-editing" }, + { "path": "../json-document-file-intake" }, { "path": "../json-document-rich-text" }, { "path": "../json-document-rich-text-mention" }, { "path": "../json-document-rich-text-mention-react" }, diff --git a/packages/json-document-composer/README.md b/packages/json-document-composer/README.md index 32b8262fd..508b8d4d8 100644 --- a/packages/json-document-composer/README.md +++ b/packages/json-document-composer/README.md @@ -9,3 +9,34 @@ those validated candidates into Composer context attachments. `resolveComposerSuggestions(trigger, suggestions)` owns trigger-aware matching of a product-configured suggestion catalog. React menu lifecycle and atom projection live in `@interactive-os/json-document-composer-react`. + +## 이미지 첨부 + +`ComposerAttachment`와 `ComposerAttachmentCandidate`의 선택적 `image`는 File Intake의 +`RasterImageContent`입니다. 기존 metadata-only 첨부는 그대로 유효합니다. +`createComposerAttachments`는 image source·치수와 media type을 검사한 뒤 ID를 할당하고, +`addComposerAttachments`는 한 batch를 기존 Rich Text editor의 한 편집/Undo로 추가합니다. +이미지 내용은 같은 draft JSON에 남으므로 별도의 임시 blob URL에 의존하지 않습니다. + +```ts +const prepared = createComposerAttachments([ + { name: "screenshot.png", size: fileSize, mediaType: "image/png", image: decodedImage }, +], { policy, createId, currentCount: draft.attachments.length }); +if (prepared.ok) addComposerAttachments(editor, draft, prepared.attachments); +``` + +`image`가 없는 첨부는 파일 이름·크기·형식 정보뿐입니다. 실제 byte 저장이나 서버 업로드가 +완료된 파일이라고 해석하지 않습니다. Clipboard HTML의 글+이미지 변환과 이미지 asset +저장소 연결은 TBD입니다. 실제 Usage·Source는 [Composer](/demo/composer)에 있습니다. + +`composerInteractionFromKeyStroke(stroke, policy)` preserves the existing +`commandKey` input (Meta or Control) and accepts optional `altKey` alongside +`shiftKey`. Omitted modifiers are false. Its keyboard compatibility boundary +uses `@interactive-os/json-document-web`'s pure default resolver for Undo/Redo: +Mod+Z undoes, Mod+Shift+Z redoes, and Alt-modified variants return `null`. +Composer still owns Escape and the configured Enter submit/newline meaning. +The keyboard dependency is confined to `interaction.ts`; draft model, schema, +and commands do not interpret Web events. No DOM environment is required. + +Usage: [Composer](https://developer-1px.github.io/json-document/demo/composer). +The React integration passes all modifier facts to this boundary. diff --git a/packages/json-document-composer/package.json b/packages/json-document-composer/package.json index b0d1612e0..49ec3d430 100644 --- a/packages/json-document-composer/package.json +++ b/packages/json-document-composer/package.json @@ -21,6 +21,7 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document-web": "^0.1.0-rc.0", "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", @@ -28,6 +29,7 @@ "@interactive-os/json-document-rich-text": "^0.1.0-rc.0" }, "devDependencies": { + "@interactive-os/json-document-web": "*", "@interactive-os/json-document": "*", "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-rich-text-mention": "*", diff --git a/packages/json-document-composer/src/commands.ts b/packages/json-document-composer/src/commands.ts index 8891ead72..76317127e 100644 --- a/packages/json-document-composer/src/commands.ts +++ b/packages/json-document-composer/src/commands.ts @@ -5,7 +5,7 @@ import { type RichTextNode, type RichTextPoint, } from "@interactive-os/json-document-rich-text"; -import { validateFileCandidates } from "@interactive-os/json-document-file-intake"; +import { assertRasterImageContent, validateFileCandidates } from "@interactive-os/json-document-file-intake"; import { insertRichTextMention } from "@interactive-os/json-document-rich-text-mention"; import { COMPOSER_MENTION_NODE, COMPOSER_PROFILE_V1, COMPOSER_SKILL_NODE, type ComposerAttachment, type ComposerAttachmentCandidate, type ComposerDraft, type ComposerReference, type ComposerTrigger } from "./model.js"; import type { ComposerAttachmentPolicy } from "./host-config.js"; @@ -23,7 +23,18 @@ export function createComposerAttachments( ): ComposerAttachmentResult { const validated = validateFileCandidates(candidates, options.policy, options.currentCount === undefined ? {} : { currentCount: options.currentCount }); if (!validated.ok) return { ok: false, code: composerAttachmentError(validated.code), candidate: validated.candidate }; - const attachments: ComposerAttachment[] = validated.candidates.map((candidate) => ({ id: options.createId(), kind: candidate.mediaType?.startsWith("image/") ? "image" : "document", ...candidate })); + for (const candidate of candidates) { + if (candidate.image === undefined) continue; + try { + assertRasterImageContent(candidate.image); + if (!candidate.image.source.startsWith(`data:${candidate.mediaType};base64,`)) throw new TypeError("Attachment media type does not match its image."); + } catch { return { ok: false, code: "composer.attachments.invalid", candidate }; } + } + const attachments: ComposerAttachment[] = validated.candidates.map((candidate) => ({ + ...candidate, + id: options.createId(), kind: candidate.mediaType?.startsWith("image/") ? "image" : "document", + ...(candidate.image ? { image: { source: candidate.image.source, width: candidate.image.width, height: candidate.image.height } } : {}), + })); return { ok: true, attachments }; } diff --git a/packages/json-document-composer/src/interaction.ts b/packages/json-document-composer/src/interaction.ts index d37f422e3..d1eed3e43 100644 --- a/packages/json-document-composer/src/interaction.ts +++ b/packages/json-document-composer/src/interaction.ts @@ -1,19 +1,32 @@ +import { createWebKeyboardAdapter } from "@interactive-os/json-document-web"; import type { ComposerInteractionPolicy } from "./host-config.js"; +const keyboard = createWebKeyboardAdapter(); + export interface ComposerKeyStroke { readonly key: string; readonly shiftKey?: boolean; readonly commandKey?: boolean; + readonly altKey?: boolean; } export type ComposerInteraction = "dismiss" | "history.redo" | "history.undo" | "newline" | "submit"; +/** Uses the Web default history keymap, then applies Composer submit/newline policy. */ export function composerInteractionFromKeyStroke( stroke: ComposerKeyStroke, policy: ComposerInteractionPolicy, ): ComposerInteraction | null { if (stroke.key === "Escape") return "dismiss"; - if (stroke.commandKey && stroke.key.toLowerCase() === "z") return stroke.shiftKey ? "history.redo" : "history.undo"; + const command = keyboard.resolve({ + key: stroke.key, + shiftKey: stroke.shiftKey ?? false, + metaKey: stroke.commandKey ?? false, + ctrlKey: false, + altKey: stroke.altKey ?? false, + }); + if (command?.type === "undo") return "history.undo"; + if (command?.type === "redo") return "history.redo"; if (stroke.key !== "Enter") return null; const submits = policy.submit === "mod-enter" ? stroke.commandKey === true : stroke.commandKey !== true && stroke.shiftKey !== true; if (submits) return "submit"; diff --git a/packages/json-document-composer/src/model.ts b/packages/json-document-composer/src/model.ts index dc0891b74..dedce4cd7 100644 --- a/packages/json-document-composer/src/model.ts +++ b/packages/json-document-composer/src/model.ts @@ -1,5 +1,5 @@ import type { JSONValue } from "@interactive-os/json-document"; -import type { FileCandidate } from "@interactive-os/json-document-file-intake"; +import type { FileCandidate, RasterImageContent } from "@interactive-os/json-document-file-intake"; import type { RichTextDocument } from "@interactive-os/json-document-rich-text"; import { RICH_TEXT_MENTION_NODE, type RichTextMention } from "@interactive-os/json-document-rich-text-mention"; @@ -11,15 +11,17 @@ export type ComposerReference = | ({ readonly kind: "mention" } & RichTextMention) | { readonly kind: "skill"; readonly id: string; readonly label: string }; -export interface ComposerAttachment extends Record { +export type ComposerAttachment = Record & { readonly id: string; readonly kind: "document" | "image"; readonly name: string; readonly size: number; readonly mediaType: string | null; -} + /** Absent for metadata-only attachments. Presence retains actual embedded raster content. */ + readonly image?: RasterImageContent; +}; -export type ComposerAttachmentCandidate = FileCandidate; +export type ComposerAttachmentCandidate = FileCandidate & { readonly image?: RasterImageContent }; export interface ComposerDraft extends Record { readonly id: string; diff --git a/packages/json-document-composer/tests/composer.test.ts b/packages/json-document-composer/tests/composer.test.ts index 7594cb172..0f0d4fe44 100644 --- a/packages/json-document-composer/tests/composer.test.ts +++ b/packages/json-document-composer/tests/composer.test.ts @@ -111,6 +111,41 @@ describe("Composer domain", () => { expect((document.value as typeof draft).attachments).toEqual([]); }); + test("owns image content and preserves it through JSON and History", () => { + const image = { source: "data:image/png;base64,AAAA", width: 64, height: 32 }; + const created = createComposerAttachments( + [{ name: "brief.png", size: 3, mediaType: "image/png", image }], + { createId: () => "image-1", policy: { acceptedMediaTypes: ["image/*"], maxFiles: 2, maxBytesPerFile: 100 } }, + ); + expect(created.ok).toBe(true); + if (!created.ok) return; + image.width = 1; + expect(created.attachments[0]?.image?.width).toBe(64); + const draft = createComposerDraft({ id: "draft", instructionId: "instruction", paragraphId: "paragraph", model: "fast" }); + const document = createJSONDocument(draft); + const editor = createRichTextEditor({ document, pointer: "/instruction", schema: composerSchema }); + expect(addComposerAttachments(editor, draft, created.attachments).ok).toBe(true); + expect(JSON.parse(JSON.stringify(document.value)).attachments[0].image).toEqual({ source: image.source, width: 64, height: 32 }); + expect(editor.undo().ok).toBe(true); + expect((document.value as typeof draft).attachments).toEqual([]); + expect(editor.redo().ok).toBe(true); + expect((document.value as typeof draft).attachments).toEqual(created.attachments); + }); + + test.each([ + { source: "https://example.com/image.png", width: 64, height: 32 }, + { source: "data:image/jpeg;base64,AAAA", width: 64, height: 32 }, + { source: "data:image/png;base64,AAAA", width: 0, height: 32 }, + ])("rejects an invalid image batch before allocating IDs: %j", (image) => { + let ids = 0; + const result = createComposerAttachments([ + { name: "valid.txt", size: 1, mediaType: "text/plain" }, + { name: "invalid.png", size: 3, mediaType: "image/png", image }, + ], { createId: () => String(++ids), policy: { acceptedMediaTypes: ["*/*"], maxFiles: null, maxBytesPerFile: null } }); + expect(result).toMatchObject({ ok: false, code: "composer.attachments.invalid" }); + expect(ids).toBe(0); + }); + test("resolves product-configured Composer interaction meaning", () => { const policy = { submit: "enter", newline: "shift-enter" } as const; expect(composerInteractionFromKeyStroke({ key: "Enter" }, policy)).toBe("submit"); @@ -120,3 +155,16 @@ describe("Composer domain", () => { expect(composerInteractionFromKeyStroke({ key: "Escape" }, policy)).toBe("dismiss"); }); }); + + +describe("Composer default history keyboard", () => { + for (const commandKey of [false, true]) for (const shiftKey of [false, true]) for (const altKey of [false, true]) { + test(`history command=${commandKey} shift=${shiftKey} alt=${altKey}`, () => { + for (const key of ["z", "Z"]) { + const stroke = { key, commandKey, shiftKey, altKey }; + expect(composerInteractionFromKeyStroke(stroke, { submit: "enter", newline: "shift-enter" })) + .toBe(commandKey && !altKey ? shiftKey ? "history.redo" : "history.undo" : null); + } + }); + } +}); diff --git a/packages/json-document-composer/tsconfig.json b/packages/json-document-composer/tsconfig.json index 04c4097d3..074937d34 100644 --- a/packages/json-document-composer/tsconfig.json +++ b/packages/json-document-composer/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ { "path": "../json-document" }, + { "path": "../json-document-web" }, { "path": "../json-document-file-intake" }, { "path": "../json-document-rich-text-mention" }, { "path": "../json-document-rich-text-suggestion" }, diff --git a/packages/json-document-database/README.md b/packages/json-document-database/README.md index 01e767e28..9ab389660 100644 --- a/packages/json-document-database/README.md +++ b/packages/json-document-database/README.md @@ -1,8 +1,9 @@ # @interactive-os/json-document-database Enterprise React Database Hands for existing schemas and CRUD APIs. The host -owns data, authorization, and business rules; the package owns the interaction -quality of querying, projecting, editing, and recovering from failures. +owns data, authorization, and business rules; `@interactive-os/json-document-editing` +owns the saved-view projection contract, and this package owns the React interaction +quality of querying, editing, and recovering from failures. ```tsx import { Database, createDatabaseResource, createDatabaseView } from "@interactive-os/json-document-database"; @@ -57,3 +58,11 @@ private surface. This package does not implement a backend, authentication, authorization policy, database migration, formula runtime, or product business rules. + +## Saved-view 저장 형태 전환 + +Saved view는 Editing의 `DatabaseTableView.projection`으로 저장합니다. 기존 +`type/propertyOrder/propertyVisibility/propertyWidths/sort/filter` 형태의 view는 +새 `layout/ownership/projection` 형태로 전환해야 하며 자동 마이그레이션하지 않습니다. +`projection`은 `search`, `filter` group, `sorts`, `groups`, `columns`를 소유합니다. +문서의 schema·records와 legacy Zod records 입력은 계속 지원합니다. diff --git a/packages/json-document-database/src/contracts.ts b/packages/json-document-database/src/contracts.ts index 63e6bb3f2..804a8cfb6 100644 --- a/packages/json-document-database/src/contracts.ts +++ b/packages/json-document-database/src/contracts.ts @@ -1,4 +1,5 @@ import type { ZodType } from "zod/v4"; +import type { DatabaseTableView } from "@interactive-os/json-document-editing"; export type DatabaseRow = Record; export type DatabaseRowId = string; @@ -10,64 +11,8 @@ export interface DatabaseResource readonly createDraft: () => Create; } -export type DatabaseFilterOperator = - | "equals" - | "not-equals" - | "contains" - | "greater-than" - | "greater-than-or-equal" - | "less-than" - | "less-than-or-equal" - | "is-empty"; - -export interface DatabaseFilterRule { - readonly id: string; - readonly propertyId: string; - readonly operator: DatabaseFilterOperator; - readonly value?: unknown; -} - -export interface DatabaseFilterGroup { - readonly id: string; - readonly conjunction: "and" | "or"; - readonly items: ReadonlyArray; -} - -export interface DatabaseSortRule { - readonly propertyId: string; - readonly direction: "ascending" | "descending"; -} - -export interface DatabaseGroupRule { - readonly propertyId: string; - readonly direction: "ascending" | "descending"; -} - -export interface DatabaseColumnProjection { - readonly propertyId: string; - readonly visible: boolean; - readonly width?: number; - readonly pinned?: "start" | "end"; -} - -export interface DatabaseProjection { - readonly search: string; - readonly filter: DatabaseFilterGroup; - readonly sorts: ReadonlyArray; - readonly groups: ReadonlyArray; - readonly columns: ReadonlyArray; -} - -export interface DatabaseViewDocument { - readonly id: string; - readonly name: string; - readonly ownership: "personal" | "shared" | "locked"; - readonly layout: "table"; - readonly projection: DatabaseProjection; -} - export interface DatabaseQueryRequest { - readonly view: DatabaseViewDocument; + readonly view: DatabaseTableView; readonly cursor?: string; readonly pageSize: number; readonly signal: AbortSignal; @@ -140,8 +85,8 @@ export function createDatabaseView( id: string, name: string, propertyIds: ReadonlyArray, - ownership: DatabaseViewDocument["ownership"] = "personal", -): DatabaseViewDocument { + ownership: DatabaseTableView["ownership"] = "personal", +): DatabaseTableView { return { id, name, @@ -152,7 +97,7 @@ export function createDatabaseView( filter: { id: `${id}:root`, conjunction: "and", items: [] }, sorts: [], groups: [], - columns: propertyIds.map((propertyId) => ({ propertyId, visible: propertyId !== "id" })), + columns: propertyIds.map((propertyId) => ({ propertyId, visible: propertyId !== "id", width: null, pinned: null })), }, }; } diff --git a/packages/json-document-database/src/database-context.tsx b/packages/json-document-database/src/database-context.tsx index b816af2cc..664eca681 100644 --- a/packages/json-document-database/src/database-context.tsx +++ b/packages/json-document-database/src/database-context.tsx @@ -15,8 +15,8 @@ import type { DatabaseResource, DatabaseRow, DatabaseRowId, - DatabaseViewDocument, } from "./contracts.js"; +import type { DatabaseTableView } from "@interactive-os/json-document-editing"; import { DatabaseOperationError } from "./contracts.js"; export interface DatabaseStatus { @@ -31,15 +31,15 @@ export interface DatabaseContextValue { readonly rows: ReadonlyArray; readonly total: number; readonly nextCursor?: string; - readonly view: DatabaseViewDocument; - readonly views: ReadonlyArray; + readonly view: DatabaseTableView; + readonly views: ReadonlyArray; readonly status: DatabaseStatus; readonly capabilities: Required; readonly selectedRowIds: ReadonlyArray; readonly activeRow: Row | null; readonly isCreating: boolean; - setView(view: DatabaseViewDocument): void; - saveView(view: DatabaseViewDocument): Promise; + setView(view: DatabaseTableView): void; + saveView(view: DatabaseTableView): Promise; selectRows(ids: ReadonlyArray): void; openRow(row: Row | null): void; startCreate(): void; @@ -66,11 +66,11 @@ const defaultCapabilities: Required = { export interface DatabaseProviderProps, Update = Partial> { readonly resource: DatabaseResource; readonly operations: DatabaseOperations; - readonly defaultView: DatabaseViewDocument; - readonly view?: DatabaseViewDocument; - readonly onViewChange?: (view: DatabaseViewDocument) => void; - readonly views?: ReadonlyArray; - readonly onSaveView?: (view: DatabaseViewDocument) => Promise | void; + readonly defaultView: DatabaseTableView; + readonly view?: DatabaseTableView; + readonly onViewChange?: (view: DatabaseTableView) => void; + readonly views?: ReadonlyArray; + readonly onSaveView?: (view: DatabaseTableView) => Promise | void; readonly capabilities?: DatabaseCapabilities; readonly pageSize?: number; readonly children: ReactNode; @@ -124,13 +124,13 @@ export function DatabaseProvider, return () => queryController.current?.abort(); }, [activeView]); // eslint-disable-line react-hooks/exhaustive-deps - function setView(next: DatabaseViewDocument) { + function setView(next: DatabaseTableView) { if (!capabilities.configureView || activeView.ownership === "locked") return; if (props.view === undefined) setInternalView(next); props.onViewChange?.(next); } - async function saveView(next: DatabaseViewDocument) { + async function saveView(next: DatabaseTableView) { if (!capabilities.saveView || next.ownership === "locked") return; await props.onSaveView?.(next); setStatus({ phase: "ready", message: `View ${next.name} saved` }); diff --git a/packages/json-document-database/src/database-hand.tsx b/packages/json-document-database/src/database-hand.tsx index 73615c69c..830b91b1a 100644 --- a/packages/json-document-database/src/database-hand.tsx +++ b/packages/json-document-database/src/database-hand.tsx @@ -18,11 +18,13 @@ import { type DatabaseClipboard, type DatabaseEditor, type DatabaseFilter, + type DatabaseFilterGroup, type DatabaseProperty, type DatabaseRecord, type DatabaseSelection, type DatabaseSort, type DatabaseTableView, + type DatabaseTopology, type EditingResult, type EditingSnapshot, } from "@interactive-os/json-document-editing"; @@ -38,14 +40,17 @@ import { import { databaseDocumentFromZod } from "@interactive-os/json-document-zod"; import { Check, Command, GridCell, Toolbar, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; import type { InteractionHandleEvent } from "@interactive-os/json-document-affordance"; +import { DatabasePropertyControl } from "./database-property-control.js"; import type { ZodType } from "zod/v4"; import type { JSONValue } from "@interactive-os/json-document"; -import { ArrowDown, ArrowUp, Columns3, Minus, Plus, Redo2, Undo2, X } from "lucide-react"; +import { ArrowDown, ArrowUp, Minus, Plus, Redo2, Undo2 } from "lucide-react"; +import { DatabaseViewControls } from "./database-view-controls.js"; export interface DatabaseHandChange { readonly records: ReadonlyArray; readonly origin: "cell.commit" | "record.add" | "record.delete" | "undo" | "redo"; readonly revision: number; + readonly updates?: ReadonlyArray<{ readonly recordId: string; readonly patch: Partial }>; } export interface DatabaseHandFeatures { @@ -149,6 +154,7 @@ export interface DatabaseHandDocumentChange { } type DatabaseHandOrigin = DatabaseHandChange>["origin"] | "view.configure"; +type DatabaseHandEmission = Pick, "origin" | "updates">; export type DatabaseHandProps> = DatabaseHandCommonProps & ( | DatabaseHandEditorSource @@ -201,10 +207,10 @@ function DatabaseHandDocumentProfile>(props: if (lastEmitted.current === fingerprint) { lastEmitted.current = null; return; } if (open.fingerprint !== fingerprint) setOpen({ editor: createDatabaseEditor(props.document), fingerprint }); }, [fingerprint, open.fingerprint, props.document]); - return { + return { const document = open.editor.snapshot.value as DatabaseDocument; lastEmitted.current = recordsFingerprint([document]); - props.onDocumentChange(document, { origin, revision: open.editor.snapshot.revision }); + props.onDocumentChange(document, { origin: change.origin, revision: open.editor.snapshot.revision }); }} />; } @@ -238,14 +244,14 @@ function DatabaseHandLegacyProfile>(props: D viewId={(open.editor.snapshot.value as DatabaseDocument).views[0]!.id} features={features} labels={labels} - onEmit={(origin) => { - if (origin === "view.configure") return; + onEmit={(change) => { + if (change.origin === "view.configure") return; const records = hostRecords(open.editor.snapshot.value as DatabaseDocument); const fingerprint = recordsFingerprint([records, props.presentation]); lastEmitted.current = fingerprint; props.onRecordsChange?.(records, { records, - origin, + ...change, revision: open.editor.snapshot.revision, }); }} @@ -259,13 +265,12 @@ function DatabaseTableSurface>(props: Databa readonly directEditing?: boolean; readonly features: Required; readonly labels: Required; - readonly onEmit: (origin: DatabaseHandOrigin) => void; + readonly onEmit: (change: DatabaseHandEmission | { readonly origin: "view.configure" }) => void; }) { const { editor } = props; const [announcement, setAnnouncement] = useState(""); const [lastResult, setLastResult] = useState | null>(null); const [nativeTextLease, setNativeTextLease] = useState(null); - const [filterPropertyId, setFilterPropertyId] = useState(""); const [editingKey, setEditingKey] = useState(null); const [editingInitialValue, setEditingInitialValue] = useState(); const [headerMenu, setHeaderMenu] = useState<{ readonly propertyId: string; readonly x: number; readonly y: number } | null>(null); @@ -275,12 +280,19 @@ function DatabaseTableSurface>(props: Databa const snapshot = useEditingSnapshot(editor); const document = snapshot.value as DatabaseDocument; const view = document.views.find((candidate) => candidate.id === props.viewId) ?? document.views[0]!; + const columns = view.projection.columns; + const propertyOrder = columns.map((column) => column.propertyId); + const propertyVisibility = Object.fromEntries(columns.map((column) => [column.propertyId, column.visible])); + const propertyWidths = Object.fromEntries(columns.flatMap((column) => column.width === null ? [] : [[column.propertyId, column.width]])); + const propertyPinned = Object.fromEntries(columns.flatMap((column) => column.pinned === null ? [] : [[column.propertyId, column.pinned]])); + const sort = view.projection.sorts[0] ?? null; + const filter = firstFilter(view.projection.filter); const topology = editor.tableTopology(view.id); - const properties = view.propertyOrder - .filter((id) => view.propertyVisibility[id] !== false) + const properties = propertyOrder + .filter((id) => propertyVisibility[id] !== false) .map((id) => document.schema.properties.find((property) => property.id === id)!) .filter(Boolean); - const hiddenProperties = document.schema.properties.filter((property) => view.propertyVisibility[property.id] === false); + const hiddenProperties = document.schema.properties.filter((property) => propertyVisibility[property.id] === false); const records = topology.recordIds.map((id) => document.records.find((record) => record.id === id)!).filter(Boolean); const focus = snapshot.selection.focus; const editing = useEditing({ @@ -331,14 +343,14 @@ function DatabaseTableSurface>(props: Databa return result; } - function emit(origin: DatabaseHandChange["origin"], message: string) { + function emit(change: DatabaseHandEmission, message: string) { announce(message); - props.onEmit(origin); + props.onEmit(change); } function commit(recordId: string, propertyId: string, value: string | number | boolean) { const result = observe(editor.dispatch({ type: "cell.commit", recordId, propertyId, value })); - if (result.ok) emit("cell.commit", `${propertyId} saved`); + if (result.ok) emit({ origin: "cell.commit", updates: [{ recordId, patch: { [propertyId]: value } as Partial }] }, `${propertyId} saved`); else announce(result.code); } @@ -359,19 +371,19 @@ function DatabaseTableSurface>(props: Databa recordId: id, ...(values === undefined ? {} : { values }), })); - if (result.ok) emit("record.add", "Record added"); + if (result.ok) emit({ origin: "record.add" }, "Record added"); } function deleteSelected() { const recordId = snapshot.selection.focus?.recordId; if (!recordId) return announce("Select a record first"); const result = observe(editor.dispatch({ type: "record.delete", recordId })); - if (result.ok) emit("record.delete", "Record deleted"); + if (result.ok) emit({ origin: "record.delete" }, "Record deleted"); } function history(kind: "undo" | "redo") { const result = observe(editor[kind]()); - if (result.ok) emit(kind, kind === "undo" ? "Undone" : "Redone"); + if (result.ok) emit({ origin: kind }, kind === "undo" ? "Undone" : "Redone"); } function configure(input: { @@ -381,8 +393,20 @@ function DatabaseTableSurface>(props: Databa readonly propertyOrder?: ReadonlyArray; readonly propertyWidths?: Readonly>; }) { - const result = observe(editor.dispatch({ type: "view.configure", viewId: view.id, ...input })); - if (result.ok) props.onEmit("view.configure"); + const nextSort = input.sort === undefined ? view.projection.sorts : input.sort === null ? [] : [input.sort]; + const nextFilter = input.filter === undefined ? view.projection.filter : filterGroup(view.id, input.filter); + const nextColumns = (input.propertyOrder ?? propertyOrder).map((propertyId) => { + const current = columns.find((column) => column.propertyId === propertyId); + const width = input.propertyWidths?.[propertyId] ?? current?.width; + return { + propertyId, + visible: input.propertyVisibility?.[propertyId] ?? current?.visible ?? true, + width: width ?? null, + pinned: current?.pinned ?? null, + }; + }); + const result = observe(editor.dispatch({ type: "view.configure", viewId: view.id, projection: { ...view.projection, sorts: nextSort, filter: nextFilter, columns: nextColumns } })); + if (result.ok) props.onEmit({ origin: "view.configure" }); announce(result.ok ? "View updated" : result.code); } @@ -453,7 +477,7 @@ function DatabaseTableSurface>(props: Databa return; } if (result.operation === "copy") announce("Selection copied"); - if (result.operation === "paste") emit("cell.commit", "Selection pasted"); + if (result.operation === "paste") emit({ origin: "cell.commit", updates: clipboardUpdates(result.payload, topology, snapshot.selection.focus) }, "Selection pasted"); }, }); @@ -474,35 +498,7 @@ function DatabaseTableSurface>(props: Databa history("redo")}> ) : null} - {props.features.filter ? ( - configure({ filter })} - /> - ) : null} - {props.features.columns ? ( -
- -
- {document.schema.properties.map((property) => ( - - ))} -
-
- ) : null} + {props.features.filter || props.features.columns ? { const result = observe(editor.dispatch({ type: "view.configure", viewId: view.id, projection })); if (result.ok) props.onEmit({ origin: "view.configure" }); announce(result.ok ? "View updated" : result.code); }} /> : null} {props.toolbar} {props.renderToolbar?.(context)} {announcement ? {announcement} : null} @@ -524,13 +520,13 @@ function DatabaseTableSurface>(props: Databa key={property.id} scope="col" aria-label={`${property.name} ${property.type}`} - aria-sort={ariaSort(view.sort, property.id)} + aria-sort={ariaSort(sort, property.id)} draggable onDragStart={() => setDraggedPropertyId(property.id)} onDragOver={(event) => event.preventDefault()} onDrop={() => { if (!draggedPropertyId || draggedPropertyId === property.id) return; - const order = [...view.propertyOrder]; + const order = [...propertyOrder]; const from = order.indexOf(draggedPropertyId); const to = order.indexOf(property.id); if (from < 0 || to < 0) return; @@ -543,23 +539,23 @@ function DatabaseTableSurface>(props: Databa event.preventDefault(); setHeaderMenu({ propertyId: property.id, x: event.clientX, y: event.clientY }); }} - style={{ ...columnStyle(property.id, properties, view.propertyWidths, props.presentation?.propertyPinned), position: "relative" }} - data-pinned={props.presentation?.propertyPinned?.[property.id]} + style={{ ...columnStyle(property.id, properties, propertyWidths, propertyPinned), position: "relative" }} + data-pinned={propertyPinned[property.id]} > - configure({ sort: nextDatabasePropertySort(view.sort, property.id) })}> + configure({ sort: nextDatabasePropertySort(sort, property.id) })}> {property.name} - {property.type}{sortMark(view.sort, property.id)} + {property.type}{sortMark(sort, property.id)} configure({ propertyWidths: { ...view.propertyWidths, [property.id]: width } })} + width={propertyWidths[property.id] ?? 160} + onCommit={(width) => configure({ propertyWidths: { ...propertyWidths, [property.id]: width } })} /> ))} {hiddenProperties.map((property) => ( - configure({ propertyVisibility: { ...view.propertyVisibility, [property.id]: true } })}>· + configure({ propertyVisibility: { ...propertyVisibility, [property.id]: true } })}>· ))} Row @@ -591,8 +587,8 @@ function DatabaseTableSurface>(props: Databa setEditingKey(key); requestAnimationFrame(() => findWebGridCell(tableRef.current, point)?.querySelector("input, select")?.focus()); }} - style={columnStyle(property.id, properties, view.propertyWidths, props.presentation?.propertyPinned)} - data-pinned={props.presentation?.propertyPinned?.[property.id]} + style={columnStyle(property.id, properties, propertyWidths, propertyPinned)} + data-pinned={propertyPinned[property.id]} > {custom ? custom({ property, @@ -633,9 +629,9 @@ function DatabaseTableSurface>(props: Databa const property = document.schema.properties.find((candidate) => candidate.id === headerMenu.propertyId); if (!property) return null; return
- { configure({ propertyVisibility: { ...view.propertyVisibility, [property.id]: false } }); setHeaderMenu(null); }}>Hide - {filterItems(property).map((item) => { configure({ filter: { propertyId: property.id, operator: "equals", value: item.value } }); setHeaderMenu(null); }}>Filter {item.label})} - {view.filter?.propertyId === property.id ? { configure({ filter: null }); setHeaderMenu(null); }}>Clear filter : null} + { configure({ propertyVisibility: { ...propertyVisibility, [property.id]: false } }); setHeaderMenu(null); }}>Hide + {filterItems(property).map((item) => { configure({ filter: { id: `${view.id}:filter`, propertyId: property.id, operator: "equals", value: item.value } }); setHeaderMenu(null); }}>Filter {item.label})} + {filter?.propertyId === property.id ? { configure({ filter: null }); setHeaderMenu(null); }}>Clear filter : null}
; })() : null}
@@ -704,28 +700,21 @@ function DefaultCell(props: { requestAnimationFrame(() => cell?.focus()); } if (props.readOnly || (!props.editing && !props.directEditing)) return {String(value)}; - if (props.property.type === "checkbox") { - return finish(event.currentTarget.checked, event.currentTarget)} onBlur={props.finish} />; - } - if (props.property.type === "select") { - return ( - - ); - } return ( - (props.property.type === "title" || props.property.type === "text") && props.onLease(false)} onCompositionStart={() => props.onLease(true)} onCompositionEnd={() => props.onLease(false)} - onBlur={(event) => { - if (props.directEditing) props.commit(databaseValueFromText(props.property, event.currentTarget.value)); + onChange={(next) => finish(next, document.activeElement as HTMLInputElement | HTMLSelectElement)} + onBlur={(next) => { + if (props.directEditing) props.commit(next); props.onLease(null); props.finish(); }} @@ -733,7 +722,10 @@ function DefaultCell(props: { cancel(event); if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); - finish(databaseValueFromText(props.property, event.currentTarget.value), event.currentTarget); + const next = event.currentTarget instanceof HTMLInputElement && event.currentTarget.type === "checkbox" + ? event.currentTarget.checked + : databaseValueFromText(props.property, event.currentTarget.value); + finish(next, event.currentTarget); props.moveAfterCommit(event.key === "Tab" ? (event.shiftKey ? "left" : "right") : (event.shiftKey ? "up" : "down")); } }} @@ -741,58 +733,6 @@ function DefaultCell(props: { ); } -function FilterControl(props: { - readonly properties: ReadonlyArray; - readonly propertyId: string; - readonly filter: DatabaseFilter | null; - readonly labels: Required; - readonly onProperty: (id: string) => void; - readonly onFilter: (filter: DatabaseFilter | null) => void; -}) { - const propertyId = props.propertyId || props.filter?.propertyId || props.properties[0]?.id || ""; - const property = props.properties.find((candidate) => candidate.id === propertyId); - const value = props.filter?.propertyId === propertyId ? props.filter.value : ""; - return ( -
- - {property ? props.onFilter({ propertyId, operator: "equals", value: next })} /> : null} - {props.filter ? props.onFilter(null)}> : null} -
- ); -} - -function FilterValue(props: { readonly property: DatabaseProperty; readonly value: unknown; readonly onChange: (value: string | number | boolean) => void }) { - if (props.property.type === "checkbox") { - return ( - - ); - } - if (props.property.type === "select") { - return ( - - ); - } - return ( - props.onChange(databaseValueFromText(props.property, event.currentTarget.value))} - /> - ); -} - 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 }]; @@ -820,19 +760,35 @@ function openDatabase>( const available = translated.value.schema.properties.map((property) => property.id); const order = presentation?.propertyOrder ? [...presentation.propertyOrder.filter((id) => available.includes(id)), ...available.filter((id) => !presentation.propertyOrder!.includes(id))] - : firstView.propertyOrder; + : firstView.projection.columns.map((column) => column.propertyId); const value: DatabaseDocument = { ...translated.value, views: [{ ...firstView, - propertyOrder: order, - propertyVisibility: presentation?.propertyVisibility ?? firstView.propertyVisibility, - propertyWidths: presentation?.propertyWidths ?? firstView.propertyWidths, + projection: { + ...firstView.projection, + columns: order.map((propertyId) => ({ + propertyId, + visible: presentation?.propertyVisibility?.[propertyId] ?? true, + width: presentation?.propertyWidths?.[propertyId] ?? null, + pinned: presentation?.propertyPinned?.[propertyId] ?? null, + })), + }, }], }; return { ok: true, editor: createDatabaseEditor(value), fingerprint }; } +function firstFilter(group: DatabaseFilterGroup): DatabaseFilter | null { + for (const item of group.items) { + const found = isFilter(item) ? item : firstFilter(item); + if (found) return found; + } + return null; +} +function isFilter(item: DatabaseFilter | DatabaseFilterGroup): item is DatabaseFilter { return typeof item.propertyId === "string"; } +function filterGroup(viewId: string, filter: DatabaseFilter | null): DatabaseFilterGroup { return { id: `${viewId}:root`, conjunction: "and", items: filter === null ? [] : [filter] }; } + function hostRecords(document: DatabaseDocument): ReadonlyArray { return document.records.map((record) => hostRecordFor(record)); } @@ -872,6 +828,19 @@ function databaseClipboardFromText( return { type: "application/vnd.interactive-os.database+json" as const, cells, text }; } +function clipboardUpdates(clipboard: { readonly cells: ReadonlyArray> }, topology: DatabaseTopology, focus: DatabaseSelection["focus"]): ReadonlyArray<{ readonly recordId: string; readonly patch: Partial }> { + if (focus === null) return []; + const rowStart = topology.recordIds.indexOf(focus.recordId); + const columnStart = topology.propertyIds.indexOf(focus.propertyId); + if (rowStart < 0 || columnStart < 0) return []; + return clipboard.cells.flatMap((cells, rowOffset) => { + const recordId = topology.recordIds[rowStart + rowOffset]; + if (recordId === undefined) return []; + const patch = Object.fromEntries(cells.flatMap((value, columnOffset) => topology.propertyIds[columnStart + columnOffset] === undefined ? [] : [[topology.propertyIds[columnStart + columnOffset], value]])) as Partial; + return [{ recordId, patch }]; + }); +} + function arrowDirection(key: string): "up" | "down" | "left" | "right" | null { if (key === "ArrowUp") return "up"; if (key === "ArrowDown") return "down"; diff --git a/packages/json-document-database/src/database-hands.tsx b/packages/json-document-database/src/database-hands.tsx index 838c47ce3..754cd92c5 100644 --- a/packages/json-document-database/src/database-hands.tsx +++ b/packages/json-document-database/src/database-hands.tsx @@ -5,14 +5,9 @@ import { databaseValueFromText } from "@interactive-os/json-document-editing"; import { Check as CheckControl, Command, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; import { DatabaseHand, type DatabaseHandCellRenderProps } from "./database-hand.js"; import { DatabaseProvider, useDatabase, type DatabaseProviderProps } from "./database-context.js"; -import type { - DatabaseColumnProjection, - DatabaseFilterGroup, - DatabaseFilterOperator, - DatabaseFilterRule, - DatabaseRow, - DatabaseViewDocument, -} from "./contracts.js"; +import { DatabasePropertyControl } from "./database-property-control.js"; +import { DatabaseViewControls } from "./database-view-controls.js"; +import type { DatabaseRow } from "./contracts.js"; export interface DatabaseTableProps { readonly renderCell?: Readonly) => ReactNode>>; @@ -28,8 +23,8 @@ export function DatabaseTable(props: DatabaseTableProps const presentation = { propertyOrder: columns.map((column) => column.propertyId), propertyVisibility: Object.fromEntries(columns.map((column) => [column.propertyId, column.visible])), - propertyWidths: Object.fromEntries(columns.flatMap((column) => column.width === undefined ? [] : [[column.propertyId, column.width]])), - propertyPinned: Object.fromEntries(columns.flatMap((column) => column.pinned === undefined ? [] : [[column.propertyId, column.pinned]])), + propertyWidths: Object.fromEntries(columns.flatMap((column) => column.width === null ? [] : [[column.propertyId, column.width]])), + propertyPinned: Object.fromEntries(columns.flatMap((column) => column.pinned === null ? [] : [[column.propertyId, column.pinned]])), }; return
(props: DatabaseTableProps readOnly={!database.capabilities.update} onSelectionChange={database.selectRows} onRecordOpen={database.openRow} - onRecordsChange={(next) => { - const previous = new Map(database.rows.map((row) => [database.resource.getRowId(row), row])); - for (const row of next) { - const id = database.resource.getRowId(row); - const before = previous.get(id); - if (before && JSON.stringify(before) !== JSON.stringify(row)) { - const patch = Object.fromEntries(Object.entries(row).filter(([key, value]) => !Object.is(before[key], value))); - void database.update(id, patch as Partial); - return; - } - } - }} + onRecordsChange={(_next, change) => change.updates?.forEach(({ recordId, patch }) => { void database.update(recordId, patch); })} /> {database.capabilities.create ? void database.create(database.resource.createDraft())}> : null}
; @@ -76,10 +60,6 @@ export function DatabaseViewToolbar() { const properties = resourceProperties(database.resource.schema); const canConfigure = database.capabilities.configureView && view.ownership !== "locked"; - function projection(next: Partial) { - database.setView({ ...view, projection: { ...view.projection, ...next } }); - } - return (