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 8bc59c7c2..783de0c01 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,198 +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 계약을 확장하지 않습니다. - -EditingSession의 확정된 공통 의미는 `standards/editing-session.md`가 소유합니다. -ES 규칙은 공통 의미를 규정하고 현재 TypeScript binding·local History 정책은 별도 -표에서 구별합니다. callback 형태·재시도 시점·구독 전략을 보편 조건으로 굳히지 -않습니다. 각 규칙을 owner의 행동 테스트에 연결합니다. `docs:evaluate`는 그 증거 연결을 검사하고 package test가 -실제 행동을 검증합니다. 이 확정은 전체 Hands의 Stable 선언을 뜻하지 않습니다. - -편집 문법의 안정화 설계는 `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 index c7e6c8a61..4e8cfed6c 100644 --- a/docs/api-reference/annotation.md +++ b/docs/api-reference/annotation.md @@ -1,8 +1,8 @@ # @interactive-os/json-document-annotation API -**Owner:** Hands +**탐색 분류:** Hands -Annotation Hand interaction과 SVG projection의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. +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`를 실행하세요. 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..c3bca26ce 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`를 실행하세요. diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 2137c922d..19307823d 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`를 실행하세요. @@ -240,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; @@ -306,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; @@ -427,11 +397,7 @@ type CalendarOccurrenceRange = { ## `CalendarOccurrenceSelection` ```ts -interface CalendarOccurrenceSelection { - readonly eventId: string; - readonly start: string; - readonly end: string; -} +type CalendarOccurrenceSelection = CalendarOccurrenceInterval; ``` ## `calendarOccurrenceTopology` @@ -570,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 @@ -580,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 @@ -600,6 +596,11 @@ createEditingId(prefix: string): string ```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 @@ -615,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 @@ -880,14 +886,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` @@ -1008,6 +1008,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 @@ -1229,11 +1248,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` @@ -1265,13 +1286,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; @@ -1292,11 +1320,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 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 fb35e1126..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`를 실행하세요. 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 0ba9cdfe1..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,12 +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 계약"], - ["annotation", "@interactive-os/json-document-annotation", "packages/json-document-annotation/src/index.ts", "Hands", "Annotation Hand interaction과 SVG projection"], - ["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"], @@ -29,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..9e0f77dbe 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` @@ -406,6 +432,7 @@ interface WebClipboardCodec { ```ts interface WebClipboardData { readonly types: ReadonlyArray; + readonly files?: WebFileCandidateList; getData(format: string): string; setData(format: string, data: string): void; } @@ -418,6 +445,15 @@ interface WebClipboardEvent { 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 @@ -606,6 +642,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 +844,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 25fd93e34..bb7c57615 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -1,15 +1,20 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +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"), @@ -141,83 +148,42 @@ 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(/(? { 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..236c2df26 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( 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 786fc770a..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)에 있습니다. ## 문서 만들기 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/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 3c1b40b30..40840ddcc 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -40,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/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 9a4ea6d35..9cfd24bf7 100644 --- a/docs/public/intent.md +++ b/docs/public/intent.md @@ -116,6 +116,8 @@ commit은 되돌아가지 않습니다. Selection mapping/reconciliation callbac | `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 9e822d892..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한다. @@ -53,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 문법은 @@ -201,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 @@ -215,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에 채택하지 않는다. @@ -249,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는 바뀌지 않는다. @@ -273,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/package-lock.json b/package-lock.json index d8fb94424..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", @@ -21,6 +23,7 @@ "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", @@ -1133,6 +1136,14 @@ "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 @@ -1169,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 @@ -6465,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" } }, @@ -6544,8 +6561,12 @@ "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": "*", @@ -6560,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", @@ -6567,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", @@ -6598,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" @@ -6607,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": { @@ -6617,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": "*", @@ -6637,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", @@ -6713,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", @@ -6725,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" } }, @@ -6764,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", @@ -7023,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" }, @@ -7062,6 +7160,8 @@ "@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": "*", @@ -7071,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 1cab39479..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", @@ -18,6 +20,7 @@ "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-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..f97cd0bc2 --- /dev/null +++ b/packages/json-document-canvas/src/use-canvas-hand.ts @@ -0,0 +1,322 @@ +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 { 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) { + if (isWebEditableTarget(event.target)) return; + 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-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-editing/README.md b/packages/json-document-editing/README.md index eef058567..79580b55d 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -73,6 +73,42 @@ Further commands cannot author until restoration succeeds. Fix the callback or recreate the editor; do not retry the history operation as if it were rejected. Callback exceptions are programming errors, not `{ ok: false }` commit rejections. +## 비동기 준비 순서 + +`createEditingPreparationQueue({ apply, onResult?, onPendingChange?, cancelCode?, errorCode? })`는 +`enqueue(prepare, cancelPreparation?)`, `cancel()`, `isPending`을 제공합니다. +`prepare`는 `{ ok: true, value }` 또는 `{ ok: false, code, reason? }`를 즉시/Promise로 +반환합니다. 준비는 병행할 수 있지만 `apply`는 입력 순서대로 동기 실행합니다. +준비 실패는 apply를 호출하지 않고, 취소는 대기 Promise를 settle하며 늦은 결과를 무시합니다. +기본 오류 코드는 `editing.preparation-cancelled`, `editing.preparation-failed`입니다. 기존 public binding은 +`cancelCode/errorCode`로 자신의 오류 어휘를 유지할 수 있습니다. + +queue는 문서·selection을 모르며 원자적 편집은 `apply`가 호출하는 정본 editor의 책임입니다. +이미 완료한 편집을 취소로 되돌리지 않습니다. Object의 `createObjectPasteSession`은 +외부 문서/선택 변경에 취소하고, Composer의 첨부 준비는 typing 중 유지합니다. +각 요청의 History 단위도 실제 domain apply가 정합니다. +Usage·Source: [Canvas](/demo/canvas), [Composer](/demo/composer). + +## Canvas 외부 내용 변환 + +`createCanvasClipboard(content, { bounds, textColor, fontSize, imageOffset?, contentGap? })`는 +일반 텍스트, 이미지 목록, 순서 있는 글·이미지를 Object clipboard로 변환합니다. +`{ type: "mixed", items: CanvasClipboardItem[] }`의 item은 `{ type: "text", text }` 또는 +`{ type: "image", source, width, height, label }`입니다. HTML parsing·이미지 decode는 +Web 소유이며 이 API는 DOM이나 File을 받지 않습니다. + +mixed는 입력 순서의 세로 흐름으로 배치합니다. `contentGap`의 기본값은 24이며 유한한 +0 이상 값입니다. 전체 높이가 `bounds.height`를 넘으면 객체·글자 크기·간격을 같은 비율로 +축소합니다. 이미지 비율은 유지하며 원본 CSS·Office layout이나 긴 글의 가독성을 보장하지 +않습니다. 기존 text는 단일 객체, images는 `imageOffset`(기본 24)의 cascade를 유지합니다. +빈 입력·유효하지 않은 geometry는 예외로 거절합니다. + +결과의 ID는 clipboard 내부 참조입니다. 실제 문서 ID 할당·선택·History는 Object paste가 +소유하고 마지막 item이 primary가 됩니다. 변환 자체는 문서를 쓰지 않습니다. +Usage·Source: [Canvas](/demo/canvas). + +## Editing identity and observation + `createEditingId(prefix)` supplies opaque UUID-based identities for Document, Order, Object, Tree, Calendar and Rich Text. IDs do not restart per editor or replica. Custom `createId` injection remains supported; its provider must ensure @@ -119,10 +155,10 @@ Its structural selection slices split into two reusable families without pretending that every topology is the same: - `Document`, `Order`, `Sheet`, and `Tree` use the range family. - Their host or domain slice supplies ordered axes, visible order, and JSON - Patch planning. -- `Object` uses the key family. The host owns pointer - geometry and hit-testing, then sends only stable object IDs to the editor. + Ordered axes, visible projections and JSON Patch planning come from the + canonical document/editor or external-model connector. +- `Object` uses the key family. Canonical platform geometry and hit-testing + APIs supply stable object IDs to the editor; the Host composes that path. Its public `selection.set` accepts the shared `replace`, `extend`, and `toggle` vocabulary directly; `extend` has key-family union semantics. @@ -180,6 +216,16 @@ It publishes rows with hierarchy/ARIA facts and the matching `TreeTopology`. `gridPointKey` and `gridPointFromKey` provide the canonical reversible string identity when a selection or rendering adapter needs to key a `GridPoint`. +`Calendar` follows the owner-local [Calendar protocol profile](docs/calendar-profile.md). + +The document model, validation, semantic operations and projections are owned by +`@interactive-os/json-document-calendar-document`; the existing exports here are +compatibility paths to that implementation. Editing owns selection, clipboard, +Intent execution and history, and consumes the Document Type's public plans. +`paste(clipboard)` defaults to `primaryOccurrence.start`, including later recurring +occurrences. The shared grammar binding is `tests/conformance/calendar-grammar.test.ts`. +The profile is also rendered on the site's Editing API page; it documents the +current RC implementation, not a frozen Official Hands wire standard. `Calendar` keeps interval events `{ id, title, start, end, allDay }`. Timed events use datetime-local strings; all-day events use exclusive-end dates. `parseCalendarView` validates untrusted runtime values against the canonical @@ -210,7 +256,8 @@ the public editors; each domain still owns its projection and removal plan. `interpretCalendarAllDayPointer`, and `interpretCalendarMonthPointer` map a press-release to those intents from the origin event, not the current selection. `calendarTimedLayout` places a timed event on its `start`/`end` -span. Pixel grids and view chrome stay in the Host. +span. Calendar Hands own reusable grids and lifecycle, Web owns pixel-coordinate +translation, and Hosts retain visual composition and policy values. `Annotation` keeps a target selector separate from its presentation. Point targets may use numbered `marker` presentations for instructions or a diff --git a/packages/json-document-editing/docs/calendar-profile.md b/packages/json-document-editing/docs/calendar-profile.md new file mode 100644 index 000000000..1bcd6bbcc --- /dev/null +++ b/packages/json-document-editing/docs/calendar-profile.md @@ -0,0 +1,96 @@ +## Calendar protocol profile (RC) + +Editing lifecycle의 소유자는 `@interactive-os/json-document-editing`이다. 문서 모델·검증·연산·projection은 +`@interactive-os/json-document-calendar-document`가 소유한다. 이 문서는 현재 +`0.1.0-rc.0` Calendar 구현의 계약과 한계를 명시한다. Core Stable 프로파일이나 +EditingSession 계약을 변경하지 않으며, Draft인 Official Hands / Editing Grammar를 +동결된 wire 표준으로 승격하지 않는다. Calendar Hands는 이 계약을 입력과 화면에 연결한다. + +### 문서 규칙의 소유자 + +시간 값, recurrence, legacy 필드와 calendar 검증은 +[Calendar Document Type API](/docs/api/calendar-document)의 RC 계약을 소비한다. +생성자도 같은 공개 `assertCalendarDocument`를 사용한다. 잘못된 calendars 타입, +calendar id/title/hidden/color와 event 규칙을 각 소비자가 별도로 판단하지 않는다. + +### 선택, 범위와 편집 + +`{ eventId, occurrenceStart }`가 발생분의 정체성이다. Selection 정본의 materialized +targets를 사용하며, 화면 밖에 있어도 현재 반복 규칙에 존재하는 선택은 유지된다. + +`this` / `this-and-following` / `all`의 event/series 의미와 문서 연산은 +Document Type이 소유한다. Editing은 공개 계획의 `affectedOccurrence`를 후속 선택으로 연결한다. + +시작만 바꾸면 해당 발생분의 길이를 보존한다. resize는 지정한 경계만 바꾸며 +반복 시리즈의 원본 날짜와 선택 발생 날짜를 혼동하지 않는다. Inspector, 포인터, +preview, 그룹 이동은 같은 순수 event/series 계획을 사용한다. preview ID는 임시이며 +commit ID와 같을 필요는 없지만 실제 일정 구간·반복 결과는 같아야 한다. + +그룹 이동은 선택 전체에 하나의 시간/일 변화량을 적용한다. this에서는 발생분별로 +분리하고, all / following에서는 같은 시리즈를 한 번만 편집한다. following 기준은 +선택·primary 순서와 무관하게 그 시리즈에서 선택된 가장 이른 발생분이다. +월/년 주기를 재기준화해 모든 선택점의 같은 변화량을 표현할 수 없으면 +`selection.unrepresentable-series-move`로 그룹 전체를 거절한다. 단일 발생분의 all 편집도 +요청한 구간이 결과 시리즈에 실제로 존재해야 하며 같은 규칙으로 거절한다. + +시리즈 이동은 유한한 until과 제외 날짜도 시작일 변화량만큼 옮긴다. following은 +기존 종료일과 이후 제외 날짜를 보존하며 무조건 무기한으로 늘리지 않는다. +allDay / calendarId / recurrence 자체의 Inspector 변경은 시리즈 속성 변경이다. +`selection.remove`는 선택된 원본 일정을 삭제한다. 발생분 범위 삭제는 +`occurrence.remove`와 scope, clipboard cut은 캡처된 발생분을 사용한다. + +### 거절과 원자성 + +생성·update·발생분 편집·paste는 생성자와 같은 도메인 불변식을 검증한다. +잘못된 구간, 미등록 calendar, 소수 recurrence, 알 수 없는 Intent type은 +`{ ok: false, code, reason? }`로 끝나며 값·선택·undo/redo·알림을 바꾸지 않는다. +성공한 문서는 다시 editor로 읽고 projection할 수 있어야 한다. + +드래그의 캡처 구간은 현재 사실이 아니라 precondition이다. commit 시 실제 발생분의 +존재와 start/end를 다시 확인한다. 삭제·제외되었거나 길이가 바뀐 발생분은 +`selection.stale-occurrence`로 거절한다. 제목만 바뀌거나 선택·뷰가 달라진 것은 +드래그를 무효화하지 않으며 새 제목을 보존한다. 그룹 실패는 일부만 적용하지 않는다. + +한 번의 편집/cut/paste/그룹 이동은 하나의 EditingSession 이력 단위다. +입력 거절과 잘못된 provider/programmer 예외는 다르다. 예를 들어 ID provider가 +100회 충돌하면 공통 bounded allocator가 예외를 던지고 문서는 변경하지 않는다. +Hands는 성공한 결과에만 선택/rename aftercare를 수행하고 `onResult`로 결과를 전달한다. + +### Clipboard 호환성과 외부 경계 + +`application/vnd.interactive-os.calendar+json`은 현재 materialized occurrence +clipboard 형식이다. `calendarClipboardFormat.parse(unknown)`는 잘못된 날짜·역전 +구간·비정규 event를 거절하고, 직접 호출한 `paste`도 같은 검증을 수행한다. +anchor가 생략된 기존 payload는 첫 occurrence를 anchor로 읽는다. +알 수 없는 추가 JSON 필드는 유지하지만 새 버전/새 의미의 호환을 보장하지 않는다. + +`copy()`는 빈 선택에서 null이다. `cut(capturedClipboard)`는 이미 기록한 payload의 +발생분만 삭제한다. 그 사이 선택이 달라져도 대상을 다시 선택하지 않으며, 기록하지 +않은 내용 변경이나 사라진 발생분이 있으면 거절한다. Web은 기록 실패 시 cut을 +호출하지 않는다. Web의 별도 이벤트 기본 동작 정책은 이 프로파일을 확장하지 않는다. + +`paste(clipboard)`의 기본 목적지는 `primaryOccurrence.start`다. 빈 선택에서는 +명시적인 target이 필요하다. 원본 반복 시리즈의 시작일로 되돌아가지 않는다. +Hand의 빈 슬롯 cursor는 명시적 target이며 해당 Editing revision에서만 유효하다. + +paste는 길이와 상대 위치를 보존하고 새 ID를 부여한다. 외부 문서의 calendarId를 +자동으로 다른 calendar로 치환하지 않는다. 명시적 재배치가 필요하면 기존 Editing API로 +목적지를 지정한다. + +```ts +import { createCalendarEditor } from "@interactive-os/json-document-editing"; + +const destination = createCalendarEditor(destinationDocument); +const result = destination.paste(clipboard, "2026-08-02T12:00", { calendarId: "personal" }); +if (!result.ok) showError(result.code); +``` + +timezone/DST 변환, 서버 저장·동기화의 revision/충돌/재시도, AI command wire, +외부 calendar connector, recurrence의 범용 RRULE 호환, 프로파일 버전 협상과 +독립 구현 conformance는 **TBD**다. 현재 앱에서 검증한 로컬 계약과 구분한다. + +Usage 및 Source는 [Calendar](/editors#calendar-editor), public API는 [Editing API](/docs/api/editing)와 +[Calendar Hands API](/docs/api/calendar)에 있다. Usage Source는 Document Type의 validation·event/series plan·projection, +Editing의 selection move와 Calendar Hands의 입력 연결까지 추적한다. 공통 적합성 검사는 +`tests/conformance/calendar-grammar.test.ts`, 직접 API/Hand 경로 회귀는 +Calendar package의 `tests/calendar-protocol.test.tsx`에 있다. diff --git a/packages/json-document-editing/docs/object-selection.md b/packages/json-document-editing/docs/object-selection.md new file mode 100644 index 000000000..8d9942271 --- /dev/null +++ b/packages/json-document-editing/docs/object-selection.md @@ -0,0 +1,120 @@ +## Object Selection · primary와 집합 편집 + +`selection.set`은 `objectIds`, `mode`와 선택적인 `primaryKey`를 받습니다. +`primaryKey`를 생략하면 Selection key family의 기본 전이를 사용합니다. +명시하면 **전이 후 선택 집합에 포함된 key**여야 하며, 아니면 +`selection.primary-not-selected`로 기존 상태·문서·History를 보존합니다. + +`object.translate`, `object.resize`, `object.text`의 대상이 현재 선택의 부분집합이면 +선택 집합과 primary를 보존합니다. 선택 밖 대상을 직접 지정하면 기존대로 그 대상을 +선택합니다. 따라서 전체 선택 이동, primary만 resize/text 편집을 같은 ObjectEditor로 +실행할 수 있습니다. 문서 연산·검증은 Object Document Type이 소유합니다. + +`object.text`는 독립 text뿐 아니라 rectangle·ellipse·sticky-note의 `label` 본문을 +편집합니다. 지원 여부는 `projectObjectText`가 정의하며 image·path·legacy Object의 +메타데이터 label은 편집 대상으로 승격하지 않습니다. 같은 본문은 no-op, 빈 문자열은 +유효한 편집입니다. 도형/노트도 기존 선택·복제·Clipboard·History 경로를 그대로 씁니다. + +```ts +editor.dispatch({ type: "selection.set", objectIds: ["a", "b"], primaryKey: "a" }); +editor.dispatch({ type: "object.translate", objectIds: ["a", "b"], dx: 20, dy: 10 }); +editor.dispatch({ type: "object.resize", objectIds: ["a"], dx: 0, dy: 0, dw: 10, dh: 0 }); +``` + +선택은 문서 밖의 Editing session 상태입니다. 선택 전이는 문서 commit이나 Undo 기록을 +만들지 않고 redo도 제거하지 않습니다. 집합 이동·삭제는 객체마다 dispatch하지 않고 하나의 +Intent를 사용합니다. Undo/Redo는 해당 문서 변경과 함께 원인 선택 집합·primary를 복원합니다. + +실제 public API 소비: [Canvas Usage와 Source](/demo/canvas). + +### 선택 스타일 + +`selection.style`은 `{ style: Partial }`을 받아 선택한 객체에 한 번 적용합니다. +스타일의 값·기본값·종류별 지원 여부·검증은 [Object Document Type](/docs/api/object-document)의 +`style` 연산에 위임합니다. 예를 들어 글자·도형·이미지를 함께 선택한 뒤 fontSize를 바꾸면 +글자와 도형 본문이 바뀌며 이미지와 선택 집합·primary는 유지됩니다. +`textColor`는 도형·노트의 본문 글자색이고 `color`는 기존대로 종류별 주 색상입니다. + +```ts +editor.dispatch({ type: "selection.style", style: { color: "#3b82f6", fontSize: 48, fontWeight: 700 } }); +``` + +한 번의 변경은 한 번의 commit/Undo입니다. Undo/Redo는 그때의 선택도 복원합니다. +같은 유효값과 지원 대상이 없는 속성은 문서·History·redo branch를 바꾸지 않습니다. +일반 Editing session의 관찰 revision 의미는 그대로이며 no-op publication을 금지하는 +계약은 아닙니다. 빈 선택은 `selection.empty`, 잘못된 스타일은 실패를 반환합니다. +복제·Clipboard·JSON은 저장된 스타일 필드를 그대로 보존합니다. 기존 `selection.fill`은 +종류에 관계없이 color를 쓰는 호환 동작을 유지합니다. + +### 복제와 Clipboard + +`object.duplicate`는 `objectIds` 집합을 문서 순서로 복제합니다. 생략한 `placement`는 +`{ type: "offset", dx: 24, dy: 24 }`이며 Alt drag는 최종 delta를 명시합니다. +원본 형태·확장 필드·path points·상대 위치를 보존하고 새 ID를 할당합니다. 복제된 +집합을 선택하고 기존 primary와 대응하는 사본을 primary로 삼습니다. source에 primary가 +없으면 마지막 사본이 primary입니다. 반복 복제는 새 선택을 대상으로 같은 offset을 적용합니다. + +`clipboard.paste`도 같은 ID 할당·변환·insert 경로를 사용합니다. placement 생략은 기존대로 +zero offset입니다. `{ type: "offset", dx, dy }`는 명시 좌표를 유지하고, +`{ type: "cascade", dx, dy }`는 첫 source 객체의 시작점에 1배, 2배… delta를 적용하여 +기존 객체의 시작점과 일치하지 않는 첫 위치를 찾습니다. 모든 사본에 같은 delta를 적용합니다. +Object/Canvas native paste는 cascade 24/24를 사용합니다. Undo/삭제 후에는 빈 위치를 +재사용하므로 횟수 counter가 없습니다. 직사각형 충돌 회피·snap·슬라이드 내부 배치는 아닙니다. +0/0 cascade, 비유한 delta, 계산 overflow는 거절합니다. 기존 offset API는 그대로입니다. + +`ObjectClipboard`는 구조화 MIME `application/vnd.interactive-os.objects+json`, `objects`, +`text`와 선택적인 `primaryKey`를 갖습니다. `copy()`는 문서 순서의 객체·label을 결합한 text와 +primary를 투영할 뿐 OS clipboard에 쓰지 않습니다. `objectClipboardFormat.parse`는 +legacy primary 없는 payload도 수용하고, 있으면 복사된 집합의 ID 또는 null인지 검증합니다. +paste는 primary를 새 ID로 remap합니다. 기존 문서와 source 양쪽 ID를 재사용하지 않습니다. + +```ts +editor.dispatch({ type: "object.duplicate", objectIds: editor.snapshot.selection.keys }); +const captured = editor.copy(); +// Web write가 성공한 뒤에만, 현재 선택을 다시 읽지 않고 캡처된 대상 제거: +if (captured) editor.dispatch({ type: "object.remove", objectIds: captured.objects.map((object) => object.id) }); +``` + +`object.remove`는 명시한 ID 집합을 제거합니다. `selection.remove`와 같은 원자적 연산을 +사용하며, 일부 ID가 없으면 전체 거절합니다. `cut()`은 OS 이벤트 밖의 headless 명령이므로 +브라우저에서는 Web binding으로 **write → captured 대상 remove** 순서를 보장해야 합니다. + +복제·paste·remove는 명령당 하나의 commit/Undo입니다. ID 할당 불가, 잘못된 offset이나 +payload, 대상/프로파일 위반은 부분 문서·선택·History를 남기지 않습니다. Canvas의 strict +profile은 generic Object clipboard라도 결과가 유효한 경우만 삽입합니다. + +### 외부 Canvas 내용과 비동기 paste + +`createCanvasClipboard(content, options)`는 `{ type: "text", text }` 또는 +`{ type: "images", images: [{ source, width, height, label }] }`를 ObjectClipboard로 바꿉니다. +문자열은 줄바꿈과 Unicode를 보존하며 HTML로 해석하지 않습니다. 텍스트의 초기 상자는 bounds와 +fontSize로 결정하고, 이미지는 Object Document Type의 `createCanvasImage`로 맞춥니다. +여러 이미지는 `imageOffset`(기본 24)만큼 분산합니다. `clipboard:0` 같은 임시 source ID는 +payload 내부 식별자일 뿐이며 실제 문서 ID는 paste commit 때만 할당합니다. + +```ts +import { createCanvasClipboard, createObjectPasteSession } from "@interactive-os/json-document-editing"; + +const pastes = createObjectPasteSession(editor, { + placement: { type: "cascade", dx: 24, dy: 24 }, + onResult: showEditingResult, + onPendingChange: showPending, +}); +pastes.enqueue(() => ({ ok: true, clipboard: createCanvasClipboard({ type: "text", text: "안녕\nCanvas" }, { + bounds: { x: 0, y: 0, width: 960, height: 540 }, textColor: "black", fontSize: 36, +}) })); +``` + +`enqueue(prepare, cancelPreparation?)`는 동기 결과 또는 Promise를 받아 요청 순서로 +채택합니다. 준비 결과는 `{ ok: true, clipboard }` 또는 `{ ok: false, code, reason? }`입니다. +준비는 병행할 수 있지만 앞 요청이 끝나기 전에 뒤 요청이 commit되지 않습니다. 실패한 요청은 +문서·ID·History를 만들지 않으며 다음 요청을 막지 않습니다. 동기 payload는 대기 요청이 없으면 +동기 commit하고 반환 Promise는 해당 EditingResult로 완료됩니다. + +`pending`, `onPendingChange`, `onResult`로 상태/결과를 관찰합니다. 외부 문서·선택 변경과 +`cancel()`은 대기 전체를 `clipboard.cancelled`로 완료하고 취소 callback을 부릅니다. +취소된 작업의 늦은 결과는 commit/`onResult`를 호출하지 않습니다. `cancel()`은 구독을 +해제하며 같은 session을 다시 사용할 수 있으므로 Hand cleanup에서도 호출합니다. +이 세션은 DOM, React, FileReader를 모르고 Canvas에 한정되지 않습니다. + +실제 연결은 [Canvas Usage/Source](/demo/canvas)의 Canvas Clipboard binding에서 볼 수 있습니다. diff --git a/packages/json-document-editing/package.json b/packages/json-document-editing/package.json index 85d2cf621..edef275ce 100644 --- a/packages/json-document-editing/package.json +++ b/packages/json-document-editing/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", @@ -34,13 +34,14 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-calendar-document": "^0.1.0-rc.0", "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-selection": "^0.1.0-rc.0" }, - "dependencies": { - "@js-temporal/polyfill": "^0.5.1" - }, "devDependencies": { + "@interactive-os/json-document-object-document": "*", + "@interactive-os/json-document-calendar-document": "*", "@interactive-os/json-document": "*", "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", diff --git a/packages/json-document-editing/src/calendar-allday-pointer.ts b/packages/json-document-editing/src/calendar-allday-pointer.ts index 9aa141046..6b8b46fbc 100644 --- a/packages/json-document-editing/src/calendar-allday-pointer.ts +++ b/packages/json-document-editing/src/calendar-allday-pointer.ts @@ -1,7 +1,12 @@ -import type { CalendarEvent, CalendarIntent } from "./calendar.js"; -import { calendarEventRecurrence } from "./calendar-occurrence.js"; +import type { + CalendarIntent, +} from "./calendar.js"; +import type { + CalendarEvent, +} from "@interactive-os/json-document-calendar-document"; +import { calendarEventRecurrence, resolveCalendarOccurrence } from "@interactive-os/json-document-calendar-document"; import { bindCalendarMonthIntent } from "./calendar-month-pointer.js"; -import { addCalendarDate, calendarAllDaySpan, calendarDaysBetween, parseCalendarDate } from "./calendar-validation.js"; +import { addCalendarDate, calendarAllDaySpan, calendarDaysBetween, parseCalendarDate } from "@interactive-os/json-document-calendar-document"; export type CalendarAllDayHandle = "body" | "start" | "end"; @@ -75,12 +80,12 @@ export function bindCalendarAllDayIntent( if (intent.type !== "event.resize") return intent; if (event === undefined || calendarEventRecurrence(event) === null) return intent; const start = occurrenceStart ?? event.start; - if (scope === "all") return intent; + const end = resolveCalendarOccurrence([event], { eventId: event.id, occurrenceStart: start })?.end; return { type: "occurrence.edit", eventId: intent.eventId, occurrenceStart: start, scope, - ...(intent.edge === "start" ? { start: intent.instant } : { end: intent.instant }), + ...(intent.edge === "start" ? { start: intent.instant, ...(end === undefined ? {} : { end }) } : { end: intent.instant }), }; } diff --git a/packages/json-document-editing/src/calendar-month-pointer.ts b/packages/json-document-editing/src/calendar-month-pointer.ts index 2dbb5902c..57af5153f 100644 --- a/packages/json-document-editing/src/calendar-month-pointer.ts +++ b/packages/json-document-editing/src/calendar-month-pointer.ts @@ -1,6 +1,11 @@ -import type { CalendarEvent, CalendarIntent } from "./calendar.js"; -import { calendarEventRecurrence } from "./calendar-occurrence.js"; -import { addCalendarDate, calendarAllDaySpan, calendarDatePart, calendarDaysBetween, isCalendarAllDay, parseCalendarDate } from "./calendar-validation.js"; +import type { + CalendarIntent, +} from "./calendar.js"; +import type { + CalendarEvent, +} from "@interactive-os/json-document-calendar-document"; +import { calendarEventRecurrence } from "@interactive-os/json-document-calendar-document"; +import { addCalendarDate, calendarAllDaySpan, calendarDatePart, calendarDaysBetween, isCalendarAllDay, parseCalendarDate } from "@interactive-os/json-document-calendar-document"; export type CalendarMonthPointerRelease = { readonly originDay: string; @@ -48,15 +53,6 @@ export function bindCalendarMonthIntent( if (intent.type !== "event.move-day") return intent; if (event === undefined || calendarEventRecurrence(event) === null) return intent; const start = occurrenceStart ?? event.start; - const occDay = calendarDatePart(start); - const origin = parseCalendarDate(occDay); - const next = parseCalendarDate(intent.day); - if (origin === null || next === null) return intent; - if (scope === "all") { - const day = addCalendarDate(calendarDatePart(event.start), calendarDaysBetween(origin, next)); - if (day === null) return intent; - return { type: "event.move-day", eventId: intent.eventId, day }; - } return { type: "occurrence.edit", eventId: intent.eventId, diff --git a/packages/json-document-editing/src/calendar-preview.ts b/packages/json-document-editing/src/calendar-preview.ts index 6f414b7e0..c044e632f 100644 --- a/packages/json-document-editing/src/calendar-preview.ts +++ b/packages/json-document-editing/src/calendar-preview.ts @@ -1,94 +1,22 @@ -import type { CalendarEvent } from "./calendar.js"; -import { calendarEventExcludeDates, calendarEventRecurrence } from "./calendar-occurrence.js"; -import { - bindCalendarAllDayIntent, - interpretCalendarAllDayPointer, - type CalendarAllDayPointerRelease, -} from "./calendar-allday-pointer.js"; -import { - bindCalendarMonthIntent, - interpretCalendarMonthPointer, - type CalendarMonthPointerRelease, -} from "./calendar-month-pointer.js"; -import { - bindCalendarTimeGridIntent, - interpretCalendarTimeGridPointer, - type CalendarTimeGridPointerIntent, - type CalendarTimeGridPointerRelease, -} from "./calendar-time-grid-pointer.js"; -import { - addCalendarDate, - calendarDatePart, - calendarDaysBetween, - calendarMinutesBetween, - formatCalendarDate, - formatCalendarInstant, - isCalendarAllDay, - parseCalendarDate, - parseCalendarInstant, -} from "./calendar-validation.js"; +import type { + CalendarIntent, +} from "./calendar.js"; +import type { + CalendarEvent, +} from "@interactive-os/json-document-calendar-document"; +import { planCalendarEventEdit } from "@interactive-os/json-document-calendar-document"; +import { createEditingIdAllocator } from "./identity.js"; +import { bindCalendarAllDayIntent, interpretCalendarAllDayPointer, type CalendarAllDayPointerRelease } from "./calendar-allday-pointer.js"; +import { bindCalendarMonthIntent, interpretCalendarMonthPointer, type CalendarMonthPointerRelease } from "./calendar-month-pointer.js"; +import { bindCalendarTimeGridIntent, interpretCalendarTimeGridPointer, type CalendarTimeGridPointerRelease } from "./calendar-time-grid-pointer.js"; export function previewCalendarAllDay( events: ReadonlyArray, release: CalendarAllDayPointerRelease, scope: "this" | "this-and-following" | "all" = "this", ): ReadonlyArray { - const intent = interpretCalendarAllDayPointer(release); - if (intent === null || intent.type === "selection.set" || intent.type === "selection.clear") return events; - if (intent.type === "event.create") { - return [...events, { - id: "preview", - title: "Event", - start: intent.start, - end: intent.end, - allDay: true, - calendarId: "", - recurrence: null, - excludeDates: [], - }]; - } - const event = events.find((item) => item.id === intent.eventId); - const bound = bindCalendarAllDayIntent(intent, event, release.originEventStart, scope) ?? intent; - if (bound.type === "occurrence.edit" && event !== undefined && release.originEventStart !== null) { - const excluded = calendarDatePart(release.originEventStart); - const start = bound.start ?? release.originEventStart; - const end = bound.end ?? event.end; - return [ - ...events.map((item) => item.id === event.id - ? { ...item, excludeDates: [...calendarEventExcludeDates(item), excluded] } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: bound.scope === "this" ? null : event.recurrence, - excludeDates: [], - }, - ]; - } - if (intent.type === "event.move-day") { - return events.map((item) => { - if (item.id !== intent.eventId || !isCalendarAllDay(item)) return item; - const from = parseCalendarDate(item.start); - const to = parseCalendarDate(item.end); - const nextDay = parseCalendarDate(intent.day); - if (from === null || to === null || nextDay === null) return item; - const delta = calendarDaysBetween(from, nextDay); - return { - ...item, - start: formatCalendarDate(from.add({ days: delta })), - end: formatCalendarDate(to.add({ days: delta })), - }; - }); - } - return events.map((item) => { - if (item.id !== intent.eventId) return item; - const start = intent.edge === "start" ? intent.instant : item.start; - const end = intent.edge === "end" ? intent.instant : item.end; - if (start >= end) return item; - return { ...item, start, end }; - }); + const event = events.find((item) => item.id === release.originEventId); + return preview(events, bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), event, release.originEventStart, scope)); } export function previewCalendarTimeGrid( @@ -96,50 +24,8 @@ export function previewCalendarTimeGrid( release: CalendarTimeGridPointerRelease, scope: "this" | "this-and-following" | "all" = "this", ): ReadonlyArray { - const intent = interpretCalendarTimeGridPointer(release); - if (intent === null || intent.type === "selection.set" || intent.type === "selection.clear") return events; - if (intent.type === "event.create") { - return [...events, { - id: "preview", - title: "Event", - start: intent.start, - end: intent.end, - allDay: false, - calendarId: "", - recurrence: null, - excludeDates: [], - }]; - } - const event = events.find((item) => item.id === intent.eventId); - const bound = bindCalendarTimeGridIntent(intent, event, release.originEventStart, scope) ?? intent; - if ( - bound.type === "occurrence.edit" - && event !== undefined - && release.originEventStart !== null - ) { - if (bound.scope === "this-and-following") { - return previewFollowingOccurrences(events, event, release.originEventStart, intent); - } - return previewRecurringOccurrence(events, event, release.originEventStart, intent); - } - if (bound.type === "event.move") { - return events.map((item) => { - if (item.id !== bound.eventId || isCalendarAllDay(item)) return item; - const from = parseCalendarInstant(item.start); - const to = parseCalendarInstant(item.end); - const nextStart = parseCalendarInstant(bound.start); - if (from === null || to === null || nextStart === null) return item; - return { ...item, start: bound.start, end: formatCalendarInstant(nextStart.add({ minutes: calendarMinutesBetween(from, to) })) }; - }); - } - if (bound.type !== "event.resize") return events; - return events.map((item) => { - if (item.id !== bound.eventId) return item; - const start = bound.edge === "start" ? bound.instant : item.start; - const end = bound.edge === "end" ? bound.instant : item.end; - if (start >= end) return item; - return { ...item, start, end }; - }); + const event = events.find((item) => item.id === release.originEventId); + return preview(events, bindCalendarTimeGridIntent(interpretCalendarTimeGridPointer(release), event, release.originEventStart, scope)); } export function previewCalendarMonth( @@ -147,148 +33,16 @@ export function previewCalendarMonth( release: CalendarMonthPointerRelease, scope: "this" | "this-and-following" | "all" = "this", ): ReadonlyArray { - const intent = interpretCalendarMonthPointer(release); - if (intent === null || intent.type === "selection.set" || intent.type === "selection.clear") return events; - if (intent.type === "event.create") { - return [...events, { - id: "preview", - title: "Event", - start: intent.start, - end: intent.end, - allDay: true, - calendarId: "", - recurrence: null, - excludeDates: [], - }]; - } - const event = events.find((item) => item.id === intent.eventId); - const bound = bindCalendarMonthIntent(intent, event, release.originEventStart ?? null, scope) ?? intent; - if (bound.type === "occurrence.edit" && event !== undefined && (release.originEventStart ?? null) !== null) { - const occurrenceStart = release.originEventStart ?? event.start; - const excluded = calendarDatePart(occurrenceStart); - const start = bound.start ?? occurrenceStart; - const end = bound.end ?? event.end; - return [ - ...events.map((item) => item.id === event.id - ? { ...item, excludeDates: [...calendarEventExcludeDates(item), excluded] } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: bound.scope === "this" ? null : event.recurrence, - excludeDates: [], - }, - ]; - } - if (intent.type !== "event.move-day") return events; - return events.map((item) => { - if (item.id !== intent.eventId) return item; - if (isCalendarAllDay(item)) { - const from = parseCalendarDate(item.start); - const to = parseCalendarDate(item.end); - const nextDay = parseCalendarDate(intent.day); - if (from === null || to === null || nextDay === null) return item; - const delta = calendarDaysBetween(from, nextDay); - return { - ...item, - start: formatCalendarDate(from.add({ days: delta })), - end: formatCalendarDate(to.add({ days: delta })), - }; - } - const from = parseCalendarInstant(item.start); - const to = parseCalendarInstant(item.end); - const currentDay = parseCalendarInstant(`${calendarDatePart(item.start)}T00:00`); - const nextDay = parseCalendarInstant(`${intent.day}T00:00`); - if (from === null || to === null || currentDay === null || nextDay === null) return item; - const delta = calendarMinutesBetween(currentDay, nextDay); - return { - ...item, - start: formatCalendarInstant(from.add({ minutes: delta })), - end: formatCalendarInstant(to.add({ minutes: delta })), - }; - }); -} - -function previewFollowingOccurrences( - events: ReadonlyArray, - event: CalendarEvent, - occurrenceStart: string, - intent: Extract, -): ReadonlyArray { - const recurrence = calendarEventRecurrence(event); - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occStart = parseCalendarInstant(occurrenceStart); - if (recurrence === null || from === null || to === null || occStart === null) return events; - const duration = calendarMinutesBetween(from, to); - let start = occurrenceStart; - let end = formatCalendarInstant(occStart.add({ minutes: duration })); - if (intent.type === "event.move") { - const nextStart = parseCalendarInstant(intent.start); - if (nextStart === null) return events; - start = intent.start; - end = formatCalendarInstant(nextStart.add({ minutes: duration })); - } else if (intent.edge === "start") { - start = intent.instant; - } else { - end = intent.instant; - } - if (start >= end) return events; - const until = addCalendarDate(calendarDatePart(occurrenceStart), -1); - if (until === null) return events; - return [ - ...events.map((item) => item.id === event.id - ? { ...item, recurrence: { ...recurrence, until } } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: { ...recurrence, until: "" }, - excludeDates: [], - }, - ]; + const event = events.find((item) => item.id === release.originEventId); + return preview(events, bindCalendarMonthIntent(interpretCalendarMonthPointer(release), event, release.originEventStart ?? null, scope)); } -function previewRecurringOccurrence( - events: ReadonlyArray, - event: CalendarEvent, - occurrenceStart: string, - intent: Extract, -): ReadonlyArray { - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occStart = parseCalendarInstant(occurrenceStart); - if (from === null || to === null || occStart === null) return events; - const duration = calendarMinutesBetween(from, to); - let start = occurrenceStart; - let end = formatCalendarInstant(occStart.add({ minutes: duration })); - if (intent.type === "event.move") { - const nextStart = parseCalendarInstant(intent.start); - if (nextStart === null) return events; - start = intent.start; - end = formatCalendarInstant(nextStart.add({ minutes: duration })); - } else if (intent.edge === "start") { - start = intent.instant; - } else { - end = intent.instant; - } - if (start >= end) return events; - const excluded = calendarDatePart(occurrenceStart); - return [ - ...events.map((item) => item.id === event.id - ? { ...item, excludeDates: [...calendarEventExcludeDates(item), excluded] } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: null, - excludeDates: [], - }, - ]; +function preview(events: ReadonlyArray, intent: CalendarIntent | null): ReadonlyArray { + if (intent === null || !(intent.type === "event.create" || intent.type === "event.update" || intent.type === "event.move" + || intent.type === "event.move-day" || intent.type === "event.resize" || intent.type === "occurrence.edit")) return events; + let sequence = 0; + const plan = planCalendarEventEdit(events, intent, { + allocateId: createEditingIdAllocator(events.map((event) => event.id), () => sequence++ === 0 ? "preview" : `preview-${sequence}`, "calendar preview"), + }); + return plan.ok ? plan.events : events; } diff --git a/packages/json-document-editing/src/calendar-selection-move.ts b/packages/json-document-editing/src/calendar-selection-move.ts index 00bfb5ff2..46a88c7cf 100644 --- a/packages/json-document-editing/src/calendar-selection-move.ts +++ b/packages/json-document-editing/src/calendar-selection-move.ts @@ -1,10 +1,14 @@ -import { calendarEventExcludeDates, calendarEventRecurrence } from "./calendar-occurrence.js"; +import { calendarEventRecurrence, resolveCalendarOccurrence } from "@interactive-os/json-document-calendar-document"; +import { planCalendarEventEdit } from "@interactive-os/json-document-calendar-document"; +import { createEditingIdAllocator } from "./identity.js"; import type { - CalendarEvent, - CalendarOccurrencePoint, CalendarOccurrenceSelection, CalendarSelection, } from "./calendar.js"; +import type { + CalendarEvent, + CalendarOccurrencePoint, +} from "@interactive-os/json-document-calendar-document"; import { calendarDatePart, calendarDaysBetween, @@ -14,7 +18,7 @@ import { isCalendarAllDay, parseCalendarDate, parseCalendarInstant, -} from "./calendar-validation.js"; +} from "@interactive-os/json-document-calendar-document"; export type CalendarSelectionMoveTarget = | { readonly type: "instant"; readonly instant: string } @@ -29,7 +33,7 @@ export type CalendarSelectionMovePlan = } | { readonly ok: false; readonly code: string }; -/** Plans one atomic temporal translation for a materialized occurrence selection. */ +/** Plans one atomic temporal translation; captured intervals are preconditions, not current truth. */ export function planCalendarSelectionMove( events: ReadonlyArray, occurrences: ReadonlyArray, @@ -41,75 +45,61 @@ export function planCalendarSelectionMove( readonly primary?: CalendarOccurrencePoint; } = {}, ): CalendarSelectionMovePlan { - const anchorOccurrence = occurrences.find((item) => ( - item.eventId === anchor.eventId && item.start === anchor.occurrenceStart - )); - if (anchorOccurrence === undefined || occurrences.length === 0) { - return { ok: false, code: "selection.drag-source-not-found" }; + const matches = (item: CalendarOccurrenceSelection, point: CalendarOccurrencePoint) => + item.eventId === point.eventId && item.start === point.occurrenceStart; + const anchorOccurrence = occurrences.find((item) => matches(item, anchor)); + const primaryIndex = occurrences.findIndex((item) => matches(item, options.primary ?? anchor)); + if (anchorOccurrence === undefined || primaryIndex < 0) return { ok: false, code: "selection.drag-source-not-found" }; + const scope = options.scope ?? "this"; + if (scope !== "this" && scope !== "this-and-following" && scope !== "all") return { ok: false, code: "occurrence.invalid-scope" }; + const seen = new Set(); + for (const occurrence of occurrences) { + const key = JSON.stringify([occurrence.eventId, occurrence.start]); + if (seen.has(key)) return { ok: false, code: "selection.duplicate-occurrence" }; + seen.add(key); + const current = resolveCalendarOccurrence(events, { eventId: occurrence.eventId, occurrenceStart: occurrence.start }); + if (current === null || current.end !== occurrence.end) return { ok: false, code: "selection.stale-occurrence" }; } const delta = resolveDelta(anchorOccurrence.start, target); if (delta === null) return { ok: false, code: "selection.invalid-drop-target" }; - if (target.type === "instant" && occurrences.some((item) => { - const event = events.find((candidate) => candidate.id === item.eventId); - return event === undefined || isCalendarAllDay(event); - })) return { ok: false, code: "selection.incompatible-drop-target" }; + if (target.type === "instant" && occurrences.some((item) => ( + isCalendarAllDay(events.find((event) => event.id === item.eventId)!) + ))) return { ok: false, code: "selection.incompatible-drop-target" }; - const next = [...events]; + let next = events; + let sequence = 0; + const allocateId = createEditingIdAllocator(events.map((event) => event.id), options.createId ?? (() => `preview-${++sequence}`), "calendar event"); const moved: CalendarOccurrenceSelection[] = []; - const exclusions = new Map>(); - const scope = options.scope ?? "this"; - const createId = options.createId ?? (() => `preview-${moved.length + 1}`); - const seenSeries = new Set(); - - for (const occurrence of occurrences) { - const index = events.findIndex((event) => event.id === occurrence.eventId); - const event = events[index]; - if (event === undefined) return { ok: false, code: "selection.event-not-found" }; + const seriesIds = new Map(); + // Following starts at the earliest selected occurrence, independent of selection/primary order. + const ordered = occurrences.map((occurrence, index) => ({ occurrence, index })) + .sort((left, right) => left.occurrence.start.localeCompare(right.occurrence.start)); + for (const { occurrence, index } of ordered) { + const event = events.find((item) => item.id === occurrence.eventId)!; const shifted = shiftInterval(occurrence.start, occurrence.end, delta); if (shifted === null) return { ok: false, code: "event.invalid-instant" }; - const recurrence = calendarEventRecurrence(event); - if (recurrence === null) { - if (seenSeries.has(event.id)) return { ok: false, code: "selection.duplicate-event" }; - seenSeries.add(event.id); - next[index] = { ...event, start: shifted.start, end: shifted.end }; - moved.push({ eventId: event.id, ...shifted }); - continue; + const recurring = calendarEventRecurrence(event) !== null; + let eventId = recurring && scope !== "this" ? seriesIds.get(event.id) : undefined; + if (eventId === undefined) { + const plan = planCalendarEventEdit(next, recurring ? { + type: "occurrence.edit", eventId: event.id, occurrenceStart: occurrence.start, scope, ...shifted, + } : { type: "event.update", eventId: event.id, ...shifted }, { allocateId }); + if (!plan.ok) return plan; + next = plan.events; + eventId = plan.affectedOccurrence.eventId; + if (recurring && scope !== "this") seriesIds.set(event.id, eventId); } - if (scope !== "this") return { ok: false, code: "selection.recurring-group-scope-unsupported" }; - const dates = exclusions.get(event.id) ?? new Set(calendarEventExcludeDates(event)); - dates.add(calendarDatePart(occurrence.start)); - exclusions.set(event.id, dates); - const detached: CalendarEvent = { - ...event, - id: uniqueId(next, createId), - start: shifted.start, - end: shifted.end, - recurrence: null, - excludeDates: [], - }; - next.push(detached); - moved.push({ eventId: detached.id, ...shifted }); + moved[index] = { eventId, ...shifted }; } - for (const [eventId, dates] of exclusions) { - const index = next.findIndex((event) => event.id === eventId); - next[index] = { ...next[index]!, excludeDates: [...dates] }; - } - const points = moved.map((item): CalendarOccurrencePoint => ({ - eventId: item.eventId, - occurrenceStart: item.start, - })); - const primary = options.primary ?? anchor; - const primaryIndex = occurrences.findIndex((item) => ( - item.eventId === primary.eventId && item.start === primary.occurrenceStart - )); + // A monthly/yearly re-anchor may not represent every translated point: fail atomically. + if (moved.some((item) => resolveCalendarOccurrence(next, { + eventId: item.eventId, occurrenceStart: item.start, + })?.end !== item.end)) return { ok: false, code: "selection.unrepresentable-series-move" }; + const points = moved.map((item): CalendarOccurrencePoint => ({ eventId: item.eventId, occurrenceStart: item.start })); return { - ok: true, - events: next, - movedOccurrences: moved, + ok: true, events: next, movedOccurrences: moved, selectionAfter: { - kind: "range", - ranges: points.map((point) => ({ anchor: point, focus: point, points: [point] })), - primaryIndex: Math.max(0, primaryIndex), + kind: "range", ranges: points.map((point) => ({ anchor: point, focus: point, points: [point] })), primaryIndex, }, }; } @@ -146,9 +136,3 @@ function shiftInterval(start: string, end: string, delta: Delta): { start: strin end: formatCalendarInstant(to.add({ minutes })), }; } - -function uniqueId(events: ReadonlyArray, createId: () => string): string { - let id = createId(); - while (events.some((event) => event.id === id)) id = createId(); - return id; -} diff --git a/packages/json-document-editing/src/calendar-selection.ts b/packages/json-document-editing/src/calendar-selection.ts index 63be83aee..c66a78c4e 100644 --- a/packages/json-document-editing/src/calendar-selection.ts +++ b/packages/json-document-editing/src/calendar-selection.ts @@ -1,4 +1,9 @@ -import type { CalendarEvent, CalendarIntent } from "./calendar.js"; +import type { + CalendarIntent, +} from "./calendar.js"; +import type { + CalendarEvent, +} from "@interactive-os/json-document-calendar-document"; export type CalendarEventPatch = { readonly title?: string; diff --git a/packages/json-document-editing/src/calendar-time-grid-pointer.ts b/packages/json-document-editing/src/calendar-time-grid-pointer.ts index ae9e3321c..3297879db 100644 --- a/packages/json-document-editing/src/calendar-time-grid-pointer.ts +++ b/packages/json-document-editing/src/calendar-time-grid-pointer.ts @@ -1,6 +1,11 @@ -import type { CalendarEvent, CalendarIntent } from "./calendar.js"; -import { calendarEventRecurrence } from "./calendar-occurrence.js"; -import { calendarDatePart, calendarMinutesBetween, calendarShiftInstant, parseCalendarInstant } from "./calendar-validation.js"; +import type { + CalendarIntent, +} from "./calendar.js"; +import type { + CalendarEvent, +} from "@interactive-os/json-document-calendar-document"; +import { calendarEventRecurrence, resolveCalendarOccurrence } from "@interactive-os/json-document-calendar-document"; +import { calendarDatePart, calendarMinutesBetween, calendarShiftInstant, parseCalendarInstant } from "@interactive-os/json-document-calendar-document"; export type CalendarTimeGridHandle = "body" | "start" | "end"; @@ -64,7 +69,6 @@ export function bindCalendarTimeGridIntent( if (intent.type !== "event.move" && intent.type !== "event.resize") return intent; if (event === undefined || calendarEventRecurrence(event) === null) return intent; const start = occurrenceStart ?? event.start; - if (scope === "all") return bindRecurringSeriesTimeGridIntent(intent, event, start); if (intent.type === "event.move") { return { type: "occurrence.edit", @@ -75,11 +79,7 @@ export function bindCalendarTimeGridIntent( }; } if (intent.edge === "start") { - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occurrenceEnd = from === null || to === null - ? null - : calendarShiftInstant(start, calendarMinutesBetween(from, to)); + const occurrenceEnd = resolveCalendarOccurrence([event], { eventId: event.id, occurrenceStart: start })?.end ?? null; return { type: "occurrence.edit", eventId: intent.eventId, @@ -98,35 +98,6 @@ export function bindCalendarTimeGridIntent( }; } -function bindRecurringSeriesTimeGridIntent( - intent: Extract, - event: CalendarEvent, - occurrenceStart: string, -): CalendarIntent { - if (intent.type === "event.move") { - const origin = parseCalendarInstant(occurrenceStart); - const next = parseCalendarInstant(intent.start); - if (origin === null || next === null) return intent; - const start = calendarShiftInstant(event.start, calendarMinutesBetween(origin, next)); - if (start === null) return intent; - return { type: "event.move", eventId: intent.eventId, start }; - } - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occStart = parseCalendarInstant(occurrenceStart); - const instant = parseCalendarInstant(intent.instant); - if (from === null || to === null || occStart === null || instant === null) return intent; - if (intent.edge === "start") { - const start = calendarShiftInstant(event.start, calendarMinutesBetween(occStart, instant)); - if (start === null) return intent; - return { type: "event.resize", eventId: intent.eventId, edge: "start", instant: start }; - } - const occurrenceEnd = occStart.add({ minutes: calendarMinutesBetween(from, to) }); - const end = calendarShiftInstant(event.end, calendarMinutesBetween(occurrenceEnd, instant)); - if (end === null) return intent; - return { type: "event.resize", eventId: intent.eventId, edge: "end", instant: end }; -} - function timePart(instant: string): string | null { if (parseCalendarInstant(instant) === null) return null; return instant.slice(11); diff --git a/packages/json-document-editing/src/calendar-validation.ts b/packages/json-document-editing/src/calendar-validation.ts deleted file mode 100644 index 4c9107154..000000000 --- a/packages/json-document-editing/src/calendar-validation.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Temporal } from "@js-temporal/polyfill"; -import type { CalendarCalendar, CalendarDocument, CalendarEvent, CalendarView } from "./calendar.js"; - -const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; -const DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/; -const CALENDAR_VIEWS: ReadonlySet = new Set(["day", "week", "month", "year"]); - -export function parseCalendarView(value: unknown): CalendarView | null { - return typeof value === "string" && CALENDAR_VIEWS.has(value) ? value as CalendarView : null; -} - -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(document: CalendarDocument): void { - const calendarIds = new Set(); - for (const calendar of calendarDocumentCalendars(document)) { - if (calendar.id.length === 0) throw new Error("Calendar ids must not be empty."); - if (calendarIds.has(calendar.id)) throw new Error(`Calendar id must be unique: ${JSON.stringify(calendar.id)}.`); - if (typeof calendar.color !== "string" || calendar.color.length === 0) { - throw new Error(`Calendar color must not be empty: ${JSON.stringify(calendar.id)}.`); - } - calendarIds.add(calendar.id); - } - const ids = new Set(); - for (const event of calendarDocumentEvents(document)) { - if (event.id.length === 0) throw new Error("Calendar event ids must not be empty."); - if (ids.has(event.id)) throw new Error(`Calendar event id must be unique: ${JSON.stringify(event.id)}.`); - const calendarId = typeof event.calendarId === "string" ? event.calendarId : ""; - if (calendarId.length > 0 && calendarIds.size > 0 && !calendarIds.has(calendarId)) { - throw new Error(`Calendar event must belong to a calendar: ${JSON.stringify(event.id)}.`); - } - if (isCalendarAllDay(event)) { - if (parseCalendarDate(event.start) === null || parseCalendarDate(event.end) === null) { - throw new Error(`All-day calendar events must use date strings: ${JSON.stringify(event.id)}.`); - } - } else if (parseCalendarInstant(event.start) === null || parseCalendarInstant(event.end) === null) { - throw new Error(`Calendar event times must be datetime-local strings: ${JSON.stringify(event.id)}.`); - } - if (event.start >= event.end) throw new Error(`Calendar event must end after it starts: ${JSON.stringify(event.id)}.`); - ids.add(event.id); - } -} - -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"); -} diff --git a/packages/json-document-editing/src/calendar.ts b/packages/json-document-editing/src/calendar.ts index ae2385771..e43e12467 100644 --- a/packages/json-document-editing/src/calendar.ts +++ b/packages/json-document-editing/src/calendar.ts @@ -10,7 +10,6 @@ import { type MaterializedRangeSelectionCommand, type OrderedTopology, } from "@interactive-os/json-document-selection"; -import { Temporal } from "@js-temporal/polyfill"; import { createEditingSession, type EditingResult, @@ -21,61 +20,36 @@ import { resolveDocumentSource, type EditingDocumentSource } from "./document-so import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { - addCalendarDate, assertCalendarDocument, - calendarAllDaySpan, calendarDatePart, calendarDaysBetween, calendarDocumentCalendars, - calendarDocumentEvents, - calendarEventBounds, - calendarIntervalLastDate, + calendarVisibleEvents, calendarMinutesBetween, formatCalendarDate, formatCalendarInstant, isCalendarAllDay, parseCalendarDate, parseCalendarInstant, -} from "./calendar-validation.js"; -import { calendarEventExcludeDates, calendarEventRecurrence, projectCalendarOccurrences } from "./calendar-occurrence.js"; + planCalendarEventEdit, + planCalendarEventRemoval, + planCalendarOccurrenceRemoval, + planCalendarVisibility, + projectCalendarOccurrences, + resolveCalendarOccurrence, + validateCalendarEvent, + type CalendarDocument, + type CalendarEvent, + type CalendarEventOperation, + type CalendarOccurrenceInterval, + type CalendarOccurrencePoint, +} from "@interactive-os/json-document-calendar-document"; import { planCalendarSelectionMove, type CalendarSelectionMoveTarget, } from "./calendar-selection-move.js"; -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; -} +export type { CalendarCalendar, CalendarDocument, CalendarEvent, CalendarRecurrence, CalendarOccurrencePoint } from "@interactive-os/json-document-calendar-document"; export interface CalendarSelectionRange extends Record { readonly anchor: CalendarOccurrencePoint; @@ -106,11 +80,7 @@ export interface CalendarClipboard extends Record { readonly text: string; } -export interface CalendarOccurrenceSelection { - readonly eventId: string; - readonly start: string; - readonly end: string; -} +export type CalendarOccurrenceSelection = CalendarOccurrenceInterval; export interface CalendarSelectionDragSource { readonly anchor: CalendarOccurrencePoint; @@ -126,21 +96,24 @@ export const calendarClipboardFormat = { if (!Array.isArray(value.items) || value.items.length === 0) return null; if (!value.items.every((item) => ( isRecord(item) - && typeof item.sourceEventId === "string" - && typeof item.occurrenceStart === "string" + && typeof item.sourceEventId === "string" && item.sourceEventId.length > 0 && isCalendarClipboardEvent(item.event) + && item.occurrenceStart === item.event.start ))) return null; - return { - ...value, - anchorOccurrenceStart: typeof value.anchorOccurrenceStart === "string" - ? value.anchorOccurrenceStart - : (value.items[0] as CalendarClipboardItem).occurrenceStart, - } as CalendarClipboard; + const anchorOccurrenceStart = value.anchorOccurrenceStart ?? (value.items[0] as CalendarClipboardItem).occurrenceStart; + if (!value.items.some((item: CalendarClipboardItem) => item.occurrenceStart === anchorOccurrenceStart)) return null; + return { ...value, anchorOccurrenceStart } as CalendarClipboard; }, }; export type CalendarView = "day" | "week" | "month" | "year"; +const CALENDAR_VIEWS: ReadonlySet = new Set(["day", "week", "month", "year"]); + +export function parseCalendarView(value: unknown): CalendarView | null { + return typeof value === "string" && CALENDAR_VIEWS.has(value) ? value as CalendarView : null; +} + export type CalendarIntent = | { readonly type: "selection.set"; @@ -156,37 +129,7 @@ export 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; @@ -206,8 +149,8 @@ export 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; @@ -312,6 +255,10 @@ export function createCalendarEditor( if (intent.type === "selection.clear") return success(session.select(emptyCalendarSelection())); if (intent.type === "selection.move") { + if (intent.source.points.length !== intent.source.occurrences.length || intent.source.points.some((point, index) => { + const occurrence = intent.source.occurrences[index]; + return occurrence === undefined || point.eventId !== occurrence.eventId || point.occurrenceStart !== occurrence.start; + })) return failure("selection.invalid-drag-source"); const plan = planCalendarSelectionMove( value().events, intent.source.occurrences, @@ -327,50 +274,22 @@ export function createCalendarEditor( }); } - if (intent.type === "event.create") { - if (intent.start >= intent.end) return failure("event.invalid-interval"); - const allDay = intent.allDay === true; - const start = allDay ? parseCalendarDate(intent.start) : parseCalendarInstant(intent.start); - const end = allDay ? parseCalendarDate(intent.end) : parseCalendarInstant(intent.end); - if (start === null || end === null) return failure("event.invalid-instant"); - const events = value().events; - const event: CalendarEvent = { - id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), - title: intent.title ?? "Event", - start: intent.start, - end: intent.end, - allDay, - calendarId: intent.calendarId ?? calendarDocumentCalendars(value())[0]?.id ?? "", - recurrence: intent.recurrence ?? null, - excludeDates: [], - }; + if (intent.type === "event.create" || intent.type === "event.move" || intent.type === "event.resize" + || intent.type === "event.move-day" || intent.type === "event.update" || intent.type === "occurrence.edit") { + const current = value(); + const plan = planCalendarEventEdit(current.events, intent, { + allocateId: createEditingIdAllocator(current.events.map((event) => event.id), createId, "calendar event"), + calendarIds: new Set(calendarDocumentCalendars(current).map((calendar) => calendar.id)), + defaultCalendarId: calendarDocumentCalendars(current)[0]?.id ?? "", + }); + if (!plan.ok) return plan; return session.apply({ - operations: [{ op: "add", path: `/events/${events.length}`, value: event }], - selectionAfter: selectionForEvents([...events, event], [event.id]), + operations: plan.operations, + selectionAfter: selectionForOccurrence(plan.affectedOccurrence.eventId, plan.affectedOccurrence.occurrenceStart), origin: intent.type, }); } - if (intent.type === "event.move") { - return moveEvent(intent.eventId, intent.start); - } - - if (intent.type === "event.resize") { - return resizeEvent(intent.eventId, intent.edge, intent.instant); - } - - if (intent.type === "event.move-day") { - return moveEventDay(intent.eventId, intent.day); - } - - if (intent.type === "event.update") { - return updateEvent(intent); - } - - if (intent.type === "occurrence.edit") { - return editOccurrence(intent); - } - if (intent.type === "occurrence.remove") { return removeOccurrence(intent); } @@ -379,283 +298,59 @@ export function createCalendarEditor( return setCalendarHidden(intent.calendarId, intent.hidden); } + if (intent.type !== "selection.remove") return failure("intent.unsupported"); const selected = selectedEvents(); if (selected.length === 0) return failure("selection.empty"); return removeSelected(selected.map((event) => event.id)); } - function moveEvent(eventId: string, start: string): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - if (isCalendarAllDay(event)) return failure("event.all-day-move"); - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const nextStart = parseCalendarInstant(start); - if (from === null || to === null || nextStart === null) return failure("event.invalid-instant"); - const nextEnd = formatCalendarInstant(nextStart.add({ minutes: calendarMinutesBetween(from, to) })); - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "start"]), value: start }, - { op: "replace", path: buildPointer(["events", index, "end"]), value: nextEnd }, - ], - selectionAfter: selectionForOccurrence(eventId, start), - origin: "event.move", - }); - } - - function resizeEvent( - eventId: string, - edge: "start" | "end", - instant: string, - ): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - const parsed = isCalendarAllDay(event) ? parseCalendarDate(instant) : parseCalendarInstant(instant); - if (parsed === null) return failure("event.invalid-instant"); - const start = edge === "start" ? instant : event.start; - const end = edge === "end" ? instant : event.end; - if (start >= end) return failure("event.invalid-interval"); - return session.apply({ - operations: [{ op: "replace", path: buildPointer(["events", index, edge]), value: instant }], - selectionAfter: selectionForOccurrence(eventId, start), - origin: "event.resize", - }); - } - - function moveEventDay(eventId: string, day: string): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - if (parseCalendarDate(day) === null) return failure("event.invalid-day"); - if (isCalendarAllDay(event)) { - const from = parseCalendarDate(event.start); - const to = parseCalendarDate(event.end); - const nextDay = parseCalendarDate(day); - if (from === null || to === null || nextDay === null) return failure("event.invalid-instant"); - const delta = calendarDaysBetween(from, nextDay); - const movedStart = formatCalendarDate(from.add({ days: delta })); - const movedEnd = formatCalendarDate(to.add({ days: delta })); - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "start"]), value: movedStart }, - { op: "replace", path: buildPointer(["events", index, "end"]), value: movedEnd }, - ], - selectionAfter: selectionForOccurrence(eventId, movedStart), - origin: "event.move-day", - }); - } - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const currentDay = parseCalendarInstant(`${calendarDatePart(event.start)}T00:00`); - const nextDay = parseCalendarInstant(`${day}T00:00`); - if (from === null || to === null || currentDay === null || nextDay === null) return failure("event.invalid-instant"); - const delta = calendarMinutesBetween(currentDay, nextDay); - const movedStart = formatCalendarInstant(from.add({ minutes: delta })); - const movedEnd = formatCalendarInstant(to.add({ minutes: delta })); - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "start"]), value: movedStart }, - { op: "replace", path: buildPointer(["events", index, "end"]), value: movedEnd }, - ], - selectionAfter: selectionForOccurrence(eventId, movedStart), - origin: "event.move-day", - }); - } - - function updateEvent(intent: Extract): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === intent.eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - let start = intent.start ?? event.start; - let end = intent.end ?? event.end; - const allDay = intent.allDay ?? event.allDay; - 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 times = shiftedOccurrenceTimes(event, event.start, intent.start, undefined); - if (times === null) return failure("event.invalid-interval"); - start = times.start; - end = times.end; - } - if (start >= end) return failure("event.invalid-interval"); - const next: CalendarEvent = { - ...event, - title: intent.title ?? event.title, - start, - end, - allDay, - calendarId: intent.calendarId ?? event.calendarId, - recurrence: intent.recurrence === undefined ? event.recurrence : intent.recurrence, - }; - if (isCalendarAllDay(next) ? parseCalendarDate(next.start) === null : parseCalendarInstant(next.start) === null) { - return failure("event.invalid-instant"); - } - return session.apply({ - operations: [{ op: "replace", path: buildPointer(["events", index]), value: next }], - selectionAfter: selectionForOccurrence(event.id, next.start), - origin: intent.type, - }); - } - - function editOccurrence(intent: Extract): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === intent.eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - const recurrence = calendarEventRecurrence(event); - if (recurrence === null) { - return updateEvent({ - type: "event.update", - eventId: intent.eventId, - ...(intent.title === undefined ? {} : { title: intent.title }), - ...(intent.start === undefined ? {} : { start: intent.start }), - ...(intent.end === undefined ? {} : { end: intent.end }), - }); - } - if (intent.scope === "all") { - const start = intent.start === undefined - ? undefined - : shiftSeriesValue(event.start, intent.occurrenceStart, intent.start); - const end = intent.end === undefined - ? undefined - : shiftSeriesValue(event.end, occurrenceEndOf(event, intent.occurrenceStart), intent.end); - if (intent.start !== undefined && start === undefined) return failure("event.invalid-instant"); - if (intent.end !== undefined && end === undefined) return failure("event.invalid-instant"); - return updateEvent({ - type: "event.update", - eventId: intent.eventId, - ...(intent.title === undefined ? {} : { title: intent.title }), - ...(start === undefined ? {} : { start }), - ...(end === undefined ? {} : { end }), - }); - } - const times = shiftedOccurrenceTimes(event, intent.occurrenceStart, intent.start, intent.end); - if (times === null) return failure("event.invalid-interval"); - const occurrenceDate = calendarDatePart(intent.occurrenceStart); - if (intent.scope === "this") { - const split: CalendarEvent = { - ...event, - id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), - title: intent.title ?? event.title, - start: times.start, - end: times.end, - recurrence: null, - excludeDates: [], - }; - return session.apply({ - operations: [ - { - op: "replace", - path: buildPointer(["events", index, "excludeDates"]), - value: [...calendarEventExcludeDates(event), occurrenceDate], - }, - { op: "add", path: `/events/${events.length}`, value: split }, - ], - selectionAfter: selectionForEvents([...events, split], [split.id]), - origin: intent.type, - }); - } - const until = addCalendarDate(occurrenceDate, -1) ?? occurrenceDate; - const following: CalendarEvent = { - ...event, - id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), - title: intent.title ?? event.title, - start: times.start, - end: times.end, - recurrence: { ...recurrence, until: "" }, - excludeDates: [], - }; - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "recurrence"]), value: { ...recurrence, until } }, - { op: "add", path: `/events/${events.length}`, value: following }, - ], - selectionAfter: selectionForEvents([...events, following], [following.id]), - origin: intent.type, - }); - } - function removeOccurrence( intent: Extract, ): EditingResult { const events = value().events; - const index = events.findIndex((event) => event.id === intent.eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - const recurrence = calendarEventRecurrence(event); - if (recurrence === null || intent.scope === "all") { - return removeSelected([event.id]); - } - const occurrenceDate = calendarDatePart(intent.occurrenceStart); - if (intent.scope === "this") { - return session.apply({ - operations: [{ - op: "replace", - path: buildPointer(["events", index, "excludeDates"]), - value: [...calendarEventExcludeDates(event), occurrenceDate], - }], - selectionAfter: selectionForEvents(events, [event.id]), - origin: intent.type, - }); - } - const until = addCalendarDate(occurrenceDate, -1); - if (until === null || until < calendarDatePart(event.start)) { - return removeSelected([event.id]); - } + const plan = planCalendarOccurrenceRemoval(events, intent); + if (!plan.ok) return plan; return session.apply({ - operations: [{ - op: "replace", - path: buildPointer(["events", index, "recurrence"]), - value: { ...recurrence, until }, - }], - selectionAfter: selectionForEvents(events, [event.id]), + operations: plan.operations, + selectionAfter: plan.events.some((event) => event.id === intent.eventId) + ? selectionForEvents(plan.events, [intent.eventId]) + : selectionAfterRemoval(events, plan.events, [intent.eventId]), origin: intent.type, }); } function setCalendarHidden(calendarId: string, hidden: boolean): EditingResult { - const calendars = calendarDocumentCalendars(value()); - const index = calendars.findIndex((item) => item.id === calendarId); - if (index < 0) return failure("calendar.not-found"); - return session.apply({ - operations: [{ op: "replace", path: buildPointer(["calendars", index, "hidden"]), value: hidden }], + const plan = planCalendarVisibility(value(), calendarId, hidden); + return plan.ok ? session.apply({ + operations: plan.operations, selectionAfter: session.snapshot.selection, origin: "calendar.set-hidden", - }); + }) : plan; } function removeSelected(ids: ReadonlyArray): EditingResult { const events = value().events; + const plan = planCalendarEventRemoval(events, ids); + return plan.ok ? session.apply({ + operations: plan.operations, + selectionAfter: selectionAfterRemoval(events, plan.events, ids), + origin: "selection.remove", + }) : plan; + } + + function selectionAfterRemoval(events: ReadonlyArray, remaining: ReadonlyArray, ids: ReadonlyArray): CalendarSelection { const removing = new Set(ids); - const indices = events - .map((event, index) => removing.has(event.id) ? index : -1) - .filter((index) => index >= 0) - .sort((left, right) => right - left); - const remaining = events.filter((event) => !removing.has(event.id)); - const firstRemoved = Math.min(...indices); + const firstRemoved = events.findIndex((event) => removing.has(event.id)); const next = remaining[Math.min(firstRemoved, remaining.length - 1)]; - return session.apply({ - operations: indices.map((index) => ({ op: "remove", path: buildPointer(["events", index]) })), - selectionAfter: selectionForEvents(remaining, next ? [next.id] : []), - origin: "selection.remove", - }); + return selectionForEvents(remaining, next ? [next.id] : []); } function copy(occurrences?: ReadonlyArray): CalendarClipboard | null { const source = occurrences ?? selectedOccurrences(); const items = source.flatMap((occurrence): CalendarClipboardItem[] => { const event = value().events.find((candidate) => candidate.id === occurrence.eventId); - if (event === undefined || occurrence.start >= occurrence.end) return []; + const current = resolveCalendarOccurrence(value().events, { eventId: occurrence.eventId, occurrenceStart: occurrence.start }); + if (event === undefined || current === null || current.end !== occurrence.end) return []; return [{ sourceEventId: event.id, occurrenceStart: occurrence.start, @@ -663,12 +358,14 @@ export function createCalendarEditor( ...event, start: occurrence.start, end: occurrence.end, + allDay: isCalendarAllDay(event), + calendarId: event.calendarId ?? "", recurrence: null, excludeDates: [], }, }]; }); - if (items.length === 0) return null; + if (items.length === 0 || items.length !== source.length) return null; return { type: "application/vnd.interactive-os.calendar+json", anchorOccurrenceStart: items[0]!.occurrenceStart, @@ -702,36 +399,42 @@ export function createCalendarEditor( function removeClipboard(clipboard: CalendarClipboard): EditingResult { if (clipboard.items.length === 0) return failure("clipboard.empty"); const events = value().events; - const removals = new Set(); - const exclusions = new Map>(); + let remaining = events; + const preconditions: JSONPatchOperation[] = []; + const operations: JSONPatchOperation[] = []; for (const item of clipboard.items) { const event = events.find((candidate) => candidate.id === item.sourceEventId); if (event === undefined) return failure("selection.event-not-found"); - if (calendarEventRecurrence(event) === null) removals.add(event.id); - else { - const dates = exclusions.get(event.id) ?? new Set(calendarEventExcludeDates(event)); - dates.add(calendarDatePart(item.occurrenceStart)); - exclusions.set(event.id, dates); + const current = resolveCalendarOccurrence(events, { eventId: event.id, occurrenceStart: item.occurrenceStart }); + if (current === null || current.end !== item.event.end) return failure("selection.stale-occurrence"); + const expected: Record = { ...item.event, start: event.start, end: event.end }; + // Clipboard events are materialized; keep the source's legacy optional-field shape. + for (const field of ["allDay", "calendarId", "recurrence", "excludeDates"] as const) { + if (event[field] === undefined) delete expected[field]; + else if (field === "recurrence" || field === "excludeDates") expected[field] = event[field]; } + preconditions.push({ op: "test", path: buildPointer(["events", events.indexOf(event)]), value: expected }); + const plan = planCalendarOccurrenceRemoval(remaining, { + eventId: event.id, occurrenceStart: item.occurrenceStart, scope: "this", + }); + if (!plan.ok) return plan; + remaining = plan.events; + operations.push(...plan.operations); } - const operations: JSONPatchOperation[] = []; - for (const [eventId, dates] of exclusions) { - const index = events.findIndex((event) => event.id === eventId); - operations.push({ op: "replace", path: buildPointer(["events", index, "excludeDates"]), value: [...dates] }); - } - for (const index of events.map((event, index) => removals.has(event.id) ? index : -1).filter((index) => index >= 0).sort((a, b) => b - a)) { - operations.push({ op: "remove", path: buildPointer(["events", index]) }); - } - return session.apply({ operations, selectionAfter: emptyCalendarSelection(), origin: "clipboard.cut" }); + return session.apply({ operations: [...preconditions, ...operations], selectionAfter: emptyCalendarSelection(), origin: "clipboard.cut" }); } - function paste(clipboard: CalendarClipboard, target?: string): EditingResult { - if (clipboard.items.length === 0) return failure("clipboard.empty"); - const resolvedTarget = target ?? selectedEvents()[0]?.start; + function paste(clipboard: CalendarClipboard, target?: string, options: { readonly calendarId?: string } = {}): EditingResult { + const parsed = calendarClipboardFormat.parse(clipboard); + if (parsed === null) return failure("clipboard.invalid"); + clipboard = parsed; + const calendarIds = new Set(calendarDocumentCalendars(value()).map((calendar) => calendar.id)); + if (options.calendarId !== undefined && !calendarIds.has(options.calendarId)) return failure("calendar.not-found"); + const resolvedTarget = target ?? primaryOccurrence()?.start; if (resolvedTarget === undefined) return failure("clipboard.invalid-target"); const targetInstant = parseCalendarInstant(resolvedTarget); const targetDate = parseCalendarDate(calendarDatePart(resolvedTarget)); - if (targetInstant === null && targetDate === null) return failure("clipboard.invalid-target"); + if (targetInstant === null && parseCalendarDate(resolvedTarget) === null) return failure("clipboard.invalid-target"); const timedAnchor = parseCalendarInstant(clipboard.anchorOccurrenceStart) ?? clipboard.items.map((item) => parseCalendarInstant(item.event.start)).find((item) => item !== null) ?? null; @@ -763,8 +466,10 @@ export function createCalendarEditor( start = source.allDay ? formatCalendarDate(nextStart) : `${formatCalendarDate(nextStart)}T${source.start.slice(11)}`; end = source.allDay ? formatCalendarDate(nextStart.add({ days: duration })) : `${formatCalendarDate(nextStart.add({ days: duration }))}T${source.end.slice(11)}`; } - const event = { ...source, id: allocateId(), start, end, recurrence: null, excludeDates: [] }; - pasted.push(event); + const event = { ...source, start, end, calendarId: options.calendarId ?? source.calendarId, recurrence: null, excludeDates: [] }; + const validation = validateCalendarEvent(event, calendarIds); + if (!validation.ok) return validation; + pasted.push({ ...event, id: allocateId() }); } return session.apply({ operations: pasted.map((event, offset) => ({ op: "add", path: `/events/${existing.length + offset}`, value: event })), @@ -781,8 +486,8 @@ export function createCalendarEditor( prepareSelectionDrag, dispatch, copy, - cut: (occurrences) => cutEditingClipboard( - () => copy(occurrences), + cut: (source) => cutEditingClipboard( + () => source !== undefined && "type" in source ? calendarClipboardFormat.parse(source) : copy(source), removeClipboard, ), paste, @@ -793,29 +498,15 @@ export function createCalendarEditor( } function isCalendarClipboardEvent(value: unknown): value is CalendarEvent { - return isRecord(value) - && typeof value.id === "string" - && typeof value.title === "string" - && typeof value.start === "string" - && typeof value.end === "string" - && typeof value.allDay === "boolean" - && typeof value.calendarId === "string" - && value.recurrence === null - && Array.isArray(value.excludeDates) - && value.excludeDates.every((date) => typeof date === "string"); + return isRecord(value) && validateCalendarEvent(value).ok + && typeof value.allDay === "boolean" && typeof value.calendarId === "string" + && value.recurrence === null && Array.isArray(value.excludeDates) && value.excludeDates.length === 0; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -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 calendarOccurrenceTopology( document: CalendarDocument, rangeStart: string, @@ -868,346 +559,6 @@ function compareCalendarOccurrencePoints( || left.eventId.localeCompare(right.eventId); } -function resolveCalendarOccurrence( - events: ReadonlyArray, - point: CalendarOccurrencePoint, -): CalendarOccurrenceSelection | null { - const event = events.find((candidate) => candidate.id === point.eventId); - if (event === undefined) 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, - }; -} - -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, - })); -} - -function occurrenceEndOf(event: CalendarEvent, occurrenceStart: string): string { - const bounds = calendarEventBounds(event); - if (bounds === null) return occurrenceStart; - if (isCalendarAllDay(event)) { - const from = parseCalendarDate(calendarDatePart(occurrenceStart)); - if (from === null) return occurrenceStart; - return formatCalendarDate(from.add({ days: calendarDaysBetween(bounds.from.toPlainDate(), bounds.to.toPlainDate()) })); - } - const from = parseCalendarInstant(occurrenceStart); - if (from === null) return occurrenceStart; - return formatCalendarInstant(from.add({ minutes: calendarMinutesBetween(bounds.from, bounds.to) })); -} - -function shiftSeriesValue(seriesValue: string, origin: string, next: string): string | undefined { - const originInstant = parseCalendarInstant(origin); - const nextInstant = parseCalendarInstant(next); - const seriesInstant = parseCalendarInstant(seriesValue); - if (originInstant !== null && nextInstant !== null && seriesInstant !== null) { - return formatCalendarInstant(seriesInstant.add({ minutes: calendarMinutesBetween(originInstant, nextInstant) })); - } - const originDate = parseCalendarDate(calendarDatePart(origin)); - const nextDate = parseCalendarDate(calendarDatePart(next)); - const seriesDate = parseCalendarDate(calendarDatePart(seriesValue)); - if (originDate === null || nextDate === null || seriesDate === null) return undefined; - return formatCalendarDate(seriesDate.add({ days: calendarDaysBetween(originDate, nextDate) })); -} - -function shiftedOccurrenceTimes( - event: CalendarEvent, - occurrenceStart: string, - start: string | undefined, - end: string | undefined, -): { readonly start: string; readonly end: string } | null { - const nextStart = start ?? occurrenceStart; - if (end !== undefined) return nextStart < end ? { start: nextStart, end } : null; - const bounds = calendarEventBounds(event); - if (bounds === null) return null; - if (isCalendarAllDay(event)) { - const from = parseCalendarDate(calendarDatePart(nextStart)); - if (from === null) return null; - const duration = calendarDaysBetween(bounds.from.toPlainDate(), bounds.to.toPlainDate()); - const nextEnd = formatCalendarDate(from.add({ days: duration })); - const dateStart = calendarDatePart(nextStart); - return dateStart < nextEnd ? { start: dateStart, end: nextEnd } : null; - } - const from = parseCalendarInstant(nextStart); - if (from === null) return null; - const duration = calendarMinutesBetween(bounds.from, bounds.to); - const nextEnd = formatCalendarInstant(from.add({ minutes: duration })); - return nextStart < nextEnd ? { start: nextStart, end: nextEnd } : null; -} - function emptyCalendarSelection(): CalendarSelection { return { kind: "range", ranges: [], primaryIndex: null }; } diff --git a/packages/json-document-editing/src/canvas-clipboard.ts b/packages/json-document-editing/src/canvas-clipboard.ts new file mode 100644 index 000000000..a6ac3bac1 --- /dev/null +++ b/packages/json-document-editing/src/canvas-clipboard.ts @@ -0,0 +1,57 @@ +import { createCanvasImage, createCanvasObject, type CanvasObjectDraft, type ObjectBounds } from "@interactive-os/json-document-object-document"; +import { objectClipboardFormat, type ObjectClipboard } from "./object.js"; + +export 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 }; + +export type CanvasClipboardItem = { readonly type: "text"; readonly text: string } + | ({ readonly type: "image" } & Parameters[0]); + +export interface CanvasClipboardOptions { + readonly bounds: ObjectBounds; + readonly textColor: string; + readonly fontSize: number; + readonly imageOffset?: number; + readonly contentGap?: number; +} + +/** External content → domain clipboard. No platform objects, document IDs, or history writes. */ +export function createCanvasClipboard(content: CanvasClipboardContent, options: CanvasClipboardOptions): ObjectClipboard { + const { bounds } = options; + const offset = options.imageOffset ?? 24; + if (![bounds.x, bounds.y, offset].every(Number.isFinite) + || ![bounds.width, bounds.height, options.fontSize].every((value) => Number.isFinite(value) && value > 0)) throw new TypeError("Canvas clipboard geometry and font size must be valid."); + if ((content.type === "text" && content.text.length === 0) || (content.type === "images" && content.images.length === 0) + || (content.type === "mixed" && content.items.length === 0)) throw new TypeError("Canvas clipboard must not be empty."); + const drafts = content.type === "mixed" ? mixedObjects(content.items, options) + : content.type === "text" ? [textObject(content.text, options)] + : content.images.map((image, index) => createCanvasImage(image, { ...bounds, x: bounds.x + index * offset, y: bounds.y + index * offset })); + const objects = drafts.map((object, index) => ({ ...object, id: `clipboard:${index}` })); + const payload: ObjectClipboard = { type: objectClipboardFormat.mimeType, objects, text: objects.map((object) => object.label).join("\n"), primaryKey: objects.at(-1)!.id }; + if (!objectClipboardFormat.parse(payload)) throw new TypeError("Invalid Canvas clipboard content."); + return payload; +} + +function textObject(text: string, options: CanvasClipboardOptions): CanvasObjectDraft { + if (text.length === 0) throw new TypeError("Canvas clipboard text must not be empty."); + return createCanvasObject("text", { ...options.bounds, height: Math.min(options.bounds.height, Math.max(1, text.split("\n").length) * options.fontSize * 1.2) }, { color: options.textColor, fontSize: options.fontSize, label: text }); +} + +/** Flow order is domain geometry, not a reconstruction of the source page's CSS. */ +function mixedObjects(items: ReadonlyArray, options: CanvasClipboardOptions): CanvasObjectDraft[] { + const gap = options.contentGap ?? 24; + if (!Number.isFinite(gap) || gap < 0) throw new TypeError("Canvas content gap must be finite and nonnegative."); + let height = 0; + const drafts = items.map((item) => { + const object = item.type === "text" ? textObject(item.text, options) : createCanvasImage(item, options.bounds); + const positioned = { ...object, y: height }; + height += object.height + gap; + return positioned; + }); + const scale = Math.min(1, options.bounds.height / (height - gap)); + return drafts.map((object) => ({ ...object, x: options.bounds.x, y: options.bounds.y + object.y * scale, width: object.width * scale, height: object.height * scale, + ...(object.kind === "text" ? { fontSize: object.fontSize * scale } : {}), + })); +} diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index ca8f04548..d8975aebd 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -1,3 +1,15 @@ +// Published compatibility exports; implementations belong to Calendar Document Type. +export { + calendarAllDayLayout, + calendarBusyDates, + calendarEventsInMonth, + calendarEventsOnDay, + calendarMonthDayLayout, + calendarMonthWeekLayout, + calendarNowMarker, + calendarTimedLayout, + calendarVisibleEvents, +} from "@interactive-os/json-document-calendar-document"; export { createDocumentEditor, documentClipboardFormat, documentSelectionFocus } from "./document.js"; export { cutEditingClipboard } from "./clipboard.js"; export type { EditingClipboardCut } from "./clipboard.js"; @@ -16,6 +28,9 @@ export type { GridPoint, GridRangeBounds, GridTopology, LineTopology } from "./t export { createDatabaseEditor, databaseClipboardFormat, nextDatabasePropertySort } from "./database.js"; export { acceptsDatabaseValue, databaseValueFromText, defaultDatabaseValue } from "./database-property-value.js"; export { createObjectEditor, objectClipboardFormat } from "./object.js"; +export { createCanvasClipboard, type CanvasClipboardContent, type CanvasClipboardItem, type CanvasClipboardOptions } from "./canvas-clipboard.js"; +export { createObjectPasteSession, type ObjectPasteSession, type ObjectPastePreparation } from "./object-paste-session.js"; +export { createEditingPreparationQueue, type EditingPreparationQueue, type EditingPreparation, type EditingPreparationFailure } from "./preparation-queue.js"; export { createOrderEditor, orderClipboardFormat } from "./order.js"; export { createEditingSession } from "./session.js"; export { createEditingId, createEditingIdAllocator } from "./identity.js"; @@ -25,17 +40,9 @@ export { createTreeEditor, treeClipboardFormat } from "./tree.js"; export { projectTreeVisibility, treeVisibilityNeighbor } from "./tree-visibility.js"; export { createKanbanEditor } from "./kanban.js"; export { - calendarAllDayLayout, - calendarBusyDates, - calendarEventsInMonth, - calendarEventsOnDay, - calendarMonthDayLayout, - calendarMonthWeekLayout, - calendarNowMarker, calendarOccurrenceTopology, - calendarTimedLayout, - calendarVisibleEvents, createCalendarEditor, + parseCalendarView, calendarClipboardFormat, } from "./calendar.js"; export { @@ -43,7 +50,7 @@ export { calendarRecurrenceWithInterval, calendarRecurrenceWithUntil, projectCalendarOccurrences, -} from "./calendar-occurrence.js"; +} from "@interactive-os/json-document-calendar-document"; export { calendarOccurrenceAfterIntent, calendarOccurrenceForInspector, @@ -67,8 +74,7 @@ export { calendarShiftInstant, formatCalendarInstant, isCalendarAllDay, - parseCalendarView, -} from "./calendar-validation.js"; +} from "@interactive-os/json-document-calendar-document"; export { ANNOTATION_PROFILE_V1, annotationResizeHandle, annotationSelectorBounds, createAnnotationEditor, transformAnnotationSelector } from "./annotation.js"; export { assertAnnotationDocument } from "./annotation-validation.js"; export type { @@ -201,7 +207,7 @@ export type { CalendarSelectionMovePlan, CalendarSelectionMoveTarget, } from "./calendar-selection-move.js"; -export type { CalendarOccurrence } from "./calendar-occurrence.js"; +export type { CalendarOccurrence } from "@interactive-os/json-document-calendar-document"; export type { CalendarAllDayHandle, CalendarAllDayPointerIntent, diff --git a/packages/json-document-editing/src/object-paste-session.ts b/packages/json-document-editing/src/object-paste-session.ts new file mode 100644 index 000000000..9611fc143 --- /dev/null +++ b/packages/json-document-editing/src/object-paste-session.ts @@ -0,0 +1,61 @@ +import type { EditingResult } from "./session.js"; +import type { ObjectClipboard, ObjectEditor, ObjectPastePlacement, ObjectSelection } from "./object.js"; +import { createEditingPreparationQueue, type EditingPreparation } from "./preparation-queue.js"; + +export type ObjectPastePreparation = + | { readonly ok: true; readonly clipboard: ObjectClipboard } + | { readonly ok: false; readonly code: string; readonly reason?: string }; + +export 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; +} + +/** Ordered, atomic paste adoption. External document/selection changes invalidate pending work. */ +export function createObjectPasteSession(editor: ObjectEditor, options: { + readonly placement?: ObjectPastePlacement; + readonly onResult?: (result: EditingResult) => void; + readonly onPendingChange?: (pending: boolean) => void; +} = {}): ObjectPasteSession { + let release: (() => void) | undefined; + let applying = false; + const queue = createEditingPreparationQueue>({ + cancelCode: "clipboard.cancelled", + errorCode: "clipboard.invalid", + apply(clipboard) { + applying = true; + try { return editor.dispatch({ type: "clipboard.paste", clipboard, ...(options.placement ? { placement: options.placement } : {}) }); } + finally { applying = false; } + }, + onPendingChange(pending) { + if (!pending) { release?.(); release = undefined; } + options.onPendingChange?.(pending); + }, + onResult(result) { + try { options.onResult?.(result); } + finally { + if (result.ok && (editor.snapshot.value !== result.snapshot.value || editor.snapshot.selection !== result.snapshot.selection)) queue.cancel(); + } + }, + }); + const prepared = (result: ObjectPastePreparation): EditingPreparation => result.ok ? { ok: true, value: result.clipboard } : result; + return { + get pending() { return queue.isPending; }, + cancel: queue.cancel, + enqueue(prepare, cancelPreparation) { + if (!release) { + let base = editor.snapshot; + release = editor.subscribe((next) => { + if (!applying && (base.value !== next.value || base.selection !== next.selection)) queue.cancel(); + base = next; + }); + } + return queue.enqueue(() => { + const result = prepare(); + return "ok" in result ? prepared(result) : Promise.resolve(result).then(prepared); + }, cancelPreparation); + }, + }; +} diff --git a/packages/json-document-editing/src/object-validation.ts b/packages/json-document-editing/src/object-validation.ts deleted file mode 100644 index cac5fa944..000000000 --- a/packages/json-document-editing/src/object-validation.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ObjectDocument } from "./object.js"; - -export function assertObjectDocument(document: ObjectDocument): void { - const ids = new Set(); - for (const object of document.objects) { - if (object.id.length === 0) throw new Error("Object ids must not be empty."); - if (ids.has(object.id)) throw new Error(`Object id must be unique: ${JSON.stringify(object.id)}.`); - if (![object.x, object.y, object.width, object.height].every(Number.isFinite)) throw new Error(`Object geometry must be finite: ${JSON.stringify(object.id)}.`); - if (object.width < 0 || object.height < 0) throw new Error(`Object dimensions must not be negative: ${JSON.stringify(object.id)}.`); - ids.add(object.id); - } -} diff --git a/packages/json-document-editing/src/object.ts b/packages/json-document-editing/src/object.ts index 37f488e04..210868467 100644 --- a/packages/json-document-editing/src/object.ts +++ b/packages/json-document-editing/src/object.ts @@ -1,6 +1,4 @@ import { - buildPointer, - type JSONPatchOperation, type JSONValue, } from "@interactive-os/json-document"; import { @@ -17,21 +15,11 @@ import { resolveDocumentSource, type EditingDocumentSource } from "./document-so import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; -import { assertObjectDocument } from "./object-validation.js"; - -export interface DocumentObject extends Record { - readonly id: string; - readonly label: string; - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; - readonly color: string; -} - -export interface ObjectDocument extends Record { - readonly objects: ReadonlyArray; -} +import { + assertObjectDocument, planObjectOperation, transformObject, + type DocumentObject, type ObjectDocument, type ObjectDraft, type ObjectOperation, type ObjectStyle, +} from "@interactive-os/json-document-object-document"; +export type { DocumentObject, ObjectDocument } from "@interactive-os/json-document-object-document"; export interface ObjectSelection extends Record { readonly kind: "explicit"; @@ -41,42 +29,48 @@ export interface ObjectSelection extends Record { export type ObjectSelectionMode = "replace" | "extend" | "add" | "subtract" | "toggle"; -export interface ObjectClipboard extends Record { +export 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; +}; export const objectClipboardFormat = { mimeType: "application/vnd.interactive-os.objects+json" as const, parse(value: unknown): ObjectClipboard | null { - return isClipboardRecord(value) - && value.type === this.mimeType - && typeof value.text === "string" - && Array.isArray(value.objects) - && value.objects.every((item) => isClipboardRecord(item) - && typeof item.id === "string" && typeof item.label === "string" - && typeof item.x === "number" && typeof item.y === "number" - && typeof item.width === "number" && typeof item.height === "number" - && typeof item.color === "string") - ? value as ObjectClipboard : null; + if (!isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; + if (!Array.isArray(value.objects) || value.objects.some((object) => !isClipboardRecord(object) || typeof object.color !== "string")) return null; + try { + assertObjectDocument(value); + if (value.primaryKey !== undefined && value.primaryKey !== null && !value.objects.some((object) => object.id === value.primaryKey)) return null; + return value as ObjectClipboard; + } catch { return null; } }, }; export interface ObjectPastePlacement { - readonly type: "offset"; + readonly type: "offset" | "cascade"; readonly dx: number; readonly dy: number; } export 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; @@ -147,6 +141,21 @@ export function createObjectEditor( } function dispatch(intent: ObjectIntent): EditingResult { + if (intent.type === "object.create") { + const allocate = createEditingIdAllocator(value().objects.map((object) => object.id), createId, "object"); + let id: string; + try { id = allocate(); } catch (error) { return { ok: false, code: "object.identity-unavailable", reason: error instanceof Error ? error.message : String(error) }; } + const object = { ...intent.object, id }; + return apply({ type: "insert", objects: [object] }, selectionFor([object.id]), intent.type); + } + if (intent.type === "object.text") { + return apply({ type: "text", objectId: intent.objectId, text: intent.text }, selectionForTargets([intent.objectId]), intent.type); + } + if (intent.type === "document.replace") { + // A profile-specific session cannot silently become another document type. + if (value().profile !== intent.document?.profile) return failure("object.profile-mismatch"); + return apply({ type: "replace", document: intent.document }, selectionFor([]), intent.type); + } if (intent.type === "selection.set") { const available = new Set(value().objects.map((object) => object.id)); if (intent.objectIds.some((id) => !available.has(id))) { @@ -156,95 +165,91 @@ export function createObjectEditor( type: intent.mode === "extend" ? "add" : intent.mode ?? "replace", keys: intent.objectIds, }; - const selection = selectionFamily.transition( + const context = selectionContext(); + let selection = selectionFamily.transition( session.snapshot.selection, command, - selectionContext(), + context, ).state; + if (intent.primaryKey !== undefined) { + if (!selectionFamily.targets(selection, context).includes(intent.primaryKey)) return failure("selection.primary-not-selected"); + selection = selectionFamily.transition(selection, { type: "set-primary", key: intent.primaryKey }, context).state; + } return success(session.select(selectionFor( - selectionFamily.targets(selection, selectionContext()), + selectionFamily.targets(selection, context), selection.primaryKey, ))); } if (intent.type === "object.translate") { - const objects = value().objects; - const moving = new Set(intent.objectIds); - if (intent.objectIds.some((id) => !objects.some((object) => object.id === id))) { - return failure("selection.object-not-found"); - } - return session.apply({ - operations: objects.flatMap((object, index) => { - if (!moving.has(object.id)) return []; - return [ - { op: "replace", path: buildPointer(["objects", index, "x"]), value: object.x + intent.dx }, - { op: "replace", path: buildPointer(["objects", index, "y"]), value: object.y + intent.dy }, - ]; - }), - selectionAfter: selectionFor(intent.objectIds), - origin: intent.type, - }); + return apply({ type: "transform", objectIds: intent.objectIds, transform: { dx: intent.dx, dy: intent.dy } }, selectionForTargets(intent.objectIds), intent.type); } if (intent.type === "object.resize") { - const objects = value().objects; - const resizing = new Set(intent.objectIds); - if (intent.objectIds.some((id) => !objects.some((object) => object.id === id))) { - return failure("selection.object-not-found"); - } - return session.apply({ - operations: objects.flatMap((object, index) => { - if (!resizing.has(object.id)) return []; - const width = Math.max(1, object.width + intent.dw); - const height = Math.max(1, object.height + intent.dh); - return [ - { op: "replace", path: buildPointer(["objects", index, "x"]), value: object.x + intent.dx }, - { op: "replace", path: buildPointer(["objects", index, "y"]), value: object.y + intent.dy }, - { op: "replace", path: buildPointer(["objects", index, "width"]), value: width }, - { op: "replace", path: buildPointer(["objects", index, "height"]), value: height }, - ]; - }), - selectionAfter: selectionFor(intent.objectIds), - origin: intent.type, - }); + return apply({ type: "transform", objectIds: intent.objectIds, transform: { dx: intent.dx, dy: intent.dy, dw: intent.dw, dh: intent.dh } }, selectionForTargets(intent.objectIds), intent.type); + } + + if (intent.type === "object.remove") return removeSelected(intent.objectIds, intent.type); + + if (intent.type === "object.duplicate") { + const ids = new Set(intent.objectIds); + const source = value().objects.filter((object) => ids.has(object.id)); + if (source.length !== ids.size) return failure("selection.object-not-found"); + if (source.length === 0) return failure("selection.empty"); + return insertCopies(source, session.snapshot.selection.primaryKey, intent.placement ?? { type: "offset", dx: 24, dy: 24 }, intent.type); } if (intent.type === "clipboard.paste") { - const objects = value().objects; - const pasted = cloneObjectsWithUniqueIds(intent.clipboard.objects, objects, createId).map((object) => ({ - ...object, - x: object.x + (intent.placement?.dx ?? 0), - y: object.y + (intent.placement?.dy ?? 0), - })); - if (pasted.length === 0) return failure("clipboard.empty"); - return session.apply({ - operations: pasted.map((object, offset) => ({ - op: "add", - path: `/objects/${objects.length + offset}`, - value: object, - })), - selectionAfter: selectionFor(pasted.map((object) => object.id)), - origin: intent.type, - }); + if (!objectClipboardFormat.parse(intent.clipboard)) return failure("clipboard.invalid"); + if (intent.clipboard.objects.length === 0) return failure("clipboard.empty"); + return insertCopies(intent.clipboard.objects, intent.clipboard.primaryKey ?? null, intent.placement, intent.type); } const selected = selectedObjects(); if (selected.length === 0) return failure("selection.empty"); if (intent.type === "selection.fill") { - const objects = value().objects; - const operations: JSONPatchOperation[] = selected.map((object) => ({ - op: "replace", - path: buildPointer(["objects", objects.findIndex((candidate) => candidate.id === object.id), "color"]), - value: intent.color, - })); - return session.apply({ - operations, - selectionAfter: session.snapshot.selection, - origin: intent.type, - }); + return apply({ type: "fill", objectIds: selected.map((object) => object.id), color: intent.color }, session.snapshot.selection, intent.type); + } + if (intent.type === "selection.style") { + return apply({ type: "style", objectIds: selected.map((object) => object.id), style: intent.style }, session.snapshot.selection, intent.type); + } + + return intent.type === "selection.remove" ? removeSelected(selected.map((object) => object.id)) : failure("object.unsupported-intent"); + } + + function selectionForTargets(ids: readonly string[]): ObjectSelection { + const selection = session.snapshot.selection; + const selected = new Set(selection.keys); + return ids.length > 0 && ids.every((id) => selected.has(id)) ? selection : selectionFor(ids); + } + + function insertCopies(source: readonly DocumentObject[], primaryKey: string | null, placement: ObjectPastePlacement | undefined, origin: string): EditingResult { + let dx = placement?.dx ?? 0, dy = placement?.dy ?? 0; + if (![dx, dy].every(Number.isFinite)) return failure("object.invalid"); + if (placement?.type === "cascade") { + if (dx === 0 && dy === 0) return failure("object.invalid"); + const occupied = new Set(value().objects.map((object) => `${object.x}:${object.y}`)); + const anchor = source[0]!; + let step = 1; + while (occupied.has(`${anchor.x + dx}:${anchor.y + dy}`)) { + if (++step > occupied.size + 1) return failure("object.invalid"); + dx = placement.dx * step; dy = placement.dy * step; + } + } + if (source.some((object) => !Number.isFinite(object.x + dx) || !Number.isFinite(object.y + dy))) return failure("object.invalid"); + let copies: DocumentObject[]; + try { + copies = cloneObjectsWithUniqueIds(source, value().objects, createId).map((object) => transformObject(object, { dx, dy })); + } catch (error) { + return { ok: false, code: "object.identity-unavailable", reason: error instanceof Error ? error.message : String(error) }; } + const primary = copies[source.findIndex((object) => object.id === primaryKey)]?.id ?? copies.at(-1)!.id; + return apply({ type: "insert", objects: copies }, selectionFor(copies.map((object) => object.id), primary), origin); + } - return removeSelected(selected.map((object) => object.id)); + function apply(operation: ObjectOperation, selectionAfter: ObjectSelection, origin: string): EditingResult { + const plan = planObjectOperation(value(), operation); + return plan.ok ? session.apply({ operations: plan.operations, selectionAfter, origin }) : plan; } function copy(): ObjectClipboard | null { @@ -254,13 +259,15 @@ export function createObjectEditor( type: "application/vnd.interactive-os.objects+json", objects, text: objects.map((object) => object.label).join("\n"), + primaryKey: session.snapshot.selection.primaryKey, }; } - function removeSelected(ids: ReadonlyArray): EditingResult { + function removeSelected(ids: ReadonlyArray, origin = "selection.remove"): EditingResult { const objects = value().objects; if (ids.length === 0) return failure("selection.empty"); const selectedIds = new Set(ids); + if (ids.some((id) => !objects.some((object) => object.id === id))) return failure("selection.object-not-found"); const indices = objects .map((object, index) => selectedIds.has(object.id) ? index : -1) .filter((index) => index >= 0) @@ -268,11 +275,7 @@ export function createObjectEditor( const remaining = objects.filter((object) => !selectedIds.has(object.id)); const firstRemoved = Math.min(...indices); const next = remaining[Math.min(firstRemoved, remaining.length - 1)]; - return session.apply({ - operations: indices.map((index) => ({ op: "remove", path: buildPointer(["objects", index]) })), - selectionAfter: selectionFor(next ? [next.id] : []), - origin: "selection.remove", - }); + return apply({ type: "remove", objectIds: [...selectedIds] }, selectionFor(next ? [next.id] : []), origin); } return { @@ -280,7 +283,7 @@ export function createObjectEditor( get selectedObjects() { return selectedObjects(); }, dispatch, copy, - cut: () => cutEditingClipboard(copy, () => removeSelected(selectedObjects().map((object) => object.id))), + cut: () => cutEditingClipboard(copy, (clipboard) => removeSelected(clipboard.objects.map((object) => object.id))), undo: () => session.undo(), redo: () => session.redo(), subscribe: (listener) => session.subscribe(listener), @@ -292,7 +295,7 @@ function cloneObjectsWithUniqueIds( existing: ReadonlyArray, createId: () => string, ): DocumentObject[] { - const allocateId = createEditingIdAllocator(existing.map((object) => object.id), createId, "object"); + const allocateId = createEditingIdAllocator([...existing, ...source].map((object) => object.id), createId, "object"); return source.map((object) => ({ ...object, id: allocateId() })); } diff --git a/packages/json-document-editing/src/preparation-queue.ts b/packages/json-document-editing/src/preparation-queue.ts new file mode 100644 index 000000000..ebc7d42f9 --- /dev/null +++ b/packages/json-document-editing/src/preparation-queue.ts @@ -0,0 +1,80 @@ +export type EditingPreparationFailure = { readonly ok: false; readonly code: string; readonly reason?: string }; +export type EditingPreparation = { readonly ok: true; readonly value: Value } | EditingPreparationFailure; + +export interface EditingPreparationQueue { + readonly isPending: boolean; + enqueue(prepare: () => EditingPreparation | Promise>, cancelPreparation?: () => void): Promise; + cancel(): void; +} + +/** Orders asynchronous preparation before synchronous edits. Targets and invalidation policy belong to the consumer. */ +export function 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 { + type Job = { prepared?: EditingPreparation; resolve: (result: Result | EditingPreparationFailure) => void; cancel?: () => void }; + let queue: Job[] = []; + let draining = false; + let publishedPending = false; + + function publishPending(pending: boolean) { + if (publishedPending === pending) return; + publishedPending = pending; + try { options.onPendingChange?.(pending); } catch { /* Observers do not own queue progress. */ } + } + function failure(error: unknown): EditingPreparationFailure { + return { ok: false, code: options.errorCode ?? "editing.preparation-failed", reason: error instanceof Error ? error.message : String(error) }; + } + function cancel() { + const cancelled = queue; + queue = []; + publishPending(false); + for (const job of cancelled) { + job.resolve({ ok: false, code: options.cancelCode ?? "editing.preparation-cancelled" }); + try { job.cancel?.(); } catch { /* Every cancelled job must settle. */ } + } + } + function drain() { + if (draining) return; + draining = true; + try { + while (queue[0]?.prepared) { + const job = queue.shift()!; + const prepared = job.prepared!; + let result: Result | EditingPreparationFailure; + try { result = prepared.ok ? options.apply(prepared.value) : prepared; } + catch (error) { result = failure(error); } + job.resolve(result); + try { options.onResult?.(result); } catch { /* A committed edit remains committed. */ } + } + } finally { + draining = false; + if (queue.length === 0) publishPending(false); + } + } + function ready(job: Job, prepared: EditingPreparation) { + if (!queue.includes(job)) return; + job.prepared = prepared; + drain(); + } + return { + get isPending() { return queue.length > 0 || draining; }, + cancel, + enqueue(prepare, cancelPreparation) { + return new Promise((resolve) => { + const job: Job = { resolve, ...(cancelPreparation ? { cancel: cancelPreparation } : {}) }; + queue.push(job); + publishPending(true); + if (!queue.includes(job)) return; + try { + const result = prepare(); + if ("ok" in result) ready(job, result); + else void Promise.resolve(result).then((value) => ready(job, value), (error: unknown) => ready(job, failure(error))); + } catch (error) { ready(job, failure(error)); } + }); + }, + }; +} diff --git a/packages/json-document-editing/tests/calendar-pointer.test.ts b/packages/json-document-editing/tests/calendar-pointer.test.ts index 11adcf75b..8e0a390d8 100644 --- a/packages/json-document-editing/tests/calendar-pointer.test.ts +++ b/packages/json-document-editing/tests/calendar-pointer.test.ts @@ -314,7 +314,7 @@ describe("previewCalendarTimeGrid", () => { expect(preview.find((item) => item.id === "preview")).toMatchObject({ start: "2026-08-04T11:00", end: "2026-08-04T11:30", - recurrence: { freq: "daily", interval: 1, until: "" }, + recurrence: { freq: "daily", interval: 1, until: "2026-08-05" }, }); }); }); @@ -473,9 +473,11 @@ describe("bindCalendarTimeGridIntent", () => { }); const intent = bindCalendarTimeGridIntent(move, series, "2026-08-04T09:00", "all"); expect(intent).toEqual({ - type: "event.move", + type: "occurrence.edit", eventId: "standup", - start: "2026-08-03T11:00", + occurrenceStart: "2026-08-04T09:00", + scope: "all", + start: "2026-08-04T11:00", }); const editor = createCalendarEditor({ calendars: [{ id: "home", title: "Home", hidden: false, color: "subtle" }], diff --git a/packages/json-document-editing/tests/calendar-protocol.test.ts b/packages/json-document-editing/tests/calendar-protocol.test.ts new file mode 100644 index 000000000..b35918c76 --- /dev/null +++ b/packages/json-document-editing/tests/calendar-protocol.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, test } from "vitest"; +import * as calendarDocument from "@interactive-os/json-document-calendar-document"; +import * as editing from "../src/index.js"; +import { + addCalendarDate, bindCalendarAllDayIntent, bindCalendarMonthIntent, bindCalendarTimeGridIntent, + calendarClipboardFormat, calendarOccurrenceTopology, calendarUpdateIntent, createCalendarEditor, + interpretCalendarAllDayPointer, interpretCalendarMonthPointer, interpretCalendarTimeGridPointer, + previewCalendarAllDay, previewCalendarMonth, previewCalendarTimeGrid, planCalendarSelectionMove, + projectCalendarOccurrences, + type CalendarDocument, type CalendarEvent, type CalendarIntent, +} from "../src/index.js"; + +const event = (patch: Partial = {}): CalendarEvent => ({ + id: "a", title: "A", start: "2026-08-01T09:00", end: "2026-08-01T10:00", + allDay: false, calendarId: "work", recurrence: null, excludeDates: [], ...patch, +}); +const document = (events = [event()]): CalendarDocument => ({ + calendars: [{ id: "work", title: "Work", hidden: false, color: "accent" }], events, +}); +function editor(events = [event()]) { + let sequence = 0; + return createCalendarEditor(document(events), { createId: () => `new-${++sequence}` }); +} +const recurring = (allDay = false) => event({ + ...(allDay ? { start: "2026-08-01", end: "2026-08-02", allDay } : {}), + recurrence: { freq: "daily", interval: 1, until: "2026-08-08" }, +}); +function capture(instance: ReturnType, start = "2026-08-01T09:00") { + const point = { eventId: "a", occurrenceStart: start }; + const topology = calendarOccurrenceTopology(instance.snapshot.value as CalendarDocument, "2026-08-01", "2026-08-09"); + instance.dispatch({ type: "selection.set", point, topology }); + return instance.prepareSelectionDrag(point, topology)!; +} +function visible(events: ReadonlyArray) { + return projectCalendarOccurrences(events, "2026-08-01", "2026-08-09") + .map(({ event, start, end }) => ({ title: event.title, start, end })) + .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); +} + +describe("Calendar protocol rejection", () => { + test("legacy Editing exports resolve to the canonical Document Type implementation", () => { + for (const symbol of [ + "addCalendarDate", "calendarDocumentCalendars", "calendarVisibleEvents", "calendarNowMarker", + "calendarTimedLayout", "calendarAllDayLayout", "calendarMonthDayLayout", "calendarMonthWeekLayout", + "calendarEventsOnDay", "calendarEventsInMonth", "calendarBusyDates", "projectCalendarOccurrences", + "calendarRecurrenceWithFrequency", "calendarRecurrenceWithInterval", "calendarRecurrenceWithUntil", + ] as const) { + expect(editing[symbol]).toBe(calendarDocument[symbol]); + } + }); + + test.each([ + "work", + null, + [{ id: 123, title: "Work", hidden: false, color: "accent" }], + [{ id: "work", title: 123, hidden: false, color: "accent" }], + [{ id: "work", title: "Work", hidden: "false", color: "accent" }], + ])("rejects malformed calendars instead of taking the legacy omission path: %j", (calendars) => { + expect(() => createCalendarEditor({ calendars, events: [] } as unknown as CalendarDocument)).toThrow(); + }); + + test.each([false, true])("default paste uses the primary recurring occurrence, allDay=%s", (allDay) => { + const instance = editor([recurring(allDay)]); + const start = allDay ? "2026-08-03" : "2026-08-03T09:00"; + capture(instance, start); + const payload = instance.copy()!; + const before = instance.snapshot; + expect(instance.paste(payload).ok).toBe(true); + expect(instance.primaryOccurrence?.start).toBe(start); + expect(instance.selectedEvents[0]?.start).toBe(start); + expect(instance.undo().ok).toBe(true); + expect(instance.snapshot.value).toEqual(before.value); + expect(instance.snapshot.selection).toEqual(before.selection); + }); + + test.each([ + { type: "event.typo" }, + { type: "event.update", eventId: "a", end: "zz" }, + { type: "event.update", eventId: "a", calendarId: "unknown" }, + { type: "event.create", start: "2026-08-01T11:00", end: "2026-08-01T12:00", calendarId: "unknown" }, + { type: "event.update", eventId: "a", recurrence: { freq: "daily", interval: 1.5, until: "" } }, + { type: "event.update", eventId: "a", recurrence: { freq: "daily", interval: 1, until: "bad-date" } }, + ])("rejects $type without changing value, selection, history or notifications", (intent) => { + const instance = editor(); + instance.dispatch({ type: "event.update", eventId: "a", title: "Before undo" }); + instance.undo(); + const before = instance.snapshot; + const observed: unknown[] = []; + const release = instance.subscribe((snapshot) => observed.push(snapshot)); + expect(instance.dispatch(intent as CalendarIntent)).toMatchObject({ ok: false, code: expect.any(String) }); + expect(instance.snapshot).toEqual(before); + expect(observed).toEqual([]); + expect(() => createCalendarEditor(instance.snapshot.value as CalendarDocument)).not.toThrow(); + expect(instance.redo().ok).toBe(true); + release(); + }); + + test("rejects inverted clipboard intervals at parse and direct paste boundaries", () => { + const payload = structuredClone(editor().copy()!); + const invalid = { ...payload, items: payload.items.map((item) => ({ + ...item, event: { ...item.event, start: "2026-08-01T11:00", end: "2026-08-01T10:00" }, + })) }; + expect(calendarClipboardFormat.parse(invalid)).toBeNull(); + const instance = editor([]); + const before = instance.snapshot; + expect(instance.paste(invalid, "2026-08-02T12:00").ok).toBe(false); + expect(instance.snapshot).toEqual(before); + }); + + test("rejects a foreign calendar reference without silently changing its owner", () => { + const payload = editor().copy()!; + const instance = createCalendarEditor({ + calendars: [{ id: "personal", title: "Personal", hidden: false, color: "subtle" }], events: [], + }); + const before = instance.snapshot; + expect(instance.paste(payload, "2026-08-02T12:00").ok).toBe(false); + expect(instance.snapshot).toEqual(before); + expect(instance.paste(payload, "2026-08-02T12:00", { calendarId: "personal" }).ok).toBe(true); + expect((instance.snapshot.value as CalendarDocument).events[0]?.calendarId).toBe("personal"); + expect(() => createCalendarEditor(instance.snapshot.value as CalendarDocument)).not.toThrow(); + }); + + test("captured cut rejects content changed after the clipboard write", () => { + const instance = editor(); + const payload = instance.copy()!; + instance.dispatch({ type: "event.update", eventId: "a", title: "Not written to clipboard" }); + const before = instance.snapshot; + expect(instance.cut(payload)?.result.ok).toBe(false); + expect(instance.snapshot).toEqual(before); + }); + + test("legacy documents emit canonical clipboard events and can still cut", () => { + const instance = createCalendarEditor({ events: [{ id: "legacy", title: "Legacy", start: "2026-08-01T09:00", end: "2026-08-01T10:00" }] } as unknown as CalendarDocument); + expect(calendarClipboardFormat.parse(instance.copy())).not.toBeNull(); + expect(instance.cut()?.result.ok).toBe(true); + }); + + test("rejects inconsistent captured points and duplicate occurrences atomically", () => { + const instance = editor(); + const source = capture(instance); + const before = instance.snapshot; + for (const invalid of [ + { ...source, points: [] }, + { ...source, points: [...source.points, ...source.points], occurrences: [...source.occurrences, ...source.occurrences] }, + ]) { + expect(instance.dispatch({ type: "selection.move", source: invalid, target: { type: "day", day: "2026-08-02" } }).ok).toBe(false); + expect(instance.snapshot).toEqual(before); + } + }); + + test("rejects an occurrence removed after capture", () => { + const instance = editor([recurring()]); + const source = capture(instance, "2026-08-03T09:00"); + instance.dispatch({ type: "occurrence.remove", eventId: "a", occurrenceStart: "2026-08-03T09:00", scope: "this" }); + const before = instance.snapshot; + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-03T12:00" } })).toMatchObject({ ok: false }); + expect(instance.snapshot).toEqual(before); + }); + + test("rejects a drag whose captured interval changed but preserves unrelated edits", () => { + const instance = editor(); + const source = capture(instance); + instance.dispatch({ type: "event.update", eventId: "a", end: "2026-08-01T11:00" }); + const before = instance.snapshot; + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-01T12:00" } })).toMatchObject({ ok: false }); + expect(instance.snapshot).toEqual(before); + const fresh = capture(instance); + instance.dispatch({ type: "event.update", eventId: "a", title: "New title" }); + expect(instance.dispatch({ type: "selection.move", source: fresh, target: { type: "instant", instant: "2026-08-01T12:00" } }).ok).toBe(true); + expect((instance.snapshot.value as CalendarDocument).events[0]).toMatchObject({ title: "New title", start: "2026-08-01T12:00", end: "2026-08-01T14:00" }); + }); + + test("bounds allocator collisions before mutation", () => { + let calls = 0; + const instance = createCalendarEditor(document([recurring()]), { createId: () => ++calls <= 100 ? "a" : "new" }); + const source = capture(instance, "2026-08-03T09:00"); + const before = instance.snapshot; + expect(() => instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-03T12:00" } })).toThrow(/unique/); + expect(calls).toBe(100); + expect(instance.snapshot).toEqual(before); + }); +}); + +describe("Calendar recurrence and input parity", () => { + const scopes = ["this", "this-and-following", "all"] as const; + test.each(scopes)("time resize preview matches commit: %s", (scope) => { + const original = recurring(); + const instance = editor([original]); + const release = { originInstant: "2026-08-03T10:00", targetInstant: "2026-08-03T11:00", originEventId: "a", originEventStart: "2026-08-03T09:00", originHandle: "end" as const }; + const preview = previewCalendarTimeGrid([original], release, scope); + expect(instance.dispatch(bindCalendarTimeGridIntent(interpretCalendarTimeGridPointer(release), original, release.originEventStart, scope)!).ok).toBe(true); + expect(visible(preview)).toEqual(visible((instance.snapshot.value as CalendarDocument).events)); + }); + test.each(scopes)("all-day resize preview matches commit: %s", (scope) => { + const original = recurring(true); + const instance = editor([original]); + const release = { originDay: "2026-08-03", targetDay: "2026-08-04", originEventId: "a", originEventStart: "2026-08-03", originHandle: "end" as const }; + const preview = previewCalendarAllDay([original], release, scope); + expect(instance.dispatch(bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), original, release.originEventStart, scope)!).ok).toBe(true); + expect(visible(preview)).toEqual(visible((instance.snapshot.value as CalendarDocument).events)); + }); + test.each(scopes)("month move preview matches commit: %s", (scope) => { + const original = recurring(true); + const instance = editor([original]); + const release = { originDay: "2026-08-03", targetDay: "2026-08-04", originEventId: "a", originEventStart: "2026-08-03", eventsOnTargetDay: [] }; + const preview = previewCalendarMonth([original], release, scope); + expect(instance.dispatch(bindCalendarMonthIntent(interpretCalendarMonthPointer(release), original, release.originEventStart, scope)!).ok).toBe(true); + expect(visible(preview)).toEqual(visible((instance.snapshot.value as CalendarDocument).events)); + }); + test("all-day all-scope resize and Inspector preserve the same two-day interval", () => { + const original = recurring(true); + const pointer = editor([original]); + const inspector = editor([original]); + const release = { originDay: "2026-08-03", targetDay: "2026-08-04", originEventId: "a", originEventStart: "2026-08-03", originHandle: "end" as const }; + pointer.dispatch(bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), original, release.originEventStart, "all")!); + inspector.dispatch(calendarUpdateIntent(original, "2026-08-03", "all", { end: "2026-08-05" })); + expect(pointer.snapshot.value).toEqual(inspector.snapshot.value); + expect((pointer.snapshot.value as CalendarDocument).events[0]).toMatchObject({ start: "2026-08-01", end: "2026-08-03" }); + }); + test.each(scopes)("single recurring selection drag supports %s and one-step undo", (scope) => { + const instance = editor([recurring()]); + const source = capture(instance, "2026-08-03T09:00"); + const before = instance.snapshot; + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-03T11:00" }, scope }).ok).toBe(true); + expect(instance.undo().ok).toBe(true); + expect(instance.snapshot.value).toEqual(before.value); + expect(instance.snapshot.selection).toEqual(before.selection); + }); + test.each([399, 400, 600, 100_000])("projects a narrow window after occurrence %s without a hidden lifetime cap", (index) => { + const original = event({ recurrence: { freq: "daily", interval: 1, until: "" } }); + const day = addCalendarDate("2026-08-01", index)!; + expect(projectCalendarOccurrences([original], day, addCalendarDate(day, 1)!)).toMatchObject([{ start: `${day}T09:00`, end: `${day}T10:00` }]); + }); + + test.each(scopes)("moves duplicate series once for scope %s, retaining every selected point", (scope) => { + const original = recurring(); + const second = { ...structuredClone(original), id: "b", title: "B" }; + const instance = editor([original, second]); + const points = [ + { eventId: "a", occurrenceStart: "2026-08-04T09:00" }, + { eventId: "a", occurrenceStart: "2026-08-03T09:00" }, + { eventId: "b", occurrenceStart: "2026-08-03T09:00" }, + ]; + points.forEach((point, index) => instance.dispatch({ type: "selection.set", point, topology: { points }, mode: index === 0 ? "replace" : "toggle" })); + const source = instance.prepareSelectionDrag(points[0]!, { points })!; + const before = instance.snapshot; + const target = { type: "instant" as const, instant: "2026-08-04T11:00" }; + const preview = planCalendarSelectionMove([original, second], source.occurrences, source.anchor, target, { scope, primary: source.primary }); + expect(preview.ok).toBe(true); + expect(instance.dispatch({ type: "selection.move", source, target, scope }).ok).toBe(true); + const current = (instance.snapshot.value as CalendarDocument).events; + expect(current).toHaveLength(scope === "this" ? 5 : scope === "all" ? 2 : 4); + expect(instance.selectedOccurrences.map(({ start }) => start).sort()).toEqual(["2026-08-03T11:00", "2026-08-03T11:00", "2026-08-04T11:00"]); + if (preview.ok) expect(visible(preview.events)).toEqual(visible(current)); + if (scope === "this-and-following") expect(current[0]?.recurrence?.until).toBe("2026-08-02"); + expect(instance.undo().ok).toBe(true); + expect(instance.snapshot.value).toEqual(before.value); + expect(instance.snapshot.selection).toEqual(before.selection); + }); + + test.each(["all", "this-and-following"] as const)("translates the finite lifetime and future exclusions for %s", (scope) => { + const instance = editor([{ ...recurring(), excludeDates: ["2026-08-05"] }]); + expect(instance.dispatch({ type: "occurrence.edit", eventId: "a", occurrenceStart: "2026-08-03T09:00", start: "2026-08-04T09:00", scope }).ok).toBe(true); + const series = (instance.snapshot.value as CalendarDocument).events.at(-1)!; + expect(series.recurrence?.until).toBe("2026-08-09"); + expect(series.excludeDates).toEqual(["2026-08-06"]); + expect(projectCalendarOccurrences([series], "2026-08-06", "2026-08-07")).toEqual([]); + expect(projectCalendarOccurrences([series], "2026-08-10", "2026-08-11")).toEqual([]); + }); + + test.each([ + ["weekly", "2026-01-01", "2038-07-01", "2038-07-02"], + ["monthly", "2026-01-31", "2076-02-29", "2076-03-01"], + ["yearly", "2000-02-29", "2600-02-28", "2600-03-01"], + ] as const)("seeks late %s occurrences, including constrained dates", (freq, start, from, to) => { + const original = event({ start: `${start}T09:00`, end: `${start}T10:00`, recurrence: { freq, interval: 1, until: "" } }); + expect(projectCalendarOccurrences([original], from, to)).toHaveLength(1); + }); + + test("seeking retains long intervals already in progress", () => { + const original = event({ start: "2026-01-01", end: "2026-01-31", allDay: true, recurrence: { freq: "weekly", interval: 2, until: "" } }); + const late = projectCalendarOccurrences([original], "2050-01-15", "2050-01-16"); + expect(late.length).toBeGreaterThanOrEqual(2); + expect(late.every((item) => item.start <= "2050-01-15" && item.end > "2050-01-15")).toBe(true); + }); + + test("preview ID collisions do not alias an existing event", () => { + const original = { ...recurring(), id: "preview" }; + const preview = previewCalendarTimeGrid([original], { + originInstant: "2026-08-03T09:00", targetInstant: "2026-08-03T11:00", + originEventId: "preview", originEventStart: "2026-08-03T09:00", originHandle: "body", + }); + expect(new Set(preview.map((item) => item.id)).size).toBe(2); + }); + + test("detached group occurrences preserve independent extension metadata", () => { + const instance = editor([{ ...recurring(), metadata: { note: "Keep me" } }]); + const source = capture(instance); + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-01T11:00" } }).ok).toBe(true); + const current = instance.snapshot.value as CalendarDocument; + expect(current.events.at(-1)?.metadata).toEqual({ note: "Keep me" }); + expect(() => createCalendarEditor(current)).not.toThrow(); + }); + + test("rejects an unrepresentable constrained monthly group instead of losing points", () => { + const original = event({ start: "2026-01-30T09:00", end: "2026-01-30T10:00", recurrence: { freq: "monthly", interval: 1, until: "" } }); + const plan = planCalendarSelectionMove([original], [ + { eventId: "a", start: "2026-01-30T09:00", end: "2026-01-30T10:00" }, + { eventId: "a", start: "2026-02-28T09:00", end: "2026-02-28T10:00" }, + ], { eventId: "a", occurrenceStart: original.start }, { type: "day", day: "2026-01-31" }, { scope: "all" }); + expect(plan).toEqual({ ok: false, code: "selection.unrepresentable-series-move" }); + }); + + test("single all-scope edits also reject a monthly anchor that cannot represent the requested occurrence", () => { + const original = event({ start: "2026-01-30T09:00", end: "2026-01-30T10:00", recurrence: { freq: "monthly", interval: 1, until: "" } }); + const instance = editor([original]); + const before = instance.snapshot; + expect(instance.dispatch({ type: "occurrence.edit", eventId: "a", occurrenceStart: "2026-02-28T09:00", start: "2026-03-01T09:00", scope: "all" })) + .toEqual({ ok: false, code: "selection.unrepresentable-series-move" }); + expect(instance.snapshot).toEqual(before); + expect(previewCalendarTimeGrid([original], { + originInstant: "2026-02-28T09:00", targetInstant: "2026-03-01T09:00", + originEventId: "a", originEventStart: "2026-02-28T09:00", originHandle: "body", + }, "all")).toEqual([original]); + }); + + test.each([ + ["monthly", true, "2026-01-30", "2026-01-31", "2026-02-28", "2026-03-01"], + ["monthly", false, "2026-01-30T23:30", "2026-01-31T00:15", "2026-02-28T23:30", "2026-03-01T00:15"], + ["yearly", true, "2028-02-28", "2028-02-29", "2029-02-28", "2029-03-01"], + ] as const)("%s projection preserves duration across constrained dates (allDay=%s)", (freq, allDay, start, end, nextStart, nextEnd) => { + const original = event({ start, end, allDay, recurrence: { freq, interval: 1, until: "" } }); + const projected = projectCalendarOccurrences([original], nextStart.slice(0, 10), addCalendarDate(nextEnd.slice(0, 10), 1)!); + expect(projected.map(({ start, end }) => ({ start, end }))).toEqual([{ start: nextStart, end: nextEnd }]); + expect(() => createCalendarEditor(document(projected.map(({ event, start, end }) => ({ ...event, start, end }))))).not.toThrow(); + }); +}); diff --git a/packages/json-document-editing/tests/canvas-clipboard.test.ts b/packages/json-document-editing/tests/canvas-clipboard.test.ts new file mode 100644 index 000000000..d91a89e72 --- /dev/null +++ b/packages/json-document-editing/tests/canvas-clipboard.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; +import { createCanvasClipboard, type CanvasClipboardContent } from "../src/index.js"; + +const source = "data:image/png;base64,AQID"; +const options = { bounds: { x: 10, y: 20, width: 200, height: 120 }, textColor: "black", fontSize: 20, contentGap: 10 }; +const content: CanvasClipboardContent = { type: "mixed", items: [ + { type: "text", text: "Before" }, { type: "image", source, width: 100, height: 50, label: "Figure" }, { type: "text", text: "After" }, +] }; + +test("mixed content becomes ordered non-overlapping editable objects, not source CSS", () => { + const clipboard = createCanvasClipboard(content, options); + expect(clipboard.objects.map((object) => [object.kind, object.label, object.x, object.y, object.height])).toEqual([ + ["text", "Before", 10, 20, 24], ["image", "Figure", 10, 54, 50], ["text", "After", 10, 114, 24], + ]); + expect(clipboard.objects.map((object) => object.id)).toEqual(["clipboard:0", "clipboard:1", "clipboard:2"]); + expect(clipboard.primaryKey).toBe("clipboard:2"); expect(clipboard.text).toBe("Before\nFigure\nAfter"); +}); + +test("an overflowing flow fits as a whole while retaining image aspect ratio and content", () => { + const clipboard = createCanvasClipboard(content, { ...options, bounds: { ...options.bounds, height: 60 } }); + const objects = clipboard.objects; + expect(objects.at(-1)!.y + objects.at(-1)!.height).toBeCloseTo(80); + expect(objects[1]!.width / objects[1]!.height).toBe(2); + expect(objects[1]!.source).toBe(source); + expect(objects[0]!.fontSize).toBeCloseTo(20 * 60 / 118); + expect(objects[0]!.y + objects[0]!.height).toBeLessThan(objects[1]!.y); +}); + +test("legacy literal text and image cascade remain unchanged", () => { + expect(createCanvasClipboard({ type: "text", text: "A\nB" }, options).objects[0]).toMatchObject({ label: "A\nB", height: 48, fontSize: 20 }); + const image = { source, width: 100, height: 50, label: "Figure" }; + expect(createCanvasClipboard({ type: "images", images: [image, image] }, options).objects.map((object) => [object.x, object.y])).toEqual([[10, 20], [34, 44]]); +}); + +test("empty parts and invalid flow policy reject before a clipboard is produced", () => { + expect(() => createCanvasClipboard({ type: "mixed", items: [] }, options)).toThrow(); + expect(() => createCanvasClipboard({ type: "mixed", items: [{ type: "text", text: "" }] }, options)).toThrow(); + expect(() => createCanvasClipboard(content, { ...options, contentGap: -1 })).toThrow(); +}); diff --git a/packages/json-document-editing/tests/canvas-profile.test.ts b/packages/json-document-editing/tests/canvas-profile.test.ts new file mode 100644 index 000000000..26b7279ad --- /dev/null +++ b/packages/json-document-editing/tests/canvas-profile.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createCanvasObject, createCanvasPath, parseCanvasDocument, serializeCanvasDocument, type CanvasDocument } from "@interactive-os/json-document-object-document"; +import { createObjectEditor, type ObjectIntent } from "../src/index.js"; + +const blank: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [] }; +const text = createCanvasObject("text", { x: 10, y: 20, width: 200, height: 100 }, { color: "black", label: "Text" }); + +test("Canvas creation, direct text edit, transform and deletion use the existing selection-restoring history", () => { + const source = createJSONDocument(blank); + let commits = 0; + source.subscribe(() => commits++); + const editor = createObjectEditor(source, { createId: () => "a" }); + expect(editor.dispatch({ type: "object.create", object: text }).ok).toBe(true); + expect(editor.snapshot.selection.keys).toEqual(["a"]); + expect(commits).toBe(1); + expect(editor.dispatch({ type: "object.text", objectId: "a", text: "A slide\n한 장" }).ok).toBe(true); + expect(commits).toBe(2); + const written = editor.snapshot.value; + expect(editor.dispatch({ type: "object.resize", objectIds: ["a"], dx: 10, dy: 10, dw: 100, dh: 50 }).ok).toBe(true); + expect(commits).toBe(3); + expect(editor.undo().ok).toBe(true); + expect(editor.snapshot.value).toEqual(written); + expect(editor.snapshot.selection.keys).toEqual(["a"]); + editor.redo(); + const resized = editor.snapshot.value; + editor.dispatch({ type: "selection.remove" }); + expect(editor.snapshot.value).toEqual(blank); + editor.undo(); + expect(editor.snapshot.value).toEqual(resized); + expect(editor.snapshot.selection.keys).toEqual(["a"]); +}); + +test("invalid imports and mutations leave document, selection, revision and history untouched", () => { + const editor = createObjectEditor(blank, { createId: () => "a" }); + editor.dispatch({ type: "object.create", object: text }); + for (const intent of [ + { type: "object.create", object: { ...text, width: -10 } }, + { type: "object.translate", objectIds: ["a"], dx: Infinity, dy: 0 }, + { type: "document.replace", document: { ...blank, objects: [{ ...text, id: "bad", kind: "unknown" }] } }, + { type: "document.replace", document: { objects: [] } }, + { type: "unsupported" }, + ]) { + const before = editor.snapshot; + expect(editor.dispatch(intent as ObjectIntent).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + } +}); + +test("JSON reopening is one undoable transaction, keeps IDs and clears only transient selection", () => { + const editor = createObjectEditor(blank); + const saved: CanvasDocument = { ...blank, objects: [{ ...createCanvasPath([{ x: 1, y: 2 }, { x: 40, y: 70 }], { color: "black", label: "Path", strokeWidth: 3 }), id: "p" }] }; + expect(editor.dispatch({ type: "document.replace", document: parseCanvasDocument(serializeCanvasDocument(saved)) }).ok).toBe(true); + expect(editor.snapshot.value).toEqual(saved); + expect(editor.snapshot.selection.keys).toEqual([]); + editor.undo(); expect(editor.snapshot.value).toEqual(blank); + editor.redo(); expect(editor.snapshot.value).toEqual(saved); + const next = createObjectEditor(parseCanvasDocument(serializeCanvasDocument(editor.snapshot.value as CanvasDocument))); + expect(next.snapshot.value).toEqual(saved); + expect(next.snapshot.canUndo).toBe(false); +}); + +test("a zero-distance gesture does not create history or clear redo", () => { + const editor = createObjectEditor(blank, { createId: () => "a" }); + editor.dispatch({ type: "object.create", object: text }); + editor.dispatch({ type: "object.text", objectId: "a", text: "Changed" }); + editor.undo(); + editor.dispatch({ type: "object.translate", objectIds: ["a"], dx: 0, dy: 0 }); + expect(editor.snapshot.canRedo).toBe(true); + editor.undo(); expect(editor.snapshot.value).toEqual(blank); +}); diff --git a/packages/json-document-editing/tests/conformance/calendar-grammar.test.ts b/packages/json-document-editing/tests/conformance/calendar-grammar.test.ts new file mode 100644 index 000000000..add48fdd3 --- /dev/null +++ b/packages/json-document-editing/tests/conformance/calendar-grammar.test.ts @@ -0,0 +1,58 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { expect } from "vitest"; +import { calendarOccurrenceTopology, createCalendarEditor, type CalendarDocument, type CalendarSelection } from "../../src/index.js"; +import { editingGrammar } from "./editing-grammar.js"; + +const value: 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-03" }, excludeDates: [] }], +}; +const points = [1, 2, 3].map((day) => ({ eventId: "a", occurrenceStart: `2026-08-0${day}T09:00` })); +const empty: CalendarSelection = { kind: "range", ranges: [], primaryIndex: null }; + +editingGrammar("Calendar / materialized recurring occurrences / local history", () => { + const document = createJSONDocument(value); + let id = 0; + const editor = createCalendarEditor(document, { createId: () => `new-${++id}` }); + const topology = calendarOccurrenceTopology(value, "2026-08-01", "2026-08-04"); + const items = points.map((point, index) => ({ sourceEventId: "a", occurrenceStart: point.occurrenceStart, + event: { ...value.events[0]!, start: point.occurrenceStart, end: `2026-08-0${index + 1}T10:00`, recurrence: null, excludeDates: [] }, + })); + const clipboard = { + type: "application/vnd.interactive-os.calendar+json" as const, + anchorOccurrenceStart: points[0]!.occurrenceStart, + items, text: items.map(({ event }) => `${event.start}\t${event.end}\t${event.title}`).join("\n"), + }; + return { + document, editor, clipboard, + selectStart: () => editor.dispatch({ type: "selection.set", point: points[0]!, topology }), + extend: () => editor.dispatch({ type: "selection.set", point: points[2]!, mode: "extend", topology }), + assertSelected(selection) { + expect(selection).toEqual({ kind: "range", primaryIndex: 0, ranges: [{ anchor: points[0], focus: points[2], points }] }); + expect(editor.primaryOccurrence?.start).toBe("2026-08-03T09:00"); + expect(editor.selectedOccurrences.map((item) => item.start)).toEqual(points.map((point) => point.occurrenceStart)); + }, + edit: () => editor.dispatch({ type: "event.update", eventId: "a", title: "Changed" }), + assertEdited(snapshot) { + expect(snapshot.value).toEqual({ ...value, events: [{ ...value.events[0]!, title: "Changed" }] }); + expect(editor.primaryOccurrence?.start).toBe("2026-08-01T09:00"); + }, + paste: () => editor.paste(clipboard), + assertPasted(snapshot) { + const pasted = items.map(({ event }, index) => ({ ...event, id: `new-${index + 1}`, start: `2026-08-0${index + 3}T09:00`, end: `2026-08-0${index + 3}T10:00` })); + expect(snapshot.value).toEqual({ ...value, events: [...value.events, ...pasted] }); + expect(editor.selectedEvents.map((event) => event.id)).toEqual(["new-3", "new-1", "new-2"]); + expect(editor.primaryOccurrence).toEqual({ eventId: "new-3", start: "2026-08-05T09:00", end: "2026-08-05T10:00" }); + }, + assertCut(snapshot) { + expect(snapshot.value).toEqual({ ...value, events: [{ ...value.events[0]!, excludeDates: ["2026-08-01", "2026-08-02", "2026-08-03"] }] }); + expect(snapshot.selection).toEqual(empty); + }, + reject: () => editor.dispatch({ type: "event.update", eventId: "a", end: "2026-08-01T08:00" }), + rejectionCode: "event.invalid-interval", + noop: () => editor.dispatch({ type: "event.update", eventId: "a", title: "A" }), + removeExternal() { expect(document.commit([{ op: "remove", path: "/events/0" }]).ok).toBe(true); }, + assertExternal(selection) { expect(selection).toEqual(empty); }, + }; +}); diff --git a/packages/json-document-editing/tests/identity.test.ts b/packages/json-document-editing/tests/identity.test.ts index dbdaaa68a..53da91a07 100644 --- a/packages/json-document-editing/tests/identity.test.ts +++ b/packages/json-document-editing/tests/identity.test.ts @@ -54,7 +54,7 @@ describe("default domain identities", () => { expect(allocateId()).toBe("next"); }); - test.each(cases)("$name preserves the collision limit and document on failure", ({ initial, insert }) => { + test.each(cases)("$name preserves the collision limit and document on failure", ({ name, initial, insert }) => { const randomUUID = vi.fn(() => "collision"); vi.stubGlobal("crypto", { randomUUID }); try { @@ -62,7 +62,9 @@ describe("default domain identities", () => { expect(insert(document)).toBe(true); const before = document.value; randomUUID.mockClear(); - expect(() => insert(document)).toThrow("createId did not produce a unique"); + // Object reports identity exhaustion as an EditingResult for native clipboard consumers. + if (name === "Object") expect(insert(document)).toBe(false); + else expect(() => insert(document)).toThrow("createId did not produce a unique"); expect(randomUUID).toHaveBeenCalledTimes(100); expect(document.value).toBe(before); } finally { vi.unstubAllGlobals(); } diff --git a/packages/json-document-editing/tests/object-copy.test.ts b/packages/json-document-editing/tests/object-copy.test.ts new file mode 100644 index 000000000..52962a66c --- /dev/null +++ b/packages/json-document-editing/tests/object-copy.test.ts @@ -0,0 +1,88 @@ +import { expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createObjectEditor, objectClipboardFormat, type ObjectClipboard, type ObjectIntent } from "../src/index.js"; +import type { CanvasDocument } from "@interactive-os/json-document-object-document"; + +const initial: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [ + { id: "text", kind: "text", x: 10, y: 20, width: 200, height: 80, color: "black", label: "Hello", fontSize: 32 }, + { id: "path", kind: "path", x: 100, y: 120, width: 150, height: 60, color: "blue", label: "Drawing", strokeWidth: 3, points: [{ x: 0, y: 1 }, { x: 1, y: 0 }] }, +] }; + +function setup() { + let id = 0; + const document = createJSONDocument(initial); + const editor = createObjectEditor(document, { createId: () => `copy-${++id}` }); + editor.dispatch({ type: "selection.set", objectIds: ["text", "path"], primaryKey: "text" }); + const commits = vi.fn(); document.subscribe(commits); + return { editor, document, commits }; +} + +test.each(["object.duplicate", "clipboard.paste"] as const)("%s remaps primary and fresh identities, preserves order/shape, and commits once", (type) => { + const { editor, document, commits } = setup(); + const selection = editor.snapshot.selection; + const clipboard = editor.copy()!; + expect(clipboard.primaryKey).toBe("text"); expect(clipboard.text).toBe("Hello\nDrawing"); + const placement = { type: "offset", dx: 70, dy: -30 } as const; + const intent: ObjectIntent = type === "object.duplicate" ? { type, objectIds: ["path", "text", "path"], placement } : { type, clipboard, placement }; + expect(editor.dispatch(intent).ok).toBe(true); + const after = document.value as CanvasDocument; + expect(after.objects.slice(0, 2)).toEqual(initial.objects); + expect(after.objects.slice(2)).toEqual(initial.objects.map((object, index) => ({ ...object, id: `copy-${index + 1}`, x: object.x + 70, y: object.y - 30 }))); + expect(editor.snapshot.selection).toEqual({ kind: "explicit", keys: ["copy-1", "copy-2"], primaryKey: "copy-1" }); + expect(commits).toHaveBeenCalledOnce(); + expect(editor.undo().ok).toBe(true); expect(document.value).toEqual(initial); expect(editor.snapshot.selection).toEqual(selection); + expect(editor.snapshot.canUndo).toBe(false); + expect(editor.redo().ok).toBe(true); expect(document.value).toEqual(after); expect(editor.snapshot.selection.primaryKey).toBe("copy-1"); +}); + +test("repeat duplicate operates on the newly selected set with the default offset", () => { + const { editor } = setup(); + for (let index = 1; index <= 2; index++) { + expect(editor.dispatch({ type: "object.duplicate", objectIds: editor.snapshot.selection.keys }).ok).toBe(true); + expect(editor.selectedObjects[0]!.x).toBe(10 + 24 * index); + expect(editor.selectedObjects[0]!.id).toBe(`copy-${index * 2 - 1}`); + } +}); + +test("legacy clipboard remains readable and cross-document paste cannot reuse source IDs", () => { + const clipboard: ObjectClipboard = { type: objectClipboardFormat.mimeType, objects: initial.objects, text: "Hello\nDrawing" }; + let id = 0; + const ids = ["text", "path", "new-text", "new-path"]; + const editor = createObjectEditor({ ...initial, objects: [] }, { createId: () => ids[id++]! }); + expect(objectClipboardFormat.parse(clipboard)).toEqual(clipboard); + expect(editor.dispatch({ type: "clipboard.paste", clipboard }).ok).toBe(true); + expect(editor.snapshot.selection).toMatchObject({ keys: ["new-text", "new-path"], primaryKey: "new-path" }); + expect(objectClipboardFormat.parse({ ...clipboard, primaryKey: "missing" })).toBeNull(); + expect(objectClipboardFormat.parse({ ...clipboard, primaryKey: 1 })).toBeNull(); +}); + +test("invalid targets, geometry, profile payload and exhausted IDs never partially mutate selection/history/document", () => { + const { editor, document, commits } = setup(); + const before = editor.snapshot; + const clipboard = editor.copy()!; + const intents: ObjectIntent[] = [ + { type: "object.duplicate", objectIds: ["text", "missing"] }, + { type: "object.duplicate", objectIds: [] }, + { type: "object.duplicate", objectIds: ["text"], placement: { type: "offset", dx: Infinity, dy: 0 } }, + { type: "object.remove", objectIds: ["text", "missing"] }, + { type: "clipboard.paste", clipboard: { ...clipboard, objects: [{ ...initial.objects[0]!, kind: "unknown" }] } }, + ]; + for (const intent of intents) { expect(editor.dispatch(intent).ok).toBe(false); expect(editor.snapshot).toEqual(before); } + expect(document.value).toEqual(initial); expect(commits).not.toHaveBeenCalled(); + const exhausted = createObjectEditor(initial, { createId: () => "text" }); + for (const intent of [{ type: "object.duplicate", objectIds: ["text"] }, { type: "clipboard.paste", clipboard }] as const) { + expect(exhausted.dispatch(intent)).toMatchObject({ ok: false, code: "object.identity-unavailable" }); + expect(exhausted.snapshot.value).toEqual(initial); expect(exhausted.snapshot.canUndo).toBe(false); + } +}); + +test("captured clipboard targets can be removed after selection changes without deleting the new selection", () => { + const { editor, commits } = setup(); + editor.dispatch({ type: "selection.set", objectIds: ["text"] }); + const clipboard = editor.copy()!; + editor.dispatch({ type: "selection.set", objectIds: ["path"] }); + expect(editor.dispatch({ type: "object.remove", objectIds: clipboard.objects.map((object) => object.id) }).ok).toBe(true); + expect((editor.snapshot.value as CanvasDocument).objects.map((object) => object.id)).toEqual(["path"]); + expect(commits).toHaveBeenCalledOnce(); + expect(editor.undo().ok).toBe(true); expect(editor.snapshot.value).toEqual(initial); +}); diff --git a/packages/json-document-editing/tests/object-editor.test.ts b/packages/json-document-editing/tests/object-editor.test.ts index b032a1eab..d1236fe00 100644 --- a/packages/json-document-editing/tests/object-editor.test.ts +++ b/packages/json-document-editing/tests/object-editor.test.ts @@ -13,6 +13,37 @@ const initial: ObjectDocument = { }; describe("object editing selection family", () => { + test("primaryKey applies after subtract and toggle, and must survive the transition", () => { + const editor = createObjectEditor(initial); + editor.dispatch({ type: "selection.set", objectIds: ["a", "b", "c"] }); + editor.dispatch({ type: "selection.set", objectIds: ["b"], mode: "subtract", primaryKey: "a" }); + expect(editor.snapshot.selection).toEqual({ kind: "explicit", keys: ["a", "c"], primaryKey: "a" }); + editor.dispatch({ type: "selection.set", objectIds: ["b"], mode: "toggle", primaryKey: "c" }); + expect(editor.snapshot.selection).toEqual({ kind: "explicit", keys: ["a", "b", "c"], primaryKey: "c" }); + const before = editor.snapshot; + expect(editor.dispatch({ type: "selection.set", objectIds: ["c"], mode: "subtract", primaryKey: "c" }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + }); + test("explicit primary survives set movement and primary-only edits, including undo/redo", () => { + const editor = createObjectEditor(initial); + editor.dispatch({ type: "selection.set", objectIds: ["a", "c"], primaryKey: "a" }); + const selected = editor.snapshot.selection; + expect(selected.primaryKey).toBe("a"); + expect(editor.snapshot.canUndo).toBe(false); + for (const intent of [ + { type: "object.translate", objectIds: ["a", "c"], dx: 5, dy: 10 }, + { type: "object.resize", objectIds: ["a"], dx: 0, dy: 0, dw: 10, dh: 20 }, + ] as const) { + const before = editor.snapshot.value; + expect(editor.dispatch(intent).ok).toBe(true); + expect(editor.snapshot.selection).toEqual(selected); + editor.undo(); expect(editor.snapshot.value).toEqual(before); expect(editor.snapshot.selection).toEqual(selected); + editor.redo(); expect(editor.snapshot.selection).toEqual(selected); + } + const before = editor.snapshot; + expect(editor.dispatch({ type: "selection.set", objectIds: ["a"], primaryKey: "c" }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + }); test("uses set transitions for click and host-resolved marquee candidates", () => { const editor = createObjectEditor(initial); editor.dispatch({ type: "selection.set", objectIds: ["b"], mode: "toggle" }); diff --git a/packages/json-document-editing/tests/object-paste.test.ts b/packages/json-document-editing/tests/object-paste.test.ts new file mode 100644 index 000000000..530b456bd --- /dev/null +++ b/packages/json-document-editing/tests/object-paste.test.ts @@ -0,0 +1,115 @@ +import { expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createCanvasClipboard, createObjectEditor, createObjectPasteSession, objectClipboardFormat, type ObjectPastePreparation } from "../src/index.js"; +import type { CanvasDocument } from "@interactive-os/json-document-object-document"; + +const blank: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [] }; +const options = { bounds: { x: 0, y: 0, width: 600, height: 400 }, textColor: "black", fontSize: 32 }; +const placement = { type: "cascade", dx: 24, dy: 24 } as const; +const payload = (text: string) => createCanvasClipboard({ type: "text", text }, options); +function setup() { + let id = 0; + const document = createJSONDocument(blank); + const editor = createObjectEditor(document, { createId: () => `object-${++id}` }); + const commits = vi.fn(); document.subscribe(commits); + return { editor, commits, value: () => editor.snapshot.value as CanvasDocument }; +} +function deferred() { + let resolve!: (value: ObjectPastePreparation) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test("literal Unicode text and decoded rasters become validated domain clipboard with temporary source identities", () => { + const text = payload("안녕\nSecond line"); + expect(text.objects[0]).toMatchObject({ id: "clipboard:0", kind: "text", label: "안녕\nSecond line", fontSize: 32, height: 76.8 }); + const images = createCanvasClipboard({ type: "images", images: [ + { source: "data:image/png;base64,AQID", width: 1200, height: 400, label: "first.png" }, + { source: "data:image/webp;base64,AQID", width: 100, height: 50, label: "second.webp" }, + ] }, options); + expect(images.objects.map((object) => [object.x, object.y, object.width, object.height])).toEqual([[0, 0, 600, 200], [24, 24, 100, 50]]); + expect(images.primaryKey).toBe("clipboard:1"); expect(images.text).toBe("first.png\nsecond.webp"); + expect(objectClipboardFormat.parse(images)).toEqual(images); + expect(() => payload("")).toThrow(); + expect(() => createCanvasClipboard({ type: "images", images: [] }, options)).toThrow(); + expect(() => createCanvasClipboard({ type: "text", text: "x" }, { ...options, fontSize: NaN })).toThrow(); +}); + +test("cascade finds a free group origin, preserves relative bounds and primary, and reuses undone positions without a counter", () => { + const { editor, value } = setup(); + const clipboard = createCanvasClipboard({ type: "images", images: [ + { source: "data:image/png;base64,AQID", width: 100, height: 100, label: "A" }, + { source: "data:image/png;base64,AQID", width: 100, height: 100, label: "B" }, + ] }, options); + for (let index = 0; index < 2; index++) expect(editor.dispatch({ type: "clipboard.paste", clipboard, placement }).ok).toBe(true); + expect(value().objects.map((object) => object.x)).toEqual([24, 48, 72, 96]); + expect(editor.snapshot.selection).toMatchObject({ keys: ["object-3", "object-4"], primaryKey: "object-4" }); + editor.undo(); + editor.dispatch({ type: "clipboard.paste", clipboard, placement }); + expect(value().objects.map((object) => object.x)).toEqual([24, 48, 72, 96]); + const before = editor.snapshot; + expect(editor.dispatch({ type: "clipboard.paste", clipboard, placement: { type: "cascade", dx: 0, dy: 0 } }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); +}); + +test("async preparation completes out of order but commits in request order, one Undo each", async () => { + const { editor, commits, value } = setup(); + const onResult = vi.fn(), onPendingChange = vi.fn(); + const session = createObjectPasteSession(editor, { placement, onResult, onPendingChange }); + const first = deferred(), second = deferred(); + const a = session.enqueue(() => first.promise), b = session.enqueue(() => second.promise); + const c = session.enqueue(() => ({ ok: true, clipboard: payload("C") })); + second.resolve({ ok: true, clipboard: payload("B") }); await Promise.resolve(); + expect(session.pending).toBe(true); expect(commits).not.toHaveBeenCalled(); + first.resolve({ ok: true, clipboard: payload("A") }); + expect((await Promise.all([a, b, c])).every((result) => result.ok)).toBe(true); + expect(value().objects.map((object) => [object.label, object.x])).toEqual([["A", 24], ["B", 48], ["C", 72]]); + expect(commits).toHaveBeenCalledTimes(3); expect(onResult).toHaveBeenCalledTimes(3); + expect(session.pending).toBe(false); expect(onPendingChange.mock.calls).toEqual([[true], [false]]); + editor.undo(); expect(value().objects.map((object) => object.label)).toEqual(["A", "B"]); + editor.undo(); expect(value().objects.map((object) => object.label)).toEqual(["A"]); + editor.undo(); expect(value()).toEqual(blank); +}); + +test.each(["cancel", "selection", "document"])("%s invalidates pending and ready work without stale commits, ID allocation or late callbacks", async (reason) => { + const { editor, value } = setup(); + const onResult = vi.fn(), abort = vi.fn(); + const session = createObjectPasteSession(editor, { placement, onResult }); + const first = deferred(); + const a = session.enqueue(() => first.promise, abort), b = session.enqueue(() => ({ ok: true, clipboard: payload("B") })); + if (reason === "cancel") session.cancel(); + else if (reason === "selection") editor.dispatch({ type: "selection.set", objectIds: [] }); + else editor.dispatch({ type: "document.replace", document: { ...blank, title: "New slide" } }); + expect(await a).toMatchObject({ ok: false, code: "clipboard.cancelled" }); + expect(await b).toMatchObject({ ok: false, code: "clipboard.cancelled" }); + first.resolve({ ok: true, clipboard: payload("A") }); await Promise.resolve(); + expect(value().objects).toEqual([]); expect(onResult).not.toHaveBeenCalled(); expect(abort).toHaveBeenCalledOnce(); + expect((await session.enqueue(() => ({ ok: true, clipboard: payload("Fresh") }))).ok).toBe(true); + expect(value().objects[0]!.id).toBe("object-1"); +}); + +test("sync content commits synchronously; rejected preparation does not poison the queue", async () => { + const { editor, value, commits } = setup(); + const session = createObjectPasteSession(editor, { placement }); + const bad = session.enqueue(() => Promise.reject(new Error("decode failed"))); + const good = session.enqueue(() => ({ ok: true, clipboard: payload("Valid") })); + expect(await bad).toMatchObject({ ok: false, reason: "decode failed" }); + expect((await good).ok).toBe(true); expect(commits).toHaveBeenCalledOnce(); + const sync = session.enqueue(() => ({ ok: true, clipboard: payload("Now") })); + expect(value().objects.at(-1)!.label).toBe("Now"); await sync; +}); + +test("throwing observers and cleanup callbacks cannot strand the queue or change a completed edit", async () => { + const { editor, value } = setup(); + const throwing = () => { throw new Error("observer failed"); }; + const session = createObjectPasteSession(editor, { onResult: throwing, onPendingChange: throwing }); + const first = deferred(); + const a = session.enqueue(() => first.promise), b = session.enqueue(() => ({ ok: true, clipboard: payload("B") })); + first.resolve({ ok: true, clipboard: payload("A") }); + expect((await Promise.all([a, b])).every((result) => result.ok)).toBe(true); + expect(value().objects.map((object) => object.label)).toEqual(["A", "B"]); + const next = deferred(); + const c = session.enqueue(() => next.promise, throwing), d = session.enqueue(() => next.promise, throwing); + expect(() => session.cancel()).not.toThrow(); + expect((await Promise.all([c, d])).every((result) => !result.ok && result.code === "clipboard.cancelled")).toBe(true); +}); diff --git a/packages/json-document-editing/tests/object-style.test.ts b/packages/json-document-editing/tests/object-style.test.ts new file mode 100644 index 000000000..7e1adaf26 --- /dev/null +++ b/packages/json-document-editing/tests/object-style.test.ts @@ -0,0 +1,66 @@ +import { expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { parseCanvasDocument, serializeCanvasDocument, type CanvasDocument } from "@interactive-os/json-document-object-document"; +import { createObjectEditor, objectClipboardFormat } from "../src/index.js"; + +const bounds = { x: 10, y: 20, width: 100, height: 80, label: "Text", color: "blue" }; +const initial: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [ + { ...bounds, id: "text", kind: "text", fontSize: 32 }, + { ...bounds, id: "rect", kind: "rectangle" }, + { ...bounds, id: "image", kind: "image", source: "data:image/png;base64,AQID", color: "transparent" }, +] }; + +test("mixed selection styling is one commit and Undo restores the exact document and causal selection", () => { + const document = createJSONDocument(initial), commits = vi.fn(); document.subscribe(commits); + const editor = createObjectEditor(document); + editor.dispatch({ type: "selection.set", objectIds: ["text", "rect", "image"], primaryKey: "image" }); + const selection = editor.snapshot.selection; + expect(editor.dispatch({ type: "selection.style", style: { color: "red", fontSize: 48, fontWeight: 700, textAlign: "center", strokeColor: "black", strokeWidth: 4 } }).ok).toBe(true); + expect(commits).toHaveBeenCalledOnce(); expect(editor.snapshot.selection).toEqual(selection); + const styled = editor.snapshot.value; + expect((styled as CanvasDocument).objects[2]).toEqual(initial.objects[2]); + editor.dispatch({ type: "selection.set", objectIds: ["rect"] }); + editor.undo(); expect(editor.snapshot.value).toEqual(initial); expect(editor.snapshot.selection).toEqual(selection); + editor.redo(); expect(editor.snapshot.value).toEqual(styled); expect(editor.snapshot.selection).toEqual(selection); +}); + +test("style no-ops and rejected requests preserve history including a redo branch", () => { + const editor = createObjectEditor(initial); + editor.dispatch({ type: "selection.style", style: { color: "red" } }); editor.undo(); + const before = editor.snapshot; + for (const style of [{ fontWeight: 400, textAlign: "left" }, { strokeWidth: 10 }, {}] as const) { + expect(editor.dispatch({ type: "selection.style", style }).ok).toBe(true); + expect(editor.snapshot).toMatchObject({ value: before.value, selection: before.selection, canUndo: false, canRedo: true }); + } + const beforeReject = editor.snapshot; + expect(editor.dispatch({ type: "selection.style", style: { color: "red", fontSize: -1 } }).ok).toBe(false); + expect(editor.snapshot).toEqual(beforeReject); expect(editor.snapshot.canRedo).toBe(true); + editor.dispatch({ type: "selection.set", objectIds: [] }); + expect(editor.dispatch({ type: "selection.style", style: { color: "red" } })).toMatchObject({ ok: false, code: "selection.empty" }); +}); + +test("all style fields survive duplicate, native payload serialization, fresh-ID paste and JSON reopen", () => { + let id = 0; + const editor = createObjectEditor(initial, { createId: () => `new-${++id}` }); + editor.dispatch({ type: "selection.set", objectIds: ["text", "rect"], primaryKey: "text" }); + editor.dispatch({ type: "selection.style", style: { color: "red", fontWeight: 700, textAlign: "right", strokeColor: "black", strokeWidth: 2 } }); + const originals = editor.selectedObjects; + editor.dispatch({ type: "object.duplicate", objectIds: ["text", "rect"] }); + const clipboard = objectClipboardFormat.parse(JSON.parse(JSON.stringify(editor.copy())))!; + expect(clipboard).not.toBeNull(); + editor.dispatch({ type: "clipboard.paste", clipboard }); + editor.selectedObjects.forEach((object, index) => expect(object).toEqual({ ...originals[index], id: object.id, x: originals[index]!.x + 24, y: originals[index]!.y + 24 })); + const saved = serializeCanvasDocument(editor.snapshot.value as CanvasDocument); + expect(createObjectEditor(parseCanvasDocument(saved)).snapshot.value).toEqual(editor.snapshot.value); + editor.undo(); expect((editor.snapshot.value as CanvasDocument).objects).toHaveLength(5); +}); + +test("legacy fill keeps its contract and general style does not paint image metadata", () => { + const editor = createObjectEditor(initial); + editor.dispatch({ type: "selection.set", objectIds: ["image"] }); + expect(editor.dispatch({ type: "selection.fill", color: "purple" }).ok).toBe(true); + expect(editor.selectedObjects[0]!.color).toBe("purple"); + const before = editor.snapshot; + expect(editor.dispatch({ type: "selection.style", style: { color: "green" } }).ok).toBe(true); + expect(editor.snapshot).toMatchObject({ value: before.value, selection: before.selection, canUndo: before.canUndo, canRedo: before.canRedo }); +}); diff --git a/packages/json-document-editing/tests/object-text.test.ts b/packages/json-document-editing/tests/object-text.test.ts new file mode 100644 index 000000000..095431c89 --- /dev/null +++ b/packages/json-document-editing/tests/object-text.test.ts @@ -0,0 +1,44 @@ +import { expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createCanvasObject, parseCanvasDocument, serializeCanvasDocument, type CanvasDocument } from "@interactive-os/json-document-object-document"; +import { createObjectEditor, objectClipboardFormat } from "../src/index.js"; + +const initial: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: ["rectangle", "ellipse", "sticky-note"].map((kind, index) => ({ + ...createCanvasObject(kind as "rectangle" | "ellipse" | "sticky-note", { x: index * 220, y: 100, width: 200, height: 180 }, { label: "", color: "#fff2a8" }), id: kind, +})) }; + +test.each(initial.objects)("$kind body editing is one commit and preserves a multi-selection through Undo/Redo", (object) => { + const source = createJSONDocument(initial), commits = vi.fn(); source.subscribe(commits); + const editor = createObjectEditor(source); + editor.dispatch({ type: "selection.set", objectIds: initial.objects.map((item) => item.id), primaryKey: object.id }); + const selection = editor.snapshot.selection; + expect(editor.dispatch({ type: "object.text", objectId: object.id, text: "본문\n💡" }).ok).toBe(true); + const written = editor.snapshot.value; + expect(commits).toHaveBeenCalledOnce(); expect(editor.snapshot.selection).toEqual(selection); + editor.undo(); expect(editor.snapshot.value).toEqual(initial); expect(editor.snapshot.selection).toEqual(selection); + editor.redo(); expect(editor.snapshot.value).toEqual(written); expect(editor.snapshot.selection).toEqual(selection); + expect(editor.dispatch({ type: "object.text", objectId: object.id, text: "" }).ok).toBe(true); + expect(editor.snapshot.value).toEqual(initial); +}); + +test("filled body and styles survive resize, duplication, native Clipboard payload, paste, delete and JSON reopen", () => { + let id = 0; + const editor = createObjectEditor(initial, { createId: () => `copy-${++id}` }); + initial.objects.forEach((object) => editor.dispatch({ type: "object.text", objectId: object.id, text: `${object.kind}\n한 장` })); + editor.dispatch({ type: "selection.set", objectIds: initial.objects.map((object) => object.id), primaryKey: "sticky-note" }); + editor.dispatch({ type: "selection.style", style: { textColor: "purple", fontSize: 30, fontWeight: 700, textAlign: "right" } }); + editor.dispatch({ type: "object.resize", objectIds: ["sticky-note"], dx: 0, dy: 0, dw: 40, dh: 20 }); + const originals = editor.selectedObjects; + expect(editor.dispatch({ type: "object.duplicate", objectIds: editor.snapshot.selection.keys }).ok).toBe(true); + const copies = editor.selectedObjects; + copies.forEach((object, index) => expect(object).toEqual({ ...originals[index], id: object.id, x: originals[index]!.x + 24, y: originals[index]!.y + 24 })); + const clipboard = objectClipboardFormat.parse(JSON.parse(JSON.stringify(editor.copy())))!; + expect(clipboard.text).toBe("rectangle\n한 장\nellipse\n한 장\nsticky-note\n한 장"); + expect(editor.dispatch({ type: "clipboard.paste", clipboard }).ok).toBe(true); + editor.selectedObjects.forEach((object, index) => expect(object).toEqual({ ...copies[index], id: object.id })); + const beforeDelete = editor.snapshot.value, selection = editor.snapshot.selection; + expect(editor.dispatch({ type: "selection.remove" }).ok).toBe(true); + editor.undo(); expect(editor.snapshot.value).toEqual(beforeDelete); expect(editor.snapshot.selection).toEqual(selection); + expect(new Set((beforeDelete as CanvasDocument).objects.map((object) => object.id)).size).toBe(9); + expect(createObjectEditor(parseCanvasDocument(serializeCanvasDocument(beforeDelete as CanvasDocument))).snapshot.value).toEqual(beforeDelete); +}); diff --git a/packages/json-document-editing/tests/preparation-queue.test.ts b/packages/json-document-editing/tests/preparation-queue.test.ts new file mode 100644 index 000000000..7bea5d89b --- /dev/null +++ b/packages/json-document-editing/tests/preparation-queue.test.ts @@ -0,0 +1,56 @@ +import { expect, test, vi } from "vitest"; +import { createEditingPreparationQueue, type EditingPreparation } from "../src/index.js"; + +function deferred() { + let resolve!: (value: EditingPreparation) => void; + const promise = new Promise>((done) => { resolve = done; }); + return { promise, resolve }; +} + +test("PI-ORDER: preparation order is independent of completion; each value applies once", async () => { + const applied: string[] = []; + const onPendingChange = vi.fn(); + const queue = createEditingPreparationQueue({ apply: (value: string) => { applied.push(value); return { ok: true as const }; }, onPendingChange }); + const first = deferred(), second = deferred(); + const a = queue.enqueue(() => first.promise), b = queue.enqueue(() => second.promise); + second.resolve({ ok: true, value: "B" }); await Promise.resolve(); + expect(applied).toEqual([]); + first.resolve({ ok: true, value: "A" }); await Promise.all([a, b]); + expect(applied).toEqual(["A", "B"]); expect(queue.isPending).toBe(false); + expect(onPendingChange.mock.calls).toEqual([[true], [false]]); +}); + +test("PI-CANCEL: cancellation settles every job, aborts preparation, and ignores late results", async () => { + const apply = vi.fn((value: string) => ({ ok: true as const, value })), onResult = vi.fn(); + const queue = createEditingPreparationQueue({ apply, onResult }); + const waiting = deferred(), abort = vi.fn(); + const a = queue.enqueue(() => waiting.promise, abort), b = queue.enqueue(() => ({ ok: true, value: "B" })); + queue.cancel(); + expect(await a).toMatchObject({ ok: false, code: "editing.preparation-cancelled" }); + expect(await b).toMatchObject({ ok: false }); + waiting.resolve({ ok: true, value: "late" }); await Promise.resolve(); + expect(apply).not.toHaveBeenCalled(); expect(onResult).not.toHaveBeenCalled(); expect(abort).toHaveBeenCalledOnce(); + await queue.enqueue(() => ({ ok: true, value: "fresh" })); expect(apply).toHaveBeenCalledWith("fresh"); +}); + +test("failed preparation and reentrant observers cannot poison later edits", async () => { + const values: string[] = []; + const queue = createEditingPreparationQueue({ + apply(value: string) { values.push(value); return { ok: true as const }; }, + onResult() { if (values.length === 1) void queue.enqueue(() => ({ ok: true, value: "nested" })); throw new Error("observer"); }, + }); + expect(await queue.enqueue(() => Promise.reject(new Error("read")))).toMatchObject({ ok: false, reason: "read" }); + await queue.enqueue(() => ({ ok: true, value: "first" })); expect(values).toEqual(["first", "nested"]); +}); + +test("pending observer cancellation prevents preparation and leaves a reusable queue", async () => { + const prepare = vi.fn(() => ({ ok: true as const, value: "never" })); + let shouldCancel = true; + const queue = createEditingPreparationQueue({ + apply: (value: string) => ({ ok: true as const, value }), + onPendingChange(pending) { if (pending && shouldCancel) queue.cancel(); }, + }); + expect((await queue.enqueue(prepare)).ok).toBe(false); expect(prepare).not.toHaveBeenCalled(); + shouldCancel = false; + expect((await queue.enqueue(prepare)).ok).toBe(true); +}); diff --git a/packages/json-document-editing/tsconfig.json b/packages/json-document-editing/tsconfig.json index 46c63cb77..17fb2dcdd 100644 --- a/packages/json-document-editing/tsconfig.json +++ b/packages/json-document-editing/tsconfig.json @@ -6,6 +6,8 @@ "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ + { "path": "../json-document-object-document" }, + { "path": "../json-document-calendar-document" }, { "path": "../json-document" }, { "path": "../json-document-selection" } ], diff --git a/packages/json-document-editing/vitest.config.ts b/packages/json-document-editing/vitest.config.ts index 38478cb0a..d4dbf3f01 100644 --- a/packages/json-document-editing/vitest.config.ts +++ b/packages/json-document-editing/vitest.config.ts @@ -3,6 +3,7 @@ import { defineNodeProject } from "../../test/vitest.shared.js"; export default defineNodeProject("json-document-editing", { resolve: { alias: { + "@interactive-os/json-document-calendar-document": new URL("../json-document-calendar-document/src/index.ts", import.meta.url).pathname, "@interactive-os/json-document": new URL("../json-document/src/application/document/index.ts", import.meta.url).pathname, "@interactive-os/json-document-selection": new URL("../json-document-selection/src/index.ts", import.meta.url).pathname, }, diff --git a/packages/json-document-file-intake/README.md b/packages/json-document-file-intake/README.md index 8e92f94d6..67377250a 100644 --- a/packages/json-document-file-intake/README.md +++ b/packages/json-document-file-intake/README.md @@ -13,3 +13,21 @@ validateFileCandidates(files, { formatFileSize(files[0].size); ``` + +## 포함된 이미지 내용 + +`RasterImageContent`는 `{ source: string, width: number, height: number }`인 JSON 값입니다. +`assertRasterImageSource(source)`는 PNG/JPEG/WebP base64 data URL 문법을, +`assertRasterImageContent(value)`는 같은 source와 양의 안전한 정수 치수를 검사합니다. +외부 URL·blob URL·SVG는 이 계약에 포함하지 않습니다. 파일 byte의 실제 decode와 +픽셀/파일 수용 정책 적용은 별개이며, 문법 검사 성공이 decode 가능성을 보증하지 않습니다. + +```ts +import { assertRasterImageContent } from "@interactive-os/json-document-file-intake"; + +assertRasterImageContent({ source: "data:image/png;base64,AQID", width: 100, height: 50 }); +// 문법 예시이며 실제 PNG byte를 뜻하지 않습니다. +``` + +Object Document Type과 Composer가 이 내용을 각각 객체 source와 첨부 image에 사용합니다. +실제 이미지 읽기·표시·Undo의 Usage와 Source: [Canvas](/demo/canvas), [Composer](/demo/composer). diff --git a/packages/json-document-file-intake/src/index.ts b/packages/json-document-file-intake/src/index.ts index db9e116ab..017560e71 100644 --- a/packages/json-document-file-intake/src/index.ts +++ b/packages/json-document-file-intake/src/index.ts @@ -1,6 +1,8 @@ import type { JSONValue } from "@interactive-os/json-document"; export { formatFileSize } from "./file-size.js"; +export { assertRasterImageContent, assertRasterImageSource } from "./raster-content.js"; +export type { RasterImageContent } from "./raster-content.js"; export interface FileCandidate extends Record { readonly name: string; diff --git a/packages/json-document-file-intake/src/raster-content.ts b/packages/json-document-file-intake/src/raster-content.ts new file mode 100644 index 000000000..7128635ab --- /dev/null +++ b/packages/json-document-file-intake/src/raster-content.ts @@ -0,0 +1,30 @@ +import type { JSONValue } from "@interactive-os/json-document"; + +/** Portable embedded image content. Source syntax validation does not prove that bytes decode. */ +export interface RasterImageContent extends Record { + readonly source: string; + readonly width: number; + readonly height: number; +} + +export function assertRasterImageSource(source: unknown): asserts source is string { + if (typeof source !== "string") throw new TypeError("Image source must be an embedded PNG, JPEG, or WebP data URL."); + const separator = source.indexOf(","); + const header = source.slice(0, separator); + const bytes = source.slice(separator + 1); + // Flat checks avoid recursive regex stack growth on large rasters. + if (!["data:image/png;base64", "data:image/jpeg;base64", "data:image/webp;base64"].includes(header) + || bytes.length === 0 || bytes.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(bytes) + || bytes.slice(0, -2).includes("=") || (bytes.at(-2) === "=" && bytes.at(-1) !== "=")) { + throw new TypeError("Image source must be an embedded PNG, JPEG, or WebP base64 data URL."); + } +} + +export function assertRasterImageContent(value: unknown): asserts value is RasterImageContent { + if (value === null || typeof value !== "object") throw new TypeError("Expected raster image content."); + const content = value as Record; + assertRasterImageSource(content.source); + if (typeof content.width !== "number" || typeof content.height !== "number" + || !Number.isSafeInteger(content.width) || !Number.isSafeInteger(content.height) + || content.width <= 0 || content.height <= 0) throw new TypeError("Raster dimensions must be positive safe integers."); +} diff --git a/packages/json-document-file-intake/tests/raster-content.test.ts b/packages/json-document-file-intake/tests/raster-content.test.ts new file mode 100644 index 000000000..06feb7041 --- /dev/null +++ b/packages/json-document-file-intake/tests/raster-content.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { assertRasterImageContent, assertRasterImageSource } from "../src/index.js"; + +test.each(["png", "jpeg", "webp"])("PI-CONTENT: %s embedded source is a portable syntax contract, not a decoder", (type) => { + const image = { source: `data:image/${type};base64,AQID`, width: 1, height: 2 }; + expect(() => assertRasterImageContent(image)).not.toThrow(); + expect(JSON.parse(JSON.stringify(image))).toEqual(image); +}); + +test.each(["https://example.com/a.png", "blob:temporary", "data:image/svg+xml;base64,AQID", "data:image/png;base64,A=ID", "data:image/png;base64,"])("PI-CONTENT: rejects nonportable or malformed source %s", (source) => { + expect(() => assertRasterImageSource(source)).toThrow(TypeError); +}); + +test.each([0, -1, 1.5, NaN, Infinity])("PI-CONTENT: rejects invalid raster dimension %s", (width) => { + expect(() => assertRasterImageContent({ source: "data:image/png;base64,AQID", width, height: 1 })).toThrow(TypeError); +}); diff --git a/packages/json-document-object-document/LICENSE b/packages/json-document-object-document/LICENSE new file mode 100644 index 000000000..6a984193a --- /dev/null +++ b/packages/json-document-object-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-object-document/README.md b/packages/json-document-object-document/README.md new file mode 100644 index 000000000..3aa0a5bdd --- /dev/null +++ b/packages/json-document-object-document/README.md @@ -0,0 +1,24 @@ +# Object Document Type + +`@interactive-os/json-document-object-document`는 ID가 있는 Object 문서와 +한 장짜리 Canvas 프로파일의 모델·검증·의미 연산·projection을 소유합니다. +Core만 의존하며 Editing, Selection, React, DOM 없이 사용할 수 있습니다. + +```ts +import { createJSONDocument } from "@interactive-os/json-document"; +import { parseCanvasDocument, planObjectOperation, serializeCanvasDocument } from "@interactive-os/json-document-object-document"; + +const value = parseCanvasDocument('{"profile":"canvas/1","width":1280,"height":720,"objects":[]}'); +const document = createJSONDocument(value); +const plan = planObjectOperation(value, { type: "insert", objects: [ + { id: "title", kind: "text", x: 80, y: 80, width: 640, height: 96, label: "Hello", color: "#253044", fontSize: 48 }, +] }); +if (plan.ok) document.commit(plan.operations); +serializeCanvasDocument(parseCanvasDocument(JSON.stringify(document.value))); +``` + +편집은 `createObjectEditor`, React 제품은 `CanvasHand`를 조합합니다. +기존 Editing의 `ObjectDocument`/`DocumentObject` export는 같은 타입을 가리키는 +호환 경로입니다. RC이며 안정된 wire 표준이나 PPTX 지원을 선언하지 않습니다. + +[API와 프로파일 계약](docs/api.md) · [Usage 및 Source](https://developer-1px.github.io/json-document/docs/api/canvas) diff --git a/packages/json-document-object-document/docs/api.md b/packages/json-document-object-document/docs/api.md new file mode 100644 index 000000000..c2cef7b27 --- /dev/null +++ b/packages/json-document-object-document/docs/api.md @@ -0,0 +1,131 @@ +## Object Document Type 계약 · RC + +소유자는 `@interactive-os/json-document-object-document`입니다. Object의 값과 +의미를 정의하며 선택·ID 할당·Intent·History는 Editing이, 조작 문법은 Affordance가, +입력 연결·렌더링은 Canvas Hand가 소유합니다. 기존 Editing 타입 export는 같은 정본 타입의 호환 경로입니다. + +### 모델과 Canvas 프로파일 + +`ObjectDocument.objects`는 뒤에 있는 객체가 위에 그려지는 ordered collection입니다. +`DocumentObject`의 id는 고유한 비어 있지 않은 문자열, label과 color는 문자열, +x/y/width/height는 유한수이며 width/height는 음수가 아닙니다. 예전처럼 kind가 없는 +Object도 계속 유효합니다. legacy Object의 생략된 color는 계속 수용하지만 잘못된 +타입은 거절합니다. 이 호환성 때문에 `assertObjectDocument`는 필수 필드 충족을 +주장하는 TypeScript type guard가 아닙니다. Canvas에서는 color가 필수입니다. +`projectObject`는 legacy Object를 rectangle으로 읽으며 저장값을 바꾸지 않습니다. + +`CanvasDocument`는 같은 모델에 `profile: "canvas/1"`, 양의 유한수 width/height와 +명시적인 kind를 추가한 단일 슬라이드 프로파일입니다. 같은 책임의 두 번째 객체 +모델이 아닙니다. 객체 크기도 양수여야 합니다. + +| kind | 추가 문서 값 | 표현 | +| --- | --- | --- | +| text | 양의 유한수 fontSize, 선택적 fontWeight·textAlign | label이 실제 내용인 plain text. 별도의 text 복사본 없음 | +| rectangle | 선택적 textColor·fontSize·fontWeight·textAlign·strokeColor·strokeWidth | color로 채운 사각형, label 본문 | +| ellipse | 같은 선택적 서식 | 경계 상자에 내접하는 타원, label 본문 | +| sticky-note | 같은 선택적 서식 | 여백을 둔 노트, color 채우기와 label 본문 | +| path | points, 양의 유한수 strokeWidth | color로 그린 열린 선 | +| image | source | 문서에 포함한 PNG/JPEG/WebP의 base64 data URL | + +path points는 최소 두 개의 `{ x, y }`이며 각 좌표는 `[0, 1]`입니다. 경계 상자에 +대한 정규화 좌표이므로 이동·resize는 상자만 바꾸고 점과 strokeWidth를 보존합니다. +`createCanvasPath`는 슬라이드 좌표의 점을 이 표현으로 변환합니다. 수평·수직 선의 +퇴화한 축은 최소 1 단위의 상자로 표현합니다. `createCanvasObject`는 도형/글자 초안을 +만들며 ID는 Editing의 `object.create`에서 할당합니다. `sticky-note`도 같은 생성 API를 +씁니다. 선택적인 `fontSize`와 `textColor`는 채워진 객체의 본문 서식으로 보존하며, +독립 text는 기존대로 `color`를 글자색으로 사용합니다. `CanvasTextFormat`은 객체 전체의 +글자 크기·굵기·가로 정렬 계약이며 text만 fontSize가 필수입니다. + +`createCanvasImage({ source, width, height, label }, bounds)`는 decode된 자연 크기를 +주어진 상자에 비율을 유지해 맞추며 확대하지 않습니다. image의 color는 공통 모델 호환을 +위한 `transparent`이고 렌더링에는 쓰지 않습니다. 이후 resize는 다른 객체와 같은 자유 +상자 변환이며 원본 비율을 강제하지 않습니다. source 바이트는 이동·resize·복제에 유지됩니다. + +`assertCanvasImageSource`는 PNG/JPEG/WebP MIME과 비어 있지 않은 base64 문법을 검증합니다. +외부 URL, blob URL, SVG, HTML은 거절합니다. 실제 이미지 decode나 파일 크기·픽셀 정책 검사는 +하지 않습니다. Web의 `readWebRasterFile`과 File Intake를 거친 입력만 실제 이미지로 수용하는 +경계는 Canvas Clipboard binding에 있습니다. JSON 문자열만으로 디코딩 가능성을 보증하지 않습니다. + +### 검증과 직렬화 + +`assertObjectDocument`/`assertCanvasDocument`는 구조 위반 시 TypeError를 던집니다. +`parseCanvasDocument`는 JSON과 프로파일을 검증한 독립된 immutable 값을 반환하고, +`serializeCanvasDocument`는 같은 검증 후 JSON 문자열을 반환합니다. 잘못된 root, +중복 ID, 알 수 없는 kind/profile, 잘못된 수치나 path를 조용히 보정하지 않습니다. +일반 JSON 확장 필드는 보존합니다. tool·selection·focus·preview·history는 프로파일 +필드가 아니며 Hand가 문서에 넣지 않습니다. + +### 의미 연산과 projection + +`planObjectOperation(document, operation)`은 insert, transform, fill, style, remove, +text, replace를 검증된 JSON Patch로 계획합니다. 성공은 `{ ok: true, operations }`, +실패는 `{ ok: false, code, reason? }`입니다. 계획은 입력을 변경하거나 commit하지 +않으며 선택과 History를 알지 못합니다. 없는 대상·중복 ID·유효하지 않은 결과는 +전체 거절합니다. no-op는 빈 operations를 반환합니다. + +`transformObject`는 preview와 commit의 같은 기하 규칙입니다. translate는 크기를 +유지하고 resize는 결과 크기를 최소 1로 제한합니다. Canvas Hand는 현재 모서리가 +반대쪽을 통과해도 뒤집지 않습니다. 화면 바깥 좌표는 허용하며 Hand가 슬라이드 밖을 +clip합니다. 위치를 자동 보정하거나 snap하지 않습니다. + +`projectObjectText(object): ObjectTextProjection | null`은 본문 편집 가능 여부와 표시·입력의 +공통 projection입니다. text·rectangle·ellipse·sticky-note는 `label`을 `text`로 읽고, +유효한 글자색·크기·굵기·정렬, 본문 상자(x/y/width/height), `verticalAlign`을 반환합니다. +image·path·kind 없는 legacy Object에는 null입니다. 확장 필드에 fontSize가 있어도 +본문 capability를 얻지 않습니다. 입력은 검증된 Object 값이어야 합니다. + +text는 전체 상자·상단, 사각형은 12단위 여백·중앙, 타원은 내접 사각형·중앙, +노트는 16단위 여백·상단입니다. 사각형/노트의 여백은 각 축 크기의 1/4 이하로 +제한하여 작은 상자도 양수로 남습니다. resize는 글자 크기를 바꾸지 않으며 넘친 글은 +본문 상자에서 clip합니다. projection은 문서를 변경하지 않습니다. + +`text` 연산과 Editing의 `object.text`는 이 capability를 공유합니다. 도형과 노트에 별도 +문자열 필드나 자식 text 객체를 만들지 않습니다. 빈 문자열도 유효하며 지원하지 않는 +객체는 기존 `object.not-text`로 거절합니다. + +### 객체 스타일 + +`getObjectStyle(object)`는 적용 가능한 속성의 유효값을 반환합니다. 글자의 생략된 +`fontWeight`는 400, `textAlign`은 `left`입니다. 도형·노트는 생략된 fontSize 24, +textColor `#253044`, fontWeight 400을 사용하고 정렬은 도형 `center`, 노트 `left`입니다. 굵기는 400/700, 정렬은 +`left`/`center`/`right`를 지원합니다. 도형의 생략된 `strokeColor`는 `#000000`, +`strokeWidth`는 0이므로 기존 문서는 테두리 없이 그대로 열립니다. 기본값을 읽는 것만으로 +문서를 바꾸지 않으며, 기존 kind 없는 Object는 color만 지원합니다. + +`ObjectStyle`의 color는 도형·노트의 채우기·독립 글자색·path의 선 색입니다. textColor는 +도형·노트의 본문만 칠하며 채우기를 바꾸지 않습니다. 독립 text에 textColor를 적용하지 +않습니다. strokeColor는 사각형·타원·노트의 테두리색이며 strokeWidth는 도형과 path의 선 굵기입니다. 이미지에는 +스타일 속성이 없습니다. 새 스타일의 색 값은 비어 있지 않은 문자열이고, 도형의 굵기는 +0 이상, path의 굵기와 글자 크기는 양의 유한수여야 합니다. 색 문자열의 CSS 해석은 +렌더링 플랫폼의 책임입니다. 부분 문자열 서식이나 Rich Text 모델은 아닙니다. + +`readObjectStyle(objects)`는 속성을 지원하는 객체끼리만 비교합니다. 공통 값이면 그 값, +서로 다르면 `null`, 지원하는 객체가 없으면 속성을 생략합니다. `null`은 저장값이 아니라 +혼합 선택의 읽기 결과입니다. + +```ts +import { readObjectStyle, planObjectOperation } from "@interactive-os/json-document-object-document"; + +const style = readObjectStyle(document.objects); +const plan = planObjectOperation(document, { + type: "style", objectIds: ["title", "rectangle"], + style: { color: "#3b82f6", fontWeight: 700, strokeWidth: 2 }, +}); +``` + +`style` 연산은 `Partial`을 받아 지원하는 대상 필드에만 적용합니다. +`assertObjectStyle`로 전체 요청을 먼저 검증하므로 지원하지 않는 속성도 잘못된 값이면 +전체 거절합니다. 없는 ID와 잘못된 결과도 전체 거절합니다. 특히 path가 섞인 집합에 +strokeWidth 0을 적용하면 도형만 바꾸지 않고 전체를 거절합니다. 같은 유효값, 빈 요청, +지원 대상이 없는 요청은 빈 patch이며 생략된 기본값을 저장하지 않습니다. +기존 `fill`은 이미지의 color 메타데이터를 포함한 기존 동작을 유지하는 호환 명령입니다. + +### Usage와 남은 범위 + +[Canvas Hand의 실제 Usage/Source](/docs/api/canvas)와 [Object Editing](/docs/object)가 +정본을 소비합니다. [Object 소유권 감사](/docs/document-types/object)는 영향을 받는 +모델·연산·Editing·Hand·두 Canvas Host의 소유자를 기록합니다. + +페이지·줌·팬·그룹·회전·정렬·snap·레이어 패널·PPTX·협업은 이 +Canvas slice 범위 밖입니다. 이 RC 프로파일은 독립 구현 간 Stable wire conformance를 +선언하지 않습니다. Annotation의 source/selector/body 모델도 Canvas에 통합하지 않습니다. diff --git a/packages/json-document-object-document/package.json b/packages/json-document-object-document/package.json new file mode 100644 index 000000000..e8ddafdd8 --- /dev/null +++ b/packages/json-document-object-document/package.json @@ -0,0 +1,23 @@ +{ + "name": "@interactive-os/json-document-object-document", + "version": "0.1.0-rc.0", + "description": "Object Document Type and single-slide Canvas profile: model, validation, operations and projections.", + "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-object-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", "@interactive-os/json-document-file-intake": "^0.1.0-rc.0" }, + "devDependencies": { "@interactive-os/json-document": "*", "@interactive-os/json-document-file-intake": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" } +} diff --git a/packages/json-document-object-document/src/index.ts b/packages/json-document-object-document/src/index.ts new file mode 100644 index 000000000..cdc88f237 --- /dev/null +++ b/packages/json-document-object-document/src/index.ts @@ -0,0 +1,5 @@ +export type { ObjectBounds, ObjectPoint, ObjectDraft, DocumentObject, ObjectDocument, CanvasObjectKind, CanvasTextFormat, CanvasObjectDraft, CanvasObject, CanvasDocument } from "./object-model.js"; +export { assertObjectDocument, assertCanvasDocument, assertCanvasImageSource, parseCanvasDocument, serializeCanvasDocument } from "./object-validation.js"; +export { createCanvasObject, createCanvasPath, createCanvasImage, projectObject, projectObjectText, transformObject, type ObjectTransform, type ObjectTextProjection } from "./object-projection.js"; +export { planObjectOperation, type ObjectOperation, type ObjectOperationPlan } from "./object-operation.js"; +export { getObjectStyle, readObjectStyle, assertObjectStyle, type ObjectStyle, type ObjectStyleSelection } from "./object-style.js"; diff --git a/packages/json-document-object-document/src/object-model.ts b/packages/json-document-object-document/src/object-model.ts new file mode 100644 index 000000000..662404c03 --- /dev/null +++ b/packages/json-document-object-document/src/object-model.ts @@ -0,0 +1,52 @@ +import type { JSONValue } from "@interactive-os/json-document"; + +export interface ObjectBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface ObjectPoint extends Record { + readonly x: number; + readonly y: number; +} + +export interface ObjectDraft extends ObjectBounds, Record { + readonly label: string; + readonly color: string; +} + +/** The original Object shape remains valid; a missing kind is a labelled rectangle. */ +export interface DocumentObject extends ObjectDraft { + readonly id: string; +} + +export interface ObjectDocument extends Record { + readonly objects: ReadonlyArray; +} + +export type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image"; + +export interface CanvasTextFormat { + readonly fontSize?: number; + readonly fontWeight?: 400 | 700; + readonly textAlign?: "left" | "center" | "right"; +} + +export 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 } +); + +export type CanvasObject = CanvasObjectDraft & { readonly id: string }; + +/** A fixed, single-slide profile of ObjectDocument, not a parallel object model. */ +export interface CanvasDocument extends ObjectDocument { + readonly profile: "canvas/1"; + readonly width: number; + readonly height: number; + readonly objects: ReadonlyArray; +} diff --git a/packages/json-document-object-document/src/object-operation.ts b/packages/json-document-object-document/src/object-operation.ts new file mode 100644 index 000000000..3bb1143d5 --- /dev/null +++ b/packages/json-document-object-document/src/object-operation.ts @@ -0,0 +1,67 @@ +import { applyPatch, buildPointer, jsonEqual, type JSONPatchOperation } from "@interactive-os/json-document"; +import type { DocumentObject, ObjectDocument } from "./object-model.js"; +import { projectObjectText, transformObject, type ObjectTransform } from "./object-projection.js"; +import { assertObjectDocument } from "./object-validation.js"; +import { assertObjectStyle, getObjectStyle, type ObjectStyle } from "./object-style.js"; + +export 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 }; + +export type ObjectOperationPlan = + | { readonly ok: true; readonly operations: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; + +/** Pure, atomic semantic planner. No selection, identity allocation, history or input lifecycle. */ +export function planObjectOperation(document: ObjectDocument, operation: ObjectOperation): ObjectOperationPlan { + try { + assertObjectDocument(document); + if (operation.type === "style") assertObjectStyle(operation.style); + const objects = document.objects; + const operations: JSONPatchOperation[] = []; + if (operation.type === "replace") { + assertObjectDocument(operation.document); + if (!jsonEqual(document, operation.document)) operations.push({ op: "replace", path: "", value: operation.document }); + } else if (operation.type === "insert") { + operation.objects.forEach((object, index) => operations.push({ op: "add", path: `/objects/${objects.length + index}`, value: object })); + } else { + const ids = operation.type === "text" ? [operation.objectId] : operation.objectIds; + const targets = new Set(ids); + if (ids.some((id) => !objects.some((object) => object.id === id))) return { ok: false, code: "selection.object-not-found" }; + for (let index = objects.length - 1; index >= 0; index--) { + const object = objects[index]!; + if (!targets.has(object.id)) continue; + if (operation.type === "remove") { + operations.push({ op: "remove", path: buildPointer(["objects", index]) }); + } else if (operation.type === "transform") { + const next = transformObject(object, operation.transform); + for (const key of ["x", "y", "width", "height"] as const) { + if (next[key] !== object[key]) operations.push({ op: "replace", path: buildPointer(["objects", index, key]), value: next[key] }); + } + } else if (operation.type === "fill") { + if (operation.color !== object.color) operations.push({ op: "replace", path: buildPointer(["objects", index, "color"]), value: operation.color }); + } else if (operation.type === "style") { + const current = getObjectStyle(object); + for (const [key, value] of Object.entries(operation.style)) { + const effective = current[key as keyof ObjectStyle]; + if (effective !== undefined && effective !== value) operations.push({ op: "add", path: buildPointer(["objects", index, key]), value }); + } + } else if (operation.type === "text") { + if (!projectObjectText(object)) return { ok: false, code: "object.not-text" }; + if (operation.text !== object.label) operations.push({ op: "replace", path: buildPointer(["objects", index, "label"]), value: operation.text }); + } + } + } + const result = applyPatch(document, operations); + if (!result.ok) return result; + assertObjectDocument(result.value); + return { ok: true, operations }; + } catch (error) { + return { ok: false, code: "object.invalid", reason: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/packages/json-document-object-document/src/object-projection.ts b/packages/json-document-object-document/src/object-projection.ts new file mode 100644 index 000000000..aa878226a --- /dev/null +++ b/packages/json-document-object-document/src/object-projection.ts @@ -0,0 +1,90 @@ +import type { CanvasObject, CanvasObjectDraft, CanvasObjectKind, DocumentObject, ObjectBounds, ObjectPoint } from "./object-model.js"; +import { assertCanvasImageSource } from "./object-validation.js"; +import { getObjectStyle, type ObjectStyle } from "./object-style.js"; + +export interface ObjectTransform { + readonly dx: number; + readonly dy: number; + readonly dw?: number; + readonly dh?: number; +} + +/** Shared committed/preview geometry. Path points stay normalized; resize changes only bounds. */ +export function transformObject(object: Object, transform: ObjectTransform): Object { + const { dx, dy, dw = 0, dh = 0 } = transform; + if (![dx, dy, dw, dh].every(Number.isFinite)) throw new TypeError("Object transform must be finite."); + const resized = transform.dw !== undefined || transform.dh !== undefined; + return { + ...object, + x: object.x + dx, + y: object.y + dy, + width: resized ? Math.max(1, object.width + dw) : object.width, + height: resized ? Math.max(1, object.height + dh) : object.height, + }; +} + +/** Legacy objects project without changing their persisted shape. Label is the only text value. */ +export function projectObject(object: DocumentObject): CanvasObject { + return object.kind === undefined ? { ...object, color: object.color ?? "transparent", kind: "rectangle" } : object as CanvasObject; +} + +/** Read-only body layout, shared by display, native editing, and text capability checks. */ +export interface ObjectTextProjection extends ObjectBounds, Pick { + readonly text: string; + readonly verticalAlign: "top" | "center"; +} + +export function projectObjectText(object: DocumentObject): ObjectTextProjection | null { + const style = getObjectStyle(object); + if (style.fontSize === undefined) return null; + const shape = object.kind === "rectangle" || object.kind === "ellipse"; + const padding = object.kind === "text" ? 0 : object.kind === "sticky-note" ? 16 : 12; + const insetX = object.kind === "ellipse" ? object.width * (1 - Math.SQRT1_2) / 2 : Math.min(padding, object.width / 4); + const insetY = object.kind === "ellipse" ? object.height * (1 - Math.SQRT1_2) / 2 : Math.min(padding, object.height / 4); + return { + x: object.x + insetX, y: object.y + insetY, width: object.width - 2 * insetX, height: object.height - 2 * insetY, + text: object.label, color: style.textColor ?? style.color!, fontSize: style.fontSize, + fontWeight: style.fontWeight!, textAlign: style.textAlign!, verticalAlign: shape ? "center" : "top", + }; +} + +export function createCanvasObject( + kind: Exclude, + bounds: ObjectBounds, + style: { readonly color: string; readonly label: string; readonly fontSize?: number; readonly textColor?: string }, +): CanvasObjectDraft { + const base = { ...bounds, width: Math.max(1, bounds.width), height: Math.max(1, bounds.height), color: style.color, label: style.label }; + return kind === "text" ? { ...base, kind, fontSize: style.fontSize ?? 32 } : { + ...base, kind, ...(style.fontSize === undefined ? {} : { fontSize: style.fontSize }), ...(style.textColor === undefined ? {} : { textColor: style.textColor }), + }; +} + +/** Fits a decoded raster inside bounds without upscaling; persisted bytes survive JSON round trips. */ +export function createCanvasImage( + image: { readonly source: string; readonly width: number; readonly height: number; readonly label: string }, + bounds: ObjectBounds, +): Extract { + assertCanvasImageSource(image.source); + if (![image.width, image.height, bounds.width, bounds.height].every((value) => Number.isFinite(value) && value > 0) + || ![bounds.x, bounds.y].every(Number.isFinite)) throw new TypeError("Image and fit dimensions must be positive and finite."); + const scale = Math.min(1, bounds.width / image.width, bounds.height / image.height); + return { kind: "image", source: image.source, label: image.label, color: "transparent", x: bounds.x, y: bounds.y, width: image.width * scale, height: image.height * scale }; +} + +/** Converts slide-space samples to one bounding box and normalized path geometry. */ +export function createCanvasPath( + points: ReadonlyArray, + style: { readonly color: string; readonly label: string; readonly strokeWidth: number }, +): Extract { + if (points.length < 2 || points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) throw new TypeError("A path requires at least two finite points."); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const point of points) { + minX = Math.min(minX, point.x); minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); maxY = Math.max(maxY, point.y); + } + const width = Math.max(1, maxX - minX), height = Math.max(1, maxY - minY); + return { + kind: "path", x: minX, y: minY, width, height, ...style, + points: points.map((point) => ({ x: (point.x - minX) / width, y: (point.y - minY) / height })), + }; +} diff --git a/packages/json-document-object-document/src/object-style.ts b/packages/json-document-object-document/src/object-style.ts new file mode 100644 index 000000000..12cfa5706 --- /dev/null +++ b/packages/json-document-object-document/src/object-style.ts @@ -0,0 +1,53 @@ +import type { DocumentObject } from "./object-model.js"; + +/** Object-level styles, not character-range formatting. Color is the kind's primary paint. */ +export 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; +} + +/** Missing means unsupported by every target; null means mixed among supporting targets. */ +export type ObjectStyleSelection = { readonly [Key in keyof ObjectStyle]?: ObjectStyle[Key] | null }; + +/** Effective values and capabilities have one owner, including legacy defaults. */ +export function getObjectStyle(object: DocumentObject): Partial { + const color = object.color ?? "transparent"; + const text = { fontSize: (object.fontSize ?? 24) as number, fontWeight: (object.fontWeight ?? 400) as 400 | 700, textAlign: (object.textAlign ?? (object.kind === "rectangle" || object.kind === "ellipse" ? "center" : "left")) as ObjectStyle["textAlign"] }; + switch (object.kind) { + case "image": return {}; + case "text": return { color, ...text }; + case "rectangle": case "ellipse": case "sticky-note": return { color, ...text, textColor: (object.textColor ?? "#253044") as string, strokeColor: (object.strokeColor ?? "#000000") as string, strokeWidth: (object.strokeWidth ?? 0) as number }; + case "path": return { color, strokeWidth: object.strokeWidth as number }; + default: return { color }; + } +} + +export function readObjectStyle(objects: ReadonlyArray): ObjectStyleSelection { + const selection: Record = {}; + for (const object of objects) { + for (const [key, value] of Object.entries(getObjectStyle(object))) { + selection[key] = selection[key] === undefined ? value : selection[key] === value ? value : null; + } + } + return selection; +} + +/** Validate the complete request before planning any target, even unsupported properties. */ +export function assertObjectStyle(value: unknown): asserts value is Partial { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("Object style must be a record."); + for (const [key, field] of Object.entries(value)) { + const valid = key === "color" || key === "textColor" || key === "strokeColor" ? typeof field === "string" && field.length > 0 + : key === "fontSize" ? typeof field === "number" && Number.isFinite(field) && field > 0 + : key === "strokeWidth" ? typeof field === "number" && Number.isFinite(field) && field >= 0 + : key === "fontWeight" ? field === 400 || field === 700 + : key === "textAlign" ? field === "left" || field === "center" || field === "right" + : false; + if (!valid) throw new TypeError(`Invalid Object style: ${key}.`); + } +} diff --git a/packages/json-document-object-document/src/object-validation.ts b/packages/json-document-object-document/src/object-validation.ts new file mode 100644 index 000000000..adfc0bb7d --- /dev/null +++ b/packages/json-document-object-document/src/object-validation.ts @@ -0,0 +1,71 @@ +import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; +import { assertRasterImageSource as assertCanvasImageSource } from "@interactive-os/json-document-file-intake"; +import type { CanvasDocument, DocumentObject, ObjectDocument } from "./object-model.js"; +import { assertObjectStyle, getObjectStyle } from "./object-style.js"; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function finite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function positive(value: unknown): value is number { + return finite(value) && value > 0; +} + +export function assertObjectDocument(value: unknown): void { + if (!record(value) || !Array.isArray(value.objects)) throw new TypeError("Object document requires an objects array."); + createJSONDocument(value as JSONValue); + const ids = new Set(); + for (const object of value.objects) { + if (!record(object) || typeof object.id !== "string" || object.id.length === 0) throw new TypeError("Object ids must not be empty."); + if (ids.has(object.id)) throw new TypeError(`Object id must be unique: ${JSON.stringify(object.id)}.`); + if (typeof object.label !== "string" || (object.color !== undefined && typeof object.color !== "string")) throw new TypeError("Object label and color must be strings."); + if (![object.x, object.y, object.width, object.height].every(finite)) throw new TypeError(`Object geometry must be finite: ${JSON.stringify(object.id)}.`); + if ((object.width as number) < 0 || (object.height as number) < 0) throw new TypeError(`Object dimensions must not be negative: ${JSON.stringify(object.id)}.`); + if (object.kind !== undefined) { + if (typeof object.color !== "string") throw new TypeError("Canvas objects require a color string."); + if (!["text", "rectangle", "ellipse", "sticky-note", "path", "image"].includes(object.kind as string)) throw new TypeError("Unknown Object kind."); + if (!positive(object.width) || !positive(object.height)) throw new TypeError("Canvas object dimensions must be positive."); + if (object.kind === "text" && !positive(object.fontSize)) throw new TypeError("Text fontSize must be positive and finite."); + const styleKeys = Object.keys(getObjectStyle(object as unknown as DocumentObject)).filter((key) => key !== "color"); + assertObjectStyle(Object.fromEntries(styleKeys.filter((key) => Object.hasOwn(object, key)).map((key) => [key, object[key]]))); + if (object.kind === "image") assertCanvasImageSource(object.source); + if (object.kind === "path") { + if (!positive(object.strokeWidth) || !Array.isArray(object.points) || object.points.length < 2) throw new TypeError("Path requires a positive strokeWidth and at least two points."); + for (const point of object.points) { + if (!record(point) || !finite(point.x) || !finite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1) throw new TypeError("Path points must be finite normalized coordinates in [0, 1]."); + } + } + } + ids.add(object.id); + } + if (value.profile === "canvas/1") assertCanvasShape(value as unknown as ObjectDocument); +} + +/** Embedded raster only: no external fetch, SVG, HTML, or session-scoped blob URL. Decoding belongs to the platform. */ +export { assertCanvasImageSource }; + +function assertCanvasShape(value: ObjectDocument): void { + if (!positive(value.width) || !positive(value.height)) throw new TypeError("Canvas dimensions must be positive and finite."); + if (value.objects.some((object) => object.kind === undefined)) throw new TypeError("Canvas objects require an explicit kind."); +} + +export function assertCanvasDocument(value: unknown): asserts value is CanvasDocument { + assertObjectDocument(value); + if (!record(value) || value.profile !== "canvas/1") throw new TypeError("Expected Canvas profile canvas/1."); +} + +/** Validates the complete JSON tree as well as the profile; does not retain caller-owned values. */ +export function parseCanvasDocument(json: string): CanvasDocument { + const value: unknown = JSON.parse(json); + assertCanvasDocument(value); + return createJSONDocument(value).value as CanvasDocument; +} + +export function serializeCanvasDocument(document: CanvasDocument): string { + assertCanvasDocument(document); + return JSON.stringify(createJSONDocument(document as JSONValue).value, null, 2); +} diff --git a/packages/json-document-object-document/tests/object-document.test.ts b/packages/json-document-object-document/tests/object-document.test.ts new file mode 100644 index 000000000..7f62fd2bb --- /dev/null +++ b/packages/json-document-object-document/tests/object-document.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import { applyPatch } from "@interactive-os/json-document"; +import { assertCanvasDocument, assertCanvasImageSource, assertObjectDocument, createCanvasImage, createCanvasObject, createCanvasPath, parseCanvasDocument, planObjectOperation, serializeCanvasDocument, transformObject, type CanvasDocument } from "../src/index.js"; + +const blank: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [] }; +const text = { ...createCanvasObject("text", { x: 10, y: 20, width: 300, height: 100 }, { color: "#123456", label: "Hello\n안녕", fontSize: 36 }), id: "text" }; +const path = { ...createCanvasPath([{ x: 10, y: 20 }, { x: 40, y: 80 }, { x: 70, y: 50 }], { color: "#123456", label: "Path", strokeWidth: 4 }), id: "path" }; + +describe("Object document and Canvas profile", () => { + test("round-trips every variant, order, identity and extension data without editor state", () => { + const document: CanvasDocument = { ...blank, title: "Slide", objects: [text, path, + { ...createCanvasObject("rectangle", { x: 100, y: 150, width: 80, height: 60 }, { color: "#abcdef", label: "" }), id: "rect" }, + { ...createCanvasObject("ellipse", { x: 200, y: 150, width: 80, height: 60 }, { color: "#abcdef", label: "" }), id: "ellipse" }, + { ...createCanvasImage({ source: "data:image/png;base64,AQID", width: 800, height: 400, label: "Picture" }, { x: 30, y: 40, width: 200, height: 200 }), id: "image" }, + ] }; + expect(parseCanvasDocument(serializeCanvasDocument(document))).toEqual(document); + expect(Object.keys(JSON.parse(serializeCanvasDocument(blank)))).toEqual(["profile", "width", "height", "objects"]); + }); + + test("embedded raster fitting preserves aspect and transform leaves bytes unchanged", () => { + const object = { ...createCanvasImage({ source: "data:image/jpeg;base64,AQID", width: 800, height: 400, label: "Picture" }, { x: 10, y: 20, width: 200, height: 200 }), id: "image" }; + expect(object).toMatchObject({ kind: "image", width: 200, height: 100 }); + expect(transformObject(object, { dx: 20, dy: 30, dw: 10, dh: 20 })).toMatchObject({ source: object.source, x: 30, y: 50, width: 210, height: 120 }); + expect(() => createCanvasImage({ source: object.source, width: 0, height: 10, label: "" }, object)).toThrow(); + expect(() => assertCanvasImageSource(`data:image/webp;base64,${"AQID".repeat(100_000)}`)).not.toThrow(); + }); + + test.each([undefined, "https://example.com/a.png", "blob:temporary", "data:image/svg+xml;base64,AQID", "data:text/html;base64,AQID", "data:image/png;base64,", "data:image/png;base64,A", "data:image/png;base64,AQ=Z", "data:image/png;base64,A===", "data:image/png;base64,AQID\n"])("rejects nonportable or malformed image source %#", (source) => { + expect(() => assertCanvasDocument({ ...blank, objects: [{ ...text, kind: "image", source }] })).toThrow(); + }); + + test.each([null, [], {}, { ...blank, objects: null }, { ...blank, width: 0 }, { ...blank, height: Infinity }, + { ...blank, objects: [text, text] }, { ...blank, objects: [{ ...text, id: "" }] }, + { ...blank, objects: [{ ...text, label: 1 }] }, { ...blank, objects: [{ ...text, x: NaN }] }, + { ...blank, objects: [{ ...text, kind: "image" }] }, { ...blank, objects: [{ ...text, fontSize: 0 }] }, + { ...blank, objects: [{ ...path, points: [{ x: 0, y: 0 }] }] }, { ...blank, objects: [{ ...path, strokeWidth: -1 }] }, + { ...blank, objects: [{ ...path, points: [{ x: 0, y: 0 }, { x: 1.1, y: 0 }] }] }, + ])("rejects malformed persisted state %#", (value) => expect(() => assertCanvasDocument(value)).toThrow()); + + test("requires a known Canvas profile but keeps legacy Object data valid", () => { + const legacy = { objects: [{ id: "a", x: 0, y: 0, width: 0, height: 0, label: "Alpha", color: "blue" }] }; + expect(() => assertObjectDocument(legacy)).not.toThrow(); + expect(() => assertCanvasDocument(legacy)).toThrow(); + expect(() => parseCanvasDocument(JSON.stringify({ ...blank, profile: "canvas/99" }))).toThrow(); + }); + + test("path normalization and resize have one geometry and identical preview/commit", () => { + expect(path).toMatchObject({ x: 10, y: 20, width: 60, height: 60, points: [{ x: 0, y: 0 }, { x: 0.5, y: 1 }, { x: 1, y: 0.5 }] }); + const document = { ...blank, objects: [path] }; + const transform = { dx: 5, dy: -2, dw: 60, dh: 30 }; + const plan = planObjectOperation(document, { type: "transform", objectIds: ["path"], transform }); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + const result = applyPatch(document, plan.operations); + expect(result).toMatchObject({ ok: true, value: { objects: [transformObject(path, transform)] } }); + expect(transformObject(path, transform).points).toEqual(path.points); + expect(transformObject(path, { dx: 0, dy: 0, dw: -1000, dh: -1000 })).toMatchObject({ width: 1, height: 1 }); + }); + + test("vertical and horizontal strokes remain finite and resizable", () => { + for (const points of [[{ x: 0, y: 0 }, { x: 0, y: 100 }], [{ x: 0, y: 0 }, { x: 100, y: 0 }]]) { + const object = { ...createCanvasPath(points, { color: "black", label: "", strokeWidth: 2 }), id: "p" }; + expect(() => assertCanvasDocument({ ...blank, objects: [object] })).not.toThrow(); + } + }); + + test("planning rejects invalid/unknown targets atomically and does not emit no-op patches", () => { + const document = { ...blank, objects: [text] }; + expect(planObjectOperation(document, { type: "transform", objectIds: ["text"], transform: { dx: NaN, dy: 0 } })).toMatchObject({ ok: false }); + expect(planObjectOperation(document, { type: "transform", objectIds: ["text", "missing"], transform: { dx: 10, dy: 0 } })).toMatchObject({ ok: false }); + expect(planObjectOperation(document, { type: "insert", objects: [text] })).toMatchObject({ ok: false }); + expect(planObjectOperation(document, { type: "text", objectId: "text", text: text.label })).toEqual({ ok: true, operations: [] }); + expect(planObjectOperation(document, { type: "transform", objectIds: ["text"], transform: { dx: 0, dy: 0 } })).toEqual({ ok: true, operations: [] }); + expect(document.objects[0]).toBe(text); + }); +}); diff --git a/packages/json-document-object-document/tests/object-style.test.ts b/packages/json-document-object-document/tests/object-style.test.ts new file mode 100644 index 000000000..ebe0f000d --- /dev/null +++ b/packages/json-document-object-document/tests/object-style.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "vitest"; +import { applyPatch } from "@interactive-os/json-document"; +import { assertCanvasDocument, assertObjectStyle, getObjectStyle, parseCanvasDocument, planObjectOperation, readObjectStyle, serializeCanvasDocument, type CanvasDocument, type ObjectStyle } from "../src/index.js"; + +const bounds = { x: 10, y: 20, width: 100, height: 80, label: "", color: "blue" }; +const document: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects: [ + { ...bounds, id: "text", kind: "text", fontSize: 32 }, + { ...bounds, id: "rect", kind: "rectangle" }, + { ...bounds, id: "ellipse", kind: "ellipse", color: "red", strokeColor: "green", strokeWidth: 4 }, + { ...bounds, id: "path", kind: "path", strokeWidth: 4, points: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }, + { ...bounds, id: "image", kind: "image", source: "data:image/png;base64,AQID", color: "transparent" }, +] }; + +test("effective styles preserve old documents and expose kind-specific capabilities", () => { + expect(getObjectStyle(document.objects[0]!)).toEqual({ color: "blue", fontSize: 32, fontWeight: 400, textAlign: "left" }); + expect(getObjectStyle(document.objects[1]!)).toEqual({ color: "blue", textColor: "#253044", fontSize: 24, fontWeight: 400, textAlign: "center", strokeColor: "#000000", strokeWidth: 0 }); + expect(getObjectStyle(document.objects[3]!)).toEqual({ color: "blue", strokeWidth: 4 }); + expect(getObjectStyle(document.objects[4]!)).toEqual({}); + expect(getObjectStyle({ ...bounds, id: "legacy" })).toEqual({ color: "blue" }); + expect(parseCanvasDocument(serializeCanvasDocument(document))).toEqual(document); + expect(document.objects[0]).not.toHaveProperty("fontWeight"); +}); + +test("mixed values only compare supporting targets and retain unsupported as absent", () => { + expect(readObjectStyle([])).toEqual({}); + expect(readObjectStyle(document.objects)).toEqual({ color: null, textColor: "#253044", fontSize: null, fontWeight: 400, textAlign: null, strokeColor: null, strokeWidth: null }); + expect(readObjectStyle([document.objects[0]!, document.objects[4]!])).toEqual(getObjectStyle(document.objects[0]!)); + expect(readObjectStyle([document.objects[0]!, { ...document.objects[0]!, id: "bold", fontWeight: 700, textAlign: "right" }])).toMatchObject({ fontWeight: null, textAlign: null }); +}); + +test("one atomic style plan changes only applicable fields without replacing extension data or geometry", () => { + const style = { color: "purple", fontSize: 48, fontWeight: 700, textAlign: "center", strokeColor: "orange", strokeWidth: 6 } as const; + const plan = planObjectOperation(document, { type: "style", objectIds: document.objects.map((object) => object.id), style }); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + const result = applyPatch(document, plan.operations); + expect(result.ok).toBe(true); + if (!result.ok) return; + const next = result.value as CanvasDocument; + expect(next.objects[0]).toEqual({ ...document.objects[0], color: "purple", fontSize: 48, fontWeight: 700, textAlign: "center" }); + expect(next.objects[1]).toEqual({ ...document.objects[1], color: "purple", fontSize: 48, fontWeight: 700, strokeColor: "orange", strokeWidth: 6 }); + expect(next.objects[3]).toEqual({ ...document.objects[3], color: "purple", strokeWidth: 6 }); + expect(next.objects[4]).toEqual(document.objects[4]); + expect(parseCanvasDocument(serializeCanvasDocument(next))).toEqual(next); +}); + +test("effective default and unsupported properties are no-ops, while missing targets reject the whole plan", () => { + expect(planObjectOperation(document, { type: "style", objectIds: ["text"], style: { fontWeight: 400, textAlign: "left", strokeColor: "red" } })).toEqual({ ok: true, operations: [] }); + expect(planObjectOperation(document, { type: "style", objectIds: ["rect"], style: { strokeWidth: 0, strokeColor: "#000000" } })).toEqual({ ok: true, operations: [] }); + expect(planObjectOperation(document, { type: "style", objectIds: ["image"], style: { color: "red", fontSize: 42 } })).toEqual({ ok: true, operations: [] }); + expect(planObjectOperation(document, { type: "style", objectIds: ["text", "missing"], style: { color: "red" } })).toMatchObject({ ok: false, code: "selection.object-not-found" }); +}); + +test.each([null, [], { color: "" }, { color: 1 }, { strokeColor: "" }, { fontSize: 0 }, { fontSize: Infinity }, { fontSize: "32" }, { fontWeight: 500 }, { textAlign: "justify" }, { strokeWidth: -1 }, { strokeWidth: NaN }, { unknown: 1 }, { color: "red", fontSize: undefined }])("rejects malformed style requests before partial or unsupported updates %#", (style) => { + expect(() => assertObjectStyle(style)).toThrow(); + expect(planObjectOperation(document, { type: "style", objectIds: ["rect", "image"], style: style as Partial })).toMatchObject({ ok: false }); +}); + +test.each([{ fontWeight: 500 }, { textAlign: "justify" }])("validates optional persisted text fields %#", (style) => { + expect(() => assertCanvasDocument({ ...document, objects: [{ ...document.objects[0], ...style }] })).toThrow(); +}); + +test("stroke removal is valid for shapes but a path still requires positive width", () => { + expect(planObjectOperation(document, { type: "style", objectIds: ["ellipse"], style: { strokeWidth: 0 } }).ok).toBe(true); + expect(planObjectOperation(document, { type: "style", objectIds: ["ellipse", "path"], style: { strokeWidth: 0, color: "red" } })).toMatchObject({ ok: false }); + expect(() => assertCanvasDocument({ ...document, objects: [{ ...document.objects[1], strokeWidth: -1 }] })).toThrow(); + expect(() => assertCanvasDocument({ ...document, objects: [{ ...document.objects[1], strokeColor: 0 }] })).toThrow(); +}); diff --git a/packages/json-document-object-document/tests/object-text.test.ts b/packages/json-document-object-document/tests/object-text.test.ts new file mode 100644 index 000000000..95503ae20 --- /dev/null +++ b/packages/json-document-object-document/tests/object-text.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "vitest"; +import { applyPatch } from "@interactive-os/json-document"; +import { assertCanvasDocument, createCanvasObject, getObjectStyle, parseCanvasDocument, planObjectOperation, projectObjectText, readObjectStyle, serializeCanvasDocument, type CanvasDocument } from "../src/index.js"; + +const bounds = { x: 40, y: 60, width: 200, height: 160 }; +const filledKinds = ["rectangle", "ellipse", "sticky-note"] as const; +const objects = filledKinds.map((kind) => ({ ...createCanvasObject(kind, bounds, { color: "#fff2a8", label: "한 장\n💡" }), id: kind })); +const document: CanvasDocument = { profile: "canvas/1", width: 1280, height: 720, objects }; + +test.each(filledKinds)("%s shares label authoring, font defaults and separate fill/text paint", (kind) => { + const object = objects.find((item) => item.id === kind)!; + const before = serializeCanvasDocument(document); + expect(projectObjectText(object)).toMatchObject({ text: "한 장\n💡", color: "#253044", fontSize: 24, fontWeight: 400, textAlign: kind === "sticky-note" ? "left" : "center", verticalAlign: kind === "sticky-note" ? "top" : "center" }); + const plan = planObjectOperation(document, { type: "text", objectId: kind, text: "새 본문\n" }); + expect(plan).toEqual({ ok: true, operations: [{ op: "replace", path: `/objects/${objects.indexOf(object)}/label`, value: "새 본문\n" }] }); + expect(planObjectOperation(document, { type: "text", objectId: kind, text: object.label })).toEqual({ ok: true, operations: [] }); + expect(serializeCanvasDocument(document)).toBe(before); + expect(parseCanvasDocument(before)).toEqual(document); + expect(object).not.toHaveProperty("fontSize"); +}); + +test("body bounds are inset, finite at minimum sizes and ellipse corners remain inside its curve", () => { + expect(projectObjectText(objects[0]!)).toMatchObject({ x: 52, y: 72, width: 176, height: 136 }); + expect(projectObjectText(objects[2]!)).toMatchObject({ x: 56, y: 76, width: 168, height: 128 }); + const ellipse = projectObjectText(objects[1]!)!; + expect(ellipse.width).toBeCloseTo(bounds.width * Math.SQRT1_2); + expect(ellipse.height).toBeCloseTo(bounds.height * Math.SQRT1_2); + for (const object of objects) { + const tiny = projectObjectText({ ...object, width: 1, height: 1 })!; + expect(tiny.width).toBeGreaterThan(0); expect(tiny.width).toBeLessThanOrEqual(1); + expect(tiny.height).toBeGreaterThan(0); expect(tiny.height).toBeLessThanOrEqual(1); + } +}); + +test("text keeps its color and uninset bounds; metadata-only labels do not acquire text capability", () => { + const text = { ...createCanvasObject("text", bounds, { color: "red", label: "Title", fontSize: 32 }), id: "text" }; + expect(projectObjectText(text)).toEqual({ ...bounds, text: "Title", color: "red", fontSize: 32, fontWeight: 400, textAlign: "left", verticalAlign: "top" }); + for (const object of [ + { ...bounds, id: "legacy", color: "blue", label: "Legacy" }, + { ...bounds, id: "image", color: "transparent", label: "Image", kind: "image", source: "data:image/png;base64,AQID" }, + { ...bounds, id: "path", color: "blue", label: "Line", kind: "path", strokeWidth: 2, points: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }, + ]) { + expect(projectObjectText(object)).toBeNull(); + expect(planObjectOperation({ objects: [object] }, { type: "text", objectId: object.id, text: "No" })).toMatchObject({ ok: false, code: "object.not-text" }); + } +}); + +test("mixed body styles use one atomic plan and round-trip without painting the fill", () => { + expect(readObjectStyle(objects)).toMatchObject({ color: "#fff2a8", textColor: "#253044", fontSize: 24, textAlign: null }); + const style = { textColor: "purple", fontSize: 40, fontWeight: 700, textAlign: "right" } as const; + const plan = planObjectOperation(document, { type: "style", objectIds: filledKinds, style }); + expect(plan.ok).toBe(true); if (!plan.ok) return; + const result = applyPatch(document, plan.operations); + expect(result.ok).toBe(true); if (!result.ok) return; + const next = result.value as CanvasDocument; + next.objects.forEach((object, index) => { + expect(object).toEqual({ ...objects[index], ...style }); + expect(getObjectStyle(object)).toMatchObject(style); + }); + expect(parseCanvasDocument(serializeCanvasDocument(next))).toEqual(next); +}); + +test.each(filledKinds)("%s rejects invalid persisted body formatting and atomic style requests", (kind) => { + const object = objects.find((item) => item.id === kind)!; + for (const style of [{ fontSize: 0 }, { fontSize: "24" }, { fontWeight: 500 }, { textAlign: "justify" }, { textColor: "" }, { textColor: null }]) { + expect(() => assertCanvasDocument({ ...document, objects: [{ ...object, ...style }] })).toThrow(); + expect(planObjectOperation(document, { type: "style", objectIds: filledKinds, style: { color: "red", ...style } as never }).ok).toBe(false); + } +}); diff --git a/packages/json-document-object-document/tsconfig.json b/packages/json-document-object-document/tsconfig.json new file mode 100644 index 000000000..22d7f20bc --- /dev/null +++ b/packages/json-document-object-document/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig/library.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, + "references": [{ "path": "../json-document" }, { "path": "../json-document-file-intake" }], + "include": ["src/**/*.ts"] +} diff --git a/packages/json-document-object-document/tsconfig.test.json b/packages/json-document-object-document/tsconfig.test.json new file mode 100644 index 000000000..47af24776 --- /dev/null +++ b/packages/json-document-object-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-object-document/vitest.config.ts b/packages/json-document-object-document/vitest.config.ts new file mode 100644 index 000000000..16f569740 --- /dev/null +++ b/packages/json-document-object-document/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineNodeProject } from "../../test/vitest.shared.js"; + +export default defineNodeProject("json-document-object-document"); diff --git a/packages/json-document-rich-text-web/README.md b/packages/json-document-rich-text-web/README.md index 590202dbb..778e0f13a 100644 --- a/packages/json-document-rich-text-web/README.md +++ b/packages/json-document-rich-text-web/README.md @@ -8,6 +8,11 @@ Selection round-trips both text offsets and container child boundaries. Copy, cut, and paste publish/consume structured Rich Text, safe semantic HTML, and plain text in that priority order. +HTML syntax is read through Web's `parseWebHTMLFragment`, an inert template parser +shared with Canvas and Composer intake. Active and foreign content is excluded; +returned nodes are never inserted into a live document. Rich Text Web still owns +schema-specific block/mark conversion. This profile does not add inline images. + Keyboard Undo/Redo consumes the Web package's `createWebKeyboardAdapter` defaults (`Mod-z`, `Mod-Shift-z`). This binding retains its historical Alt variants through explicit keymap entries. Root ownership and composition handling stay in this diff --git a/packages/json-document-rich-text-web/src/clipboard.ts b/packages/json-document-rich-text-web/src/clipboard.ts index 582c2a0b6..1ad7e32eb 100644 --- a/packages/json-document-rich-text-web/src/clipboard.ts +++ b/packages/json-document-rich-text-web/src/clipboard.ts @@ -14,7 +14,7 @@ import { type RichTextParagraph, type RichTextSchema, } from "@interactive-os/json-document-rich-text"; -import type { WebClipboardCodec, WebClipboardRepresentation } from "@interactive-os/json-document-web"; +import { parseWebHTMLFragment, type WebClipboardCodec, type WebClipboardRepresentation } from "@interactive-os/json-document-web"; export function createRichTextClipboardCodec(schema: RichTextSchema = richTextSchemaV1): WebClipboardCodec { return { @@ -59,10 +59,10 @@ export function serializeRichTextSlice(slice: RichTextSlice): string { } export function parseRichTextHTML(html: string, createId: () => string, profile: string = RICH_TEXT_PROFILE_V1): RichTextClipboard | null { - if (html.length === 0 || typeof DOMParser === "undefined") return null; - const document = new DOMParser().parseFromString(html, "text/html"); + const fragment = parseWebHTMLFragment(html); + if (!fragment) return null; const blocks: RichTextNode[] = []; - for (const child of Array.from(document.body.childNodes)) { + for (const child of Array.from(fragment.childNodes)) { if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) { blocks.push(paragraph([textNode(child.textContent, [], createId)], createId)); continue; diff --git a/packages/json-document-rich-text-web/tests/clipboard.test.ts b/packages/json-document-rich-text-web/tests/clipboard.test.ts index 719a77832..11f13b740 100644 --- a/packages/json-document-rich-text-web/tests/clipboard.test.ts +++ b/packages/json-document-rich-text-web/tests/clipboard.test.ts @@ -91,4 +91,11 @@ describe("Official Rich Text Web clipboard", () => { expect(parsed?.slice.content).toMatchObject([{ content: [{ text: "safe text", marks: [] }] }]); expect(parsed?.html).not.toContain("javascript:"); }); + + it("uses the inert Web parser without turning active/foreign content into document text", () => { + let id = 0; + const parsed = parseRichTextHTML('foreign

Kept

', () => `id-${++id}`); + expect(parsed?.text).toBe("Kept"); + expect(parsed?.html).toBe("

Kept

"); + }); }); diff --git a/packages/json-document-selection/README.md b/packages/json-document-selection/README.md index 935a6cea1..819e54970 100644 --- a/packages/json-document-selection/README.md +++ b/packages/json-document-selection/README.md @@ -6,7 +6,10 @@ DOM-free selection families and semantic interaction controllers for structural The package owns JSON-safe selection state, pure family transitions, reconciliation/mapping, target publication, and pointer/keyboard interaction lifecycles after physical input has been translated into semantic operations. -The host owns DOM or canvas geometry, hit testing implementation, modifier-key mapping, focus and accessibility wiring, native text caret/IME state, and domain edits such as delete, move, fill, and paste. +플랫폼 geometry 관찰·hit testing·modifier 해석·DOM focus·native caret/IME는 +Adapter와 해당 UI owner가, delete·move·fill·paste의 의미는 문서·Editing owner가 +소유합니다. Host는 제품 정책 값과 구체 인스턴스·layout을 조합하며 이 책임을 +직접 재구현하지 않습니다. ```text platform adapter ─┐ @@ -19,7 +22,7 @@ editing history ──┘ - `KeySelection`: explicit keys or symbolic `all` with exclusions and a host-issued universe token. - `RangeSelection`: directional anchor/focus ranges over a host-provided `OrderedTopology`. - `MaterializedRangeSelection`: directional ranges whose resolved points survive virtualized or paged topology changes. Each range keeps its anchor/focus and the points produced by the topology at transition time; reconciliation removes only identities that the topology no longer recognizes. -- `MaskSelection`: an extension protocol whose weighted representation and algebra remain host-owned. +- `MaskSelection`: an extension protocol whose weighted representation and algebra belong to the implementing document/editor owner, not anonymous Host logic. These families share `SelectionFamily`; they do not share a universal reducer. @@ -30,11 +33,9 @@ Use `createMaterializedRangeSelectionFamily` when the visible topology can chang Translate physical input before calling the package: ```ts -const operation: SelectionOperation = event.shiftKey - ? "extend" - : event.metaKey || event.ctrlKey - ? "toggle" - : "replace"; +import { selectionOperationFromModifiers } from "@interactive-os/json-document-web"; + +const operation = selectionOperationFromModifiers(event); ``` Viewport-to-domain coordinate conversion, pointer capture, auto-scroll, and accessibility remain in the adapter. Pass only `PointerSample` values to `reducePressInteraction` or `reduceMarqueeInteraction`. diff --git a/packages/json-document-ui-primitives-react/docs/popover.md b/packages/json-document-ui-primitives-react/docs/popover.md new file mode 100644 index 000000000..7ec920236 --- /dev/null +++ b/packages/json-document-ui-primitives-react/docs/popover.md @@ -0,0 +1,22 @@ +## Popover · 아이콘 trigger + +`Popover`는 `label`, `trigger`, `open`, `onOpenChange`와 panel 내용을 받습니다. +`triggerPresentation="icon"`이면 trigger를 공통 `Command`의 아이콘·툴팁으로 표시합니다. +label이 접근성 이름과 툴팁의 정본이며 icon은 `aria-hidden`으로 렌더링합니다. +기본 trigger presentation은 기존 label 표현입니다. + +열 때 panel로 focus를 옮기고, Escape로 닫으면 trigger로 복귀합니다. 바깥 pointerdown과 +panel 밖으로의 focus 이동은 panel만 닫고 사용자의 새 focus를 가로채지 않습니다. +panel 내부의 입력과 선택은 열린 상태를 유지합니다. Dialog와 달리 focus를 가두지 않습니다. +입력 draft를 언제 확정할지는 소비하는 Hand가 정합니다. + +```tsx +import { Popover } from "@interactive-os/json-document-ui-primitives-react"; + + +``` + +[Canvas Usage/Source](/demo/canvas)의 선택 스타일이 이 API를 사용합니다. diff --git a/packages/json-document-ui-primitives-react/package.json b/packages/json-document-ui-primitives-react/package.json index 3009b3438..04f586846 100644 --- a/packages/json-document-ui-primitives-react/package.json +++ b/packages/json-document-ui-primitives-react/package.json @@ -13,7 +13,7 @@ "directory": "packages/json-document-ui-primitives-react" }, "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, - "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "files": ["dist", "!dist/.tsbuildinfo", "docs", "README.md", "LICENSE"], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./content-interaction.css": "./dist/content-interaction.css" diff --git a/packages/json-document-ui-primitives-react/src/controls.tsx b/packages/json-document-ui-primitives-react/src/controls.tsx index aab999615..923c5421c 100644 --- a/packages/json-document-ui-primitives-react/src/controls.tsx +++ b/packages/json-document-ui-primitives-react/src/controls.tsx @@ -53,12 +53,12 @@ export function Command( export function Toggle( props: Omit, "aria-pressed"> & FocusPreservingControl & ControlAffordanceProps & { readonly pressed: boolean; - readonly presentation?: "button" | "chip"; + readonly presentation?: "button" | "chip" | "icon"; readonly label?: string; readonly tooltip?: string; }, ): ReactNode { - const { "aria-label": ariaLabel, affordance, children, label, onMouseDown, presentation = "button", preserveFocus = false, pressed, tooltip, type = "button", ...buttonProps } = props; + const { "aria-label": ariaLabel, affordance, children, label, onMouseDown, presentation = label ? "icon" : "button", preserveFocus = false, pressed, tooltip, type = "button", ...buttonProps } = props; const accessibleLabel = label ?? ariaLabel; const tooltipId = useId(); const button = ( diff --git a/packages/json-document-ui-primitives-react/src/presentations.tsx b/packages/json-document-ui-primitives-react/src/presentations.tsx index 6642971df..4b532365f 100644 --- a/packages/json-document-ui-primitives-react/src/presentations.tsx +++ b/packages/json-document-ui-primitives-react/src/presentations.tsx @@ -1,26 +1,39 @@ import { useEffect, useId, useRef, type ReactNode } from "react"; +import { Command } from "./controls.js"; export function 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 { const id = `json-document-popover-${useId().replaceAll(":", "")}`; - const triggerRef = useRef(null); + const rootRef = useRef(null); const panelRef = useRef(null); useEffect(() => { if (props.open) panelRef.current?.focus(); }, [props.open]); + useEffect(() => { + if (!props.open) return; + const dismissOutside = (event: PointerEvent) => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) props.onOpenChange(false); + }; + document.addEventListener("pointerdown", dismissOutside, true); + return () => document.removeEventListener("pointerdown", dismissOutside, true); + }, [props.open, props.onOpenChange]); const close = () => { props.onOpenChange(false); - queueMicrotask(() => triggerRef.current?.focus()); + queueMicrotask(() => rootRef.current?.querySelector("button")?.focus()); }; + const triggerProps = { "aria-label": props.label, "aria-haspopup": "dialog", "aria-controls": id, "aria-expanded": props.open, onClick: () => props.onOpenChange(!props.open) } as const; return ( - - - {props.open ? : null} + { + if (props.open && event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)) props.onOpenChange(false); + }}> + {props.triggerPresentation === "icon" ? {props.trigger} : } + {props.open ? : null} ); } diff --git a/packages/json-document-ui-primitives-react/src/surfaces.tsx b/packages/json-document-ui-primitives-react/src/surfaces.tsx index 082d04403..8e932449e 100644 --- a/packages/json-document-ui-primitives-react/src/surfaces.tsx +++ b/packages/json-document-ui-primitives-react/src/surfaces.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type HTMLAttributes, type PointerEvent, type ReactNode, type TdHTMLAttributes } from "react"; +import { useEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type HTMLAttributes, type PointerEvent, type ReactNode, type TdHTMLAttributes } from "react"; import { createInteractionHandleSession, interactionHandleCursor, @@ -49,6 +49,12 @@ export function useInteractionHandle( const [interaction] = useState(() => createInteractionHandleSession()); const [active, setActive] = useState(false); const stopNativeContinuation = useRef<(() => void) | null>(null); + useEffect(() => () => { + stopNativeContinuation.current?.(); + const active = pointer.getSnapshot(); + if (active) pointer.cancel(active.pointerId); + interaction.cancel(); + }, [pointer, interaction]); const sessionActive = active || interaction.getSnapshot() !== null; function point(event: PointerEvent) { diff --git a/packages/json-document-ui-primitives-react/tests/primitives.test.tsx b/packages/json-document-ui-primitives-react/tests/primitives.test.tsx index 95e73e819..12c341bfd 100644 --- a/packages/json-document-ui-primitives-react/tests/primitives.test.tsx +++ b/packages/json-document-ui-primitives-react/tests/primitives.test.tsx @@ -111,6 +111,25 @@ describe("UI Primitives", () => { expect(screen.queryByRole("dialog", { name: "Delete document" })).toBeNull(); }); + test("icon Popover reuses Command tooltips, dismisses outside, and restores focus only on Escape", async () => { + const user = userEvent.setup(); + function Harness() { + const [open, setOpen] = useState(false); + return <>} triggerPresentation="icon" open={open} onOpenChange={setOpen}>; + } + render(); + const trigger = screen.getByRole("button", { name: "Style" }); + expect(trigger.getAttribute("data-ui-control")).toBe("command"); + expect(document.getElementById(trigger.getAttribute("aria-describedby")!)?.textContent).toBe("Style"); + await user.click(trigger); await user.click(screen.getByRole("button", { name: "Inside" })); + expect(screen.getByRole("dialog", { name: "Style" })).toBeTruthy(); + await user.keyboard("{Escape}"); expect(document.activeElement).toBe(trigger); + await user.click(trigger); const outside = screen.getByRole("button", { name: "Outside" }); await user.click(outside); + expect(screen.queryByRole("dialog", { name: "Style" })).toBeNull(); expect(document.activeElement).toBe(outside); + await user.click(trigger); await user.tab(); await user.tab(); + expect(screen.queryByRole("dialog", { name: "Style" })).toBeNull(); expect(document.activeElement).toBe(outside); + }); + test("Dialog moves focus inside, traps Tab, and restores the invoking control", async () => { const user = userEvent.setup(); function Harness() { @@ -270,6 +289,22 @@ describe("UI Primitives", () => { expect(screen.getByRole("button", { name: "Details" }).getAttribute("aria-expanded")).toBe("true"); }); + test("Toggle labels default to icon presentation while text tooltips and explicit presentations remain intact", () => { + render(<> + + Details + Full label + ); + const icon = screen.getByRole("button", { name: "Draw" }); + expect(icon.getAttribute("data-ui-presentation")).toBe("icon"); + expect(icon.getAttribute("aria-pressed")).toBe("true"); + expect(icon.getAttribute("aria-describedby")).toBe(screen.getByRole("tooltip", { name: "Draw" }).id); + const text = screen.getByRole("button", { name: "Details" }); + expect(text.getAttribute("data-ui-presentation")).toBe("button"); + expect(text.getAttribute("aria-describedby")).toBe(screen.getByRole("tooltip", { name: "Show details" }).id); + expect(screen.getByRole("button", { name: "Full label" }).getAttribute("data-ui-presentation")).toBe("button"); + }); + test("Command can preserve an editing surface focus during pointer activation", () => { render(<>
Format); const editor = screen.getByRole("textbox"); diff --git a/packages/json-document-web/README.md b/packages/json-document-web/README.md index ac5dfe8d3..53b2ed278 100644 --- a/packages/json-document-web/README.md +++ b/packages/json-document-web/README.md @@ -31,12 +31,16 @@ ARIA projection, composite focus, and text input. It translates native `Clipboar conventional keyboard chords without rendering UI or deciding product keyboard policy. -Once a supported cut has written its payload or a paste has decoded a supported -payload, the binding cancels the native event before calling the editor. A +When a cut callback is configured, the binding cancels native cut before attempting +to write, including unavailable/failed/partial writes. It calls the editor only after +every representation is written. A supported paste is cancelled after decoding and before editing. A rejected edit remains `editing.rejected` and cannot fall through to a browser mutation. Unsupported or undecodable paste data keeps its existing pass-through behavior. +See the owning [Clipboard event contract](docs/clipboard.md) for captured targets, +native editable ownership and observable failure semantics. + `registerWebVirtualSelectionScope` coordinates native Select All and copy when a surface mounts only part of its model. It selects the mounted root with a real DOM Range, then writes the registered complete model text during the native @@ -68,8 +72,9 @@ const dragDrop = createWebDragDropSession({ }); ``` -The sessions own platform lifecycle state. Hit testing, valid targets, geometry, -and document Intent remain in the host. +세션은 플랫폼 수명주기를 소유합니다. Hit testing과 geometry의 플랫폼 관찰, +유효 대상과 문서 Intent의 의미는 각각 Adapter와 문서·Editing owner의 계약을 +소비합니다. Host는 제품의 대상·정책 값과 실행 경로를 연결합니다. `createWebViewportPositionPorts` measures an exact target and its paired tail reserve, writes temporary scroll range, performs smooth or instant positioning, @@ -118,12 +123,12 @@ const keyboard = createWebKeyboardAdapter(); surface.addEventListener("click", (event) => { const operation = selectionOperationFromModifiers(event); - // The host resolves geometry and dispatches its domain selection intent. + // Connect canonical geometry/selection APIs with the product's target. }); surface.addEventListener("keydown", (event) => { const command = keyboard.resolve(event); - // Official adapter output. The host maps it through topology to a domain intent. + // Pass the command to the canonical topology/editor API. }); input.addEventListener("input", (event) => { @@ -146,20 +151,38 @@ the formats it enables and their priority, while `createWebJSONClipboardRepresentation` owns JSON serialization. The legacy named codecs remain compatibility aliases over those domain formats. Clipboard surfaces write both the structured json-document MIME payload and its -`text/plain` projection. Paste -consumes only a valid structured payload. Parsing arbitrary external plain text -into domain records or cells remains a host policy. +`text/plain` projection. `captureWebClipboardPaste` captures an enabled structured +representation, files, opt-in image-containing HTML (`html: "images"`), or literal +text before the event expires. `delegatedMimeTypes` leaves recognized formats to +an existing nested binding before this priority. Its codec is optional. Domain conversion belongs to the canonical +Editing or Hand API; the Host supplies product policy. + +`readWebRasterFiles` validates a PNG/JPEG/WebP batch and prepares its embedded +content and intrinsic dimensions through `readWebRasterFile`. Canvas and Composer +share this path. File Intake owns `RasterImageContent`; Web owns reading and +decoding, not document mutation or server upload. See the +[Clipboard API and remaining TBD](docs/clipboard.md). + +`parseWebHTMLFragment` is the inert platform parser shared with Rich Text Web; +its nodes are conversion input, never live DOM insertion output. +`parseWebClipboardHTML` projects ordered text/image sources. `readWebHTMLClipboard` +checks embedded PNG/JPEG/WebP data URLs before allocating bytes and reuses the +raster batch reader. It does not fetch external, relative, blob, or cid URLs. +Canvas consumes mixed content; Composer accepts image-only HTML and explicitly +rejects mixed text/images until its document profile can represent them. The official keyboard adapter owns `defaultWebKeymap`. `resolve` returns a semantic command or `null`; `moveLinePoint` and `moveGridPoint` locate the visible neighbor. The host still decides when a command applies and which domain Intent to dispatch. -The clipboard binding calls `preventDefault()` only after a successful copy, -canonical cut, or canonical paste. Cut writes the selected payload before -asking the Editing companion to remove it. Missing clipboard data, malformed -payloads, unsupported cut, and rejected editing results leave native handling -available. +The clipboard binding cancels cut before attempting a write, and only removes +the captured selection after the write succeeds. Copy cancels after writing; +its synchronous paste cancels after decoding a supported representation and +before invoking Editing. Decode failures retain that binding's existing +pass-through. In contrast, `captureWebClipboardPaste` claims a recognized +representation before decoding, so an invalid structured payload cannot fall +back to other content. Missing or unrecognized content remains unclaimed. `createWebClipboardSurface` is the public surface-level orchestration API. It projects one binding into `onCopy`, `onCut`, and `onPaste` handlers and reports @@ -187,11 +210,17 @@ The Adapter owns: The host owns: -- the event target, canonical focus, when a command applies, and role workflow policy; -- DOM/canvas geometry and hit testing; -- external plain-text interpretation and product-specific paste policy; +- product-specific activation, permissions, and workflow policy; +- concrete DOM/external instances and visual composition; +- selection of canonical geometry, editor, focus, and clipboard APIs; +- product-specific paste policy values; - enabled representations and their priority; -- native text selection, IME, drag/drop, persistence, and remote protocols. +- composition of canonical text-selection, IME and drag/drop bindings; +- injection of persistence and remote-system instances. + +Native text selection, IME, drag/drop lifecycle, serialization, and reusable UI +behavior remain at their canonical Adapter, Affordance, Connector, or UI owner. +Host composition is not an exemption from those module boundaries. The module does not access `window`, `document`, or `navigator` during import, so non-browser tooling can load it safely. @@ -239,3 +268,13 @@ const range = textSelectionFromControl({ currentTarget: textarea }); [Document Usage](https://developer-1px.github.io/json-document/demo); its source view links the React binding to this package's `input.ts` implementation and [API reference](https://developer-1px.github.io/json-document/docs/api/web). + +Default keyboard interpretation has one owner here. `chordFromStroke` folds +Meta/Control into `Mod`, preserves Alt/Shift, normalizes single-character case, +and maps the space key to `Space`. Unlisted chords resolve to `null`; for +example, Mod+Alt+Z and Mod+Backspace have no default structural command. +`createWebKeyboardAdapter({ keymap, defaults: false })` can explicitly assign +such chords for a product profile. Affordance consumes the default delete +mapping; Composer consumes its Undo/Redo mapping. Select-all remains an +Affordance policy over the canonical chord normalizer, outside +`WebKeyboardCommand`. diff --git a/packages/json-document-web/docs/clipboard.md b/packages/json-document-web/docs/clipboard.md new file mode 100644 index 000000000..8751cf2e4 --- /dev/null +++ b/packages/json-document-web/docs/clipboard.md @@ -0,0 +1,104 @@ +## Web Clipboard · 이벤트 소유권 + +`createWebClipboardBinding`은 플랫폼의 copy/cut/paste와 구조화 payload codec을 연결합니다. +`createWebClipboardSurface`는 같은 binding의 `onCopy/onCut/onPaste`와 결과 관찰을 제공합니다. +정본 export는 `@interactive-os/json-document-web`에 있습니다. 도메인 객체와 ID·Selection·History는 Editing 소유입니다. + +```ts +import { createWebClipboardBinding, objectClipboardCodec } from "@interactive-os/json-document-web"; + +const clipboard = 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 }), +}); +``` + +- 기본 write는 codec의 구조화 MIME과 `text/plain`을 함께 기록합니다. `representations`를 + 전달하면 지정한 표현 목록을 사용합니다. 전부 쓰기 성공한 뒤에만 `cut(payload)`를 호출합니다. +- cut callback이 있으면 쓰기 시도 **전에** native cut을 취소합니다. clipboard 없음·쓰기 거절· + 부분 쓰기·빈 선택·Editing 거절이 native fallback 삭제로 이어지지 않습니다. native editable + target인지 판별하여 호출할 책임은 binding 소비자에게 있습니다. 미지원 cut callback은 이벤트를 소유하지 않습니다. +- `cut`은 `read`가 반환하고 실제 쓴 payload를 받습니다. 변경 가능한 현재 선택을 다시 읽지 말고 + 이 캡처 대상을 제거합니다. clipboard의 여러 MIME 쓰기 자체는 OS 원자적 transaction이 아니므로 + 앞 표현이 남고 뒤 쓰기가 실패할 수 있습니다. 이때도 도메인 문서는 삭제하지 않습니다. +- paste는 지원하는 MIME을 decode한 뒤 native event를 취소하고 Editing을 호출합니다. + 미지원/해독 불가 데이터는 `clipboard.empty/invalid`, 사용 불가는 `clipboard.unavailable`, + Editing 거절은 `editing.rejected`로 관찰합니다. 미지원/해독 불가 paste의 기존 pass-through는 유지합니다. +- Mod+C/X/V keydown을 막으면 native clipboard event가 오지 않을 수 있습니다. 키 명령에서 + 가상의 복사 동작을 만들지 않고 native 이벤트를 이 binding에 연결합니다. + +실제 소비와 Source: [Canvas](/demo/canvas), [Object Demo](/demo/object), +[Clipboard Adapter](/adapters/clipboard). Canvas text/JSON textarea는 native clipboard를 유지합니다. + +### 비동기 입력을 위한 동기 캡처 + +`captureWebClipboardPaste(event, { codec, files: true, text: true })`는 이벤트가 끝나기 전에 +구조화 MIME → 파일 → `text/plain` 순서로 하나의 표현을 캡처합니다. 성공은 `type`이 +`structured`(payload), `files`(파일 배열), `text`(문자열)인 결과입니다. files/text는 명시한 +경우만 처리합니다. `codec`을 생략한 `{ files: true }`는 file-only 소비자를 +지원하며 텍스트/HTML의 native 처리를 소유하지 않습니다. 파일 메타데이터는 별도 `fileCandidatesFromWebFiles` +API로 File Intake에 전달하고, 파일 참조는 실제 browser File이어야 읽을 수 있습니다. + +캡처한 파일 배열과 문자열을 비동기 작업에 넘기며 ClipboardEvent/DataTransfer를 나중에 다시 +읽지 않습니다. 자신이 처리하는 표현은 즉시 preventDefault합니다. 구조화 MIME이 있으면 +decode 실패도 소유한 실패이며 다른 표현으로 떨어지지 않습니다. 이 strict 캡처는 위의 기존 +동기 binding이 제공하는 여러 representation fallback/pass-through와 구분되는 계약입니다. +일치하는 표현이 없으면 native 처리를 막지 않고 `clipboard.empty`를 반환합니다. + +`html: "images"`를 명시하면 파일 다음, 일반 텍스트 전에 이미지가 포함된 HTML을 +선택합니다. 이 overload는 기존 결과에 `{ ok: true, type: "html", content }`를 더합니다. +이미지가 없는 HTML은 캡처하지 않으며 `text`를 켜지 않은 소비자는 기존 Rich Text 처리를 +유지합니다. `delegatedMimeTypes`에 지정한 MIME이 있으면 모든 캡처보다 먼저 +`clipboard.empty`로 위임하고 이벤트를 소유하지 않습니다. Composer는 내부 Rich Text +구조화 MIME을 이렇게 기존 binding에 남깁니다. 위임할 실제 binding이 있는 경우에만 지정합니다. + +`readWebRasterFile(file, { signal? })`는 기존 FileReader와 Image decode 정본입니다. +성공은 dataURL과 자연 width/height, 실패는 `raster.read-failed`, `raster.decode-failed`, +취소는 `raster.cancelled`입니다. 구조적 `WebRasterReadSignal`은 browser AbortSignal과 +호환되며 read/decode 중 취소하면 리스너를 해제하고 읽기/이미지 요청을 중단합니다. +파일 형식·크기·개수·픽셀 제한이나 문서 객체 생성은 이 플랫폼 API의 책임이 아닙니다. + +### 이미지 batch 준비 + +`readWebRasterFiles(files, { policy, maxImagePixels, signal?, readRaster? })`는 File Intake의 +정책 검사를 먼저 실행한 뒤 PNG/JPEG/WebP를 순서대로 decode합니다. 성공은 +`{ ok: true, files: [{ candidate, image }] }`이며 image는 File Intake의 `RasterImageContent`입니다. +한 파일이라도 실패하면 전체 batch의 실패만 반환합니다. 파일 정책 실패, `raster.unsupported`, +읽기/decode 실패, `raster.pixel-limit`, `raster.cancelled`를 구별합니다. + +`policy`와 `maxImagePixels`는 소비자가 결정합니다. 이 함수는 실제 DOM 읽기를 +순차화하지만 문서를 변경하거나 Undo 단위·삽입 위치를 결정하지 않습니다. 준비한 +batch를 어떻게 적용하고 언제 취소할지는 Editing과 각 Hand가 소유합니다. + +### HTML 이미지와 순서 있는 내용 + +`parseWebHTMLFragment(html)`은 브라우저의 inert `template`에서 HTML 문법을 읽습니다. +`script`·`iframe`·`style`·SVG 등의 요소를 제외하고, DOM 순서의 `childNodes`를 가진 +`WebHTMLFragment` 또는 빈/DOM 미지원 환경에서 `null`을 반환합니다. 반환 노드는 live DOM에 +삽입하지 않습니다. 이 API는 문서 의미로 변환할 입력이지, 임의 HTML을 삽입하는 sanitizer가 +아닙니다. Rich Text Web도 같은 parser를 사용하되 block·mark·schema 변환은 직접 소유합니다. + +`parseWebClipboardHTML(html)`은 `{ parts }` 또는 `null`을 반환합니다. 각 part는 +`{ type: "text", text }` 또는 `{ type: "image", source, label }`이며 HTML 내부의 순서를 +유지합니다. block·`br`의 줄바꿈과 table cell 경계를 일반 텍스트로 옮기며 서식·CSS 배치는 +보존하지 않습니다. 문자열 16,777,216 UTF-16 code unit, 탐색 노드 10,000개, part 256개를 +넘으면 `RangeError`입니다. opt-in capture에서 이 실패는 `clipboard.invalid`로 소유하며 +일반 텍스트로 조용히 떨어지지 않습니다. + +`readWebHTMLClipboard(content, { policy, maxImagePixels, currentCount?, signal?, readRaster? })`는 +PNG/JPEG/WebP base64 data URL만 준비합니다. 모든 source의 형식과 byte 수·파일 정책을 +검사한 뒤 실제 bytes를 할당하고 `readWebRasterFiles`로 decode합니다. 성공 결과의 +`parts`는 text 또는 `{ type: "image", candidate, image }`입니다. 하나라도 실패하면 +일부 part를 돌려주지 않으며 기존 raster/파일 오류와 `raster.source-unsupported`를 구분합니다. +외부·상대·blob·cid URL은 다운로드하지 않습니다. 빈 source와 읽을 수 없는 이미지도 실패입니다. + +Canvas는 이 결과를 Editing의 순서 있는 객체 변환으로 전달합니다. Composer는 이미지-only +HTML만 첨부로 받으며 글+이미지 입력은 `composer.clipboard.mixed-unsupported`로 전체를 거절합니다. +native 파일이 함께 있으면 파일 표현이 우선합니다. 파일과 HTML 이미지의 대응을 추측하거나 +같은 내용을 두 번 추가하지 않으며, 두 표현의 혼합 의미 보존은 아직 TBD입니다. + +실제 텍스트·이미지 입력 및 순서/취소 연결: [Canvas Usage/Source](/demo/canvas), +[Composer Usage/Source](/demo/composer). HTML의 남은 표현 범위, 명시적인 plain paste, 이미지 쓰기와 +OS-native 왕복의 남은 범위는 [Clipboard 기본기 TBD](/docs/clipboard)에 공개합니다. diff --git a/packages/json-document-web/package.json b/packages/json-document-web/package.json index 98454e309..0a2c51e9b 100644 --- a/packages/json-document-web/package.json +++ b/packages/json-document-web/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", @@ -42,6 +42,7 @@ "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", + "jsdom": "^29.1.1", "typescript": "^5.0.0", "vitest": "^4.1.7" } diff --git a/packages/json-document-web/src/clipboard.ts b/packages/json-document-web/src/clipboard.ts index 16e27cb12..3aed5c549 100644 --- a/packages/json-document-web/src/clipboard.ts +++ b/packages/json-document-web/src/clipboard.ts @@ -6,6 +6,8 @@ import { sheetClipboardFormat, treeClipboardFormat, } from "@interactive-os/json-document-editing"; +import type { WebFileCandidate, WebFileCandidateList } from "./file-intake.js"; +import { parseWebClipboardHTML, type WebHTMLClipboardContent } from "./html-clipboard.js"; export interface WebClipboardPayload { readonly type: string; @@ -14,6 +16,7 @@ export interface WebClipboardPayload { export interface WebClipboardData { readonly types: ReadonlyArray; + readonly files?: WebFileCandidateList; getData(format: string): string; setData(format: string, data: string): void; } @@ -23,6 +26,61 @@ export interface WebClipboardEvent { preventDefault(): void; } +export 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 }>; + +export type WebHTMLClipboardPaste = WebClipboardPaste + | { readonly ok: true; readonly type: "html"; readonly content: WebHTMLClipboardContent }; + +/** Existing callers retain the original result union; HTML image capture is opt-in. */ +export function captureWebClipboardPaste(event: WebClipboardEvent, options: { + readonly codec?: WebClipboardCodec; readonly files?: boolean; readonly text?: boolean; readonly html?: never; readonly delegatedMimeTypes?: ReadonlyArray; +}): WebClipboardPaste; +export function captureWebClipboardPaste(event: WebClipboardEvent, options: { + readonly codec?: WebClipboardCodec; readonly files?: boolean; readonly text?: boolean; readonly html?: "images"; readonly delegatedMimeTypes?: ReadonlyArray; +}): WebHTMLClipboardPaste; +/** Captures one enabled representation: structured → files → HTML with images → literal text. */ +export function captureWebClipboardPaste(event: WebClipboardEvent, options: { + readonly codec?: WebClipboardCodec; + readonly files?: boolean; + readonly text?: boolean; + readonly html?: "images"; + readonly delegatedMimeTypes?: ReadonlyArray; +}): WebHTMLClipboardPaste { + const data = event.clipboardData; + if (data === null) return failure("clipboard.unavailable"); + try { + if (options.delegatedMimeTypes && Array.from(data.types).some((type) => options.delegatedMimeTypes!.includes(type))) return failure("clipboard.empty"); + if (options.codec && Array.from(data.types).includes(options.codec.mimeType)) { + event.preventDefault(); + const result = readRepresentations(data, [options.codec]); + return result.ok ? { ok: true, type: "structured", payload: result.payload } : result; + } + if (options.files && data.files && data.files.length > 0) { + event.preventDefault(); + return { ok: true, type: "files", files: Array.from(data.files) }; + } + if (options.html === "images" && Array.from(data.types).includes("text/html")) { + let content: WebHTMLClipboardContent | null; + try { content = parseWebClipboardHTML(data.getData("text/html")); } + catch (error) { event.preventDefault(); return failure("clipboard.invalid", errorMessage(error)); } + if (content?.parts.some((part) => part.type === "image")) { + event.preventDefault(); + return { ok: true, type: "html", content }; + } + } + if (options.text && Array.from(data.types).includes("text/plain")) { + event.preventDefault(); + const text = data.getData("text/plain"); + return text.length > 0 ? { ok: true, type: "text", text } : failure("clipboard.empty"); + } + return failure("clipboard.empty"); + } catch (error) { return failure("clipboard.unavailable", errorMessage(error)); } +} + export interface WebClipboardCodec { readonly mimeType: Payload["type"]; encode(payload: Payload): string; @@ -165,9 +223,11 @@ export function createWebClipboardBinding< }, cut(event) { if (options.cut === undefined) return failure("clipboard.unsupported"); + // The binding owns this cut, including write failure. Never allow native + // fallback deletion after a refused/partial structured clipboard write. + event.preventDefault(); const written = write(event); if (!written.ok) return written; - event.preventDefault(); const result = options.cut(written.payload); if (result === null) return failure("editing.rejected", "clipboard.empty"); if (!result.ok) return failure("editing.rejected", result.reason ?? result.code); @@ -181,21 +241,9 @@ export function createWebClipboardBinding< encode: options.codec.encode, decode: options.codec.decode, }]; - let payload: Payload | null = null; - let matched = false; - let invalidReason: string | undefined; - for (const representation of representations) { - if (!Array.from(data.types).includes(representation.mimeType)) continue; - matched = true; - try { - payload = representation.decode(data.getData(representation.mimeType)); - } catch (error) { - invalidReason = errorMessage(error); - } - if (payload !== null) break; - } - if (!matched) return failure("clipboard.empty"); - if (payload === null) return failure("clipboard.invalid", invalidReason); + const captured = readRepresentations(data, representations); + if (!captured.ok) return captured; + const { payload } = captured; event.preventDefault(); const result = options.paste(payload); if (!result.ok) return failure("editing.rejected", result.reason ?? result.code); @@ -228,10 +276,26 @@ export function createWebClipboardSurface< }; } +function readRepresentations(data: WebClipboardData, representations: ReadonlyArray>): + | { readonly ok: true; readonly payload: Payload } + | Extract, { readonly ok: false }> { + let matched = false; + let invalidReason: string | undefined; + for (const representation of representations) { + if (!Array.from(data.types).includes(representation.mimeType)) continue; + matched = true; + try { + const payload = representation.decode(data.getData(representation.mimeType)); + if (payload !== null) return { ok: true, payload }; + } catch (error) { invalidReason = errorMessage(error); } + } + return failure(matched ? "clipboard.invalid" : "clipboard.empty", invalidReason); +} + function failure( code: Extract, { readonly ok: false }>["code"], reason?: string, -): WebClipboardResult { +): Extract, { readonly ok: false }> { return reason === undefined ? { ok: false, code } : { ok: false, code, reason }; } diff --git a/packages/json-document-web/src/html-clipboard.ts b/packages/json-document-web/src/html-clipboard.ts new file mode 100644 index 000000000..418528483 --- /dev/null +++ b/packages/json-document-web/src/html-clipboard.ts @@ -0,0 +1,79 @@ +import { assertRasterImageSource, validateFileCandidates, type FileCandidate } from "@interactive-os/json-document-file-intake"; +import { parseWebHTMLFragment } from "./html-fragment.js"; +import { readWebRasterFiles, type WebRasterFileContent } from "./raster-files.js"; + +export type WebHTMLClipboardPart = + | { readonly type: "text"; readonly text: string } + | { readonly type: "image"; readonly source: string; readonly label: string }; + +export interface WebHTMLClipboardContent { readonly parts: ReadonlyArray } +export type WebHTMLClipboardResult = + | { readonly ok: true; readonly parts: ReadonlyArray | ({ readonly type: "image" } & WebRasterFileContent)> } + | { readonly ok: false; readonly code: string; readonly reason?: string }; + +/** DOM order and plain text boundaries, without CSS layout, external source resolution, or document semantics. */ +export function parseWebClipboardHTML(html: string): WebHTMLClipboardContent | null { + if (html.length > 16 * 1024 * 1024) throw new RangeError("Clipboard HTML exceeds 16,777,216 code units."); + const fragment = parseWebHTMLFragment(html); + if (!fragment) return null; + const parts: WebHTMLClipboardPart[] = []; + let text = "", visited = 0; + const flush = () => { if (text.trim()) parts.push({ type: "text", text: text.trim() }); text = ""; }; + const boundary = () => { if (text && !text.endsWith("\n")) text += "\n"; }; + const stack = Array.from(fragment.childNodes).reverse().map((node) => ({ node, exit: false, pre: false })); + while (stack.length > 0) { + const { node, exit, pre } = stack.pop()!; + if (exit) { boundary(); continue; } + if (++visited > 10_000 || parts.length > 256) throw new RangeError("Clipboard HTML exceeds the node or content limit."); + if (node.nodeType === 3) { + const value = pre ? node.textContent ?? "" : (node.textContent ?? "").replace(/[\t\r\n\f ]+/g, " "); + text += !pre && /[ \n]$/.test(text) ? value.replace(/^ /, "") : value; + continue; + } + if (node.nodeType !== 1) continue; + const element = node as Element, tag = element.localName; + if (tag === "img") { + flush(); + parts.push({ type: "image", source: (element.getAttribute("src") ?? "").trim(), label: element.getAttribute("alt") ?? "" }); + continue; + } + if (tag === "br") { text += "\n"; continue; } + const block = /^(p|div|h[1-6]|blockquote|pre|ul|ol|li|section|article|header|footer|figure|figcaption|table|tr)$/.test(tag); + if (block) { boundary(); stack.push({ node, exit: true, pre }); } + else if (tag === "td" || tag === "th") { if (text && !/[\t\n]$/.test(text)) text += "\t"; } + for (const child of Array.from(node.childNodes).reverse()) stack.push({ node: child, exit: false, pre: pre || tag === "pre" }); + } + flush(); + if (parts.length > 256) throw new RangeError("Clipboard HTML exceeds 256 content parts."); + return parts.length > 0 ? { parts } : null; +} + +/** Embedded raster only. Validates every candidate before allocating bytes; prepares one complete ordered result. */ +export async function readWebHTMLClipboard(content: WebHTMLClipboardContent, options: Parameters[1] & { readonly currentCount?: number }): Promise { + if (options.signal?.aborted) return { ok: false, code: "raster.cancelled" }; + const parts = content.parts.map((part) => ({ ...part })); + const images = parts.filter((part): part is Extract => part.type === "image"); + const candidates: FileCandidate[] = []; + try { + for (const [index, image] of images.entries()) { + assertRasterImageSource(image.source); + const mediaType = image.source.slice(5, image.source.indexOf(";")); + const bytes = image.source.slice(image.source.indexOf(",") + 1); + const size = bytes.length / 4 * 3 - (bytes.endsWith("==") ? 2 : bytes.endsWith("=") ? 1 : 0); + candidates.push({ name: image.label.trim() || `clipboard-image-${index + 1}.${mediaType === "image/jpeg" ? "jpg" : mediaType.slice(6)}`, size, mediaType }); + } + } catch { return { ok: false, code: "raster.source-unsupported", reason: "HTML images require embedded PNG, JPEG, or WebP content." }; } + const accepted = validateFileCandidates(candidates, options.policy, options.currentCount === undefined ? {} : { currentCount: options.currentCount }); + if (!accepted.ok) return accepted; + try { + const files = images.map((image, index) => { + const candidate = candidates[index]!; + const bytes = Uint8Array.from(atob(image.source.slice(image.source.indexOf(",") + 1)), (character) => character.charCodeAt(0)); + return new File([bytes], candidate.name, { type: candidate.mediaType! }); + }); + const prepared = await readWebRasterFiles(files, options); + if (!prepared.ok) return prepared; + let index = 0; + return { ok: true, parts: parts.map((part) => part.type === "text" ? part : { type: "image", ...prepared.files[index++]! }) }; + } catch (error) { return { ok: false, code: "raster.read-failed", reason: error instanceof Error ? error.message : String(error) }; } +} diff --git a/packages/json-document-web/src/html-fragment.ts b/packages/json-document-web/src/html-fragment.ts new file mode 100644 index 000000000..4a6ee0659 --- /dev/null +++ b/packages/json-document-web/src/html-fragment.ts @@ -0,0 +1,17 @@ +/** Read-only structural Node boundary; Web declarations remain consumable without lib.dom. */ +export interface WebHTMLNode { + readonly nodeType: number; + readonly textContent: string | null; + readonly childNodes: ArrayLike; +} +export interface WebHTMLFragment { readonly childNodes: ArrayLike } + +/** Parses clipboard HTML in a template's inert document. Never append the returned nodes to a live document. */ +export function parseWebHTMLFragment(html: string): WebHTMLFragment | null { + if (html.length === 0 || typeof document === "undefined") return null; + const template = document.createElement("template"); + template.innerHTML = html; + // Projection input, not a general-purpose HTML sanitizer or an insertion-ready DOM tree. + for (const element of Array.from(template.content.querySelectorAll("script,style,noscript,iframe,object,embed,template,svg,math,link,meta,base,title"))) element.remove(); + return template.content; +} diff --git a/packages/json-document-web/src/index.ts b/packages/json-document-web/src/index.ts index 15ba7e06e..67effb318 100644 --- a/packages/json-document-web/src/index.ts +++ b/packages/json-document-web/src/index.ts @@ -1,5 +1,7 @@ export { createWebClipboardBinding, + captureWebClipboardPaste, + type WebHTMLClipboardPaste, createWebJSONClipboardRepresentation, createWebClipboardSurface, createWebClipboardTextWriter, @@ -30,6 +32,9 @@ export { createWebViewportPositionPorts } from "./viewport-position.js"; export { createWebAnchoredFloatingPositionPorts } from "./anchored-floating-position.js"; export { projectWebClientPointToSVG, webSVGViewportFromElement } from "./svg-coordinate.js"; export { readWebRasterFile } from "./raster-source.js"; +export { readWebRasterFiles, type WebRasterFileContent, type WebRasterFilesResult } from "./raster-files.js"; +export { parseWebHTMLFragment, type WebHTMLFragment, type WebHTMLNode } from "./html-fragment.js"; +export { parseWebClipboardHTML, readWebHTMLClipboard, type WebHTMLClipboardContent, type WebHTMLClipboardPart, type WebHTMLClipboardResult } from "./html-clipboard.js"; export { composerAttachmentCandidateFromWebFile, composerAttachmentCandidatesFromWebClipboard, composerAttachmentCandidatesFromWebFiles, fileCandidateFromWebFile, fileCandidatesFromWebClipboard, fileCandidatesFromWebFiles } from "./file-intake.js"; export { renderWebAnnotationRaster } from "./annotation-raster.js"; export { registerWebVirtualSelectionScope } from "./virtual-selection-scope.js"; @@ -65,6 +70,7 @@ export type { } from "./anchored-floating-position.js"; export type { WebClipboardBinding, + WebClipboardPaste, WebClipboardBindingOptions, WebClipboardCodec, WebClipboardData, @@ -118,7 +124,7 @@ export type { } from "./viewport-position.js"; export type { WebKanbanTargetElement } from "./kanban-drop-target.js"; export type { WebClientPoint, WebSVGElement, WebSVGViewport } from "./svg-coordinate.js"; -export type { WebRasterFile, WebRasterSourceResult } from "./raster-source.js"; +export type { WebRasterFile, WebRasterReadSignal, WebRasterSourceResult } from "./raster-source.js"; export type { WebComposerClipboardEvent, WebComposerFile, WebComposerFileList, WebFileCandidate, WebFileCandidateList, WebFileClipboardEvent } from "./file-intake.js"; export type { WebAnnotationRasterResult, WebAnnotationRasterStyle } from "./annotation-raster.js"; export type { diff --git a/packages/json-document-web/src/raster-files.ts b/packages/json-document-web/src/raster-files.ts new file mode 100644 index 000000000..58c19fc36 --- /dev/null +++ b/packages/json-document-web/src/raster-files.ts @@ -0,0 +1,44 @@ +import { assertRasterImageContent, validateFileCandidates, type FileAcceptancePolicy, type FileCandidate, type RasterImageContent } from "@interactive-os/json-document-file-intake"; +import { fileCandidatesFromWebFiles, type WebFileCandidate } from "./file-intake.js"; +import { readWebRasterFile, type WebRasterReadSignal } from "./raster-source.js"; + +export interface WebRasterFileContent { + readonly candidate: FileCandidate; + readonly image: RasterImageContent; +} + +export type WebRasterFilesResult = + | { readonly ok: true; readonly files: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; + +/** Validates before reading, decodes sequentially, and returns a complete batch or failure. No document mutation. */ +export async function readWebRasterFiles(files: ReadonlyArray, options: { + readonly policy: FileAcceptancePolicy; + readonly maxImagePixels: number; + readonly signal?: WebRasterReadSignal; + readonly readRaster?: typeof readWebRasterFile; +}): Promise { + if (!Number.isFinite(options.maxImagePixels) || options.maxImagePixels <= 0) throw new TypeError("maxImagePixels must be positive and finite."); + const captured = Array.from(files); + if (options.signal?.aborted) return { ok: false, code: "raster.cancelled" }; + const candidates = fileCandidatesFromWebFiles(captured); + const accepted = validateFileCandidates(candidates, options.policy); + if (!accepted.ok) return accepted; + if (candidates.some((file) => !["image/png", "image/jpeg", "image/webp"].includes(file.mediaType ?? ""))) return { ok: false, code: "raster.unsupported" }; + const prepared: WebRasterFileContent[] = []; + for (let index = 0; index < captured.length; index++) { + if (options.signal?.aborted) return { ok: false, code: "raster.cancelled" }; + try { + const result = await (options.readRaster ?? readWebRasterFile)(captured[index]!, options.signal ? { signal: options.signal } : {}); + if (options.signal?.aborted) return { ok: false, code: "raster.cancelled" }; + if (!result.ok) return result; + const image = { source: result.dataURL, width: result.width, height: result.height }; + assertRasterImageContent(image); + if (image.width * image.height > options.maxImagePixels) return { ok: false, code: "raster.pixel-limit" }; + prepared.push({ candidate: candidates[index]!, image }); + } catch (error) { + return { ok: false, code: "raster.decode-failed", reason: error instanceof Error ? error.message : String(error) }; + } + } + return { ok: true, files: prepared }; +} diff --git a/packages/json-document-web/src/raster-source.ts b/packages/json-document-web/src/raster-source.ts index 66ec00333..28f80e66b 100644 --- a/packages/json-document-web/src/raster-source.ts +++ b/packages/json-document-web/src/raster-source.ts @@ -1,6 +1,6 @@ export 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 }; /** Structural boundary accepted by `FileReader`; browser `File` instances satisfy it. */ export interface WebRasterFile { @@ -8,37 +8,64 @@ export interface WebRasterFile { readonly type: string; } -export async function readWebRasterFile(file: WebRasterFile): Promise { - const dataURL = await readDataURL(file); +/** Structural AbortSignal boundary, usable by DOM-free consumers of Web declarations. */ +export interface WebRasterReadSignal { + readonly aborted: boolean; + addEventListener(type: "abort", listener: () => void, options?: { readonly once?: boolean }): void; + removeEventListener(type: "abort", listener: () => void): void; +} + +export async function readWebRasterFile(file: WebRasterFile, options: { readonly signal?: WebRasterReadSignal } = {}): Promise { + if (options.signal?.aborted) return { ok: false, code: "raster.cancelled" }; + const dataURL = await readDataURL(file, options.signal); if (!dataURL.ok) return dataURL; - return decodeRaster(dataURL.dataURL); + return decodeRaster(dataURL.dataURL, options.signal); } -function readDataURL(file: WebRasterFile): Promise | Extract> { +function readDataURL(file: WebRasterFile, signal?: WebRasterReadSignal): Promise { return new Promise((resolve) => { - const reader = new FileReader(); - reader.onerror = () => resolve({ + let reader: FileReader; + try { reader = new FileReader(); } catch (error) { resolve({ ok: false, code: "raster.read-failed", reason: message(error) }); return; } + function finish(result: WebRasterSourceResult) { + signal?.removeEventListener("abort", abort); + reader.onload = reader.onerror = reader.onabort = null; + resolve(result); + } + function abort() { finish({ ok: false, code: "raster.cancelled" }); reader.abort(); } + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) { abort(); return; } + reader.onabort = () => finish({ ok: false, code: "raster.cancelled" }); + reader.onerror = () => finish({ ok: false, code: "raster.read-failed", ...(reader.error === null ? {} : { reason: reader.error.message }), }); reader.onload = () => typeof reader.result === "string" - ? resolve({ ok: true, dataURL: reader.result, width: 0, height: 0 }) - : resolve({ ok: false, code: "raster.read-failed" }); + ? finish({ ok: true, dataURL: reader.result, width: 0, height: 0 }) + : finish({ ok: false, code: "raster.read-failed" }); try { reader.readAsDataURL(file as unknown as Blob); } catch (error) { - resolve({ ok: false, code: "raster.read-failed", reason: message(error) }); + finish({ ok: false, code: "raster.read-failed", reason: message(error) }); } }); } -function decodeRaster(dataURL: string): Promise { +function decodeRaster(dataURL: string, signal?: WebRasterReadSignal): Promise { return new Promise((resolve) => { - const image = new Image(); - image.onerror = () => resolve({ ok: false, code: "raster.decode-failed" }); + if (signal?.aborted) { resolve({ ok: false, code: "raster.cancelled" }); return; } + let image: HTMLImageElement; + try { image = new Image(); } catch (error) { resolve({ ok: false, code: "raster.decode-failed", reason: message(error) }); return; } + function finish(result: WebRasterSourceResult) { + signal?.removeEventListener("abort", abort); + image.onload = image.onerror = null; + resolve(result); + } + function abort() { finish({ ok: false, code: "raster.cancelled" }); image.src = ""; } + signal?.addEventListener("abort", abort, { once: true }); + image.onerror = () => finish({ ok: false, code: "raster.decode-failed" }); image.onload = () => image.naturalWidth > 0 && image.naturalHeight > 0 - ? resolve({ ok: true, dataURL, width: image.naturalWidth, height: image.naturalHeight }) - : resolve({ ok: false, code: "raster.decode-failed" }); - image.src = dataURL; + ? finish({ ok: true, dataURL, width: image.naturalWidth, height: image.naturalHeight }) + : finish({ ok: false, code: "raster.decode-failed" }); + try { image.src = dataURL; } catch (error) { finish({ ok: false, code: "raster.decode-failed", reason: message(error) }); } }); } diff --git a/packages/json-document-web/tests/clipboard-paste.test.ts b/packages/json-document-web/tests/clipboard-paste.test.ts new file mode 100644 index 000000000..99dbc522c --- /dev/null +++ b/packages/json-document-web/tests/clipboard-paste.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { captureWebClipboardPaste, objectClipboardCodec, readWebRasterFile, type WebClipboardEvent } from "../src/index.js"; + +const payload = { type: objectClipboardCodec.mimeType, text: "Object", objects: [{ id: "a", x: 0, y: 0, width: 1, height: 1, label: "Object", color: "blue" }] }; +const file = { name: "picture.png", type: "image/png", size: 100 }; +const options = { codec: objectClipboardCodec, files: true, text: true }; +function event(values: Record = {}, files = [] as typeof file[]) { + return { clipboardData: { types: Object.keys(values), files, getData: (type: string) => values[type] ?? "", setData() {} }, preventDefault: vi.fn() }; +} +afterEach(() => vi.unstubAllGlobals()); + +test("captures a portable snapshot in strict structured → files → text order before returning from the event", () => { + const structured = event({ [payload.type]: JSON.stringify(payload), "text/plain": "fallback" }, [file]); + expect(captureWebClipboardPaste(structured, options)).toEqual({ ok: true, type: "structured", payload }); + expect(structured.preventDefault).toHaveBeenCalledOnce(); + const withFiles = event({ "text/plain": "fallback" }, [file]); + const captured = captureWebClipboardPaste(withFiles, options); + withFiles.clipboardData.files.length = 0; + expect(captured).toEqual({ ok: true, type: "files", files: [file] }); + const plain = event({ "text/plain": "안녕\nSecond" }); + expect(captureWebClipboardPaste(plain, options)).toEqual({ ok: true, type: "text", text: "안녕\nSecond" }); + expect(plain.preventDefault).toHaveBeenCalledOnce(); +}); + +test("invalid owned MIME never becomes files or text, while unmatched data remains native", () => { + for (const serialized of ["{", "{}", ""]) { + const owned = event({ [payload.type]: serialized, "text/plain": "fallback" }, [file]); + expect(captureWebClipboardPaste(owned, options)).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(owned.preventDefault).toHaveBeenCalledOnce(); + } + const html = event({ "text/html": "

Unknown

" }); + expect(captureWebClipboardPaste(html, options)).toMatchObject({ ok: false, code: "clipboard.empty" }); + expect(html.preventDefault).not.toHaveBeenCalled(); + const plain = event({ "text/plain": "text" }); + expect(captureWebClipboardPaste(plain, { codec: objectClipboardCodec })).toMatchObject({ ok: false, code: "clipboard.empty" }); + expect(plain.preventDefault).not.toHaveBeenCalled(); +}); + +test("unavailable and throwing clipboard reads are observable without exceptions", () => { + expect(captureWebClipboardPaste({ clipboardData: null, preventDefault() {} }, options)).toMatchObject({ ok: false, code: "clipboard.unavailable" }); + const broken: WebClipboardEvent = { ...event({ "text/plain": "x" }), clipboardData: { types: ["text/plain"], setData() {}, getData() { throw new Error("blocked"); } } }; + expect(captureWebClipboardPaste(broken, options)).toMatchObject({ ok: false, code: "clipboard.unavailable", reason: "blocked" }); +}); + +test.each(["read", "decode"])("raster cancellation during %s releases listeners and settles without late success", async (phase) => { + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + const abort = vi.fn(); + let lateRead: (() => void) | null = null, lateDecode: (() => void) | null = null; + class Reader { + result = "data:image/png;base64,AQID"; error = null; onload: (() => void) | null = null; onerror: (() => void) | null = null; onabort: (() => void) | null = null; + readAsDataURL() { lateRead = this.onload; if (phase === "decode") this.onload?.(); } + abort = abort; + } + class Raster { + naturalWidth = 100; naturalHeight = 100; onload: (() => void) | null = null; onerror: (() => void) | null = null; + set src(value: string) { if (value) lateDecode = this.onload; } + } + vi.stubGlobal("FileReader", Reader); vi.stubGlobal("Image", Raster); + const pending = readWebRasterFile(file, { signal: controller.signal }); + await Promise.resolve(); controller.abort(); + expect(await pending).toEqual({ ok: false, code: "raster.cancelled" }); + expect(remove).toHaveBeenCalled(); + if (phase === "read") expect(abort).toHaveBeenCalledOnce(); + (lateRead as (() => void) | null)?.(); (lateDecode as (() => void) | null)?.(); + expect(await readWebRasterFile(file, { signal: controller.signal })).toEqual({ ok: false, code: "raster.cancelled" }); +}); diff --git a/packages/json-document-web/tests/clipboard-rejection.test.ts b/packages/json-document-web/tests/clipboard-rejection.test.ts index 754224bed..24e150d3d 100644 --- a/packages/json-document-web/tests/clipboard-rejection.test.ts +++ b/packages/json-document-web/tests/clipboard-rejection.test.ts @@ -39,16 +39,18 @@ describe("clipboard event ownership", () => { const published: unknown[] = []; const release = editor.subscribe((snapshot) => published.push(snapshot)); const written = new Map(); + const preventDefault = vi.fn(); try { const result = binding.cut({ clipboardData: { types: [], getData: () => "", setData(format, data) { if (format === (failedWrite === "structured" ? payload.type : "text/plain")) throw new Error("Clipboard write refused"); written.set(format, data); } }, - preventDefault() {}, + preventDefault, }); expect(result).toMatchObject({ ok: false, code: "clipboard.unavailable" }); expect(remove).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledOnce(); expect(document.value).toEqual(before.value); expect(editor.snapshot).toMatchObject(before); expect(published).toEqual([]); diff --git a/packages/json-document-web/tests/html-clipboard.test.ts b/packages/json-document-web/tests/html-clipboard.test.ts new file mode 100644 index 000000000..609f71786 --- /dev/null +++ b/packages/json-document-web/tests/html-clipboard.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { expect, expectTypeOf, test, vi } from "vitest"; +import { captureWebClipboardPaste, objectClipboardCodec, parseWebClipboardHTML, parseWebHTMLFragment, readWebHTMLClipboard, type readWebRasterFile, type WebClipboardPaste, type WebClipboardPayload, type WebRasterSourceResult } from "../src/index.js"; + +const source = "data:image/png;base64,AQID"; +const image = { ok: true as const, dataURL: source, width: 100, height: 50 }; +const options = { policy: { acceptedMediaTypes: ["image/*"], maxFiles: 4, maxBytesPerFile: 100 }, maxImagePixels: 10_000 }; +const markup = `

문장 & 글사진
문장

`; +const event = (html = markup, files: File[] = []) => ({ clipboardData: { types: ["text/html", "text/plain"], files, getData: (type: string) => type === "text/html" ? html : "Fallback", setData() {} }, preventDefault: vi.fn() }); + +test("HTML parts preserve text/image order, entities, and line boundaries", () => { + expect(parseWebClipboardHTML(markup)).toEqual({ parts: [ + { type: "text", text: "앞 문장 & 글" }, { type: "image", source, label: "사진" }, { type: "text", text: "뒤\n문장" }, + ] }); + expect(parseWebClipboardHTML('
One
Two
a\n  b
')?.parts).toEqual([{ type: "text", text: "One\nTwo\na\n b" }]); + expect(parseWebClipboardHTML('
AB
CD
')?.parts).toEqual([{ type: "text", text: "A\tB\nC\tD" }]); + expect(parseWebClipboardHTML(``)?.parts).toHaveLength(2); +}); + +test("fragment parsing stays in the template document and excludes active/foreign content", () => { + const fragment = parseWebHTMLFragment('foreign

Kept

')!; + const nodes = Array.from(fragment.childNodes) as Node[]; + expect(nodes.map((node) => node.nodeName)).toEqual(["P", "IMG"]); + expect(nodes.every((node) => !node.isConnected && node.ownerDocument !== document)).toBe(true); + expect(parseWebClipboardHTML('

Kept

')?.parts).toEqual([{ type: "text", text: "Kept" }]); +}); + +test("HTML image capture is opt-in, synchronous, and does not claim text-only HTML", () => { + const legacy = captureWebClipboardPaste(event(), { files: true, text: true }); + expectTypeOf(legacy).toEqualTypeOf>(); + expect(legacy).toMatchObject({ type: "text", text: "Fallback" }); + const mixed = event(); + expect(captureWebClipboardPaste(mixed, { html: "images" })).toMatchObject({ type: "html", content: { parts: [{ type: "text" }, { type: "image" }, { type: "text" }] } }); + expect(mixed.preventDefault).toHaveBeenCalledOnce(); + const plain = event("

Formatted text

"); + expect(captureWebClipboardPaste(plain, { files: true, html: "images" })).toMatchObject({ ok: false }); + expect(plain.preventDefault).not.toHaveBeenCalled(); +}); + +test("structured and native files retain priority; equivalent HTML is never inserted twice", () => { + const withFiles = event('', [new File(["image"], "native.png", { type: "image/png" })]); + expect(captureWebClipboardPaste(withFiles, { files: true, html: "images", text: true })).toMatchObject({ type: "files" }); + const invalid = event(); invalid.clipboardData.types.unshift(objectClipboardCodec.mimeType); + invalid.clipboardData.getData = () => "{"; + expect(captureWebClipboardPaste(invalid, { codec: objectClipboardCodec, files: true, html: "images", text: true })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(invalid.preventDefault).toHaveBeenCalledOnce(); +}); + +test("a delegated structured format stays with its existing editor binding", () => { + const delegated = event(); delegated.clipboardData.types.unshift("application/editor+json"); + expect(captureWebClipboardPaste(delegated, { files: true, html: "images", delegatedMimeTypes: ["application/editor+json"] })).toMatchObject({ ok: false, code: "clipboard.empty" }); + expect(delegated.preventDefault).not.toHaveBeenCalled(); +}); + +test.each(["https://example.invalid/image", "/image.png", "blob:https://example.invalid/id", "cid:image", "javascript:alert(1)", "data:image/svg+xml;base64,AQID", "data:image/png;base64,broken", ""])("unavailable HTML source %s fails without reading or fallback", async (src) => { + const readRaster = vi.fn(); + expect(await readWebHTMLClipboard(parseWebClipboardHTML(`

TextAfter

`)!, { ...options, readRaster })).toMatchObject({ ok: false, code: "raster.source-unsupported" }); + expect(readRaster).not.toHaveBeenCalled(); +}); + +test("HTML image preparation owns candidate bytes and preserves every ordered part", async () => { + const readRaster = vi.fn().mockResolvedValue(image); + const result = await readWebHTMLClipboard(parseWebClipboardHTML(markup)!, { ...options, readRaster }); + expect(result).toEqual({ ok: true, parts: [ + { type: "text", text: "앞 문장 & 글" }, + { type: "image", candidate: { name: "사진", size: 3, mediaType: "image/png" }, image: { source, width: 100, height: 50 } }, + { type: "text", text: "뒤\n문장" }, + ] }); + expect(readRaster.mock.calls[0]?.[0]).toBeInstanceOf(File); +}); + +test("policy rejects before byte allocation, including the current attachment count", async () => { + const atob = vi.spyOn(globalThis, "atob"); + const readRaster = vi.fn(); + try { + expect(await readWebHTMLClipboard(parseWebClipboardHTML(markup)!, { ...options, currentCount: 4, readRaster })).toMatchObject({ ok: false, code: "file-intake.limit" }); + expect(await readWebHTMLClipboard(parseWebClipboardHTML(markup)!, { ...options, policy: { ...options.policy, maxBytesPerFile: 2 }, readRaster })).toMatchObject({ ok: false, code: "file-intake.size" }); + expect(atob).not.toHaveBeenCalled(); expect(readRaster).not.toHaveBeenCalled(); + } finally { atob.mockRestore(); } +}); + +test("a later decode failure returns neither prepared images nor partial text", async () => { + const readRaster = vi.fn().mockResolvedValueOnce(image).mockResolvedValueOnce({ ok: false, code: "raster.decode-failed" }); + expect(await readWebHTMLClipboard(parseWebClipboardHTML(`${markup}`)!, { ...options, readRaster })).toEqual({ ok: false, code: "raster.decode-failed" }); +}); + +test("cancellation ignores a late decode and prevents later HTML image reads", async () => { + let resolve!: (value: WebRasterSourceResult) => void; + const readRaster = vi.fn(() => new Promise((done) => { resolve = done; })); + const controller = new AbortController(); + const pending = readWebHTMLClipboard(parseWebClipboardHTML(`${markup}`)!, { ...options, readRaster, signal: controller.signal }); + controller.abort(); resolve(image); + expect(await pending).toMatchObject({ ok: false, code: "raster.cancelled" }); expect(readRaster).toHaveBeenCalledOnce(); +}); + +test("oversized HTML fails as an owned input rather than falling back to partial text", () => { + const oversized = event(``.repeat(257)); + expect(captureWebClipboardPaste(oversized, { html: "images", text: true })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(oversized.preventDefault).toHaveBeenCalledOnce(); + expect(() => parseWebClipboardHTML(" ".repeat(16 * 1024 * 1024 + 1))).toThrow(RangeError); + expect(() => parseWebClipboardHTML("
".repeat(10_001))).toThrow(RangeError); +}); diff --git a/packages/json-document-web/tests/raster-files.test.ts b/packages/json-document-web/tests/raster-files.test.ts new file mode 100644 index 000000000..8c2d83dd5 --- /dev/null +++ b/packages/json-document-web/tests/raster-files.test.ts @@ -0,0 +1,52 @@ +import { expect, test, vi } from "vitest"; +import { captureWebClipboardPaste, readWebRasterFiles, type readWebRasterFile, type WebRasterSourceResult } from "../src/index.js"; + +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 policy = { acceptedMediaTypes: ["image/*"], maxFiles: 4, maxBytesPerFile: 100 }; +const options = { policy, maxImagePixels: 10_000 }; + +test("PI-FILE: sequential decoding yields portable image content in file order", async () => { + let first!: (value: WebRasterSourceResult) => void; + const readRaster = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { first = resolve; })).mockResolvedValue(image); + const pending = readWebRasterFiles([file, { ...file, name: "second.png" }], { ...options, readRaster }); + expect(readRaster).toHaveBeenCalledOnce(); + first(image); + expect(await pending).toEqual({ ok: true, files: [file.name, "second.png"].map((name) => ({ candidate: { name, size: 3, mediaType: "image/png" }, image: { source: image.dataURL, width: 100, height: 50 } })) }); + expect(readRaster).toHaveBeenCalledTimes(2); +}); + +test.each([ + { input: { ...file, size: 101 }, code: "file-intake.size" }, + { input: { ...file, type: "image/svg+xml" }, code: "raster.unsupported" }, + { input: { ...file, name: "" }, code: "file-intake.invalid" }, +])("PI-FILE: $code is rejected before reading", async ({ input, code }) => { + const readRaster = vi.fn(); + expect(await readWebRasterFiles([input], { ...options, readRaster })).toMatchObject({ ok: false, code }); + expect(readRaster).not.toHaveBeenCalled(); +}); + +test.each([ + { result: { ...image, height: 200 }, code: "raster.pixel-limit" }, + { result: { ...image, dataURL: "https://example.com/image.png" }, code: "raster.decode-failed" }, + { result: { ...image, width: NaN }, code: "raster.decode-failed" }, +])("PI-FILE: $code returns no partial batch", async ({ result, code }) => { + const readRaster = vi.fn().mockResolvedValueOnce(image).mockResolvedValueOnce(result); + expect(await readWebRasterFiles([file, file], { ...options, readRaster })).toMatchObject({ ok: false, code }); +}); + +test("PI-CANCEL: cancellation between decoded files prevents subsequent reads", async () => { + const controller = new AbortController(); + const readRaster = vi.fn(async () => { controller.abort(); return image; }); + expect(await readWebRasterFiles([file, file], { ...options, readRaster, signal: controller.signal })).toMatchObject({ code: "raster.cancelled" }); + expect(readRaster).toHaveBeenCalledOnce(); +}); + +test("file-only Clipboard consumers capture native files without borrowing an Object codec", () => { + const files = [file], preventDefault = vi.fn(); + const event = { clipboardData: { files, types: ["Files", "text/html"], getData: () => "", setData() {} }, preventDefault }; + const captured = captureWebClipboardPaste(event, { files: true }); + files.length = 0; + expect(captured).toEqual({ ok: true, type: "files", files: [file] }); expect(preventDefault).toHaveBeenCalledOnce(); + expect(captureWebClipboardPaste({ ...event, clipboardData: { ...event.clipboardData, types: ["text/plain"] } }, { files: true })).toMatchObject({ ok: false, code: "clipboard.empty" }); +}); diff --git a/packages/json-document-web/tests/web-adapters.test.ts b/packages/json-document-web/tests/web-adapters.test.ts index 3b93bd467..b2a0cbc63 100644 --- a/packages/json-document-web/tests/web-adapters.test.ts +++ b/packages/json-document-web/tests/web-adapters.test.ts @@ -397,7 +397,7 @@ describe("Web clipboard Adapter", () => { const unavailable = event(null); expect(binding.cut(unavailable)).toMatchObject({ ok: false, code: "clipboard.unavailable" }); - expect(unavailable.defaultPrevented).toBe(false); + expect(unavailable.defaultPrevented).toBe(true); expect(cutAttempts).toBe(0); expect((editor.snapshot.value as BlockDocument).blocks.map((block) => block.id)).toEqual(["a", "b"]); @@ -646,6 +646,24 @@ describe("Web keyboard Adapter", () => { expect(adapter.resolve({ key: "c", shiftKey: false, metaKey: true, ctrlKey: false })).toBeNull(); }); + test("preserves modifiers while allowing explicit product chord assignments", () => { + const product = createWebKeyboardAdapter({ + defaults: false, + keymap: { "Mod-Alt-z": { type: "undo" }, "Mod-Backspace": { type: "delete" } }, + }); + for (const modifiers of [{ metaKey: true, ctrlKey: false }, { metaKey: false, ctrlKey: true }]) { + const undo = { key: "Z", shiftKey: false, altKey: true, ...modifiers }; + const remove = { key: "Backspace", shiftKey: false, ...modifiers }; + expect(adapter.resolve(undo)).toBeNull(); + expect(adapter.resolve(remove)).toBeNull(); + expect(product.resolve(undo)).toEqual({ type: "undo" }); + expect(product.resolve(remove)).toEqual({ type: "delete" }); + expect(product.resolve({ ...undo, altKey: false })).toBeNull(); + expect(adapter.resolve({ ...undo, altKey: false })).toEqual({ type: "undo" }); + expect(adapter.resolve({ ...undo, altKey: false, shiftKey: true })).toEqual({ type: "redo" }); + } + }); + test("lets the host replace chords without inventing editing commands", () => { const custom = createWebKeyboardAdapter({ keymap: { ...defaultWebKeymap, Enter: { type: "toggle" } }, diff --git a/packages/json-document/README.md b/packages/json-document/README.md index d4247e27b..d5f1962d0 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -200,15 +200,22 @@ const body = JSON.stringify(operations); body satisfies string; ``` -## Connector와 host 경계 +## 생태계와 Host 경계 Form, data-grid, outliner, rich text, persistence/collaboration extension은 여섯 -member `JSONDocument`를 포트로 받는 것이 권장됩니다. DOM focus, geometry, -keyboard, system clipboard, filesystem, network, formula, CRDT와 OT는 host가 -소유합니다. +member `JSONDocument`를 포트로 받습니다. 문서 고유 모델·의미 연산·Projection은 +Document Type, 선택·작업·History는 Editing, 플랫폼 입력과 DOM lifecycle은 +Adapter의 책임입니다. 이 기능을 Core나 Host에 재구현하지 않습니다. React, Zod와 TanStack Table 같은 외부 생태계의 반복되는 integration은 Root가 아니라 `@interactive-os/json-document-` 공식 Connector가 제공합니다. +Host는 제품 정책 값·copy·fixture·layout, 정본 모듈의 조합·실행 순서와 +구체 외부 인스턴스 주입을 소유합니다. -- GitHub Wiki: https://github.com/developer-1px/json-document/wiki -- Extension guide: https://github.com/developer-1px/json-document/wiki/Labs-and-Extensions +현재 package 배치와 목표 책임의 수렴은 구별합니다. Document Type 후보와 +Official Hands Profile의 전체 완료는 아직 TBD이며 Core v3의 Stable 계약을 +확장하지 않습니다. + +- [Concept Map](../../docs/public/concepts.md) +- [Building Blocks](../../docs/public/building-blocks.md) +- [Document Types · TBD](../../docs/public/document-types.md) diff --git a/scripts/generate-api-reference.mjs b/scripts/generate-api-reference.mjs index d3595a2d0..b91ab3a73 100644 --- a/scripts/generate-api-reference.mjs +++ b/scripts/generate-api-reference.mjs @@ -2,14 +2,15 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; -import { apiReferencePackages } from "../docs/api-reference/packages.mjs"; +import { apiReferenceCoverageErrors, apiReferencePackages } from "../docs/api-reference/packages.mjs"; const root = dirname(dirname(fileURLToPath(import.meta.url))); const check = process.argv.includes("--check"); const configPath = join(root, "tsconfig.build.json"); const parsed = ts.parseJsonConfigFileContent(ts.readConfigFile(configPath, ts.sys.readFile).config, ts.sys, root); const entrypoints = apiReferencePackages.flatMap(({ entrypoint, subpaths }) => [entrypoint, ...subpaths.map((subpath) => subpath.entrypoint)]).map((entrypoint) => join(root, entrypoint)); -const sourcePaths = Object.fromEntries(apiReferencePackages.map(({ packageName, entrypoint }) => [packageName, [entrypoint]])); +const sourcePaths = Object.fromEntries(apiReferencePackages.flatMap((descriptor) => + [descriptor, ...descriptor.subpaths].map(({ packageName, entrypoint }) => [packageName, [entrypoint]]))); const program = ts.createProgram([...new Set([...parsed.fileNames, ...entrypoints])], { ...parsed.options, baseUrl: root, @@ -20,7 +21,9 @@ const program = ts.createProgram([...new Set([...parsed.fileNames, ...entrypoint noEmit: true, }); const checker = program.getTypeChecker(); -const failures = []; +const manifests = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).workspaces + .map((workspace) => JSON.parse(readFileSync(join(root, workspace, "package.json"), "utf8"))); +const failures = apiReferenceCoverageErrors(manifests); let exportCount = 0; const siteRoutes = JSON.parse(readFileSync(join(root, "site/site-routes.json"), "utf8")); @@ -60,8 +63,11 @@ function clean(signature) { for (const descriptor of apiReferencePackages) { const referencePath = `/docs/api/${descriptor.slug}`; - const ownerRoutes = siteRoutes.filter((route) => route.path === referencePath && route.navigationGroup === descriptor.owner); - if (ownerRoutes.length !== 1) failures.push(`${descriptor.packageName} owner route`); + const referenceRoutes = siteRoutes.filter((route) => + route.path === referencePath + && route.navigationGroup === descriptor.navigationGroup + && route.documentSource === `docs/api-reference/${descriptor.slug}.md`); + if (referenceRoutes.length !== 1) failures.push(`${descriptor.packageName} owner reference route`); const entry = program.getSourceFile(join(root, descriptor.entrypoint)); if (!entry) throw new Error(`public entrypoint를 찾을 수 없습니다: ${descriptor.entrypoint}`); const moduleSymbol = checker.getSymbolAtLocation(entry); @@ -89,9 +95,9 @@ for (const descriptor of apiReferencePackages) { const output = [ `# ${descriptor.packageName} API`, "", - `**Owner:** ${descriptor.owner}`, + `**탐색 분류:** ${descriptor.navigationGroup}`, "", - `${descriptor.responsibility}의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다.`, + `${descriptor.responsibility}의 public entrypoint입니다. API의 owner는 이 package이며 탐색 분류는 사이트에서 읽는 위치입니다. 별도 subpath 표시가 없는 항목은 package root에서 import합니다. internal 경로는 계약이 아닙니다.`, "", `> 이 문서는 \`${descriptor.entrypoint}\`에서 생성됩니다. API를 변경한 뒤 \`npm run docs:api\`를 실행하세요.`, "", diff --git a/scripts/verify-external-kit.mjs b/scripts/verify-external-kit.mjs index 35f54e279..fd0809324 100644 --- a/scripts/verify-external-kit.mjs +++ b/scripts/verify-external-kit.mjs @@ -12,6 +12,7 @@ import { readJson, repositoryRoot } from "./workspace-graph.mjs"; const kitWorkspaces = [ "@interactive-os/json-document", "@interactive-os/json-document-selection", + "@interactive-os/json-document-calendar-document", "@interactive-os/json-document-editing", "@interactive-os/json-document-rich-text", "@interactive-os/json-document-file-intake", diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index 12c5aa013..c4d61b6ce 100644 --- a/site/config/json-document-source-aliases.ts +++ b/site/config/json-document-source-aliases.ts @@ -7,6 +7,12 @@ export interface SourceAlias { export function jsonDocumentSourceAliases(): SourceAlias[] { return [ + { find: "@interactive-os/json-document-object-document", replacement: sourceFile("packages/json-document-object-document/src/index.ts") }, + { find: "@interactive-os/json-document-canvas", replacement: sourceFile("packages/json-document-canvas/src/index.ts") }, + { + find: "@interactive-os/json-document-calendar-document", + replacement: sourceFile("packages/json-document-calendar-document/src/index.ts"), + }, { find: "@interactive-os/json-document-a2ui", replacement: sourceFile("packages/json-document-a2ui/src/index.ts"), diff --git a/site/package.json b/site/package.json index 88ec1dd0e..1bf74dd83 100644 --- a/site/package.json +++ b/site/package.json @@ -49,6 +49,8 @@ "typecheck": "npm run check:ui && npm run check:icons && npm run check:primitives && npm run check:ui-roles && npm run check:choice-id && npm run check:canonical-modules && npm run check:interaction-handles && npm run check:contextual-affordance && npm run check:content-interaction && npm run check:product-shell-toolbar && npm run check:calendar-temporal && npm run check:calendar-keyboard && npm run check:calendar-rename && npm run check:calendar-viewport && npm run check:calendar-pointer-coordinate && npm run check:calendar-selection-drag && npm run check:anchored-floating-position && npm run check:calendar-year-months && npm run check:calendar-month-weeks && npm run check:calendar-period-label && npm run check:calendar-visible-date-shift && npm run check:calendar-time-label && npm run check:calendar-event-label && npm run check:calendar-interval-last-date && npm run check:calendar-date-part && npm run check:calendar-period-cells && npm run check:calendar-allday-span && npm run check:calendar-view-parser && npm run check:calendar-instant-format && npm run check:calendar-cell-interval && npm run check:calendar-date-grid && npm run check:calendar-month-grid && npm run check:calendar-time-grid && npm run check:calendar-event-inspector && npm run check:calendar-recurrence-transition && npm run check:calendar-document-calendars && npm run check:tokens && tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@interactive-os/json-document-object-document": "*", + "@interactive-os/json-document-canvas": "*", "@interactive-os/json-document-annotation": "*", "@a2ui/web_core": "^0.10.6", "@ag-ui/core": "^0.0.59", @@ -58,6 +60,7 @@ "@interactive-os/json-document-a2ui": "*", "@interactive-os/json-document-animation-react": "*", "@interactive-os/json-document-calendar": "*", + "@interactive-os/json-document-calendar-document": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-composer": "*", "@interactive-os/json-document-composer-react": "*", diff --git a/site/scripts/check-calendar-allday-span.mjs b/site/scripts/check-calendar-allday-span.mjs index 7a40663ae..58ceadc83 100644 --- a/site/scripts/check-calendar-allday-span.mjs +++ b/site/scripts/check-calendar-allday-span.mjs @@ -2,10 +2,11 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(new URL("../..", import.meta.url).pathname); -const owner = read("packages/json-document-editing/src/calendar-validation.ts"); -const ownerIndex = read("packages/json-document-editing/src/index.ts"); +const owner = read("packages/json-document-calendar-document/src/calendar-validation.ts"); +const ownerIndex = read("packages/json-document-calendar-document/src/index.ts"); const ownerTest = read("packages/json-document-editing/tests/calendar-validation.test.ts"); const editor = read("packages/json-document-editing/src/calendar.ts"); +const eventPlan = read("packages/json-document-calendar-document/src/calendar-operation.ts"); const allDayPointer = read("packages/json-document-editing/src/calendar-allday-pointer.ts"); const monthPointer = read("packages/json-document-editing/src/calendar-month-pointer.ts"); const host = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); @@ -16,17 +17,18 @@ const usage = read("site/src/shared/demo-workbench/demo-sources.ts"); requireText(owner, "export function calendarAllDaySpan"); requireText(ownerIndex, "calendarAllDaySpan"); requireText(ownerTest, 'calendarAllDaySpan("2026-05-27", "2026-05-25")'); -requireText(editor, "calendarAllDaySpan(start, start)?.end"); +requireText(editor, "planCalendarEventEdit(current.events, intent"); +requireText(eventPlan, "calendarAllDaySpan(start, start)?.end"); requireCount(allDayPointer, "calendarAllDaySpan(", 2); requireCount(monthPointer, "calendarAllDaySpan(", 1); requireCount(host, "calendarAllDaySpan(", 0); requireCount(inspector, "calendarAllDaySpan(", 1); requireCount(timeGrid, "calendarAllDaySpan(", 1); forbid(allDayPointer, /addCalendarDate\(release\.targetDay, 1\)/); -forbid(editor, /addCalendarDate\(start, 1\)/); +forbid(eventPlan, /addCalendarDate\(start, 1\)/); forbid(host, /addCalendarDate\((?:day|value), 1\)/); requireText(usage, 'symbol: "calendarAllDaySpan"'); -requireText(usage, 'sourcePath: "packages/json-document-editing/src/calendar-validation.ts"'); +requireText(usage, 'sourcePath: "packages/json-document-calendar-document/src/calendar-validation.ts"'); console.log("Calendar all-day span guard ok; owner, pointer consumers, editor, Host, CalendarTimeGrid, and Usage checked."); diff --git a/site/scripts/check-calendar-cell-interval.mjs b/site/scripts/check-calendar-cell-interval.mjs index 9fae381f2..c331265f8 100644 --- a/site/scripts/check-calendar-cell-interval.mjs +++ b/site/scripts/check-calendar-cell-interval.mjs @@ -18,7 +18,7 @@ requireText(navigator, "calendarCellInterval(cells)"); forbid(calendar, /addCalendarDate\(yearStart,\s*-7\)/); forbid(calendar, /addCalendarDate\(yearEnd,\s*14\)/); forbid(navigator, /cells\[0\]\?\.date|cells\.at\(-1\)\?\.date/); -requireText(usage, "UI Primitives `calendarCellInterval`"); +requireText(usage, "`calendarCellInterval`"); requireText(sources, 'symbol: "calendarCellInterval"'); requireText(sources, 'sourcePath: "packages/json-document-calendar/src/date-values.ts"'); diff --git a/site/scripts/check-calendar-date-part.mjs b/site/scripts/check-calendar-date-part.mjs index 627ac576d..17be5986b 100644 --- a/site/scripts/check-calendar-date-part.mjs +++ b/site/scripts/check-calendar-date-part.mjs @@ -2,8 +2,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(new URL("../..", import.meta.url).pathname); -const owner = read("packages/json-document-editing/src/calendar-validation.ts"); -const ownerIndex = read("packages/json-document-editing/src/index.ts"); +const owner = read("packages/json-document-calendar-document/src/calendar-validation.ts"); +const ownerIndex = read("packages/json-document-calendar-document/src/index.ts"); const ownerTest = read("packages/json-document-editing/tests/calendar-validation.test.ts"); const host = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); const inspector = read("packages/json-document-calendar/src/calendar-event-inspector.tsx"); @@ -16,9 +16,9 @@ requireCount(host, "calendarDatePart(", 2); requireCount(inspector, "calendarDatePart(", 1); forbid(host, /\.slice\(0,\s*10\)/); requireText(usage, 'symbol: "calendarDatePart"'); -requireText(usage, 'sourcePath: "packages/json-document-editing/src/calendar-validation.ts"'); +requireText(usage, 'sourcePath: "packages/json-document-calendar-document/src/calendar-validation.ts"'); -console.log("Calendar date-part guard ok; Editing owner, public export, contract test, Usage, and three Host consumers checked."); +console.log("Calendar date-part guard ok; Document Type owner, public export, contract test, Usage, and three Host consumers checked."); function read(path) { return readFileSync(resolve(root, path), "utf8"); diff --git a/site/scripts/check-calendar-document-calendars.mjs b/site/scripts/check-calendar-document-calendars.mjs index a19f99446..49f8de6bf 100644 --- a/site/scripts/check-calendar-document-calendars.mjs +++ b/site/scripts/check-calendar-document-calendars.mjs @@ -2,8 +2,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(new URL("../..", import.meta.url).pathname); -const owner = read("packages/json-document-editing/src/calendar-validation.ts"); -const ownerIndex = read("packages/json-document-editing/src/index.ts"); +const owner = read("packages/json-document-calendar-document/src/calendar-validation.ts"); +const ownerIndex = read("packages/json-document-calendar-document/src/index.ts"); const ownerTest = read("packages/json-document-editing/tests/calendar-validation.test.ts"); const host = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); const inspector = read("packages/json-document-calendar/src/calendar-event-inspector.tsx"); @@ -21,7 +21,7 @@ for (const symbol of ["calendarDocumentCalendars", "calendarDocumentCalendar"]) forbid(host, /document\.calendars\s*\?\?\s*\[\]/); forbid(host, /document\.calendars\.find/); -console.log("Calendar document calendars guard ok; Editing owner/export/tests, Host consumers, Usage, and source registration checked."); +console.log("Calendar document calendars guard ok; Document Type owner/export/tests, Host consumers, Usage, and source registration checked."); function read(path) { return readFileSync(resolve(root, path), "utf8"); diff --git a/site/scripts/check-calendar-instant-format.mjs b/site/scripts/check-calendar-instant-format.mjs index 2ac892c36..21c1dff9a 100644 --- a/site/scripts/check-calendar-instant-format.mjs +++ b/site/scripts/check-calendar-instant-format.mjs @@ -2,8 +2,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(new URL("../..", import.meta.url).pathname); -const owner = read("packages/json-document-editing/src/calendar-validation.ts"); -const ownerIndex = read("packages/json-document-editing/src/index.ts"); +const owner = read("packages/json-document-calendar-document/src/calendar-validation.ts"); +const ownerIndex = read("packages/json-document-calendar-document/src/index.ts"); const ownerTest = read("packages/json-document-editing/tests/calendar-validation.test.ts"); const host = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); const usage = read("docs/public/hands.md"); @@ -15,11 +15,11 @@ requireText(ownerTest, 'formatCalendarInstant(Temporal.PlainDateTime.from("2026- requireText(host, "formatCalendarInstant(Temporal.Now.plainDateTimeISO())"); forbid(host, /function clockNow/); forbid(host, /\.toString\(\{ smallestUnit: "minute" \}\)/); -requireText(usage, "Editing `formatCalendarInstant`"); +requireText(usage, "@interactive-os/json-document-calendar-document"); requireText(sources, 'symbol: "formatCalendarInstant"'); -requireText(sources, 'sourcePath: "packages/json-document-editing/src/calendar-validation.ts"'); +requireText(sources, 'sourcePath: "packages/json-document-calendar-document/src/calendar-validation.ts"'); -console.log("Calendar instant format guard ok; Editing owner/export/test, Host clock composition, Usage, and source registration checked."); +console.log("Calendar instant format guard ok; Document Type owner/export/test, Host clock composition, Usage, and source registration checked."); function read(path) { return readFileSync(resolve(root, path), "utf8"); diff --git a/site/scripts/check-calendar-interval-last-date.mjs b/site/scripts/check-calendar-interval-last-date.mjs index a03bb1dbe..97809d3ac 100644 --- a/site/scripts/check-calendar-interval-last-date.mjs +++ b/site/scripts/check-calendar-interval-last-date.mjs @@ -2,9 +2,9 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(new URL("../..", import.meta.url).pathname); -const owner = read("packages/json-document-editing/src/calendar-validation.ts"); -const editingConsumer = read("packages/json-document-editing/src/calendar.ts"); -const ownerIndex = read("packages/json-document-editing/src/index.ts"); +const owner = read("packages/json-document-calendar-document/src/calendar-validation.ts"); +const projectionConsumer = read("packages/json-document-calendar-document/src/calendar-projection.ts"); +const ownerIndex = read("packages/json-document-calendar-document/src/index.ts"); const monthGrid = read("packages/json-document-calendar/src/calendar-month-grid.tsx"); const timeGrid = read("packages/json-document-calendar/src/calendar-time-grid.tsx"); const inspector = read("packages/json-document-calendar/src/calendar-event-inspector.tsx"); @@ -12,7 +12,7 @@ const host = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); requireText(owner, "calendarIntervalLastDate"); requireText(owner, "endInstant.hour === 0"); -requireText(editingConsumer, "calendarIntervalLastDate(start, end, allDay)"); +requireText(projectionConsumer, "calendarIntervalLastDate(start, end, allDay)"); requireText(ownerIndex, "calendarIntervalLastDate"); requireCount(host, "calendarIntervalLastDate(", 0); requireCount(inspector, "calendarIntervalLastDate(", 1); @@ -20,7 +20,7 @@ requireCount(monthGrid, "calendarIntervalLastDate(", 2); requireCount(timeGrid, "calendarIntervalLastDate(", 1); forbid(host, /addCalendarDate\([^\n]*\.end[^\n]*, -1\)/); -console.log("Calendar interval last-date guard ok; Editing owner, occurrence, Host, CalendarMonthGrid, and CalendarTimeGrid consumers checked."); +console.log("Calendar interval last-date guard ok; Document Type owner, occurrence, Host, CalendarMonthGrid, and CalendarTimeGrid consumers checked."); function read(path) { return readFileSync(resolve(root, path), "utf8"); diff --git a/site/scripts/check-calendar-recurrence-transition.mjs b/site/scripts/check-calendar-recurrence-transition.mjs index b5fd25ec1..e2ee2ad95 100644 --- a/site/scripts/check-calendar-recurrence-transition.mjs +++ b/site/scripts/check-calendar-recurrence-transition.mjs @@ -2,8 +2,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(new URL("../..", import.meta.url).pathname); -const owner = read("packages/json-document-editing/src/calendar-occurrence.ts"); -const ownerIndex = read("packages/json-document-editing/src/index.ts"); +const owner = read("packages/json-document-calendar-document/src/calendar-occurrence.ts"); +const ownerIndex = read("packages/json-document-calendar-document/src/index.ts"); const ownerTest = read("packages/json-document-editing/tests/calendar-editor.test.ts"); const host = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); const inspector = read("packages/json-document-calendar/src/calendar-event-inspector.tsx"); @@ -22,11 +22,11 @@ forbid(inspector, /as CalendarRecurrence\["freq"\]/); forbid(inspector, /Math\.max\(1, Math\.floor\(Number\(event\.target\.value\)/); forbid(inspector, /recurrence:\s*\{\s*\.\.\.selectedEvent\.recurrence!/); forbid(inspector, /selectedEvent\.recurrence!/); -requireText(usage, "Editing\n`calendarRecurrenceWithFrequency`"); -requireText(sources, 'sourcePath: "packages/json-document-editing/src/calendar-occurrence.ts"'); +requireText(usage, "@interactive-os/json-document-calendar-document"); +requireText(sources, 'sourcePath: "packages/json-document-calendar-document/src/calendar-occurrence.ts"'); requireText(browserTest, "Calendar recurrence inspector applies canonical model transitions"); -console.log("Calendar recurrence transition guard ok; Editing owner/tests, three Host handlers, Usage, and source registration checked."); +console.log("Calendar recurrence transition guard ok; Document Type owner/tests, three Host handlers, Usage, and source registration checked."); function read(path) { return readFileSync(resolve(root, path), "utf8"); diff --git a/site/scripts/check-calendar-temporal.mjs b/site/scripts/check-calendar-temporal.mjs index 05177b1fe..baa94e6d5 100644 --- a/site/scripts/check-calendar-temporal.mjs +++ b/site/scripts/check-calendar-temporal.mjs @@ -30,6 +30,7 @@ if (calendarTemporalViolations(goodFixture, "conforming fixture").length !== 0) } const files = [ + ...collect(path.join(repositoryRoot, "packages/json-document-calendar-document/src"), /^calendar.*\.ts$/), ...collect(path.join(repositoryRoot, "packages/json-document-editing/src"), /^calendar.*\.ts$/), ...collect(path.join(repositoryRoot, "packages/json-document-calendar/src"), /\.[jt]sx?$/), path.join(repositoryRoot, "packages/json-document-calendar/src/date-values.ts"), diff --git a/site/scripts/check-calendar-view-parser.mjs b/site/scripts/check-calendar-view-parser.mjs index 097fdbb3e..2c68d7abb 100644 --- a/site/scripts/check-calendar-view-parser.mjs +++ b/site/scripts/check-calendar-view-parser.mjs @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(new URL("../..", import.meta.url).pathname); -const owner = read("packages/json-document-editing/src/calendar-validation.ts"); +const owner = read("packages/json-document-editing/src/calendar.ts"); const ownerIndex = read("packages/json-document-editing/src/index.ts"); const ownerTest = read("packages/json-document-editing/tests/calendar-validation.test.ts"); const route = read("site/src/routes/calendar-demo/calendar-search.ts"); @@ -15,9 +15,9 @@ requireText(ownerTest, '["day", "week", "month", "year"].map(parseCalendarView)' requireText(route, "parseCalendarView(search.view) ?? calendarSearchDefaults.view"); forbid(route, /new Set\(\["day", "week", "month", "year"\]\)/); forbid(route, /as CalendarView/); -requireText(usage, "`parseCalendarView`가 판별하고"); +requireText(usage, "`parseCalendarView`"); requireText(sources, 'symbol: "parseCalendarView"'); -requireText(sources, 'sourcePath: "packages/json-document-editing/src/calendar-validation.ts"'); +requireText(sources, 'sourcePath: "packages/json-document-editing/src/calendar.ts"'); console.log("Calendar view parser guard ok; Editing owner, public export, tests, Host composition, Usage, and source registration checked."); diff --git a/site/scripts/check-content-interaction-grammar.mjs b/site/scripts/check-content-interaction-grammar.mjs index 88de6850b..f17117138 100644 --- a/site/scripts/check-content-interaction-grammar.mjs +++ b/site/scripts/check-content-interaction-grammar.mjs @@ -12,7 +12,7 @@ const calendarTime = read("packages/json-document-calendar/src/calendar-time-gri const calendarMonth = read("packages/json-document-calendar/src/calendar-month-grid.tsx"); const database = read("packages/json-document-database/src/database-hand.tsx"); const board = read("site/src/routes/widgets/BoardWidgetRoute.tsx"); -const canvas = read("site/src/routes/widgets/CanvasWidgetRoute.tsx"); +const canvas = read("packages/json-document-canvas/src/canvas-object-view.tsx"); const usage = read("docs/public/ui-primitives.md"); const sources = read("site/src/shared/demo-workbench/demo-sources.ts"); @@ -27,7 +27,7 @@ requireText(calendarMonth, 'role: "insertion"'); requireText(database, " route.navigationGroup === "Document Types" && route.path !== "/docs/document-types") + .filter((route) => route.navigationGroup === "Document Types" && route.path.startsWith("/docs/document-types/")) .map((route) => route.path.slice("/docs/document-types/".length)); if (JSON.stringify(ledger.candidates) !== JSON.stringify(expectedCandidates)) throw new Error("Document Type audit candidates do not match the TBD navigation denominator"); @@ -51,8 +51,25 @@ const calendar = ledger.audits.calendar; for (const role of ["Document Model", "Validation", "Projection", "Document Operation", "Editing lifecycle", "Affordance", "Web Adapter", "Hand composition", "Reusable UI behavior", "Host composition"]) { if (!calendar.occurrences.some((occurrence) => occurrence.role === role)) throw new Error(`Calendar audit is missing role: ${role}`); } -if (calendar.status !== "audited-tbd" || !calendar.occurrences.some((occurrence) => !["canonical consumer", "Host composition"].includes(occurrence.disposition))) { - throw new Error("Calendar must remain audited-tbd while nonconforming occurrences remain"); +const remaining = calendar.occurrences.filter((occurrence) => !["canonical consumer", "Host composition"].includes(occurrence.disposition)); +if (calendar.status !== (remaining.length === 0 ? "owner-closed" : "audited-tbd")) { + throw new Error("Calendar audit status must reflect its remaining ownership gaps"); +} +if (calendar.status === "owner-closed") { + const closure = calendar.closure; + if (!Array.isArray(closure?.verification) || closure.verification.length === 0) throw new Error("Calendar closure needs executable verification evidence"); + for (const path of [closure.publicEntry, closure.referencePath, closure.sourceRegistration, ...closure.verification]) { + if (typeof path !== "string" || !existsSync(join(root, path))) throw new Error(`Calendar closure evidence missing: ${path}`); + } + for (const path of [closure.apiPath, closure.usagePath, closure.usagePagePath?.split("#")[0]]) { + if (!siteRoutes.some((route) => route.path === path)) throw new Error(`Calendar closure route missing: ${path}`); + } + const manifest = JSON.parse(readFileSync(join(root, "packages/json-document-calendar-document/package.json"), "utf8")); + if (closure.owner !== manifest.name) throw new Error("Calendar closure owner must match its public package"); + const dependencies = Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies }); + if (dependencies.some((name) => ["@interactive-os/json-document-editing", "@interactive-os/json-document-selection", "@interactive-os/json-document-calendar", "react"].includes(name))) { + throw new Error("Calendar Document Type must remain usable without Editing, Selection or React"); + } } console.log(`Document Type audits ok; candidates=${ledger.candidates.length}; candidate profiles=${Object.keys(ledger.candidateProfiles).length}; audited=${Object.keys(ledger.audits).length}; Calendar occurrences=${calendar.occurrences.length}.`); diff --git a/site/scripts/check-documentation-page.mjs b/site/scripts/check-documentation-page.mjs index 2d3c047f6..eefdbd4bd 100644 --- a/site/scripts/check-documentation-page.mjs +++ b/site/scripts/check-documentation-page.mjs @@ -4,7 +4,7 @@ import { join } from "node:path"; const root = new URL("../..", import.meta.url).pathname; const docsRoot = join(root, "site/src/routes/docs"); const canonicalOwner = "DocumentationPage.tsx"; -const consumers = ["DocsRoute.tsx", "ConceptsRoute.tsx", "DocumentTypeCandidateRoute.tsx"]; +const consumers = ["DocsRoute.tsx", "DocumentTypeCandidateRoute.tsx"]; for (const name of readdirSync(docsRoot).filter((entry) => entry.endsWith(".tsx"))) { const source = readFileSync(join(docsRoot, name), "utf8"); @@ -14,7 +14,7 @@ for (const name of readdirSync(docsRoot).filter((entry) => entry.endsWith(".tsx" } const owner = readFileSync(join(docsRoot, canonicalOwner), "utf8"); -for (const contract of ["PageFrame", "PageHeader", "MarkdownViewer", "markdownHeadings", "Documentation sections", "On this page", "max-w-3xl"]) { +for (const contract of ["PageFrame", "PageHeader", "MarkdownViewer", "data-doc-heading", "Documentation sections", "On this page", "max-w-3xl"]) { if (!owner.includes(contract)) throw new Error(`DocumentationPage is missing canonical contract: ${contract}`); } for (const consumer of consumers) { diff --git a/site/scripts/check-interaction-handles.mjs b/site/scripts/check-interaction-handles.mjs index 0f1a7cc8c..ddf54cf7e 100644 --- a/site/scripts/check-interaction-handles.mjs +++ b/site/scripts/check-interaction-handles.mjs @@ -7,7 +7,7 @@ const sources = { owner: read("packages/json-document-affordance/src/interaction-handle.ts"), react: read("packages/json-document-ui-primitives-react/src/surfaces.tsx"), calendar: read("packages/json-document-calendar/src/use-calendar-pointer-interactions.ts"), - canvas: read("site/src/routes/canvas-demo/CanvasDemoRoute.tsx"), + canvas: read("packages/json-document-canvas/src/canvas-object-view.tsx"), database: read("packages/json-document-database/src/database-hand.tsx"), annotation: read("packages/json-document-annotation/src/annotation-hand.tsx"), }; diff --git a/site/scripts/check-product-shell-toolbar.mjs b/site/scripts/check-product-shell-toolbar.mjs index c72f6802e..48f6e21e8 100644 --- a/site/scripts/check-product-shell-toolbar.mjs +++ b/site/scripts/check-product-shell-toolbar.mjs @@ -9,6 +9,7 @@ const usage = read("docs/public/ui-primitives.md"); const sources = read("site/src/shared/demo-workbench/demo-sources.ts"); const calendar = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); const consumers = files("site/src/routes").map((path) => [path, read(path)]); +const canvasConsumers = files("packages/json-document-canvas/src").map((path) => [path, read(path)]); const databaseConsumers = files("packages/json-document-database/src").map((path) => [path, read(path)]); for (const symbol of ["ProductShell", "ProductCanvas", "ProductInspector"]) { @@ -29,11 +30,11 @@ requireText(calendar, 'toolbarLabel="Calendar controls"'); requireText(calendar, ''); requireText(usage, ''); -const productShellConsumers = consumers.filter(([, source]) => source.includes(" source.includes(" typeof source !== "string" || !/^packages\/[^/]+\/docs\/[^/]+\.md$/.test(source)))) { + fail(`site route ${route.path} has invalid owner documentation includes.`); + } if (route.chrome !== undefined && route.chrome !== "app") { fail(`site route ${route.path} has an invalid chrome.`); } @@ -81,7 +87,8 @@ export function validateSiteRoutes(routes, fail) { files.add(file); } - const navigationLabel = `${route.navigationGroup ?? "hidden"}:${route.label}`; + const labelScope = route.parentPath ?? route.navigationGroup ?? (route.sidebar === false ? route.path : "root"); + const navigationLabel = `${labelScope}:${route.label}`; if (labels.has(navigationLabel)) fail(`site navigation group contains duplicate label ${route.label}.`); labels.add(navigationLabel); diff --git a/site/scripts/route-checks.test.mjs b/site/scripts/route-checks.test.mjs new file mode 100644 index 000000000..8b69114a8 --- /dev/null +++ b/site/scripts/route-checks.test.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { validateSiteRoutes } from "./route-checks.mjs"; + +const root = { path: "/", label: "Home", title: "Home", description: "Home page" }; +const route = (path, extra = {}) => ({ path, title: path, description: `About ${path}`, label: "Overview", ...extra }); +const errors = (routes) => { + const failures = []; + validateSiteRoutes(routes, (failure) => failures.push(failure)); + return failures; +}; + +test("validates the real site registry and separate section landings", () => { + assert.deepEqual(errors(JSON.parse(readFileSync(new URL("../site-routes.json", import.meta.url), "utf8"))), []); + assert.deepEqual(errors([root, route("/docs/foundation", { sidebar: false }), route("/docs/building-blocks", { sidebar: false })]), []); +}); + +test("rejects duplicate visible sibling labels and invalid documentation paths", () => { + assert.deepEqual(errors([root, route("/docs/one", { navigationGroup: "Editing" }), route("/docs/two", { navigationGroup: "Editing" })]), [ + "site navigation group contains duplicate label Overview.", + ]); + assert.deepEqual(errors([root, route("/docs/one", { documentSource: "../private.md" })]), [ + "site route /docs/one has an invalid documentation source.", + ]); +}); + +test("validates package-owned documentation includes", () => { + assert.deepEqual(errors([root, route("/docs/one", { documentIncludes: ["packages/json-document-canvas/docs/api.md"] })]), []); + assert.deepEqual(errors([root, route("/docs/one", { documentIncludes: ["../private.md"] })]), ["site route /docs/one has invalid owner documentation includes."]); +}); diff --git a/site/site-routes.json b/site/site-routes.json index 6fa7596e3..a8c6e58aa 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -7,10 +7,10 @@ }, { "path": "/viewer", - "label": "Document · Presentation · Spreadsheet", - "title": "Artifact - json-document", - "heading": "Artifact", - "description": "MD·PPT·Sheet artifact가 한 host에 나타나고 서로 다른 Hands를 얻는 미래의 mock입니다.", + "label": "Content Prototype · TBD", + "title": "Artifact · TBD - json-document", + "heading": "Artifact · TBD", + "description": "Application 안에서 MD·PPT·Sheet를 다루는 visual prototype과 아직 연결하지 않은 문서·Hands·호환성 계약을 구분합니다.", "language": "ko", "navigationGroup": "Artifact" }, @@ -19,9 +19,10 @@ "label": "Why", "title": "json-document Docs - json-document", "heading": "왜 json-document인가", - "description": "문서·표·보드가 같은 JSON 계약을 쓰고, 협업과 장르의 손과 붙이는 층을 같은 커널 위에 올리는 이유를 설명합니다.", + "description": "문서·표·보드가 JSON Document 계약을 공유하고, 독립적인 편집·플랫폼·생태계 책임을 조합하는 이유를 설명합니다.", "language": "ko", - "navigationGroup": "Introduction" + "navigationGroup": "Introduction", + "documentSource": "docs/public/overview.md" }, { "path": "/docs/foundation", @@ -30,15 +31,17 @@ "heading": "Foundation", "description": "JSON Document, Document Types, Editing과 Collaboration이 공유하는 기반 계약을 설명합니다.", "language": "ko", - "navigationGroup": "JSON Document" + "documentSource": "docs/public/foundation.md", + "sidebar": false }, { "path": "/docs/concepts", "label": "Concept Map", "title": "Concept Map - json-document", - "description": "JSON Document에서 Artifact까지 책임이 쌓이는 순서와 각 계층의 경계를 설명합니다.", + "description": "목표 레이어·책임 경계와 실제 프로토콜을 구분하고, 현재 제공되는 계약과 TBD를 함께 설명합니다.", "language": "ko", - "navigationGroup": "Introduction" + "navigationGroup": "Introduction", + "documentSource": "docs/public/concepts.md" }, { "path": "/docs/how-we-build", @@ -47,7 +50,8 @@ "heading": "제품에서 정본 모듈을 발견하는 방법", "description": "Application을 먼저 만들고 실제 제품 책임을 canonical module로 추출한 뒤 제품이 다시 소비하는 개발 순환을 설명합니다.", "language": "ko", - "navigationGroup": "Introduction" + "navigationGroup": "Introduction", + "documentSource": "docs/public/how-we-build.md" }, { "path": "/applications", @@ -56,7 +60,8 @@ "heading": "Applications", "description": "Artifact와 Hands를 실제 제품 경험으로 조합하고 재사용 책임을 발견하는 Application 목록입니다.", "language": "ko", - "navigationGroup": "Applications" + "navigationGroup": "Applications", + "documentSource": "docs/public/applications.md" }, { "path": "/applications/calendar", @@ -83,7 +88,8 @@ "heading": "Document Types · TBD", "description": "JSON Document의 의미 모델을 소유하는 Document Type 책임과 아직 확정하지 않은 후보를 정리합니다.", "language": "ko", - "navigationGroup": "Document Types" + "navigationGroup": "Document Types", + "documentSource": "docs/public/document-types.md" }, { "path": "/docs/document-types/rich-text", @@ -103,9 +109,9 @@ }, { "path": "/docs/document-types/object", - "label": "Object · TBD", - "title": "Object Document Type · TBD - json-document", - "description": "Object의 Document Type 소유권을 확정하기 전 후보 상태와 필요한 증거를 정리합니다.", + "label": "Object · RC", + "title": "Object Document Type · RC - json-document", + "description": "Object와 Canvas 프로파일의 공개 소유자와 책임 감사 증거를 설명합니다.", "language": "ko", "navigationGroup": "Document Types" }, @@ -127,9 +133,9 @@ }, { "path": "/docs/document-types/calendar", - "label": "Calendar · TBD", - "title": "Calendar Document Type · TBD - json-document", - "description": "Calendar의 Document Type 소유권을 확정하기 전 후보 상태와 필요한 증거를 정리합니다.", + "label": "Calendar · RC", + "title": "Calendar Document Type · RC - json-document", + "description": "Calendar 문서 모델·검증·의미 연산·projection의 공개 소유자와 RC 계약을 설명합니다.", "language": "ko", "navigationGroup": "Document Types" }, @@ -159,12 +165,13 @@ }, { "path": "/docs/api", - "label": "API Reference", - "title": "json-document API - json-document", - "heading": "json-document API", - "description": "여섯 가지 JSON Document 진입점과 JSON Patch, Pointer, JSONPath 공개 API를 정리합니다.", + "label": "JSON Document Protocol", + "title": "JSON Document Protocol - json-document", + "heading": "JSON Document Protocol", + "description": "로컬·협업 구현이 공유하는 여섯 member의 JSONDocument 계약과 JSON 표준 연산 API를 설명합니다.", "language": "ko", - "navigationGroup": "JSON Document" + "navigationGroup": "JSON Document", + "documentSource": "docs/public/api.md" }, { "path": "/docs/api/json-document", @@ -172,7 +179,8 @@ "title": "JSON Document public API - json-document", "description": "@interactive-os/json-document의 전체 public export와 signature입니다.", "language": "ko", - "navigationGroup": "JSON Document" + "navigationGroup": "JSON Document", + "documentSource": "docs/api-reference/json-document.md" }, { "path": "/docs/api/selection", @@ -180,7 +188,8 @@ "title": "Selection API - json-document", "description": "@interactive-os/json-document-selection의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "documentSource": "docs/api-reference/selection.md" }, { "path": "/docs/api/editing", @@ -188,7 +197,12 @@ "title": "Editing API - json-document", "description": "@interactive-os/json-document-editing의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "documentSource": "docs/api-reference/editing.md", + "documentIncludes": [ + "packages/json-document-editing/docs/object-selection.md", + "packages/json-document-editing/docs/calendar-profile.md" + ] }, { "path": "/docs/api/rich-text", @@ -196,7 +210,8 @@ "title": "Rich Text API - json-document", "description": "@interactive-os/json-document-rich-text의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "documentSource": "docs/api-reference/rich-text.md" }, { "path": "/docs/api/file-intake", @@ -204,7 +219,8 @@ "title": "File Intake API - json-document", "description": "@interactive-os/json-document-file-intake의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Artifact" + "navigationGroup": "Artifact", + "documentSource": "docs/api-reference/file-intake.md" }, { "path": "/docs/api/rich-text-mention", @@ -212,7 +228,8 @@ "title": "Rich Text Mention API - json-document", "description": "@interactive-os/json-document-rich-text-mention의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/rich-text-mention.md" }, { "path": "/docs/api/rich-text-suggestion", @@ -220,7 +237,8 @@ "title": "Rich Text Suggestion API - json-document", "description": "@interactive-os/json-document-rich-text-suggestion의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/rich-text-suggestion.md" }, { "path": "/docs/api/rich-text-suggestion-react", @@ -228,7 +246,8 @@ "title": "Rich Text Suggestion React API - json-document", "description": "@interactive-os/json-document-rich-text-suggestion-react의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/rich-text-suggestion-react.md" }, { "path": "/docs/api/rich-text-mention-react", @@ -236,7 +255,8 @@ "title": "Rich Text Mention React API - json-document", "description": "@interactive-os/json-document-rich-text-mention-react의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/rich-text-mention-react.md" }, { "path": "/docs/api/composer", @@ -244,7 +264,8 @@ "title": "Composer API - json-document", "description": "@interactive-os/json-document-composer의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/composer.md" }, { "path": "/docs/api/composer-react", @@ -252,7 +273,8 @@ "title": "Composer React API - json-document", "description": "@interactive-os/json-document-composer-react의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/composer-react.md" }, { "path": "/docs/api/web", @@ -260,7 +282,11 @@ "title": "Web API - json-document", "description": "@interactive-os/json-document-web의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/api-reference/web.md", + "documentIncludes": [ + "packages/json-document-web/docs/clipboard.md" + ] }, { "path": "/docs/api/contenteditable", @@ -268,7 +294,8 @@ "title": "Contenteditable API - json-document", "description": "@interactive-os/json-document-contenteditable의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/api-reference/contenteditable.md" }, { "path": "/docs/api/rich-text-web", @@ -276,7 +303,8 @@ "title": "Rich Text Web API - json-document", "description": "@interactive-os/json-document-rich-text-web의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/api-reference/rich-text-web.md" }, { "path": "/docs/api/react", @@ -284,7 +312,8 @@ "title": "React API - json-document", "description": "@interactive-os/json-document-react의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/api-reference/react.md" }, { "path": "/docs/api/react-hook-form", @@ -292,7 +321,8 @@ "title": "React Hook Form API - json-document", "description": "@interactive-os/json-document-react-hook-form의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/api-reference/react-hook-form.md" }, { "path": "/docs/api/ajv", @@ -300,7 +330,8 @@ "title": "Ajv API - json-document", "description": "@interactive-os/json-document-ajv의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/api-reference/ajv.md" }, { "path": "/docs/api/a2ui", @@ -308,7 +339,8 @@ "title": "A2UI API - json-document", "description": "@interactive-os/json-document-a2ui의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/api-reference/a2ui.md" }, { "path": "/docs/connector-a2ui", @@ -316,7 +348,8 @@ "title": "A2UI Connector - json-document", "description": "A2UI 메시지와 JSONL을 JSONDocument로 연결합니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/public/connector-a2ui.md" }, { "path": "/docs/api/zod", @@ -324,7 +357,8 @@ "title": "Zod API - json-document", "description": "@interactive-os/json-document-zod의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/api-reference/zod.md" }, { "path": "/docs/api/tanstack-table", @@ -332,7 +366,8 @@ "title": "TanStack Table API - json-document", "description": "@interactive-os/json-document-tanstack-table의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/api-reference/tanstack-table.md" }, { "path": "/docs/api/rich-text-react", @@ -340,7 +375,8 @@ "title": "Rich Text React API - json-document", "description": "@interactive-os/json-document-rich-text-react의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/api-reference/rich-text-react.md" }, { "path": "/docs/api/affordance", @@ -348,7 +384,12 @@ "title": "Affordance API - json-document", "description": "@interactive-os/json-document-affordance의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Affordance" + "navigationGroup": "Affordance", + "documentSource": "docs/api-reference/affordance.md", + "documentIncludes": [ + "packages/json-document-affordance/docs/plane-select.md", + "packages/json-document-affordance/docs/resize.md" + ] }, { "path": "/docs/api/ui-primitives-react", @@ -356,7 +397,11 @@ "title": "UI Primitives API - json-document", "description": "@interactive-os/json-document-ui-primitives-react의 전체 public API입니다.", "language": "ko", - "navigationGroup": "UI Primitives" + "navigationGroup": "UI Primitives", + "documentSource": "docs/api-reference/ui-primitives-react.md", + "documentIncludes": [ + "packages/json-document-ui-primitives-react/docs/popover.md" + ] }, { "path": "/docs/api/animation-react", @@ -364,7 +409,8 @@ "title": "Animation API - json-document", "description": "@interactive-os/json-document-animation-react의 전체 public API입니다.", "language": "ko", - "navigationGroup": "UI Primitives" + "navigationGroup": "UI Primitives", + "documentSource": "docs/api-reference/animation-react.md" }, { "path": "/docs/api/markdown-react", @@ -374,7 +420,8 @@ "language": "ko", "navigationGroup": "Artifact", "relatedDemoPath": "/demo/markdown", - "relatedDemoLabel": "Streaming Markdown" + "relatedDemoLabel": "Streaming Markdown", + "documentSource": "docs/api-reference/markdown-react.md" }, { "path": "/demo/markdown", @@ -402,7 +449,8 @@ "title": "Database API - json-document", "description": "@interactive-os/json-document-database의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/database.md" }, { "path": "/docs/api/annotation", @@ -410,7 +458,44 @@ "title": "Annotation API - json-document", "description": "@interactive-os/json-document-annotation의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/annotation.md" + }, + { + "path": "/docs/api/object-document", + "label": "API · Object Document", + "title": "Object Document Type API - json-document", + "description": "Object와 Canvas의 문서 계약과 public API입니다.", + "language": "ko", + "navigationGroup": "Document Types", + "documentSource": "docs/api-reference/object-document.md", + "documentIncludes": [ + "packages/json-document-object-document/docs/api.md" + ] + }, + { + "path": "/docs/api/canvas", + "label": "API · Canvas", + "title": "Canvas Hand API - json-document", + "description": "Canvas 입력·preview·UI 조합의 API와 Usage입니다.", + "language": "ko", + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/canvas.md", + "documentIncludes": [ + "packages/json-document-canvas/docs/api.md" + ] + }, + { + "path": "/docs/api/calendar-document", + "label": "API · Calendar Document", + "title": "Calendar Document Type API - json-document", + "description": "@interactive-os/json-document-calendar-document의 문서 계약과 public API입니다.", + "language": "ko", + "navigationGroup": "Document Types", + "documentSource": "docs/api-reference/calendar-document.md", + "documentIncludes": [ + "packages/json-document-calendar-document/docs/api.md" + ] }, { "path": "/docs/api/calendar", @@ -418,7 +503,8 @@ "title": "Calendar API - json-document", "description": "@interactive-os/json-document-calendar의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/api-reference/calendar.md" }, { "path": "/docs/api/collaboration", @@ -426,7 +512,8 @@ "title": "Collaboration API - json-document", "description": "@interactive-os/json-document-collaboration의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Collaboration" + "navigationGroup": "Collaboration", + "documentSource": "docs/api-reference/collaboration.md" }, { "path": "/docs/api/contenteditable-collaboration", @@ -434,17 +521,18 @@ "title": "Contenteditable Collaboration API - json-document", "description": "@interactive-os/json-document-contenteditable-collaboration의 전체 public API입니다.", "language": "ko", - "navigationGroup": "Collaboration" + "navigationGroup": "Collaboration", + "documentSource": "docs/api-reference/contenteditable-collaboration.md" }, { "path": "/docs/collaboration", - "label": "Collaboration", + "label": "Overview", "title": "Collaboration - json-document", "heading": "Collaboration", "description": "같은 JSON Document 계약을 여러 참여자의 인과 변경으로 수렴시킵니다.", "language": "ko", "navigationGroup": "Collaboration", - "sidebar": false + "documentSource": "docs/public/collaboration.md" }, { "path": "/docs/collaboration/replica", @@ -453,7 +541,8 @@ "heading": "Replica", "description": "한 참여자가 소유한 인과 상태와 아직 적용하지 못한 변경을 설명합니다.", "language": "ko", - "navigationGroup": "Collaboration" + "navigationGroup": "Collaboration", + "documentSource": "docs/public/collaboration-replica.md" }, { "path": "/docs/collaboration/lifecycle", @@ -462,7 +551,8 @@ "heading": "Lifecycle", "description": "epoch, checkpoint, restore, compaction이 협업 세대를 접고 되돌리는 방식을 설명합니다.", "language": "ko", - "navigationGroup": "Collaboration" + "navigationGroup": "Collaboration", + "documentSource": "docs/public/collaboration-lifecycle.md" }, { "path": "/docs/collaboration/history", @@ -471,7 +561,8 @@ "heading": "Collaborative History", "description": "다른 참여자를 덮어쓰지 않고 현재 참여자의 인과 기여를 끄거나 켭니다.", "language": "ko", - "navigationGroup": "Collaboration" + "navigationGroup": "Collaboration", + "documentSource": "docs/public/collaboration-history.md" }, { "path": "/docs/collaboration/text", @@ -480,7 +571,8 @@ "heading": "Collaborative Text", "description": "같이 쓰는 문자열의 안정된 글자 단위와 상대 위치를 설명합니다.", "language": "ko", - "navigationGroup": "Collaboration" + "navigationGroup": "Collaboration", + "documentSource": "docs/public/collaboration-text.md" }, { "path": "/docs/collaboration/text/lease", @@ -489,7 +581,8 @@ "heading": "Contenteditable lease", "description": "협업 문자열이 입력 중에도 인과 변경을 멈추지 않게 하는 native-input DOM lease를 설명합니다.", "language": "ko", - "parentPath": "/docs/collaboration/text" + "parentPath": "/docs/collaboration/text", + "documentSource": "docs/public/collaboration-lease.md" }, { "path": "/docs/intent-guide", @@ -498,7 +591,8 @@ "heading": "Editor와 Intent 만들기", "description": "Document editor를 만들고 요청을 EditingIntent로 표현해 EditingResult를 처리합니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "documentSource": "docs/public/intent-guide.md" }, { "path": "/docs/intent", @@ -507,7 +601,8 @@ "heading": "Intent 레퍼런스", "description": "EditingIntent, dispatch, EditingResult와 editor별 Intent의 공개 시그니처를 정리합니다.", "language": "ko", - "navigationGroup": "Editing" + "navigationGroup": "Editing", + "documentSource": "docs/public/intent.md" }, { "path": "/docs/topology", @@ -517,7 +612,8 @@ "description": "Selection과 Clipboard가 공유하는 화면 순서인 LineTopology와 GridTopology를 설명합니다.", "language": "ko", "navigationGroup": "Editing", - "relatedDemoPath": "/demo/topology" + "relatedDemoPath": "/demo/topology", + "documentSource": "docs/public/topology.md" }, { "path": "/demo/topology", @@ -536,7 +632,8 @@ "description": "문서 값을 바꾸지 않고 편집 대상을 기억하는 구조적 Selection을 설명합니다.", "language": "ko", "navigationGroup": "Editing", - "relatedDemoPath": "/demo/selection" + "relatedDemoPath": "/demo/selection", + "documentSource": "docs/public/selection.md" }, { "path": "/demo/selection", @@ -555,7 +652,8 @@ "description": "현재 구조적 Selection에서 copy, cut, paste에 사용할 Clipboard payload를 만듭니다.", "language": "ko", "navigationGroup": "Editing", - "relatedDemoPath": "/demo/clipboard" + "relatedDemoPath": "/demo/clipboard", + "documentSource": "docs/public/clipboard.md" }, { "path": "/demo/clipboard", @@ -574,7 +672,8 @@ "description": "document value와 구조적 Selection을 함께 복원하는 로컬 undo와 redo를 설명합니다.", "language": "ko", "navigationGroup": "Editing", - "relatedDemoPath": "/demo/history" + "relatedDemoPath": "/demo/history", + "documentSource": "docs/public/history.md" }, { "path": "/demo/history", @@ -601,7 +700,8 @@ "heading": "Hands", "description": "닫힌 장르의 손과 TBD 손을 고릅니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/public/hands.md" }, { "path": "/docs/official-hands", @@ -610,7 +710,8 @@ "heading": "Official Hands · TBD", "description": "디자인과 제품 데이터는 자유롭게 유지하면서 수렴된 편집 기능을 완성된 SDK로 제공하는 방향을 설명합니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/public/official-hands.md" }, { "path": "/demo", @@ -637,7 +738,8 @@ "description": "한 줄 목록을 범위 선택으로 집고 옮기는 Order editor를 설명합니다.", "language": "ko", "navigationGroup": "Hands", - "relatedDemoPath": "/demo/order" + "relatedDemoPath": "/demo/order", + "documentSource": "docs/public/order.md" }, { "path": "/demo/order", @@ -655,7 +757,8 @@ "description": "안정된 객체 ID를 키 가족으로 집는 Object editor를 설명합니다.", "language": "ko", "navigationGroup": "Hands", - "relatedDemoPath": "/demo/object" + "relatedDemoPath": "/demo/object", + "documentSource": "docs/public/object.md" }, { "path": "/demo/object", @@ -678,8 +781,8 @@ "path": "/demo/canvas", "label": "Canvas", "title": "Canvas - json-document", - "description": "A simple minimal canvas: pick, drag, and fill objects on a plane.", - "parentPath": "/docs/object", + "description": "한 장에 글자·도형·그리기를 만들고 편집하는 Canvas 수직 슬라이스입니다.", + "parentPath": "/docs/api/canvas", "sidebar": false }, { @@ -698,7 +801,8 @@ "description": "호스트가 만든 보이는 노드 줄에서 범위를 집는 Tree editor를 설명합니다.", "language": "ko", "navigationGroup": "Hands", - "relatedDemoPath": "/demo/tree" + "relatedDemoPath": "/demo/tree", + "documentSource": "docs/public/tree.md" }, { "path": "/demo/tree", @@ -743,7 +847,8 @@ "language": "ko", "navigationGroup": "UI Primitives", "relatedDemoPath": "/demo/animation", - "relatedDemoLabel": "Animation" + "relatedDemoLabel": "Animation", + "documentSource": "docs/public/animation.md" }, { "path": "/demo/animation", @@ -763,7 +868,8 @@ "description": "저장된 Table view가 순서·숨김·너비·정렬·필터를 투사하는 Database editor를 설명합니다.", "language": "ko", "navigationGroup": "Hands", - "relatedDemoPath": "/demo/database" + "relatedDemoPath": "/demo/database", + "documentSource": "docs/public/database.md" }, { "path": "/demo/database", @@ -781,7 +887,8 @@ "description": "사람이 agent에게 지시와 구조화된 context를 한 턴으로 건네는 Composer Hands입니다.", "language": "ko", "navigationGroup": "Hands", - "relatedDemoPath": "/demo/composer" + "relatedDemoPath": "/demo/composer", + "documentSource": "docs/public/composer.md" }, { "path": "/demo/composer", @@ -805,7 +912,8 @@ "heading": "Mention", "description": "이름으로 보이는 안정적인 entity reference를 inline atom으로 삽입하는 Hands입니다.", "language": "ko", - "navigationGroup": "Hands" + "navigationGroup": "Hands", + "documentSource": "docs/public/mention.md" }, { "path": "/adapters", @@ -822,7 +930,8 @@ "heading": "json-document Adapters", "description": "Keyboard, Clipboard, Contenteditable 공식 adapter 계약을 설명합니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/public/adapters.md" }, { "path": "/docs/adapter-keyboard", @@ -831,7 +940,8 @@ "heading": "Keyboard Adapter", "description": "Keyboard, Press와 ARIA platform contract를 Editing 입력으로 번역합니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/public/adapter-keyboard.md" }, { "path": "/docs/adapter-grid-cell", @@ -840,7 +950,8 @@ "heading": "Grid cell Adapter", "description": "GridPoint를 안정된 DOM cell 주소와 focus lookup에 연결합니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/public/adapter-grid-cell.md" }, { "path": "/docs/adapter-interaction", @@ -849,7 +960,8 @@ "heading": "Interaction Adapter", "description": "Pointer capture와 HTML Drag and Drop session을 preview, commit, cancel lifecycle에 연결합니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/public/adapter-interaction.md" }, { "path": "/docs/adapter-clipboard", @@ -858,7 +970,8 @@ "heading": "Clipboard Adapter", "description": "ClipboardEvent를 구조화된 copy, cut, paste 계약에 연결합니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/public/adapter-clipboard.md" }, { "path": "/docs/adapter-contenteditable", @@ -867,7 +980,8 @@ "heading": "Contenteditable Adapter", "description": "문자열 pointer와 native-input DOM lifecycle을 연결합니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/public/adapter-contenteditable.md" }, { "path": "/docs/adapter-virtual-selection", @@ -876,7 +990,8 @@ "heading": "Virtual Selection Adapter", "description": "부분 마운트된 DOM의 Native Selection과 전체 model-backed plain-text 복사를 연결합니다.", "language": "ko", - "navigationGroup": "Adapter" + "navigationGroup": "Adapter", + "documentSource": "docs/public/adapter-virtual-selection.md" }, { "path": "/affordances/handles", @@ -949,7 +1064,8 @@ "heading": "json-document Connectors", "description": "React 구독과 선택·커서 질의, React Hook Form, Ajv, Zod, TanStack Table 연결 계약을 설명합니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/public/connectors.md" }, { "path": "/docs/connector-react", @@ -958,7 +1074,8 @@ "heading": "React Connector", "description": "document와 editor의 변경을 React 구독과 선택 질의로 연결합니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/public/connector-react.md" }, { "path": "/docs/react-editing", @@ -969,7 +1086,8 @@ "language": "ko", "parentPath": "/docs/connector-react", "relatedDemoPath": "/connectors/react", - "relatedDemoLabel": "React" + "relatedDemoLabel": "React", + "documentSource": "docs/public/react-editing.md" }, { "path": "/docs/connector-react-hook-form", @@ -978,7 +1096,8 @@ "heading": "React Hook Form Connector", "description": "form draft와 canonical document commit을 연결합니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/public/connector-react-hook-form.md" }, { "path": "/docs/connector-ajv", @@ -987,7 +1106,8 @@ "heading": "Ajv Connector", "description": "Ajv validator와 JSON Document validation을 연결합니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/public/connector-ajv.md" }, { "path": "/docs/connector-zod", @@ -996,7 +1116,8 @@ "heading": "Zod Connector", "description": "Zod schema와 Database document 변환을 설명합니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/public/connector-zod.md" }, { "path": "/docs/connector-zod-validate", @@ -1005,7 +1126,8 @@ "heading": "Zod Validate", "description": "Zod issue path를 JSON Pointer validation 결과로 번역합니다.", "language": "ko", - "parentPath": "/docs/connector-zod" + "parentPath": "/docs/connector-zod", + "documentSource": "docs/public/connector-zod-validate.md" }, { "path": "/docs/connector-tanstack-table", @@ -1014,7 +1136,8 @@ "heading": "TanStack Table Connector", "description": "visible row와 column 순서를 SheetTopology에 연결합니다.", "language": "ko", - "navigationGroup": "Connector" + "navigationGroup": "Connector", + "documentSource": "docs/public/connector-tanstack-table.md" }, { "path": "/connectors/react", @@ -1106,7 +1229,8 @@ "navigationGroup": "UI Primitives", "relatedDemoPath": "/demo/ui-primitives", "relatedDemoLabel": "Design system", - "sidebar": false + "sidebar": false, + "documentSource": "docs/public/ui-primitives.md" }, { "path": "/docs/affordance", @@ -1116,7 +1240,8 @@ "description": "제품이 json-document를 만지는 최전선의 키보드·마우스·커서 손을 API와 사용법으로 설명합니다.", "language": "ko", "navigationGroup": "Affordance", - "sidebar": false + "sidebar": false, + "documentSource": "docs/public/affordance.md" }, { "path": "/docs/affordance/focus", @@ -1126,7 +1251,8 @@ "description": "Tab은 컴포넌트 사이로, 화살표는 컴포넌트 안에서 초점을 옮기는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-focus.md" }, { "path": "/docs/affordance/caret", @@ -1136,7 +1262,8 @@ "description": "I-beam 클릭으로 글 삽입점을 두고, 화살표로 글자 사이를 옮기는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-caret.md" }, { "path": "/docs/affordance/select", @@ -1148,7 +1275,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/widgets/listbox", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-select.md" }, { "path": "/docs/affordance/typeahead", @@ -1160,7 +1288,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/demo/order", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-typeahead.md" }, { "path": "/docs/affordance/activate", @@ -1172,7 +1301,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/demo/canvas", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-activate.md" }, { "path": "/docs/affordance/cancel", @@ -1184,7 +1314,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/demo/canvas", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-cancel.md" }, { "path": "/docs/affordance/fold", @@ -1196,7 +1327,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/widgets/tree", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-fold.md" }, { "path": "/docs/affordance/history", @@ -1208,7 +1340,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/widgets/toolbar", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-history.md" }, { "path": "/docs/affordance/delete", @@ -1220,7 +1353,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/demo/canvas", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-delete.md" }, { "path": "/docs/affordance/rename", @@ -1230,7 +1364,8 @@ "description": "F2와 느린 두 번 누르기로 고른 대상의 이름을 고치는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-rename.md" }, { "path": "/docs/affordance/nudge", @@ -1240,9 +1375,8 @@ "description": "화살표로 고른 대상을 한 단위 옮기고, Shift로 큰 단위를 쓰는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-nudge.md" }, { "path": "/docs/affordance/hover", @@ -1252,9 +1386,8 @@ "description": "누르지 않고 포인터를 올려 손과 도움말을 드러내는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-hover.md" }, { "path": "/docs/affordance/contextual", @@ -1266,7 +1399,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/demo/calendar", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-contextual.md" }, { "path": "/docs/affordance/double-click", @@ -1276,7 +1410,8 @@ "description": "click detail 2로 열기·단어 고르기·이름 바꾸기를 여는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-double-click.md" }, { "path": "/docs/affordance/triple-click", @@ -1286,7 +1421,8 @@ "description": "click detail 3으로 줄이나 문단을 고르는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-triple-click.md" }, { "path": "/docs/affordance/context-menu", @@ -1296,9 +1432,8 @@ "description": "오른쪽 클릭, Shift+F10, Menu 키로 자리 메뉴를 여는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-context-menu.md" }, { "path": "/docs/affordance/drag", @@ -1310,7 +1445,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/widgets/canvas", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-drag.md" }, { "path": "/docs/affordance/marquee", @@ -1320,9 +1456,8 @@ "description": "빈 곳에서 끌어서 여러 대상을 한 번에 고르는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-marquee.md" }, { "path": "/docs/affordance/drop", @@ -1332,9 +1467,8 @@ "description": "드래그한 대상을 어디에 둘지 정하고, 못 두면 no-drop을 보여주는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-drop.md" }, { "path": "/docs/affordance/copy-drag", @@ -1344,9 +1478,8 @@ "description": "Alt/Option을 누른 채 드래그하면 원본을 복제하는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-copy-drag.md" }, { "path": "/docs/affordance/handles", @@ -1358,7 +1491,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/affordances/handles", "relatedDemoLabel": "Usage 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-handles.md" }, { "path": "/docs/affordance/resize", @@ -1370,7 +1504,8 @@ "navigationGroup": "Affordance", "relatedDemoPath": "/demo/canvas", "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-resize.md" }, { "path": "/docs/affordance/pan", @@ -1380,9 +1515,8 @@ "description": "손바닥 커서로 평면을 밀고, Space+드래그로 화면을 옮기는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-pan.md" }, { "path": "/docs/affordance/scroll", @@ -1392,9 +1526,8 @@ "description": "휠과 드래그 중 자동 스크롤로 보이는 줄을 옮기는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-scroll.md" }, { "path": "/docs/affordance/zoom", @@ -1404,9 +1537,8 @@ "description": "Mod+휠과 +/- 키, zoom-in/out 커서로 보이는 배율을 바꾸는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-zoom.md" }, { "path": "/docs/affordance/snap", @@ -1416,9 +1548,8 @@ "description": "드래그와 크기 바꾸기 중 그리드·가이드에 붙고, 수정 키로 푸는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-snap.md" }, { "path": "/docs/affordance/forbid", @@ -1428,9 +1559,8 @@ "description": "할 수 없는 자리에서 not-allowed와 no-drop 커서를 보여주는 손입니다.", "language": "ko", "navigationGroup": "Affordance", - "relatedDemoPath": "/demo/canvas", - "relatedDemoLabel": "증명 열기", - "parentPath": "/docs/affordance" + "parentPath": "/docs/affordance", + "documentSource": "docs/public/affordance-forbid.md" }, { "path": "/widgets/toolbar", @@ -1468,7 +1598,7 @@ "path": "/widgets/canvas", "label": "Canvas proof", "title": "Canvas proof - json-document", - "description": "A canvas reads selected objects on a plane from Object selection.", + "description": "같은 Canvas Hand를 샘플 슬라이드와 Selection 관찰 UI에 조합합니다.", "parentPath": "/docs/affordance/drag", "sidebar": false }, @@ -1487,5 +1617,25 @@ "description": "A board reads columns of cards and selected keys from Kanban selection.", "parentPath": "/docs/affordance/drag", "sidebar": false + }, + { + "path": "/docs/building-blocks", + "label": "Overview", + "title": "Building Blocks - json-document", + "heading": "Building Blocks", + "description": "Adapter·Connector·Affordance·UI Primitives의 독립적인 책임과 Foundation·Hands를 연결하는 계약을 설명합니다.", + "language": "ko", + "sidebar": false, + "documentSource": "docs/public/building-blocks.md" + }, + { + "path": "/docs/editing", + "label": "Editing Protocol", + "title": "Editing Protocol - json-document", + "heading": "Editing Protocol", + "description": "Intent, EditingPlan, EditingSession과 EditingSnapshot의 현재 계약 및 아직 동결하지 않은 Hands Profile을 설명합니다.", + "language": "ko", + "navigationGroup": "Editing", + "documentSource": "docs/public/editing.md" } ] diff --git a/site/src/app/breadcrumb.tsx b/site/src/app/breadcrumb.tsx index 185b49ed7..7ff95394e 100644 --- a/site/src/app/breadcrumb.tsx +++ b/site/src/app/breadcrumb.tsx @@ -12,14 +12,14 @@ export type BreadcrumbCrumb = { const overview: BreadcrumbCrumb = { path: "/", label: "Overview" }; const groupLandings: Record = { - Introduction: { path: "/docs", label: "Introduce" }, - "JSON Document": { path: "/docs/foundation", label: "JSON Document" }, + Introduction: { path: "/docs", label: "Introduction" }, + "JSON Document": { path: "/docs/api", label: "JSON Document" }, "Document Types": { path: "/docs/document-types", label: "Document Types" }, - Editing: { path: "/docs/intent-guide", label: "Editing" }, + Editing: { path: "/docs/editing", label: "Editing" }, Collaboration: { path: "/docs/collaboration", label: "Collaboration" }, - Adapter: { path: "/docs/adapters", label: "Platform Adapters" }, - Connector: { path: "/docs/connectors", label: "Ecosystem Connectors" }, - Affordance: { path: "/docs/affordance", label: "Affordances" }, + Adapter: { path: "/docs/adapters", label: "Adapter" }, + Connector: { path: "/docs/connectors", label: "Connector" }, + Affordance: { path: "/docs/affordance", label: "Affordance" }, "UI Primitives": { path: "/docs/ui-primitives", label: "UI Primitives" }, Hands: { path: "/editors", label: "Hands" }, Artifact: { path: "/viewer", label: "Artifact" }, @@ -42,7 +42,7 @@ export function breadcrumbTrail( : undefined; } - const directSection = siteSections.find((section) => section.path === route.path && section.groups.length === 0); + const directSection = siteSections.find((section) => section.path === route.path); const group = routeGroup(route, routes); if (directSection) { stack[0] = { path: directSection.path, label: directSection.label }; diff --git a/site/src/app/index.css b/site/src/app/index.css index fea41b76b..eee460ea7 100644 --- a/site/src/app/index.css +++ b/site/src/app/index.css @@ -295,6 +295,10 @@ @apply absolute left-0 top-full z-20 mt-1 min-w-40; } + .canvas-style-panel { + @apply grid max-h-[60vh] w-64 gap-3 overflow-y-auto rounded-control border border-line-subtle bg-background-canvas p-3 text-xs text-foreground-default shadow-overlay outline-none; + } + [data-ui-presentation="dialog-backdrop"] { @apply fixed inset-0 z-40 grid place-items-center bg-foreground-strong/10; } @@ -367,7 +371,6 @@ [data-ui-component="contextual-controls"] :is( [data-ui-control="command"], [data-ui-control="toggle"], - [data-ui-control="command"][data-ui-presentation="icon"], [data-ui-segment="true"] ) { @apply rounded-control border-transparent bg-background-subtle/60 shadow-none hover:border-line-subtle hover:bg-background-subtle; @@ -380,7 +383,6 @@ [data-ui-toolbar="product"] :is( [data-ui-control="command"], [data-ui-control="toggle"], - [data-ui-control="command"][data-ui-presentation="icon"], [data-ui-segment="true"] ) { @apply rounded-control border-line-subtle/60 bg-background-canvas shadow-none hover:border-line-default hover:bg-background-canvas; @@ -474,10 +476,14 @@ transform: rotate(180deg); } - [data-ui-control="command"][data-ui-presentation="icon"] { + :is([data-ui-control="command"], [data-ui-control="toggle"])[data-ui-presentation="icon"] { @apply flex size-8 min-h-8 cursor-pointer items-center justify-center rounded-control border-0 bg-transparent p-0 text-foreground-default outline-none transition-colors hover:bg-background-subtle hover:text-foreground-strong active:bg-background-subtle focus-visible:ring-2 focus-visible:ring-line-accent/25 disabled:cursor-not-allowed disabled:bg-transparent disabled:text-foreground-muted; } + [data-ui-control="toggle"][data-ui-presentation="icon"][aria-pressed="true"] { + @apply bg-background-subtle text-foreground-strong disabled:bg-transparent disabled:text-foreground-muted; + } + :is([data-ui-component="command-presentation"], [data-ui-component="toggle-presentation"]) { @apply relative inline-flex; } diff --git a/site/src/app/navigation-layer-icon.tsx b/site/src/app/navigation-layer-icon.tsx index 8e926627e..0c2c31296 100644 --- a/site/src/app/navigation-layer-icon.tsx +++ b/site/src/app/navigation-layer-icon.tsx @@ -4,7 +4,6 @@ import { Braces, Files, Hand, - Library, PanelsTopLeft, type LucideIcon, } from "lucide-react"; @@ -16,13 +15,12 @@ type LayerIcon = { }; const layerIcons: Readonly> = { - introduce: { icon: BookOpen, size: 18 }, + introduction: { icon: BookOpen, size: 18 }, foundation: { icon: Braces, size: 19 }, "building-blocks": { icon: Blocks, size: 18 }, hands: { icon: Hand, size: 19 }, artifact: { icon: Files, size: 18 }, applications: { icon: PanelsTopLeft, size: 19 }, - reference: { icon: Library, size: 18 }, }; export function NavigationLayerIcon(props: { diff --git a/site/src/app/page-descriptors.ts b/site/src/app/page-descriptors.ts index f95c0f9ff..2fa86a6c8 100644 --- a/site/src/app/page-descriptors.ts +++ b/site/src/app/page-descriptors.ts @@ -21,6 +21,8 @@ export type SiteRoute = { readonly label: string; readonly title: string; readonly heading?: string; + readonly documentSource?: string; + readonly documentIncludes?: readonly string[]; readonly description: string; readonly language?: "en" | "ko"; readonly navigationGroup?: SiteNavigationGroup; diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index 4251679d2..98379cf78 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -61,7 +61,7 @@ import { Route as PageDocsAdapterKeyboardRouteImport } from "./routes/_page/docs import { Route as PageDocsAdapterVirtualSelectionRouteImport } from "./routes/_page/docs/adapter-virtual-selection"; import { Route as PageDocsAdaptersRouteImport } from "./routes/_page/docs/adapters"; import { Route as PageDocsAnimationRouteImport } from "./routes/_page/docs/animation"; -import { Route as PageDocsApiRouteImport } from "./routes/_page/docs/api"; +import { Route as PageDocsBuildingBlocksRouteImport } from "./routes/_page/docs/building-blocks"; import { Route as PageDocsClipboardRouteImport } from "./routes/_page/docs/clipboard"; import { Route as PageDocsComposerRouteImport } from "./routes/_page/docs/composer"; import { Route as PageDocsConceptsRouteImport } from "./routes/_page/docs/concepts"; @@ -74,6 +74,7 @@ import { Route as PageDocsConnectorZodRouteImport } from "./routes/_page/docs/co import { Route as PageDocsConnectorZodValidateRouteImport } from "./routes/_page/docs/connector-zod-validate"; import { Route as PageDocsConnectorsRouteImport } from "./routes/_page/docs/connectors"; import { Route as PageDocsDatabaseRouteImport } from "./routes/_page/docs/database"; +import { Route as PageDocsEditingRouteImport } from "./routes/_page/docs/editing"; import { Route as PageDocsFoundationRouteImport } from "./routes/_page/docs/foundation"; import { Route as PageDocsHistoryRouteImport } from "./routes/_page/docs/history"; import { Route as PageDocsHowWeBuildRouteImport } from "./routes/_page/docs/how-we-build"; @@ -128,12 +129,15 @@ import { Route as PageDocsAffordanceSnapRouteImport } from "./routes/_page/docs/ import { Route as PageDocsAffordanceTripleClickRouteImport } from "./routes/_page/docs/affordance/triple-click"; import { Route as PageDocsAffordanceTypeaheadRouteImport } from "./routes/_page/docs/affordance/typeahead"; import { Route as PageDocsAffordanceZoomRouteImport } from "./routes/_page/docs/affordance/zoom"; +import { Route as PageDocsApiIndexRouteImport } from "./routes/_page/docs/api/index"; import { Route as PageDocsApiA2uiRouteImport } from "./routes/_page/docs/api/a2ui"; import { Route as PageDocsApiAffordanceRouteImport } from "./routes/_page/docs/api/affordance"; import { Route as PageDocsApiAjvRouteImport } from "./routes/_page/docs/api/ajv"; import { Route as PageDocsApiAnimationReactRouteImport } from "./routes/_page/docs/api/animation-react"; import { Route as PageDocsApiAnnotationRouteImport } from "./routes/_page/docs/api/annotation"; import { Route as PageDocsApiCalendarRouteImport } from "./routes/_page/docs/api/calendar"; +import { Route as PageDocsApiCalendarDocumentRouteImport } from "./routes/_page/docs/api/calendar-document"; +import { Route as PageDocsApiCanvasRouteImport } from "./routes/_page/docs/api/canvas"; import { Route as PageDocsApiCollaborationRouteImport } from "./routes/_page/docs/api/collaboration"; import { Route as PageDocsApiComposerRouteImport } from "./routes/_page/docs/api/composer"; import { Route as PageDocsApiComposerReactRouteImport } from "./routes/_page/docs/api/composer-react"; @@ -144,6 +148,7 @@ import { Route as PageDocsApiEditingRouteImport } from "./routes/_page/docs/api/ import { Route as PageDocsApiFileIntakeRouteImport } from "./routes/_page/docs/api/file-intake"; import { Route as PageDocsApiJsonDocumentRouteImport } from "./routes/_page/docs/api/json-document"; import { Route as PageDocsApiMarkdownReactRouteImport } from "./routes/_page/docs/api/markdown-react"; +import { Route as PageDocsApiObjectDocumentRouteImport } from "./routes/_page/docs/api/object-document"; import { Route as PageDocsApiReactRouteImport } from "./routes/_page/docs/api/react"; import { Route as PageDocsApiReactHookFormRouteImport } from "./routes/_page/docs/api/react-hook-form"; import { Route as PageDocsApiRichTextRouteImport } from "./routes/_page/docs/api/rich-text"; @@ -436,9 +441,9 @@ const PageDocsAnimationRoute = PageDocsAnimationRouteImport.update({ path: "/docs/animation", getParentRoute: () => PageRoute, } as any); -const PageDocsApiRoute = PageDocsApiRouteImport.update({ - id: "/docs/api", - path: "/docs/api", +const PageDocsBuildingBlocksRoute = PageDocsBuildingBlocksRouteImport.update({ + id: "/docs/building-blocks", + path: "/docs/building-blocks", getParentRoute: () => PageRoute, } as any); const PageDocsClipboardRoute = PageDocsClipboardRouteImport.update({ @@ -504,6 +509,11 @@ const PageDocsDatabaseRoute = PageDocsDatabaseRouteImport.update({ path: "/docs/database", getParentRoute: () => PageRoute, } as any); +const PageDocsEditingRoute = PageDocsEditingRouteImport.update({ + id: "/docs/editing", + path: "/docs/editing", + getParentRoute: () => PageRoute, +} as any); const PageDocsFoundationRoute = PageDocsFoundationRouteImport.update({ id: "/docs/foundation", path: "/docs/foundation", @@ -792,169 +802,191 @@ const PageDocsAffordanceZoomRoute = PageDocsAffordanceZoomRouteImport.update({ path: "/docs/affordance/zoom", getParentRoute: () => PageRoute, } as any); +const PageDocsApiIndexRoute = PageDocsApiIndexRouteImport.update({ + id: "/docs/api/", + path: "/docs/api/", + getParentRoute: () => PageRoute, +} as any); const PageDocsApiA2uiRoute = PageDocsApiA2uiRouteImport.update({ - id: "/a2ui", - path: "/a2ui", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/a2ui", + path: "/docs/api/a2ui", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAffordanceRoute = PageDocsApiAffordanceRouteImport.update({ - id: "/affordance", - path: "/affordance", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/affordance", + path: "/docs/api/affordance", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAjvRoute = PageDocsApiAjvRouteImport.update({ - id: "/ajv", - path: "/ajv", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/ajv", + path: "/docs/api/ajv", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAnimationReactRoute = PageDocsApiAnimationReactRouteImport.update({ - id: "/animation-react", - path: "/animation-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/animation-react", + path: "/docs/api/animation-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAnnotationRoute = PageDocsApiAnnotationRouteImport.update({ - id: "/annotation", - path: "/annotation", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/annotation", + path: "/docs/api/annotation", + getParentRoute: () => PageRoute, } as any); const PageDocsApiCalendarRoute = PageDocsApiCalendarRouteImport.update({ - id: "/calendar", - path: "/calendar", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/calendar", + path: "/docs/api/calendar", + getParentRoute: () => PageRoute, +} as any); +const PageDocsApiCalendarDocumentRoute = + PageDocsApiCalendarDocumentRouteImport.update({ + id: "/docs/api/calendar-document", + path: "/docs/api/calendar-document", + getParentRoute: () => PageRoute, + } as any); +const PageDocsApiCanvasRoute = PageDocsApiCanvasRouteImport.update({ + id: "/docs/api/canvas", + path: "/docs/api/canvas", + getParentRoute: () => PageRoute, } as any); const PageDocsApiCollaborationRoute = PageDocsApiCollaborationRouteImport.update({ - id: "/collaboration", - path: "/collaboration", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/collaboration", + path: "/docs/api/collaboration", + getParentRoute: () => PageRoute, } as any); const PageDocsApiComposerRoute = PageDocsApiComposerRouteImport.update({ - id: "/composer", - path: "/composer", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/composer", + path: "/docs/api/composer", + getParentRoute: () => PageRoute, } as any); const PageDocsApiComposerReactRoute = PageDocsApiComposerReactRouteImport.update({ - id: "/composer-react", - path: "/composer-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/composer-react", + path: "/docs/api/composer-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiContenteditableRoute = PageDocsApiContenteditableRouteImport.update({ - id: "/contenteditable", - path: "/contenteditable", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/contenteditable", + path: "/docs/api/contenteditable", + getParentRoute: () => PageRoute, } as any); const PageDocsApiContenteditableCollaborationRoute = PageDocsApiContenteditableCollaborationRouteImport.update({ - id: "/contenteditable-collaboration", - path: "/contenteditable-collaboration", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/contenteditable-collaboration", + path: "/docs/api/contenteditable-collaboration", + getParentRoute: () => PageRoute, } as any); const PageDocsApiDatabaseRoute = PageDocsApiDatabaseRouteImport.update({ - id: "/database", - path: "/database", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/database", + path: "/docs/api/database", + getParentRoute: () => PageRoute, } as any); const PageDocsApiEditingRoute = PageDocsApiEditingRouteImport.update({ - id: "/editing", - path: "/editing", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/editing", + path: "/docs/api/editing", + getParentRoute: () => PageRoute, } as any); const PageDocsApiFileIntakeRoute = PageDocsApiFileIntakeRouteImport.update({ - id: "/file-intake", - path: "/file-intake", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/file-intake", + path: "/docs/api/file-intake", + getParentRoute: () => PageRoute, } as any); const PageDocsApiJsonDocumentRoute = PageDocsApiJsonDocumentRouteImport.update({ - id: "/json-document", - path: "/json-document", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/json-document", + path: "/docs/api/json-document", + getParentRoute: () => PageRoute, } as any); const PageDocsApiMarkdownReactRoute = PageDocsApiMarkdownReactRouteImport.update({ - id: "/markdown-react", - path: "/markdown-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/markdown-react", + path: "/docs/api/markdown-react", + getParentRoute: () => PageRoute, + } as any); +const PageDocsApiObjectDocumentRoute = + PageDocsApiObjectDocumentRouteImport.update({ + id: "/docs/api/object-document", + path: "/docs/api/object-document", + getParentRoute: () => PageRoute, } as any); const PageDocsApiReactRoute = PageDocsApiReactRouteImport.update({ - id: "/react", - path: "/react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/react", + path: "/docs/api/react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiReactHookFormRoute = PageDocsApiReactHookFormRouteImport.update({ - id: "/react-hook-form", - path: "/react-hook-form", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/react-hook-form", + path: "/docs/api/react-hook-form", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextRoute = PageDocsApiRichTextRouteImport.update({ - id: "/rich-text", - path: "/rich-text", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text", + path: "/docs/api/rich-text", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextMentionRoute = PageDocsApiRichTextMentionRouteImport.update({ - id: "/rich-text-mention", - path: "/rich-text-mention", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-mention", + path: "/docs/api/rich-text-mention", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextMentionReactRoute = PageDocsApiRichTextMentionReactRouteImport.update({ - id: "/rich-text-mention-react", - path: "/rich-text-mention-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-mention-react", + path: "/docs/api/rich-text-mention-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextReactRoute = PageDocsApiRichTextReactRouteImport.update({ - id: "/rich-text-react", - path: "/rich-text-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-react", + path: "/docs/api/rich-text-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextSuggestionRoute = PageDocsApiRichTextSuggestionRouteImport.update({ - id: "/rich-text-suggestion", - path: "/rich-text-suggestion", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-suggestion", + path: "/docs/api/rich-text-suggestion", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextSuggestionReactRoute = PageDocsApiRichTextSuggestionReactRouteImport.update({ - id: "/rich-text-suggestion-react", - path: "/rich-text-suggestion-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-suggestion-react", + path: "/docs/api/rich-text-suggestion-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextWebRoute = PageDocsApiRichTextWebRouteImport.update({ - id: "/rich-text-web", - path: "/rich-text-web", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-web", + path: "/docs/api/rich-text-web", + getParentRoute: () => PageRoute, } as any); const PageDocsApiSelectionRoute = PageDocsApiSelectionRouteImport.update({ - id: "/selection", - path: "/selection", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/selection", + path: "/docs/api/selection", + getParentRoute: () => PageRoute, } as any); const PageDocsApiTanstackTableRoute = PageDocsApiTanstackTableRouteImport.update({ - id: "/tanstack-table", - path: "/tanstack-table", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/tanstack-table", + path: "/docs/api/tanstack-table", + getParentRoute: () => PageRoute, } as any); const PageDocsApiUiPrimitivesReactRoute = PageDocsApiUiPrimitivesReactRouteImport.update({ - id: "/ui-primitives-react", - path: "/ui-primitives-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/ui-primitives-react", + path: "/docs/api/ui-primitives-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiWebRoute = PageDocsApiWebRouteImport.update({ - id: "/web", - path: "/web", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/web", + path: "/docs/api/web", + getParentRoute: () => PageRoute, } as any); const PageDocsApiZodRoute = PageDocsApiZodRouteImport.update({ - id: "/zod", - path: "/zod", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/zod", + path: "/docs/api/zod", + getParentRoute: () => PageRoute, } as any); const PageDocsCollaborationIndexRoute = PageDocsCollaborationIndexRouteImport.update({ @@ -1052,7 +1084,7 @@ export interface FileRoutesByFullPath { "/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/docs/adapters": typeof PageDocsAdaptersRoute; "/docs/animation": typeof PageDocsAnimationRoute; - "/docs/api": typeof PageDocsApiRouteWithChildren; + "/docs/building-blocks": typeof PageDocsBuildingBlocksRoute; "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/composer": typeof PageDocsComposerRoute; "/docs/concepts": typeof PageDocsConceptsRoute; @@ -1065,6 +1097,7 @@ export interface FileRoutesByFullPath { "/docs/connector-zod-validate": typeof PageDocsConnectorZodValidateRoute; "/docs/connectors": typeof PageDocsConnectorsRoute; "/docs/database": typeof PageDocsDatabaseRoute; + "/docs/editing": typeof PageDocsEditingRoute; "/docs/foundation": typeof PageDocsFoundationRoute; "/docs/history": typeof PageDocsHistoryRoute; "/docs/how-we-build": typeof PageDocsHowWeBuildRoute; @@ -1128,6 +1161,8 @@ export interface FileRoutesByFullPath { "/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; "/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/docs/api/calendar": typeof PageDocsApiCalendarRoute; + "/docs/api/calendar-document": typeof PageDocsApiCalendarDocumentRoute; + "/docs/api/canvas": typeof PageDocsApiCanvasRoute; "/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/docs/api/composer": typeof PageDocsApiComposerRoute; "/docs/api/composer-react": typeof PageDocsApiComposerReactRoute; @@ -1138,6 +1173,7 @@ export interface FileRoutesByFullPath { "/docs/api/file-intake": typeof PageDocsApiFileIntakeRoute; "/docs/api/json-document": typeof PageDocsApiJsonDocumentRoute; "/docs/api/markdown-react": typeof PageDocsApiMarkdownReactRoute; + "/docs/api/object-document": typeof PageDocsApiObjectDocumentRoute; "/docs/api/react": typeof PageDocsApiReactRoute; "/docs/api/react-hook-form": typeof PageDocsApiReactHookFormRoute; "/docs/api/rich-text": typeof PageDocsApiRichTextRoute; @@ -1158,6 +1194,7 @@ export interface FileRoutesByFullPath { "/docs/document-types/$candidate": typeof PageDocsDocumentTypesCandidateRoute; "/connectors/zod/": typeof PageConnectorsZodIndexRoute; "/docs/affordance/": typeof PageDocsAffordanceIndexRoute; + "/docs/api/": typeof PageDocsApiIndexRoute; "/docs/collaboration/": typeof PageDocsCollaborationIndexRoute; "/docs/document-types/": typeof PageDocsDocumentTypesIndexRoute; "/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; @@ -1210,7 +1247,7 @@ export interface FileRoutesByTo { "/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/docs/adapters": typeof PageDocsAdaptersRoute; "/docs/animation": typeof PageDocsAnimationRoute; - "/docs/api": typeof PageDocsApiRouteWithChildren; + "/docs/building-blocks": typeof PageDocsBuildingBlocksRoute; "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/composer": typeof PageDocsComposerRoute; "/docs/concepts": typeof PageDocsConceptsRoute; @@ -1223,6 +1260,7 @@ export interface FileRoutesByTo { "/docs/connector-zod-validate": typeof PageDocsConnectorZodValidateRoute; "/docs/connectors": typeof PageDocsConnectorsRoute; "/docs/database": typeof PageDocsDatabaseRoute; + "/docs/editing": typeof PageDocsEditingRoute; "/docs/foundation": typeof PageDocsFoundationRoute; "/docs/history": typeof PageDocsHistoryRoute; "/docs/how-we-build": typeof PageDocsHowWeBuildRoute; @@ -1286,6 +1324,8 @@ export interface FileRoutesByTo { "/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; "/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/docs/api/calendar": typeof PageDocsApiCalendarRoute; + "/docs/api/calendar-document": typeof PageDocsApiCalendarDocumentRoute; + "/docs/api/canvas": typeof PageDocsApiCanvasRoute; "/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/docs/api/composer": typeof PageDocsApiComposerRoute; "/docs/api/composer-react": typeof PageDocsApiComposerReactRoute; @@ -1296,6 +1336,7 @@ export interface FileRoutesByTo { "/docs/api/file-intake": typeof PageDocsApiFileIntakeRoute; "/docs/api/json-document": typeof PageDocsApiJsonDocumentRoute; "/docs/api/markdown-react": typeof PageDocsApiMarkdownReactRoute; + "/docs/api/object-document": typeof PageDocsApiObjectDocumentRoute; "/docs/api/react": typeof PageDocsApiReactRoute; "/docs/api/react-hook-form": typeof PageDocsApiReactHookFormRoute; "/docs/api/rich-text": typeof PageDocsApiRichTextRoute; @@ -1316,6 +1357,7 @@ export interface FileRoutesByTo { "/docs/document-types/$candidate": typeof PageDocsDocumentTypesCandidateRoute; "/connectors/zod": typeof PageConnectorsZodIndexRoute; "/docs/affordance": typeof PageDocsAffordanceIndexRoute; + "/docs/api": typeof PageDocsApiIndexRoute; "/docs/collaboration": typeof PageDocsCollaborationIndexRoute; "/docs/document-types": typeof PageDocsDocumentTypesIndexRoute; "/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; @@ -1370,7 +1412,7 @@ export interface FileRoutesById { "/_page/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/_page/docs/adapters": typeof PageDocsAdaptersRoute; "/_page/docs/animation": typeof PageDocsAnimationRoute; - "/_page/docs/api": typeof PageDocsApiRouteWithChildren; + "/_page/docs/building-blocks": typeof PageDocsBuildingBlocksRoute; "/_page/docs/clipboard": typeof PageDocsClipboardRoute; "/_page/docs/composer": typeof PageDocsComposerRoute; "/_page/docs/concepts": typeof PageDocsConceptsRoute; @@ -1383,6 +1425,7 @@ export interface FileRoutesById { "/_page/docs/connector-zod-validate": typeof PageDocsConnectorZodValidateRoute; "/_page/docs/connectors": typeof PageDocsConnectorsRoute; "/_page/docs/database": typeof PageDocsDatabaseRoute; + "/_page/docs/editing": typeof PageDocsEditingRoute; "/_page/docs/foundation": typeof PageDocsFoundationRoute; "/_page/docs/history": typeof PageDocsHistoryRoute; "/_page/docs/how-we-build": typeof PageDocsHowWeBuildRoute; @@ -1446,6 +1489,8 @@ export interface FileRoutesById { "/_page/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; "/_page/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/_page/docs/api/calendar": typeof PageDocsApiCalendarRoute; + "/_page/docs/api/calendar-document": typeof PageDocsApiCalendarDocumentRoute; + "/_page/docs/api/canvas": typeof PageDocsApiCanvasRoute; "/_page/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/_page/docs/api/composer": typeof PageDocsApiComposerRoute; "/_page/docs/api/composer-react": typeof PageDocsApiComposerReactRoute; @@ -1456,6 +1501,7 @@ export interface FileRoutesById { "/_page/docs/api/file-intake": typeof PageDocsApiFileIntakeRoute; "/_page/docs/api/json-document": typeof PageDocsApiJsonDocumentRoute; "/_page/docs/api/markdown-react": typeof PageDocsApiMarkdownReactRoute; + "/_page/docs/api/object-document": typeof PageDocsApiObjectDocumentRoute; "/_page/docs/api/react": typeof PageDocsApiReactRoute; "/_page/docs/api/react-hook-form": typeof PageDocsApiReactHookFormRoute; "/_page/docs/api/rich-text": typeof PageDocsApiRichTextRoute; @@ -1476,6 +1522,7 @@ export interface FileRoutesById { "/_page/docs/document-types/$candidate": typeof PageDocsDocumentTypesCandidateRoute; "/_page/connectors/zod/": typeof PageConnectorsZodIndexRoute; "/_page/docs/affordance/": typeof PageDocsAffordanceIndexRoute; + "/_page/docs/api/": typeof PageDocsApiIndexRoute; "/_page/docs/collaboration/": typeof PageDocsCollaborationIndexRoute; "/_page/docs/document-types/": typeof PageDocsDocumentTypesIndexRoute; "/_page/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; @@ -1530,7 +1577,7 @@ export interface FileRouteTypes { | "/docs/adapter-virtual-selection" | "/docs/adapters" | "/docs/animation" - | "/docs/api" + | "/docs/building-blocks" | "/docs/clipboard" | "/docs/composer" | "/docs/concepts" @@ -1543,6 +1590,7 @@ export interface FileRouteTypes { | "/docs/connector-zod-validate" | "/docs/connectors" | "/docs/database" + | "/docs/editing" | "/docs/foundation" | "/docs/history" | "/docs/how-we-build" @@ -1606,6 +1654,8 @@ export interface FileRouteTypes { | "/docs/api/animation-react" | "/docs/api/annotation" | "/docs/api/calendar" + | "/docs/api/calendar-document" + | "/docs/api/canvas" | "/docs/api/collaboration" | "/docs/api/composer" | "/docs/api/composer-react" @@ -1616,6 +1666,7 @@ export interface FileRouteTypes { | "/docs/api/file-intake" | "/docs/api/json-document" | "/docs/api/markdown-react" + | "/docs/api/object-document" | "/docs/api/react" | "/docs/api/react-hook-form" | "/docs/api/rich-text" @@ -1636,6 +1687,7 @@ export interface FileRouteTypes { | "/docs/document-types/$candidate" | "/connectors/zod/" | "/docs/affordance/" + | "/docs/api/" | "/docs/collaboration/" | "/docs/document-types/" | "/docs/collaboration/text/lease" @@ -1688,7 +1740,7 @@ export interface FileRouteTypes { | "/docs/adapter-virtual-selection" | "/docs/adapters" | "/docs/animation" - | "/docs/api" + | "/docs/building-blocks" | "/docs/clipboard" | "/docs/composer" | "/docs/concepts" @@ -1701,6 +1753,7 @@ export interface FileRouteTypes { | "/docs/connector-zod-validate" | "/docs/connectors" | "/docs/database" + | "/docs/editing" | "/docs/foundation" | "/docs/history" | "/docs/how-we-build" @@ -1764,6 +1817,8 @@ export interface FileRouteTypes { | "/docs/api/animation-react" | "/docs/api/annotation" | "/docs/api/calendar" + | "/docs/api/calendar-document" + | "/docs/api/canvas" | "/docs/api/collaboration" | "/docs/api/composer" | "/docs/api/composer-react" @@ -1774,6 +1829,7 @@ export interface FileRouteTypes { | "/docs/api/file-intake" | "/docs/api/json-document" | "/docs/api/markdown-react" + | "/docs/api/object-document" | "/docs/api/react" | "/docs/api/react-hook-form" | "/docs/api/rich-text" @@ -1794,6 +1850,7 @@ export interface FileRouteTypes { | "/docs/document-types/$candidate" | "/connectors/zod" | "/docs/affordance" + | "/docs/api" | "/docs/collaboration" | "/docs/document-types" | "/docs/collaboration/text/lease" @@ -1847,7 +1904,7 @@ export interface FileRouteTypes { | "/_page/docs/adapter-virtual-selection" | "/_page/docs/adapters" | "/_page/docs/animation" - | "/_page/docs/api" + | "/_page/docs/building-blocks" | "/_page/docs/clipboard" | "/_page/docs/composer" | "/_page/docs/concepts" @@ -1860,6 +1917,7 @@ export interface FileRouteTypes { | "/_page/docs/connector-zod-validate" | "/_page/docs/connectors" | "/_page/docs/database" + | "/_page/docs/editing" | "/_page/docs/foundation" | "/_page/docs/history" | "/_page/docs/how-we-build" @@ -1923,6 +1981,8 @@ export interface FileRouteTypes { | "/_page/docs/api/animation-react" | "/_page/docs/api/annotation" | "/_page/docs/api/calendar" + | "/_page/docs/api/calendar-document" + | "/_page/docs/api/canvas" | "/_page/docs/api/collaboration" | "/_page/docs/api/composer" | "/_page/docs/api/composer-react" @@ -1933,6 +1993,7 @@ export interface FileRouteTypes { | "/_page/docs/api/file-intake" | "/_page/docs/api/json-document" | "/_page/docs/api/markdown-react" + | "/_page/docs/api/object-document" | "/_page/docs/api/react" | "/_page/docs/api/react-hook-form" | "/_page/docs/api/rich-text" @@ -1953,6 +2014,7 @@ export interface FileRouteTypes { | "/_page/docs/document-types/$candidate" | "/_page/connectors/zod/" | "/_page/docs/affordance/" + | "/_page/docs/api/" | "/_page/docs/collaboration/" | "/_page/docs/document-types/" | "/_page/docs/collaboration/text/lease" @@ -2330,11 +2392,11 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsAnimationRouteImport; parentRoute: typeof PageRoute; }; - "/_page/docs/api": { - id: "/_page/docs/api"; - path: "/docs/api"; - fullPath: "/docs/api"; - preLoaderRoute: typeof PageDocsApiRouteImport; + "/_page/docs/building-blocks": { + id: "/_page/docs/building-blocks"; + path: "/docs/building-blocks"; + fullPath: "/docs/building-blocks"; + preLoaderRoute: typeof PageDocsBuildingBlocksRouteImport; parentRoute: typeof PageRoute; }; "/_page/docs/clipboard": { @@ -2421,6 +2483,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsDatabaseRouteImport; parentRoute: typeof PageRoute; }; + "/_page/docs/editing": { + id: "/_page/docs/editing"; + path: "/docs/editing"; + fullPath: "/docs/editing"; + preLoaderRoute: typeof PageDocsEditingRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/docs/foundation": { id: "/_page/docs/foundation"; path: "/docs/foundation"; @@ -2799,215 +2868,243 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsAffordanceZoomRouteImport; parentRoute: typeof PageRoute; }; + "/_page/docs/api/": { + id: "/_page/docs/api/"; + path: "/docs/api"; + fullPath: "/docs/api/"; + preLoaderRoute: typeof PageDocsApiIndexRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/docs/api/a2ui": { id: "/_page/docs/api/a2ui"; - path: "/a2ui"; + path: "/docs/api/a2ui"; fullPath: "/docs/api/a2ui"; preLoaderRoute: typeof PageDocsApiA2uiRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/affordance": { id: "/_page/docs/api/affordance"; - path: "/affordance"; + path: "/docs/api/affordance"; fullPath: "/docs/api/affordance"; preLoaderRoute: typeof PageDocsApiAffordanceRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/ajv": { id: "/_page/docs/api/ajv"; - path: "/ajv"; + path: "/docs/api/ajv"; fullPath: "/docs/api/ajv"; preLoaderRoute: typeof PageDocsApiAjvRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/animation-react": { id: "/_page/docs/api/animation-react"; - path: "/animation-react"; + path: "/docs/api/animation-react"; fullPath: "/docs/api/animation-react"; preLoaderRoute: typeof PageDocsApiAnimationReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/annotation": { id: "/_page/docs/api/annotation"; - path: "/annotation"; + path: "/docs/api/annotation"; fullPath: "/docs/api/annotation"; preLoaderRoute: typeof PageDocsApiAnnotationRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/calendar": { id: "/_page/docs/api/calendar"; - path: "/calendar"; + path: "/docs/api/calendar"; fullPath: "/docs/api/calendar"; preLoaderRoute: typeof PageDocsApiCalendarRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; + }; + "/_page/docs/api/calendar-document": { + id: "/_page/docs/api/calendar-document"; + path: "/docs/api/calendar-document"; + fullPath: "/docs/api/calendar-document"; + preLoaderRoute: typeof PageDocsApiCalendarDocumentRouteImport; + parentRoute: typeof PageRoute; + }; + "/_page/docs/api/canvas": { + id: "/_page/docs/api/canvas"; + path: "/docs/api/canvas"; + fullPath: "/docs/api/canvas"; + preLoaderRoute: typeof PageDocsApiCanvasRouteImport; + parentRoute: typeof PageRoute; }; "/_page/docs/api/collaboration": { id: "/_page/docs/api/collaboration"; - path: "/collaboration"; + path: "/docs/api/collaboration"; fullPath: "/docs/api/collaboration"; preLoaderRoute: typeof PageDocsApiCollaborationRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/composer": { id: "/_page/docs/api/composer"; - path: "/composer"; + path: "/docs/api/composer"; fullPath: "/docs/api/composer"; preLoaderRoute: typeof PageDocsApiComposerRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/composer-react": { id: "/_page/docs/api/composer-react"; - path: "/composer-react"; + path: "/docs/api/composer-react"; fullPath: "/docs/api/composer-react"; preLoaderRoute: typeof PageDocsApiComposerReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/contenteditable": { id: "/_page/docs/api/contenteditable"; - path: "/contenteditable"; + path: "/docs/api/contenteditable"; fullPath: "/docs/api/contenteditable"; preLoaderRoute: typeof PageDocsApiContenteditableRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/contenteditable-collaboration": { id: "/_page/docs/api/contenteditable-collaboration"; - path: "/contenteditable-collaboration"; + path: "/docs/api/contenteditable-collaboration"; fullPath: "/docs/api/contenteditable-collaboration"; preLoaderRoute: typeof PageDocsApiContenteditableCollaborationRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/database": { id: "/_page/docs/api/database"; - path: "/database"; + path: "/docs/api/database"; fullPath: "/docs/api/database"; preLoaderRoute: typeof PageDocsApiDatabaseRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/editing": { id: "/_page/docs/api/editing"; - path: "/editing"; + path: "/docs/api/editing"; fullPath: "/docs/api/editing"; preLoaderRoute: typeof PageDocsApiEditingRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/file-intake": { id: "/_page/docs/api/file-intake"; - path: "/file-intake"; + path: "/docs/api/file-intake"; fullPath: "/docs/api/file-intake"; preLoaderRoute: typeof PageDocsApiFileIntakeRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/json-document": { id: "/_page/docs/api/json-document"; - path: "/json-document"; + path: "/docs/api/json-document"; fullPath: "/docs/api/json-document"; preLoaderRoute: typeof PageDocsApiJsonDocumentRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/markdown-react": { id: "/_page/docs/api/markdown-react"; - path: "/markdown-react"; + path: "/docs/api/markdown-react"; fullPath: "/docs/api/markdown-react"; preLoaderRoute: typeof PageDocsApiMarkdownReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; + }; + "/_page/docs/api/object-document": { + id: "/_page/docs/api/object-document"; + path: "/docs/api/object-document"; + fullPath: "/docs/api/object-document"; + preLoaderRoute: typeof PageDocsApiObjectDocumentRouteImport; + parentRoute: typeof PageRoute; }; "/_page/docs/api/react": { id: "/_page/docs/api/react"; - path: "/react"; + path: "/docs/api/react"; fullPath: "/docs/api/react"; preLoaderRoute: typeof PageDocsApiReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/react-hook-form": { id: "/_page/docs/api/react-hook-form"; - path: "/react-hook-form"; + path: "/docs/api/react-hook-form"; fullPath: "/docs/api/react-hook-form"; preLoaderRoute: typeof PageDocsApiReactHookFormRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text": { id: "/_page/docs/api/rich-text"; - path: "/rich-text"; + path: "/docs/api/rich-text"; fullPath: "/docs/api/rich-text"; preLoaderRoute: typeof PageDocsApiRichTextRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-mention": { id: "/_page/docs/api/rich-text-mention"; - path: "/rich-text-mention"; + path: "/docs/api/rich-text-mention"; fullPath: "/docs/api/rich-text-mention"; preLoaderRoute: typeof PageDocsApiRichTextMentionRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-mention-react": { id: "/_page/docs/api/rich-text-mention-react"; - path: "/rich-text-mention-react"; + path: "/docs/api/rich-text-mention-react"; fullPath: "/docs/api/rich-text-mention-react"; preLoaderRoute: typeof PageDocsApiRichTextMentionReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-react": { id: "/_page/docs/api/rich-text-react"; - path: "/rich-text-react"; + path: "/docs/api/rich-text-react"; fullPath: "/docs/api/rich-text-react"; preLoaderRoute: typeof PageDocsApiRichTextReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-suggestion": { id: "/_page/docs/api/rich-text-suggestion"; - path: "/rich-text-suggestion"; + path: "/docs/api/rich-text-suggestion"; fullPath: "/docs/api/rich-text-suggestion"; preLoaderRoute: typeof PageDocsApiRichTextSuggestionRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-suggestion-react": { id: "/_page/docs/api/rich-text-suggestion-react"; - path: "/rich-text-suggestion-react"; + path: "/docs/api/rich-text-suggestion-react"; fullPath: "/docs/api/rich-text-suggestion-react"; preLoaderRoute: typeof PageDocsApiRichTextSuggestionReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-web": { id: "/_page/docs/api/rich-text-web"; - path: "/rich-text-web"; + path: "/docs/api/rich-text-web"; fullPath: "/docs/api/rich-text-web"; preLoaderRoute: typeof PageDocsApiRichTextWebRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/selection": { id: "/_page/docs/api/selection"; - path: "/selection"; + path: "/docs/api/selection"; fullPath: "/docs/api/selection"; preLoaderRoute: typeof PageDocsApiSelectionRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/tanstack-table": { id: "/_page/docs/api/tanstack-table"; - path: "/tanstack-table"; + path: "/docs/api/tanstack-table"; fullPath: "/docs/api/tanstack-table"; preLoaderRoute: typeof PageDocsApiTanstackTableRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/ui-primitives-react": { id: "/_page/docs/api/ui-primitives-react"; - path: "/ui-primitives-react"; + path: "/docs/api/ui-primitives-react"; fullPath: "/docs/api/ui-primitives-react"; preLoaderRoute: typeof PageDocsApiUiPrimitivesReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/web": { id: "/_page/docs/api/web"; - path: "/web"; + path: "/docs/api/web"; fullPath: "/docs/api/web"; preLoaderRoute: typeof PageDocsApiWebRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/zod": { id: "/_page/docs/api/zod"; - path: "/zod"; + path: "/docs/api/zod"; fullPath: "/docs/api/zod"; preLoaderRoute: typeof PageDocsApiZodRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/collaboration/": { id: "/_page/docs/collaboration/"; @@ -3068,78 +3165,6 @@ declare module "@tanstack/react-router" { } } -interface PageDocsApiRouteChildren { - PageDocsApiA2uiRoute: typeof PageDocsApiA2uiRoute; - PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute; - PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute; - PageDocsApiAnimationReactRoute: typeof PageDocsApiAnimationReactRoute; - PageDocsApiAnnotationRoute: typeof PageDocsApiAnnotationRoute; - PageDocsApiCalendarRoute: typeof PageDocsApiCalendarRoute; - PageDocsApiCollaborationRoute: typeof PageDocsApiCollaborationRoute; - PageDocsApiComposerRoute: typeof PageDocsApiComposerRoute; - PageDocsApiComposerReactRoute: typeof PageDocsApiComposerReactRoute; - PageDocsApiContenteditableRoute: typeof PageDocsApiContenteditableRoute; - PageDocsApiContenteditableCollaborationRoute: typeof PageDocsApiContenteditableCollaborationRoute; - PageDocsApiDatabaseRoute: typeof PageDocsApiDatabaseRoute; - PageDocsApiEditingRoute: typeof PageDocsApiEditingRoute; - PageDocsApiFileIntakeRoute: typeof PageDocsApiFileIntakeRoute; - PageDocsApiJsonDocumentRoute: typeof PageDocsApiJsonDocumentRoute; - PageDocsApiMarkdownReactRoute: typeof PageDocsApiMarkdownReactRoute; - PageDocsApiReactRoute: typeof PageDocsApiReactRoute; - PageDocsApiReactHookFormRoute: typeof PageDocsApiReactHookFormRoute; - PageDocsApiRichTextRoute: typeof PageDocsApiRichTextRoute; - PageDocsApiRichTextMentionRoute: typeof PageDocsApiRichTextMentionRoute; - PageDocsApiRichTextMentionReactRoute: typeof PageDocsApiRichTextMentionReactRoute; - PageDocsApiRichTextReactRoute: typeof PageDocsApiRichTextReactRoute; - PageDocsApiRichTextSuggestionRoute: typeof PageDocsApiRichTextSuggestionRoute; - PageDocsApiRichTextSuggestionReactRoute: typeof PageDocsApiRichTextSuggestionReactRoute; - PageDocsApiRichTextWebRoute: typeof PageDocsApiRichTextWebRoute; - PageDocsApiSelectionRoute: typeof PageDocsApiSelectionRoute; - PageDocsApiTanstackTableRoute: typeof PageDocsApiTanstackTableRoute; - PageDocsApiUiPrimitivesReactRoute: typeof PageDocsApiUiPrimitivesReactRoute; - PageDocsApiWebRoute: typeof PageDocsApiWebRoute; - PageDocsApiZodRoute: typeof PageDocsApiZodRoute; -} - -const PageDocsApiRouteChildren: PageDocsApiRouteChildren = { - PageDocsApiA2uiRoute: PageDocsApiA2uiRoute, - PageDocsApiAffordanceRoute: PageDocsApiAffordanceRoute, - PageDocsApiAjvRoute: PageDocsApiAjvRoute, - PageDocsApiAnimationReactRoute: PageDocsApiAnimationReactRoute, - PageDocsApiAnnotationRoute: PageDocsApiAnnotationRoute, - PageDocsApiCalendarRoute: PageDocsApiCalendarRoute, - PageDocsApiCollaborationRoute: PageDocsApiCollaborationRoute, - PageDocsApiComposerRoute: PageDocsApiComposerRoute, - PageDocsApiComposerReactRoute: PageDocsApiComposerReactRoute, - PageDocsApiContenteditableRoute: PageDocsApiContenteditableRoute, - PageDocsApiContenteditableCollaborationRoute: - PageDocsApiContenteditableCollaborationRoute, - PageDocsApiDatabaseRoute: PageDocsApiDatabaseRoute, - PageDocsApiEditingRoute: PageDocsApiEditingRoute, - PageDocsApiFileIntakeRoute: PageDocsApiFileIntakeRoute, - PageDocsApiJsonDocumentRoute: PageDocsApiJsonDocumentRoute, - PageDocsApiMarkdownReactRoute: PageDocsApiMarkdownReactRoute, - PageDocsApiReactRoute: PageDocsApiReactRoute, - PageDocsApiReactHookFormRoute: PageDocsApiReactHookFormRoute, - PageDocsApiRichTextRoute: PageDocsApiRichTextRoute, - PageDocsApiRichTextMentionRoute: PageDocsApiRichTextMentionRoute, - PageDocsApiRichTextMentionReactRoute: PageDocsApiRichTextMentionReactRoute, - PageDocsApiRichTextReactRoute: PageDocsApiRichTextReactRoute, - PageDocsApiRichTextSuggestionRoute: PageDocsApiRichTextSuggestionRoute, - PageDocsApiRichTextSuggestionReactRoute: - PageDocsApiRichTextSuggestionReactRoute, - PageDocsApiRichTextWebRoute: PageDocsApiRichTextWebRoute, - PageDocsApiSelectionRoute: PageDocsApiSelectionRoute, - PageDocsApiTanstackTableRoute: PageDocsApiTanstackTableRoute, - PageDocsApiUiPrimitivesReactRoute: PageDocsApiUiPrimitivesReactRoute, - PageDocsApiWebRoute: PageDocsApiWebRoute, - PageDocsApiZodRoute: PageDocsApiZodRoute, -}; - -const PageDocsApiRouteWithChildren = PageDocsApiRoute._addFileChildren( - PageDocsApiRouteChildren, -); - interface PageRouteChildren { PageDemosRoute: typeof PageDemosRoute; PageEditorsRoute: typeof PageEditorsRoute; @@ -3186,7 +3211,7 @@ interface PageRouteChildren { PageDocsAdapterVirtualSelectionRoute: typeof PageDocsAdapterVirtualSelectionRoute; PageDocsAdaptersRoute: typeof PageDocsAdaptersRoute; PageDocsAnimationRoute: typeof PageDocsAnimationRoute; - PageDocsApiRoute: typeof PageDocsApiRouteWithChildren; + PageDocsBuildingBlocksRoute: typeof PageDocsBuildingBlocksRoute; PageDocsClipboardRoute: typeof PageDocsClipboardRoute; PageDocsComposerRoute: typeof PageDocsComposerRoute; PageDocsConceptsRoute: typeof PageDocsConceptsRoute; @@ -3199,6 +3224,7 @@ interface PageRouteChildren { PageDocsConnectorZodValidateRoute: typeof PageDocsConnectorZodValidateRoute; PageDocsConnectorsRoute: typeof PageDocsConnectorsRoute; PageDocsDatabaseRoute: typeof PageDocsDatabaseRoute; + PageDocsEditingRoute: typeof PageDocsEditingRoute; PageDocsFoundationRoute: typeof PageDocsFoundationRoute; PageDocsHistoryRoute: typeof PageDocsHistoryRoute; PageDocsHowWeBuildRoute: typeof PageDocsHowWeBuildRoute; @@ -3256,12 +3282,46 @@ interface PageRouteChildren { PageDocsAffordanceTripleClickRoute: typeof PageDocsAffordanceTripleClickRoute; PageDocsAffordanceTypeaheadRoute: typeof PageDocsAffordanceTypeaheadRoute; PageDocsAffordanceZoomRoute: typeof PageDocsAffordanceZoomRoute; + PageDocsApiA2uiRoute: typeof PageDocsApiA2uiRoute; + PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute; + PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute; + PageDocsApiAnimationReactRoute: typeof PageDocsApiAnimationReactRoute; + PageDocsApiAnnotationRoute: typeof PageDocsApiAnnotationRoute; + PageDocsApiCalendarRoute: typeof PageDocsApiCalendarRoute; + PageDocsApiCalendarDocumentRoute: typeof PageDocsApiCalendarDocumentRoute; + PageDocsApiCanvasRoute: typeof PageDocsApiCanvasRoute; + PageDocsApiCollaborationRoute: typeof PageDocsApiCollaborationRoute; + PageDocsApiComposerRoute: typeof PageDocsApiComposerRoute; + PageDocsApiComposerReactRoute: typeof PageDocsApiComposerReactRoute; + PageDocsApiContenteditableRoute: typeof PageDocsApiContenteditableRoute; + PageDocsApiContenteditableCollaborationRoute: typeof PageDocsApiContenteditableCollaborationRoute; + PageDocsApiDatabaseRoute: typeof PageDocsApiDatabaseRoute; + PageDocsApiEditingRoute: typeof PageDocsApiEditingRoute; + PageDocsApiFileIntakeRoute: typeof PageDocsApiFileIntakeRoute; + PageDocsApiJsonDocumentRoute: typeof PageDocsApiJsonDocumentRoute; + PageDocsApiMarkdownReactRoute: typeof PageDocsApiMarkdownReactRoute; + PageDocsApiObjectDocumentRoute: typeof PageDocsApiObjectDocumentRoute; + PageDocsApiReactRoute: typeof PageDocsApiReactRoute; + PageDocsApiReactHookFormRoute: typeof PageDocsApiReactHookFormRoute; + PageDocsApiRichTextRoute: typeof PageDocsApiRichTextRoute; + PageDocsApiRichTextMentionRoute: typeof PageDocsApiRichTextMentionRoute; + PageDocsApiRichTextMentionReactRoute: typeof PageDocsApiRichTextMentionReactRoute; + PageDocsApiRichTextReactRoute: typeof PageDocsApiRichTextReactRoute; + PageDocsApiRichTextSuggestionRoute: typeof PageDocsApiRichTextSuggestionRoute; + PageDocsApiRichTextSuggestionReactRoute: typeof PageDocsApiRichTextSuggestionReactRoute; + PageDocsApiRichTextWebRoute: typeof PageDocsApiRichTextWebRoute; + PageDocsApiSelectionRoute: typeof PageDocsApiSelectionRoute; + PageDocsApiTanstackTableRoute: typeof PageDocsApiTanstackTableRoute; + PageDocsApiUiPrimitivesReactRoute: typeof PageDocsApiUiPrimitivesReactRoute; + PageDocsApiWebRoute: typeof PageDocsApiWebRoute; + PageDocsApiZodRoute: typeof PageDocsApiZodRoute; PageDocsCollaborationHistoryRoute: typeof PageDocsCollaborationHistoryRoute; PageDocsCollaborationLifecycleRoute: typeof PageDocsCollaborationLifecycleRoute; PageDocsCollaborationReplicaRoute: typeof PageDocsCollaborationReplicaRoute; PageDocsDocumentTypesCandidateRoute: typeof PageDocsDocumentTypesCandidateRoute; PageConnectorsZodIndexRoute: typeof PageConnectorsZodIndexRoute; PageDocsAffordanceIndexRoute: typeof PageDocsAffordanceIndexRoute; + PageDocsApiIndexRoute: typeof PageDocsApiIndexRoute; PageDocsCollaborationIndexRoute: typeof PageDocsCollaborationIndexRoute; PageDocsDocumentTypesIndexRoute: typeof PageDocsDocumentTypesIndexRoute; PageDocsCollaborationTextLeaseRoute: typeof PageDocsCollaborationTextLeaseRoute; @@ -3314,7 +3374,7 @@ const PageRouteChildren: PageRouteChildren = { PageDocsAdapterVirtualSelectionRoute: PageDocsAdapterVirtualSelectionRoute, PageDocsAdaptersRoute: PageDocsAdaptersRoute, PageDocsAnimationRoute: PageDocsAnimationRoute, - PageDocsApiRoute: PageDocsApiRouteWithChildren, + PageDocsBuildingBlocksRoute: PageDocsBuildingBlocksRoute, PageDocsClipboardRoute: PageDocsClipboardRoute, PageDocsComposerRoute: PageDocsComposerRoute, PageDocsConceptsRoute: PageDocsConceptsRoute, @@ -3327,6 +3387,7 @@ const PageRouteChildren: PageRouteChildren = { PageDocsConnectorZodValidateRoute: PageDocsConnectorZodValidateRoute, PageDocsConnectorsRoute: PageDocsConnectorsRoute, PageDocsDatabaseRoute: PageDocsDatabaseRoute, + PageDocsEditingRoute: PageDocsEditingRoute, PageDocsFoundationRoute: PageDocsFoundationRoute, PageDocsHistoryRoute: PageDocsHistoryRoute, PageDocsHowWeBuildRoute: PageDocsHowWeBuildRoute, @@ -3384,12 +3445,48 @@ const PageRouteChildren: PageRouteChildren = { PageDocsAffordanceTripleClickRoute: PageDocsAffordanceTripleClickRoute, PageDocsAffordanceTypeaheadRoute: PageDocsAffordanceTypeaheadRoute, PageDocsAffordanceZoomRoute: PageDocsAffordanceZoomRoute, + PageDocsApiA2uiRoute: PageDocsApiA2uiRoute, + PageDocsApiAffordanceRoute: PageDocsApiAffordanceRoute, + PageDocsApiAjvRoute: PageDocsApiAjvRoute, + PageDocsApiAnimationReactRoute: PageDocsApiAnimationReactRoute, + PageDocsApiAnnotationRoute: PageDocsApiAnnotationRoute, + PageDocsApiCalendarRoute: PageDocsApiCalendarRoute, + PageDocsApiCalendarDocumentRoute: PageDocsApiCalendarDocumentRoute, + PageDocsApiCanvasRoute: PageDocsApiCanvasRoute, + PageDocsApiCollaborationRoute: PageDocsApiCollaborationRoute, + PageDocsApiComposerRoute: PageDocsApiComposerRoute, + PageDocsApiComposerReactRoute: PageDocsApiComposerReactRoute, + PageDocsApiContenteditableRoute: PageDocsApiContenteditableRoute, + PageDocsApiContenteditableCollaborationRoute: + PageDocsApiContenteditableCollaborationRoute, + PageDocsApiDatabaseRoute: PageDocsApiDatabaseRoute, + PageDocsApiEditingRoute: PageDocsApiEditingRoute, + PageDocsApiFileIntakeRoute: PageDocsApiFileIntakeRoute, + PageDocsApiJsonDocumentRoute: PageDocsApiJsonDocumentRoute, + PageDocsApiMarkdownReactRoute: PageDocsApiMarkdownReactRoute, + PageDocsApiObjectDocumentRoute: PageDocsApiObjectDocumentRoute, + PageDocsApiReactRoute: PageDocsApiReactRoute, + PageDocsApiReactHookFormRoute: PageDocsApiReactHookFormRoute, + PageDocsApiRichTextRoute: PageDocsApiRichTextRoute, + PageDocsApiRichTextMentionRoute: PageDocsApiRichTextMentionRoute, + PageDocsApiRichTextMentionReactRoute: PageDocsApiRichTextMentionReactRoute, + PageDocsApiRichTextReactRoute: PageDocsApiRichTextReactRoute, + PageDocsApiRichTextSuggestionRoute: PageDocsApiRichTextSuggestionRoute, + PageDocsApiRichTextSuggestionReactRoute: + PageDocsApiRichTextSuggestionReactRoute, + PageDocsApiRichTextWebRoute: PageDocsApiRichTextWebRoute, + PageDocsApiSelectionRoute: PageDocsApiSelectionRoute, + PageDocsApiTanstackTableRoute: PageDocsApiTanstackTableRoute, + PageDocsApiUiPrimitivesReactRoute: PageDocsApiUiPrimitivesReactRoute, + PageDocsApiWebRoute: PageDocsApiWebRoute, + PageDocsApiZodRoute: PageDocsApiZodRoute, PageDocsCollaborationHistoryRoute: PageDocsCollaborationHistoryRoute, PageDocsCollaborationLifecycleRoute: PageDocsCollaborationLifecycleRoute, PageDocsCollaborationReplicaRoute: PageDocsCollaborationReplicaRoute, PageDocsDocumentTypesCandidateRoute: PageDocsDocumentTypesCandidateRoute, PageConnectorsZodIndexRoute: PageConnectorsZodIndexRoute, PageDocsAffordanceIndexRoute: PageDocsAffordanceIndexRoute, + PageDocsApiIndexRoute: PageDocsApiIndexRoute, PageDocsCollaborationIndexRoute: PageDocsCollaborationIndexRoute, PageDocsDocumentTypesIndexRoute: PageDocsDocumentTypesIndexRoute, PageDocsCollaborationTextLeaseRoute: PageDocsCollaborationTextLeaseRoute, diff --git a/site/src/app/routes/__root.tsx b/site/src/app/routes/__root.tsx index b87c333f1..8f47c8c8c 100644 --- a/site/src/app/routes/__root.tsx +++ b/site/src/app/routes/__root.tsx @@ -60,7 +60,7 @@ function AppShell() { key={section.id} to={section.path} activePath={route.path} - className={classes(ui.nav.railItem, section.separated ? ui.nav.railSeparatedItem : undefined, ui.nav.current)} + className={classes(ui.nav.railItem, ui.nav.current)} > {section.label} @@ -100,28 +100,18 @@ function AppShell() { item.navigationGroup !== undefined && section.groups.includes(item.navigationGroup) && item.sidebar !== false - && !item.path.startsWith("/docs/api") + && !item.path.startsWith("/docs/api/") ); + const landingRoute = siteRoutes.find((item) => item.path === section.path && item.navigationGroup === undefined); const sectionLabelId = `site-navigation-${section.id}`; const open = openSections.has(section.id); - if (section.groups.length === 0) return ( - - - {section.label} - - ); if (sectionRoutes.length === 0) return null; return (