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..a26f4a287 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,35 @@ npm test -w @interactive-os/json-document npm run typecheck -w @interactive-os/json-document npm run build -w @interactive-os/json-document ``` + +### 검증 운영 + +1인 개발과 agent의 로컬 검증을 기본으로 합니다. 개발·PR에서는 변경 범위에 맞는 +테스트·타입·빌드·브라우저 검증을 수행하고, 실행 명령과 결과를 인계합니다. +실행하지 못한 검사와 실패한 검사는 통과와 구분합니다. 자동 CI가 없다는 사실을 +검증 완료로 간주하지 않습니다. + +| 시점 | 원격 실행 | +| --- | --- | +| PR 생성·갱신 | 자동 전체 CI 없음 | +| main push | Pages 빌드·배포와 live 확인 | +| 릴리스 tag | 깨끗한 runner에서 기존 패키지 검증 후 publish | +| 필요 시 | 전체 CI와 장시간 collaboration soak 수동 실행 | + +전체 검증이 필요하면 기존 명령을 사용합니다. 제품 테스트와 검사 CLI는 유지하며, +변경 영향도 선택기 CLI는 자동 CI의 gate로 사용하지 않습니다. + +```sh +npm run verify +npm run release:check +npm run external-kit:verify +npm run test:collaboration:soak +``` + +원격의 깨끗한 환경에서 확인하려면 GitHub Actions의 해당 workflow에서 +`Run workflow`를 선택하거나 아래 명령으로 명시적으로 실행합니다. + +```sh +gh workflow run ci.yml --ref main +gh workflow run collaboration-soak.yml --ref main +``` diff --git a/audits/document-types.json b/audits/document-types.json index 9e08c16c0..f3723c67e 100644 --- a/audits/document-types.json +++ b/audits/document-types.json @@ -32,17 +32,19 @@ }, "object": { "why": "캔버스 object는 identity, geometry와 presentation 값을 하나의 의미 단위로 유지해야 하기 때문입니다.", - "does": "object의 위치·크기·색상 모델을 정의하고 translate·resize·fill 같은 공간 연산의 기반을 제공합니다.", - "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}", + "does": "object의 위치·크기·본문·스타일 모델을 정의하고 translate·resize·fill·style·text의 원자적 연산을 제공합니다.", + "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}\n\ninterface CanvasTextFormat {\n readonly fontSize?: number;\n readonly fontWeight?: 400 | 700;\n readonly textAlign?: \"left\" | \"center\" | \"right\";\n}\ntype CanvasObject = DocumentObject & (\n | (CanvasTextFormat & { kind: \"text\"; fontSize: number })\n | (CanvasTextFormat & { kind: \"rectangle\" | \"ellipse\" | \"sticky-note\"; textColor?: string; strokeColor?: string; strokeWidth?: number })\n | { kind: \"path\"; points: ReadonlyArray<{ x: number; y: number }>; strokeWidth: number }\n | { kind: \"image\"; source: string }\n);\ninterface CanvasDocument extends ObjectDocument {\n readonly profile: \"canvas/1\";\n readonly width: number; readonly height: number;\n readonly objects: ReadonlyArray;\n}", "fields": [ { "name": "objects", "description": "같은 공간에서 배치·선택되는 object의 ordered collection입니다." }, { "name": "objects[].id", "description": "selection, transform과 clipboard가 object를 안정적으로 참조하는 identity입니다." }, - { "name": "objects[].label", "description": "object의 사용자 가시 이름입니다." }, + { "name": "objects[].label", "description": "text·도형·노트의 실제 본문이며 별도 text 복사본이 없습니다. image·path에서는 이름입니다." }, { "name": "x / y", "description": "문서 좌표계에서 object 좌상단의 위치입니다." }, { "name": "width / height", "description": "resize 이후에도 유효해야 하는 object의 공간 크기입니다." }, - { "name": "color", "description": "object가 소유하는 현재 presentation 값입니다." } + { "name": "color / textColor", "description": "color는 도형·노트 채우기·독립 글자색·path 선 색이고 textColor는 도형·노트 본문 색입니다." }, + { "name": "fontSize / fontWeight / textAlign", "description": "본문의 크기·굵기·정렬입니다. 도형·노트의 생략된 크기는 24, 굵기는 400, 정렬은 도형 center·노트와 독립 글자 left입니다." }, + { "name": "strokeColor / strokeWidth", "description": "도형 테두리는 기본 #000000·0이며 path는 color와 양의 strokeWidth를 사용합니다." } ], - "sourcePath": "packages/json-document-editing/src/object.ts", + "sourcePath": "packages/json-document-object-document/src/object-model.ts", "symbol": "ObjectDocument" }, "tree": { @@ -86,7 +88,7 @@ { "name": "recurrence.freq / interval / until", "description": "반복 주기, 양의 간격과 반복 종료 경계를 정의합니다." }, { "name": "excludeDates", "description": "반복 projection에서 제외할 occurrence date 목록입니다." } ], - "sourcePath": "packages/json-document-editing/src/calendar.ts", + "sourcePath": "packages/json-document-calendar-document/src/calendar-model.ts", "symbol": "CalendarDocument" }, "sheet": { @@ -134,17 +136,292 @@ } }, "audits": { + "object": { + "status": "owner-closed", + "denominator": 15, + "horizon": { + "enumerators": [ + "packages/json-document-object-document/src/index.ts", + "packages/json-document-editing/src/object.ts", + "packages/json-document-canvas/src/index.ts", + "site/src/app/live-demo-registry.tsx", + "site/src/shared/demo-workbench/demo-sources.ts" + ], + "exclusions": [ + "Calendar와 Annotation의 독립 문서 의미", + "fixture·copy·layout-only CSS·tests·generated route" + ] + }, + "occurrences": [ + { + "id": "style", + "role": "Document Style", + "knowledge": "종류별 스타일·유효 기본값·혼합 선택·요청 검증", + "decision": "표시와 편집이 같은 스타일 의미를 소비", + "changeReason": "스타일 속성과 적용 가능성 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "readObjectStyle의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-style.ts와 공개 API·소유 API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-style.ts", + "symbol": "readObjectStyle", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "혼합 스타일·no-op·원자적 거절 회귀와 두 Canvas Host" + }, + { + "id": "model", + "role": "Document Model", + "knowledge": "ID·순서·한 장·variant 문서 어휘", + "decision": "기존 Object와 Canvas가 같은 Object 모델을 공유", + "changeReason": "프로파일의 값·variant 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "CanvasDocument의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-model.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-model.ts", + "symbol": "CanvasDocument", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "validation", + "role": "Validation", + "knowledge": "JSON·ID·유한 기하·정규화 path", + "decision": "잘못된 문서 전체를 거절", + "changeReason": "유효성·직렬화 계약 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "assertCanvasDocument의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-validation.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-validation.ts", + "symbol": "assertCanvasDocument", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "operation", + "role": "Document Operation", + "knowledge": "Object 의미 연산과 JSON Patch", + "decision": "대상·결과 검증 후 atomic 계획", + "changeReason": "문서 변형 의미 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "planObjectOperation의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-operation.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-operation.ts", + "symbol": "planObjectOperation", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "projection", + "role": "Projection", + "knowledge": "Object bounds·path 정규화·본문 capability와 배치", + "decision": "preview/commit의 동일한 기하와 표시/입력의 동일한 본문 projection", + "changeReason": "기하 표현 변경", + "stateLifecycle": "문서 값 또는 stateless 계산", + "inputsOutputs": "transformObject와 projectObjectText의 public 계약", + "currentOwner": "@interactive-os/json-document-object-document", + "canonicalEvidence": "packages/json-document-object-document/src/object-projection.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-object-document/src/object-projection.ts", + "symbol": "transformObject", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-object-document", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "editing", + "role": "Editing lifecycle", + "knowledge": "Object Intent와 post-selection", + "decision": "ID 생성·명령 실행·선택 publication", + "changeReason": "편집 문법 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createObjectEditor의 public 계약", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "packages/json-document-editing/src/object.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-editing/src/object.ts", + "symbol": "createObjectEditor", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "selection", + "role": "Selection", + "knowledge": "안정된 key 집합", + "decision": "키 선택 transition과 reconciliation", + "changeReason": "Selection family 규칙 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createKeySelectionFamily의 public 계약", + "currentOwner": "@interactive-os/json-document-selection", + "canonicalEvidence": "packages/json-document-selection/src/key/index.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-selection/src/key/index.ts", + "symbol": "createKeySelectionFamily", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-selection", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "history", + "role": "History", + "knowledge": "commit·inverse·selection", + "decision": "한 transaction의 undo/redo와 외부 변경 무효화", + "changeReason": "History lifecycle 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createEditingSession의 public 계약", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "packages/json-document-editing/src/session.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-editing/src/session.ts", + "symbol": "createEditingSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "gesture", + "role": "Affordance", + "knowledge": "입력 독립 gesture lifecycle", + "decision": "begin/preview/commit/cancel", + "changeReason": "interaction 의미 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createGestureSession의 public 계약", + "currentOwner": "@interactive-os/json-document-affordance", + "canonicalEvidence": "packages/json-document-affordance/src/gesture-session.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-affordance/src/gesture-session.ts", + "symbol": "createGestureSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-affordance", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "adapter", + "role": "Web Adapter", + "knowledge": "Pointer Events·capture", + "decision": "pointer identity와 release/cancel", + "changeReason": "Web platform 입력 계약 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "createWebPointerSession의 public 계약", + "currentOwner": "@interactive-os/json-document-web", + "canonicalEvidence": "packages/json-document-web/src/pointer-session.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-web/src/pointer-session.ts", + "symbol": "createWebPointerSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-web", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "ui", + "role": "Reusable UI behavior", + "knowledge": "Handle descriptor와 React/Web lifecycle", + "decision": "공통 capture continuation과 unmount 정리", + "changeReason": "handle UI 행동 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "useInteractionHandle의 public 계약", + "currentOwner": "@interactive-os/json-document-ui-primitives-react", + "canonicalEvidence": "packages/json-document-ui-primitives-react/src/surfaces.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-ui-primitives-react/src/surfaces.tsx", + "symbol": "useInteractionHandle", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-ui-primitives-react", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "hand", + "role": "Hand composition", + "knowledge": "Canvas 도구·Object Editing·Web·Affordance", + "decision": "transient preview와 text draft를 Intent에 연결", + "changeReason": "Canvas 입력 조합 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "useCanvasHand의 public 계약", + "currentOwner": "@interactive-os/json-document-canvas", + "canonicalEvidence": "packages/json-document-canvas/src/use-canvas-hand.ts와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "packages/json-document-canvas/src/use-canvas-hand.ts", + "symbol": "useCanvasHand", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-canvas", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "demo", + "role": "Host composition", + "knowledge": "빈 fixture·제품 값·레이아웃", + "decision": "정본 CanvasHand 조합", + "changeReason": "제품 fixture와 화면 구성 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "fixture·public API 입력 → 화면 조합", + "currentOwner": "Canvas Demo Host", + "canonicalEvidence": "site/src/routes/canvas-demo/CanvasDemoRoute.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "site/src/routes/canvas-demo/CanvasDemoRoute.tsx", + "symbol": "CanvasDemoRoute", + "disposition": "Host composition", + "intendedOwner": "Canvas Demo Host", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "widget", + "role": "Host composition", + "knowledge": "샘플 fixture·관찰 UI", + "decision": "같은 CanvasHand와 Connector 조합", + "changeReason": "샘플과 관찰 화면 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "fixture·public API 입력 → 화면 조합", + "currentOwner": "Canvas Widget Host", + "canonicalEvidence": "site/src/routes/widgets/CanvasWidgetRoute.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "site/src/routes/widgets/CanvasWidgetRoute.tsx", + "symbol": "CanvasWidgetRoute", + "disposition": "Host composition", + "intendedOwner": "Canvas Widget Host", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + }, + { + "id": "object-demo", + "role": "Host composition", + "knowledge": "기존 Object fixture·작업 조합", + "decision": "기존 ObjectEditor 및 Web clipboard 소비 유지", + "changeReason": "Object Demo 구성 변경", + "stateLifecycle": "소유자 API의 session/component lifecycle", + "inputsOutputs": "fixture·public API 입력 → 화면 조합", + "currentOwner": "Object Demo Host", + "canonicalEvidence": "site/src/routes/object-demo/ObjectDemoRoute.tsx와 공개 entrypoint·package API 문서·Canvas Usage/Source", + "sourcePath": "site/src/routes/object-demo/ObjectDemoRoute.tsx", + "symbol": "ObjectDemoRoute", + "disposition": "Host composition", + "intendedOwner": "Object Demo Host", + "nextCheck": "회귀 테스트와 실제 Canvas Usage/Source로 검증" + } + ], + "closure": { + "owner": "@interactive-os/json-document-object-document", + "publicEntry": "packages/json-document-object-document/src/index.ts", + "referencePath": "packages/json-document-object-document/docs/api.md", + "apiPath": "/docs/api/object-document", + "usagePath": "/demo/canvas", + "usagePagePath": "/docs/api/canvas", + "sourceRegistration": "site/src/shared/demo-workbench/demo-sources.ts", + "verification": [ + "packages/json-document-object-document/tests/object-document.test.ts", + "packages/json-document-editing/tests/canvas-profile.test.ts", + "packages/json-document-canvas/tests/canvas-hand.test.tsx", + "site/tests/unit/demo-workbench.test.tsx" + ] + } + }, "calendar": { - "status": "audited-tbd", - "denominator": 10, + "status": "owner-closed", + "denominator": 12, "horizon": { "enumerators": [ + "packages/json-document-calendar-document/src/index.ts", "packages/json-document-editing/src/index.ts", "packages/json-document-calendar/src/index.ts", "site/src/shared/demo-workbench/demo-sources.ts", "site/src/app/live-demo-registry.tsx" ], - "predicate": "Calendar의 model, invariant, semantic operation, projection, Editing lifecycle, platform translation, affordance, Hand와 reusable UI 책임", + "predicate": "Calendar의 model, invariant, semantic operation, projection, Editing lifecycle, platform translation, affordance, Hand와 reusable UI 책임; 정본 선택 대상과 명시적 paste cursor, Calendar Intent binding과 generic gesture lifecycle을 구분", "excluded": "fixtures, copy, layout-only CSS, tests and generated route files" }, "occurrences": [ @@ -156,13 +433,13 @@ "changeReason": "Calendar document vocabulary changes", "stateLifecycle": "immutable document value", "inputsOutputs": "CalendarDocument and nested JSON values", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "Document Type boundary: model survives without Editing and UI", - "sourcePath": "packages/json-document-editing/src/calendar.ts", + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-model.ts", "symbol": "CalendarDocument", - "disposition": "mislocated module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "admit and name the Calendar Document Type owner before moving the public type" + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "기존 Editing type export는 호환 경로이며 새 소비자는 Document Type API를 사용" }, { "id": "calendar-invariant", @@ -172,13 +449,13 @@ "changeReason": "Calendar validity rules change", "stateLifecycle": "stateless validation", "inputsOutputs": "CalendarDocument to validation failure or success", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "Document Type boundary owns schema and invariants", - "sourcePath": "packages/json-document-editing/src/calendar-validation.ts", - "symbol": "assertCalendarDocument", - "disposition": "mislocated module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "define a stable validation contract at the admitted owner" + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-validation.ts", + "symbol": "validateCalendarDocument", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "독립 validator와 Editing 생성자의 malformed/legacy 회귀 검사" }, { "id": "calendar-occurrence-projection", @@ -188,13 +465,13 @@ "changeReason": "Calendar recurrence semantics change", "stateLifecycle": "stateless projection", "inputsOutputs": "CalendarEvent and range to CalendarOccurrence list", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "Document Type boundary owns projections independent of Editing", - "sourcePath": "packages/json-document-editing/src/calendar-occurrence.ts", + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-occurrence.ts", "symbol": "projectCalendarOccurrences", - "disposition": "mislocated module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "separate recurrence projection from occurrence editing transitions" + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "독립 projection과 Editing 호환 export가 같은 구현을 소비" }, { "id": "calendar-semantic-operation", @@ -202,15 +479,15 @@ "knowledge": "Calendar event and recurrence mutation meaning", "decision": "the semantic event update represented by a Calendar operation", "changeReason": "Calendar mutation semantics change", - "stateLifecycle": "operation value interpreted by Editing", - "inputsOutputs": "CalendarIntent event cases to document patch", - "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "CalendarIntent currently mixes document operations with selection and clipboard intents", - "sourcePath": "packages/json-document-editing/src/calendar.ts", - "symbol": "CalendarIntent", - "disposition": "missing canonical module", - "intendedOwner": "Calendar Document Type canonical module (missing)", - "nextCheck": "split input-independent Calendar operations from Editing-only intents" + "stateLifecycle": "stateless document operation plan", + "inputsOutputs": "CalendarEventOperation to events, JSON Patch and affected occurrence; no Editing selection", + "currentOwner": "@interactive-os/json-document-calendar-document", + "canonicalEvidence": "Calendar Document Type의 public API, 소유자 docs/api.md, Calendar Usage/Source와 독립 계약 테스트", + "sourcePath": "packages/json-document-calendar-document/src/calendar-operation.ts", + "symbol": "planCalendarEventEdit", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar-document", + "nextCheck": "편집·preview·그룹 이동 및 발생분/원본 제거·visibility는 Document Type 공개 계획을 소비" }, { "id": "calendar-editor", @@ -221,28 +498,28 @@ "stateLifecycle": "stateful Editing session", "inputsOutputs": "EditingDocumentSource and Calendar Intent to EditingResult", "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "repository naming standard assigns Intent, Selection, Clipboard and History to Editing", + "canonicalEvidence": "EditingSession이 Document Type 공개 연산을 실행하며 primaryOccurrence를 기본 paste target으로 소비", "sourcePath": "packages/json-document-editing/src/calendar.ts", "symbol": "createCalendarEditor", - "disposition": "canonical API gap", + "disposition": "canonical consumer", "intendedOwner": "@interactive-os/json-document-editing", - "nextCheck": "consume the future Calendar Document Type API instead of locally owning its model and rules" + "nextCheck": "공통 Calendar Editing Grammar와 API/Hand/초기/외부 selection 경로 회귀 검사" }, { "id": "calendar-pointer-interpretation", - "role": "Affordance", - "knowledge": "input-independent pointer release gesture meaning", - "decision": "which Calendar resize or move intent a gesture means", - "changeReason": "Calendar gesture grammar changes", + "role": "Editing intent binding", + "knowledge": "Calendar interval geometry, scope and CalendarIntent", + "decision": "bind normalized release values to Calendar-specific Intent", + "changeReason": "Calendar intent mapping changes", "stateLifecycle": "pointer gesture release", "inputsOutputs": "pointer release value to Calendar intent", "currentOwner": "@interactive-os/json-document-editing", - "canonicalEvidence": "repository naming standard assigns human manipulation grammar to Affordance", + "canonicalEvidence": "generic gesture lifecycle은 Affordance, Calendar별 Intent binding은 Editing, DOM capture/좌표는 Web", "sourcePath": "packages/json-document-editing/src/calendar-time-grid-pointer.ts", "symbol": "interpretCalendarTimeGridPointer", - "disposition": "mislocated module", - "intendedOwner": "@interactive-os/json-document-affordance", - "nextCheck": "compare all-day and month pointer interpreters before choosing one Calendar affordance module" + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "time/all-day/month의 bind/interpret가 Document Type 시간 규칙을 소비하는지 확인" }, { "id": "calendar-web-input", @@ -274,7 +551,7 @@ "symbol": "useCalendarHand", "disposition": "canonical consumer", "intendedOwner": "@interactive-os/json-document-calendar", - "nextCheck": "keep composition only after Document Type capabilities move behind a public API" + "nextCheck": "선택 회차는 editor.primaryOccurrence를 읽고 빈 슬롯 paste target만 해당 Editing revision에 묶어 보존" }, { "id": "calendar-month-grid", @@ -290,7 +567,7 @@ "symbol": "CalendarMonthGrid", "disposition": "canonical consumer", "intendedOwner": "@interactive-os/json-document-calendar", - "nextCheck": "ensure projection inputs come from the future Document Type API" + "nextCheck": "Calendar UI는 Document Type의 공개 기간/occurrence projection을 직접 소비" }, { "id": "calendar-host", @@ -306,9 +583,55 @@ "symbol": "CalendarDemoRoute", "disposition": "Host composition", "intendedOwner": "site Calendar route", - "nextCheck": "audit the runtime import closure for local model, schema, operation or projection implementations" + "nextCheck": "CalendarDemoRoute와 navigator는 공개 model/projection/Editing/Hand를 사용하며 fixture·copy·layout·제품 정책만 유지" + }, + { + "id": "calendar-selection-target", + "role": "Selection target consumption", + "knowledge": "canonical occurrence and explicit paste destination", + "decision": "derive edit targets from primaryOccurrence; keep only an explicit paste cursor", + "changeReason": "Editing selection or paste destination changes", + "stateLifecycle": "derived selection and revision-scoped paste cursor", + "inputsOutputs": "CalendarEditor primaryOccurrence to inspector/edit/delete/default paste", + "currentOwner": "@interactive-os/json-document-calendar", + "canonicalEvidence": "Calendar protocol identity and direct API/Hand/initial/external selection regression tests", + "sourcePath": "packages/json-document-calendar/src/use-calendar-hand.ts", + "symbol": "useCalendarHand", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar", + "nextCheck": "같은 회차를 수정하고 explicit paste target은 상태 변경 후 재사용하지 않음" + }, + { + "id": "calendar-gesture-lifecycle", + "role": "Affordance", + "knowledge": "input-independent gesture lifecycle", + "decision": "begin/preview/commit/cancel without Calendar model or DOM capture", + "changeReason": "generic gesture lifecycle changes", + "stateLifecycle": "GestureSession", + "inputsOutputs": "generic data to preview or committed/cancelled result", + "currentOwner": "@interactive-os/json-document-affordance", + "canonicalEvidence": "Calendar useCalendarPointerInteractions consumes public createGestureSession", + "sourcePath": "packages/json-document-affordance/src/gesture-session.ts", + "symbol": "createGestureSession", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-affordance", + "nextCheck": "Calendar protocol tests preserve cancellation and one Editing transaction" } - ] + ], + "closure": { + "owner": "@interactive-os/json-document-calendar-document", + "publicEntry": "packages/json-document-calendar-document/src/index.ts", + "referencePath": "packages/json-document-calendar-document/docs/api.md", + "apiPath": "/docs/api/calendar-document", + "usagePath": "/demo/calendar", + "usagePagePath": "/editors#calendar-editor", + "sourceRegistration": "site/src/shared/demo-workbench/demo-sources.ts", + "verification": [ + "packages/json-document-calendar-document/tests/calendar-document.test.ts", + "packages/json-document-editing/tests/conformance/calendar-grammar.test.ts", + "packages/json-document-calendar/tests/calendar-protocol.test.tsx" + ] + } } } } diff --git a/docs/README.md b/docs/README.md index bf5b97316..8bc59c7c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -137,6 +137,12 @@ reconciliation까지의 DOM 편집 정본은 `standards/dom-editing-lifecycle.md 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는 diff --git a/docs/api-reference/affordance.md b/docs/api-reference/affordance.md index 15c900c28..db951a524 100644 --- a/docs/api-reference/affordance.md +++ b/docs/api-reference/affordance.md @@ -363,10 +363,15 @@ 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 -createRenameSession(options: { readonly onCommit: (key: Key, draft: string) => void; readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; }): RenameSession +createRenameSession(options: ({ readonly onCommit: (key: Key, draft: string) => void; readonly tryCommit?: never; } | { readonly tryCommit: (key: Key, draft: string) => boolean; readonly onCommit?: never; }) & { readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; }): RenameSession ``` ## `createTypeaheadSession` @@ -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; }): AffordancePreview +selectAllAffordance(stroke: Pick & Partial>, state: { readonly allSelected: boolean; }, options?: { readonly repeat?: "preserve" | "toggle"; }): AffordancePreview ``` ## `SelectOperation` diff --git a/docs/api-reference/annotation.md b/docs/api-reference/annotation.md new file mode 100644 index 000000000..c7e6c8a61 --- /dev/null +++ b/docs/api-reference/annotation.md @@ -0,0 +1,99 @@ +# @interactive-os/json-document-annotation API + +**Owner:** Hands + +Annotation Hand interaction과 SVG projection의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-annotation/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `AnnotationHand` + +```ts +AnnotationHand(props: AnnotationHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` +## `AnnotationHandClassNames` + +```ts +interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} +``` +## `AnnotationHandLabels` + +```ts +interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} +``` +## `AnnotationHandProps` + +```ts +interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} +``` +## `AnnotationOutput` + +```ts +interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} +``` +## `AnnotationOutputOptions` + +```ts +interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +``` +## `AnnotationTool` + +```ts +type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +``` +## `annotationTools` + +```ts +const annotationTools: readonly [{ readonly id: "select"; readonly label: "Select"; readonly shortcut: "V"; readonly icon: ForwardRefExoticComponent & RefAttributes>; }, ... 4 more ..., { ...; }] +``` +## `useAnnotationOutput` + +```ts +useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput +``` diff --git a/docs/api-reference/calendar-document.md b/docs/api-reference/calendar-document.md new file mode 100644 index 000000000..68cb02356 --- /dev/null +++ b/docs/api-reference/calendar-document.md @@ -0,0 +1,359 @@ +# @interactive-os/json-document-calendar-document API + +**Owner:** Document Types + +Calendar 문서 모델·검증·의미 연산·projection 계약의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 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..ed523d307 100644 --- a/docs/api-reference/calendar.md +++ b/docs/api-reference/calendar.md @@ -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..a469bca97 --- /dev/null +++ b/docs/api-reference/canvas.md @@ -0,0 +1,63 @@ +# @interactive-os/json-document-canvas API + +**Owner:** Hands + +한 장짜리 Canvas의 입력·preview·UI 조합의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 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/composer-react.md b/docs/api-reference/composer-react.md index 61f515351..ac7dd7a1c 100644 --- a/docs/api-reference/composer-react.md +++ b/docs/api-reference/composer-react.md @@ -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..3860a541e 100644 --- a/docs/api-reference/composer.md +++ b/docs/api-reference/composer.md @@ -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/editing.md b/docs/api-reference/editing.md index 711a3cfb5..ef8fa7042 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -26,6 +26,11 @@ interface Annotation extends Record { readonly id: string; re ```ts const ANNOTATION_PROFILE_V1: "urn:interactive-os:json-document:annotation:1" ``` +## `AnnotationBounds` + +```ts +interface AnnotationBounds extends AnnotationPoint { readonly width: number; readonly height: number } +``` ## `AnnotationDocument` ```ts @@ -62,6 +67,11 @@ type AnnotationPresentation = | { readonly type: "stroke" } | { readonly type: "arrow" }; ``` +## `annotationResizeHandle` + +```ts +annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null +``` ## `AnnotationSelection` ```ts @@ -76,6 +86,18 @@ type AnnotationSelector = | { readonly type: "path"; readonly points: ReadonlyArray } | { readonly type: "arrow"; readonly from: AnnotationPoint; readonly to: AnnotationPoint }; ``` +## `annotationSelectorBounds` + +```ts +annotationSelectorBounds(selector: AnnotationSelector): AnnotationBounds +``` +## `AnnotationSelectorTransform` + +```ts +type AnnotationSelectorTransform = + | { readonly type: "move"; readonly dx: number; readonly dy: number } + | { readonly type: "resize"; readonly handle: "end" | "south-east"; readonly dx: number; readonly dy: number }; +``` ## `AnnotationSource` ```ts @@ -218,8 +240,8 @@ interface CalendarEditor { ): CalendarSelectionDragSource | null; dispatch(intent: CalendarIntent): EditingResult; copy(occurrences?: ReadonlyArray): CalendarClipboard | null; - cut(occurrences?: ReadonlyArray): EditingClipboardCut> | null; - paste(clipboard: CalendarClipboard, target?: string): EditingResult; + cut(source?: ReadonlyArray | CalendarClipboard): EditingClipboardCut> | null; + paste(clipboard: CalendarClipboard, target?: string, options?: { readonly calendarId?: string }): EditingResult; undo(): EditingResult; redo(): EditingResult; subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; @@ -284,37 +306,7 @@ type CalendarIntent = readonly target: CalendarSelectionMoveTarget; readonly scope?: "this" | "this-and-following" | "all"; } - | { - readonly type: "event.create"; - readonly start: string; - readonly end: string; - readonly title?: string; - readonly allDay?: boolean; - readonly calendarId?: string; - readonly recurrence?: CalendarRecurrence | null; - } - | { readonly type: "event.move"; readonly eventId: string; readonly start: string } - | { readonly type: "event.resize"; readonly eventId: string; readonly edge: "start" | "end"; readonly instant: string } - | { readonly type: "event.move-day"; readonly eventId: string; readonly day: string } - | { - readonly type: "event.update"; - readonly eventId: string; - readonly title?: string; - readonly start?: string; - readonly end?: string; - readonly allDay?: boolean; - readonly calendarId?: string; - readonly recurrence?: CalendarRecurrence | null; - } - | { - readonly type: "occurrence.edit"; - readonly eventId: string; - readonly occurrenceStart: string; - readonly scope: "this" | "this-and-following" | "all"; - readonly title?: string; - readonly start?: string; - readonly end?: string; - } + | CalendarEventOperation | { readonly type: "occurrence.remove"; readonly eventId: string; @@ -405,11 +397,7 @@ type CalendarOccurrenceRange = { ## `CalendarOccurrenceSelection` ```ts -interface CalendarOccurrenceSelection { - readonly eventId: string; - readonly start: string; - readonly end: string; -} +type CalendarOccurrenceSelection = CalendarOccurrenceInterval; ``` ## `calendarOccurrenceTopology` @@ -548,6 +536,31 @@ calendarVisibleEvents(document: CalendarDocument): ReadonlyArray ```ts calendarVisibleHourBand(startMinutes: number, endMinutes: number, hourStart: number, hourEnd: number): { readonly startMinutes: number; readonly endMinutes: number; } | null ``` +## `CanvasClipboardContent` + +```ts +type CanvasClipboardContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "images"; readonly images: ReadonlyArray<{ readonly source: string; readonly width: number; readonly height: number; readonly label: string }> } + | { readonly type: "mixed"; readonly items: ReadonlyArray }; +``` +## `CanvasClipboardItem` + +```ts +type CanvasClipboardItem = { readonly type: "text"; readonly text: string } + | ({ readonly type: "image" } & Parameters[0]); +``` +## `CanvasClipboardOptions` + +```ts +interface CanvasClipboardOptions { + readonly bounds: ObjectBounds; + readonly textColor: string; + readonly fontSize: number; + readonly imageOffset?: number; + readonly contentGap?: number; +} +``` ## `createAnnotationEditor` ```ts @@ -558,6 +571,11 @@ createAnnotationEditor(source: EditingDocumentSource, option ```ts createCalendarEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; readonly initialEventIds?: ReadonlyArray; }): CalendarEditor ``` +## `createCanvasClipboard` + +```ts +createCanvasClipboard(content: CanvasClipboardContent, options: CanvasClipboardOptions): ObjectClipboard +``` ## `createDatabaseEditor` ```ts @@ -573,6 +591,16 @@ createDocumentEditor(source: EditingDocumentSource, options?: Edi ```ts createEditingId(prefix: string): string ``` +## `createEditingIdAllocator` + +```ts +createEditingIdAllocator(existingIds: Iterable, createId: () => string, subject: string): () => string +``` +## `createEditingPreparationQueue` + +```ts +createEditingPreparationQueue(options: { readonly apply: (value: Value) => Result; readonly onResult?: (result: Result | EditingPreparationFailure) => void; readonly onPendingChange?: (pending: boolean) => void; readonly cancelCode?: string; readonly errorCode?: string; }): EditingPreparationQueue +``` ## `createEditingSession` ```ts @@ -588,6 +616,11 @@ createKanbanEditor(source: EditingDocumentSource, options?: Edit ```ts createObjectEditor(source: EditingDocumentSource, options?: EditingHistoryOptions & { readonly createId?: () => string; }): ObjectEditor ``` +## `createObjectPasteSession` + +```ts +createObjectPasteSession(editor: ObjectEditor, options?: { readonly placement?: ObjectPastePlacement; readonly onResult?: (result: EditingResult) => void; readonly onPendingChange?: (pending: boolean) => void; }): ObjectPasteSession +``` ## `createOrderEditor` ```ts @@ -841,6 +874,7 @@ interface DocumentEditor { ```ts type DocumentIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly blockId: string; readonly mode?: "replace" | "extend" | "toggle"; readonly offset?: number } | { readonly type: "text.replace"; readonly blockId: string; readonly text: string; readonly offset?: number } | { readonly type: "block.insert"; readonly afterId?: string; readonly text?: string } @@ -852,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` @@ -980,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 @@ -1201,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` @@ -1237,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; @@ -1264,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 @@ -1322,6 +1395,7 @@ interface OrderEditor { ```ts type OrderIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly itemId: string; @@ -1454,6 +1528,7 @@ interface SheetEditor { ```ts type SheetIntent = + | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; readonly rowId: string; @@ -1518,6 +1593,11 @@ interface SheetSelection extends Record { ```ts type SheetTopology = GridTopology; ``` +## `transformAnnotationSelector` + +```ts +transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null +``` ## `TreeClipboard` ```ts @@ -1558,6 +1638,7 @@ interface TreeEditor { ```ts type TreeIntent = + | { readonly type: "selection.select-all"; readonly topology: TreeTopology } | { readonly type: "selection.set"; readonly nodeId: string; diff --git a/docs/api-reference/file-intake.md b/docs/api-reference/file-intake.md index 19b5174d8..620a03653 100644 --- a/docs/api-reference/file-intake.md +++ b/docs/api-reference/file-intake.md @@ -6,6 +6,16 @@ > 이 문서는 `packages/json-document-file-intake/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. +## `assertRasterImageContent` + +```ts +assertRasterImageContent(value: unknown): asserts value is RasterImageContent +``` +## `assertRasterImageSource` + +```ts +assertRasterImageSource(source: unknown): asserts source is string +``` ## `FileAcceptancePolicy` ```ts @@ -36,6 +46,15 @@ type FileIntakeResult = ```ts formatFileSize(bytes: number): string ``` +## `RasterImageContent` + +```ts +interface RasterImageContent extends Record { + readonly source: string; + readonly width: number; + readonly height: number; +} +``` ## `validateFileCandidates` ```ts diff --git a/docs/api-reference/json-document.md b/docs/api-reference/json-document.md index 12eb5e48c..fb35e1126 100644 --- a/docs/api-reference/json-document.md +++ b/docs/api-reference/json-document.md @@ -26,6 +26,11 @@ buildPointer(segments: ReadonlyArray, options?: { readonly uriF ```ts createJSONDocument(initial: unknown, options?: JSONDocumentOptions): JSONDocument ``` +## `isJSONValue` + +```ts +isJSONValue(value: unknown): value is JSONValue +``` ## `JSONAppliedChange` ```ts @@ -167,6 +172,11 @@ type QueryResult = readonly reason?: string; }; ``` +## `readPointer` + +```ts +readPointer(value: JSONValue, pointer: Pointer): ReadResult +``` ## `ReadResult` ```ts diff --git a/docs/api-reference/object-document.md b/docs/api-reference/object-document.md new file mode 100644 index 000000000..cf9975cf3 --- /dev/null +++ b/docs/api-reference/object-document.md @@ -0,0 +1,218 @@ +# @interactive-os/json-document-object-document API + +**Owner:** Document Types + +Object 문서와 Canvas 프로파일의 모델·검증·연산·projection의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-object-document/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `assertCanvasDocument` + +```ts +assertCanvasDocument(value: unknown): asserts value is CanvasDocument +``` +## `assertCanvasImageSource` + +```ts +assertCanvasImageSource(source: unknown): asserts source is string +``` +## `assertObjectDocument` + +```ts +assertObjectDocument(value: unknown): void +``` +## `assertObjectStyle` + +```ts +assertObjectStyle(value: unknown): asserts value is Partial +``` +## `CanvasDocument` + +```ts +interface CanvasDocument extends ObjectDocument { + readonly profile: "canvas/1"; + readonly width: number; + readonly height: number; + readonly objects: ReadonlyArray; +} +``` +## `CanvasObject` + +```ts +type CanvasObject = CanvasObjectDraft & { readonly id: string }; +``` +## `CanvasObjectDraft` + +```ts +type CanvasObjectDraft = ObjectDraft & ( + | (CanvasTextFormat & { readonly kind: "text"; readonly fontSize: number }) + | (CanvasTextFormat & { readonly kind: "rectangle" | "ellipse" | "sticky-note"; readonly textColor?: string; readonly strokeColor?: string; readonly strokeWidth?: number }) + | { readonly kind: "path"; readonly points: ReadonlyArray; readonly strokeWidth: number } + | { readonly kind: "image"; readonly source: string } +); +``` +## `CanvasObjectKind` + +```ts +type CanvasObjectKind = "text" | "rectangle" | "ellipse" | "sticky-note" | "path" | "image"; +``` +## `CanvasTextFormat` + +```ts +interface CanvasTextFormat { + readonly fontSize?: number; + readonly fontWeight?: 400 | 700; + readonly textAlign?: "left" | "center" | "right"; +} +``` +## `createCanvasImage` + +```ts +createCanvasImage(image: { readonly source: string; readonly width: number; readonly height: number; readonly label: string; }, bounds: ObjectBounds): Extract +``` +## `createCanvasObject` + +```ts +createCanvasObject(kind: Exclude, bounds: ObjectBounds, style: { readonly color: string; readonly label: string; readonly fontSize?: number; readonly textColor?: string; }): CanvasObjectDraft +``` +## `createCanvasPath` + +```ts +createCanvasPath(points: ReadonlyArray, style: { readonly color: string; readonly label: string; readonly strokeWidth: number; }): Extract +``` +## `DocumentObject` + +```ts +interface DocumentObject extends ObjectDraft { + readonly id: string; +} +``` +## `getObjectStyle` + +```ts +getObjectStyle(object: DocumentObject): Partial +``` +## `ObjectBounds` + +```ts +interface ObjectBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} +``` +## `ObjectDocument` + +```ts +interface ObjectDocument extends Record { + readonly objects: ReadonlyArray; +} +``` +## `ObjectDraft` + +```ts +interface ObjectDraft extends ObjectBounds, Record { + readonly label: string; + readonly color: string; +} +``` +## `ObjectOperation` + +```ts +type ObjectOperation = + | { readonly type: "insert"; readonly objects: ReadonlyArray } + | { readonly type: "transform"; readonly objectIds: ReadonlyArray; readonly transform: ObjectTransform } + | { readonly type: "fill"; readonly objectIds: ReadonlyArray; readonly color: string } + | { readonly type: "style"; readonly objectIds: ReadonlyArray; readonly style: Partial } + | { readonly type: "remove"; readonly objectIds: ReadonlyArray } + | { readonly type: "text"; readonly objectId: string; readonly text: string } + | { readonly type: "replace"; readonly document: ObjectDocument }; +``` +## `ObjectOperationPlan` + +```ts +type ObjectOperationPlan = + | { readonly ok: true; readonly operations: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +``` +## `ObjectPoint` + +```ts +interface ObjectPoint extends Record { + readonly x: number; + readonly y: number; +} +``` +## `ObjectStyle` + +```ts +interface ObjectStyle { + readonly color: string; + /** Body text paint for filled objects; standalone text keeps its existing color field. */ + readonly textColor: string; + readonly fontSize: number; + readonly fontWeight: 400 | 700; + readonly textAlign: "left" | "center" | "right"; + readonly strokeColor: string; + readonly strokeWidth: number; +} +``` +## `ObjectStyleSelection` + +```ts +type ObjectStyleSelection = { readonly [Key in keyof ObjectStyle]?: ObjectStyle[Key] | null }; +``` +## `ObjectTextProjection` + +```ts +interface ObjectTextProjection extends ObjectBounds, Pick { + readonly text: string; + readonly verticalAlign: "top" | "center"; +} +``` +## `ObjectTransform` + +```ts +interface ObjectTransform { + readonly dx: number; + readonly dy: number; + readonly dw?: number; + readonly dh?: number; +} +``` +## `parseCanvasDocument` + +```ts +parseCanvasDocument(json: string): CanvasDocument +``` +## `planObjectOperation` + +```ts +planObjectOperation(document: ObjectDocument, operation: ObjectOperation): ObjectOperationPlan +``` +## `projectObject` + +```ts +projectObject(object: DocumentObject): CanvasObject +``` +## `projectObjectText` + +```ts +projectObjectText(object: DocumentObject): ObjectTextProjection | null +``` +## `readObjectStyle` + +```ts +readObjectStyle(objects: ReadonlyArray): ObjectStyleSelection +``` +## `serializeCanvasDocument` + +```ts +serializeCanvasDocument(document: CanvasDocument): string +``` +## `transformObject` + +```ts +transformObject(object: Object, transform: ObjectTransform): Object +``` diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index 0e02331d9..7cae30597 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 계약"], @@ -13,6 +16,7 @@ export const apiReferencePackages = [ ["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 계약"], ["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"], diff --git a/docs/api-reference/ui-primitives-react.md b/docs/api-reference/ui-primitives-react.md index ceb8a738a..2fa215d17 100644 --- a/docs/api-reference/ui-primitives-react.md +++ b/docs/api-reference/ui-primitives-react.md @@ -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..9768c6f10 100644 --- a/docs/api-reference/web.md +++ b/docs/api-reference/web.md @@ -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/evaluate.mjs b/docs/evaluate.mjs index 1bac21b25..9cd63d0a9 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; @@ -123,6 +123,7 @@ const surfaces = { ajvReadme: read("packages/json-document-ajv/README.md"), zodReadme: read("packages/json-document-zod/README.md"), databaseReadme: read("packages/json-document-database/README.md"), + annotationReadme: read("packages/json-document-annotation/README.md"), tanstackTableReadme: read("packages/json-document-tanstack-table/README.md"), webReadme: read("packages/json-document-web/README.md"), contenteditableReadme: read("packages/json-document-contenteditable/README.md"), @@ -138,6 +139,7 @@ const publicContract = readJson("packages/json-document/public-contract.json"); const rootPackage = readJson("package.json"); const implementationShape = read("standards/repository-implementation-shape.md"); const domEditingLifecycle = read("standards/dom-editing-lifecycle.md"); +const editingSession = read("standards/editing-session.md"); if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ "adapter-clipboard.md", @@ -221,10 +223,30 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ if (JSON.stringify(fileNames("standards")) !== JSON.stringify([ "dom-editing-lifecycle.md", "editing-grammar.md", + "editing-session.md", "repository-implementation-shape.md", "repository-naming.md", ])) { - fail("standards: only repository naming, implementation shape, DOM editing lifecycle, and the editing grammar design may appear at the root."); + fail("standards: only repository naming, implementation shape, DOM editing lifecycle, EditingSession contract, and the editing grammar design may appear at the root."); +} + +// Each normative session rule must retain a concrete behavior case at its owner. +// This checks evidence references; package test execution checks the behavior itself. +const sessionRules = new Set([...editingSession.matchAll(/^\| (ES-[A-Z-]+) \| (?!\[)/gm)].map((match) => match[1])); +const sessionEvidence = [...editingSession.matchAll(/^\| (ES-[A-Z-]+) \| \[[^\]]+\]\(([^)]+)\) \| `([^`]+)` \|$/gm)]; +if (sessionRules.size === 0) fail("EditingSession: missing normative rules."); +for (const rule of sessionRules) { + if (!sessionEvidence.some((match) => match[1] === rule)) fail(`EditingSession: ${rule} has no behavior evidence.`); +} +for (const [, rule, path, name] of sessionEvidence) { + if (!sessionRules.has(rule)) fail(`EditingSession: evidence refers to unknown rule ${rule}.`); + const target = join("standards", path); + if (!existsSync(join(root, target)) || !read(target).includes(JSON.stringify(name))) { + fail(`EditingSession: ${rule} lost behavior evidence ${path}: ${name}.`); + } +} +for (const [, path] of editingSession.matchAll(/\]\(([^)]+)\)/g)) { + if (!existsSync(join(root, "standards", path))) fail(`EditingSession: missing local reference ${path}.`); } for (const token of [ @@ -265,11 +287,12 @@ const misplacedMarkdown = filesUnder("").filter((path) => { return path.endsWith(".md") && !path.startsWith("docs/") && !path.startsWith("standards/") + && !rootPackage.workspaces.some((workspace) => workspace.startsWith("packages/") && path.startsWith(`${workspace}/docs/`)) && name !== "README.md" && name !== "AGENTS.md"; }); if (misplacedMarkdown.length > 0) { - fail(`docs layout: non-README markdown must live under docs/: ${misplacedMarkdown.join(", ")}.`); + fail(`docs layout: non-README markdown must live under docs/, standards/, or a registered package's docs/: ${misplacedMarkdown.join(", ")}.`); } for (const [name, source] of Object.entries(surfaces)) { diff --git a/docs/public/adapter-clipboard.md b/docs/public/adapter-clipboard.md index 7b617d8b3..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-rename.md b/docs/public/affordance-rename.md index 9536ae5b5..10e7f4fa5 100644 --- a/docs/public/affordance-rename.md +++ b/docs/public/affordance-rename.md @@ -4,33 +4,34 @@ Rename은 고른 대상의 레이블을 고치는 손입니다. F2와 느린 dou 같은 손을 엽니다. Escape는 [Escape](affordance-cancel.md)입니다. ```ts -import { applyAffordance, renameAffordance } from "@interactive-os/json-document-affordance"; +import { applyAffordance, createRenameSession, renameAffordance } from "@interactive-os/json-document-affordance"; + +const rename = createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onSnapshot: renderDraft, + onFinish: restoreFocus, +}); function onKeyDown(event: KeyboardEvent) { applyAffordance(renameAffordance(event), { hand: (hand) => { - if (hand.type !== "rename") return; - if (hand.action === "begin") setRenaming(focusKey); - if (hand.action === "cancel") setRenaming(null); - if (hand.action === "commit" && renaming) { - editor.dispatch({ type: "item.rename", itemId: renaming, label: draft }); - setRenaming(null); - } + if (hand.type === "rename" && hand.action === "begin") rename.begin(focusKey, label); }, }); } -function onClick(event: MouseEvent, itemId: string) { - applyAffordance(renameAffordance({ type: "pointer", detail: event.detail, intervalMs }), { - hand: (hand) => { - if (hand.type === "rename" && hand.action === "begin") setRenaming(itemId); - }, - }); +function onClick(event: MouseEvent, itemId: string, label: string) { + rename.handlePointer(itemId, label, event.detail, event.timeStamp); +} + +function onDraftKeyDown(event: KeyboardEvent) { + if (rename.handleKey(event.key)) event.preventDefault(); } ``` -호스트는 레이블 필드를 그립니다. begin/cancel은 호스트 화면 상태이고, -commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다. +호스트는 session snapshot으로 레이블 필드를 그리고 입력 변경을 `rename.update`에 +연결합니다. session이 draft의 시작·확정·취소를 소유하고, 확정 시 domain editor가 +문서를 변경합니다. 글 편집 자체는 Hands와 [Caret](affordance-caret.md)입니다. 닫는 손: - F2 @@ -41,12 +42,20 @@ commit만 json-document로 갑니다. 글 편집 자체는 Hands와 [Caret](affo ## Session API -`createRenameSession({ onCommit, onCancel, onFinish, onSnapshot })`은 active key, draft, -slow double-click 간격과 commit/cancel을 소유합니다. Host는 snapshot으로 input을 -그리고 `onCommit(key, draft)`에서 domain rename Intent를 보냅니다. +`createRenameSession`은 active key, draft, slow double-click 간격과 commit/cancel을 +소유합니다. 기존 `onCommit(key, draft): void` 또는 동기 +`tryCommit(key, draft): boolean` 중 하나를 받습니다. 둘을 동시에 지정하거나 +비동기 `tryCommit`을 전달할 수 없습니다. + +`tryCommit`이 `false`이면 active key와 draft를 유지하고 `onFinish`를 호출하지 +않습니다. 사용자가 수정한 뒤 다시 확정할 수 있습니다. `true` 또는 기존 +`onCommit` 완료 시 snapshot을 비우고 `onFinish`를 한 번 호출합니다. +Escape는 commit 없이 종료합니다. 검증과 문서 변경은 domain이 소유합니다. `onCancel(key, draft)`는 저장되지 않은 값을 복구하거나, Calendar처럼 생성과 동시에 시작된 rename을 취소할 때 새 항목을 제거하는 제품 정책을 연결합니다. Calendar는 `useCalendarRenameInput(hand)`으로 focus/select, input change, Enter·Escape, blur를 같은 session에 연결합니다. Host는 반환된 `ref`, `value`, event handler만 제목 input에 전달합니다. + +Usage와 Source: [Order Demo](/demo/order)의 F2 → 입력 → Enter 또는 Escape. 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 23996de5b..6e879dc4e 100644 --- a/docs/public/affordance-select.md +++ b/docs/public/affordance-select.md @@ -4,12 +4,20 @@ 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, editingCommandFromWebKeyboardStroke, pointerSelect, planeHitAffordance, + selectAllAffordance, } from "@interactive-os/json-document-affordance"; function onPointerDown(event: PointerEvent, itemId: string) { @@ -39,11 +47,23 @@ const editing = useEditing({ neighbor: (key, command) => neighborFromProductTopology(key, command), }, }); + +function onSelectAll(event: KeyboardEvent) { + applyAffordance(selectAllAffordance(event, { + allSelected: editor.selectedItemIds.length === items.length, + }, { repeat: "preserve" }), { + hand: (hand) => { + if (hand.type !== "select-all") return; + editor.dispatch({ type: "selection.select-all" }); + event.preventDefault(); + }, + }); +} ``` 호스트는 보이는 키와 장르 Intent만 넘깁니다. keymap을 덮어쓰지 않습니다. -이미 고른 상자를 수정 키 없이 누르면 집합을 유지합니다. 안 고른 상자는 -그 상자만으로 바꿉니다. +`planeHitAffordance`는 press 시점의 집합 유지를 해석하는 단일 연산입니다. +완성된 프로파일은 drag면 그 집합을 이동하고, drag 없이 release하면 그 상자 하나로 선택합니다. ## API Reference @@ -54,6 +74,18 @@ const editing = useEditing({ 공통 해석만 소유하며, 현재 focus와 다음 이웃을 결정하는 topology는 Host가 `focusKey`와 `neighbor`로 주입합니다. +### `selectAllAffordance(stroke, state, { repeat })` + +기본 편집 Usage는 `repeat: "preserve"`를 선택합니다. Mod+A를 반복해도 +`select-all`을 보내며 전체 선택을 해제하지 않습니다. 옵션 생략 또는 +`repeat: "toggle"`은 기존 `allSelected ? clear : select-all` 동작입니다. +Document·Order·Tree·Sheet의 `selection.select-all`은 대상 전체를 한 번의 +선택 전이로 만듭니다. Tree는 visible topology, Sheet는 선언한 행·열을 사용합니다. +빈 대상은 빈 선택이 되고, 선택 변경은 문서 Undo/Redo 기록을 추가하거나 지우지 않습니다. +실제 Demo는 Web의 `isWebEditingHostTarget`으로 내부 text field의 Mod+A를 보존합니다. + +Usage와 Source: [Order](/demo/order), [Tree](/demo/tree), [Sheet](/demo/sheet), [Document](/demo). + 닫는 손: - 클릭 replace, 이미 고른 집합 유지 - Shift+click 추가/제거 @@ -67,7 +99,8 @@ const editing = useEditing({ - selection follows focus vs focus-only move는 [Focus](affordance-focus.md) - 글 단어·줄 범위는 [Double-click](affordance-double-click.md)· [Triple-click](affordance-triple-click.md)·[Caret](affordance-caret.md) -- 빈 평면의 여러 대상은 [Marquee](affordance-marquee.md) +- 독립적인 marquee 연산은 [Marquee](affordance-marquee.md), 완성된 평면 문법은 + `createPlaneSelectProfile`이 제공합니다. ## Live Demo diff --git a/docs/public/affordance.md b/docs/public/affordance.md index f3ebaf847..8c173c549 100644 --- a/docs/public/affordance.md +++ b/docs/public/affordance.md @@ -94,6 +94,12 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command 평면 위 객체에서 손은 선택 도구가 기본입니다. 빈 곳과 객체 히트가 다르고, 고른 집합이 곧 옮길 대상입니다. +최소 평면 문법은 [`createPlaneSelectProfile`](/docs/api/affordance)로 조합되어 +[Canvas Usage](/demo/canvas)에서 실행됩니다. 아래 표에는 단일 Affordance로만 제공되는 +더 넓은 기능도 포함됩니다. 축 고정·Alt 복제·Mod+D·nudge는 프로파일에 연결되어 있습니다. +그룹·중첩 선택·snap·zoom/pan은 이 최소 프로파일의 +지원 범위가 아닙니다. + 근거 축: [Figma 레이어 선택](https://help.figma.com/hc/en-us/articles/360040449873-Select-layers-and-objects), [FigJam 선택·이동](https://help.figma.com/hc/en-us/articles/1500004292221-Select-move-and-order-objects-in-FigJam), [Illustrator 기본 단축키](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), @@ -108,7 +114,7 @@ Editing은 선택과 작업을 기억합니다. Adapter는 키 chord를 command | 손 | 함의 | 수렴 | 상태 | | --- | --- | --- | --- | | 객체 click | 그 객체만 replace | 안정 | [닫힘](affordance-select.md) | -| 이미 고른 객체 click (수정 키 없음) | 집합 유지. 다음 드래그의 대상은 집합 전부 | 안정 | [닫힘](affordance-select.md) | +| 이미 고른 객체 press (수정 키 없음) | 집합 유지. drag는 집합 전부, drag 없이 release하면 단일 선택 | 안정 | [닫힘](affordance-select.md) | | 안 고른 객체 click | 집합을 그 객체 하나로 바꿈. 드래그 대상도 그 하나 | 안정 | [닫힘](affordance-select.md) | | Shift+click | 집합에 더하거나, 이미 있으면 뺌 | 안정 | [닫힘](affordance-select.md) | | Mod+click (⌘/Ctrl) | 호스트가 `nestedId`를 줄 때만 자식. 없으면 일반 click. 리스트박스 토글이 아님 | 갈림 | [닫힘](affordance-select.md) | diff --git a/docs/public/api.md b/docs/public/api.md index 81b750313..786fc770a 100644 --- a/docs/public/api.md +++ b/docs/public/api.md @@ -158,6 +158,14 @@ import { jsonEqual } from "@interactive-os/json-document"; jsonEqual({ title: "Draft", tags: [] }, { tags: [], title: "Draft" }); // true ``` +## 문서 없이 JSON 값 검증·조회하기 + +Snapshot 조회와 일반 JSON 값 검증에는 `readPointer(value, pointer)`와 +`isJSONValue(value)`를 사용합니다. 두 함수는 값을 복제하거나 정규화하지 +않습니다. 주소 조회는 `document.at`과 같은 문법·실패 결과를 사용하고 원본 +참조를 반환합니다. 상세 제약과 예제는 [Core package 문서](https://github.com/developer-1px/json-document/blob/main/packages/json-document/README.md)의 +순수 core 항목에서 확인할 수 있습니다. + ## 문서 없이 patch 적용하기 `applyPatch(value, operations)`는 document 상태를 만들지 않고 RFC 6902 @@ -287,6 +295,8 @@ type Failure = { | --- | --- | --- | | 현재 값 | `document.value` | `JSONValue` | | 한 위치 읽기 | `document.at(pointer)` | `ReadResult` | +| snapshot에서 한 위치 읽기 | `readPointer(value, pointer)` | `ReadResult` | +| JSON 값 검사 | `isJSONValue(value)` | boolean/type guard | | 여러 위치 찾기 | `document.query(jsonPath)` | `QueryResult` | | patch 검사 | `document.validatePatch(operations)` | `JSONPatchValidationResult` | | 상태 변경 | `document.commit(operations, options?)` | `JSONDocumentCommitResult` | @@ -298,13 +308,13 @@ type Failure = { ## 공개 export -Package root는 다음 23개 symbol을 공개합니다. +Package root는 다음 25개 symbol을 공개합니다. ```txt values applyPatch, createJSONDocument appendSegment, buildPointer, parentPointer, parsePointer - jsonEqual, parseArrayIndex, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parseArrayIndex, readPointer, trackPointer, tryParsePointer types JSONValue, Pointer, JSONPatchOperation diff --git a/docs/public/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/document-types.md b/docs/public/document-types.md index 390223c46..dd49e105a 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,29 +58,43 @@ 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 내비게이션은 유지합니다. -## 완료 조건 · TBD +## 소유권 확정 조건 각 Document Type의 분류를 확정할 때는 다음 증거가 모두 필요합니다. diff --git a/docs/public/hands.md b/docs/public/hands.md index c62d177a0..40840ddcc 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -14,14 +14,19 @@ editor.undo(); `AnnotationDocument`는 source와 selector geometry, presentation을 직렬화하고, selection과 undo/redo는 editor snapshot에 둡니다. Point, rectangle, path와 arrow selector는 geometry의 유일한 정본이며 presentation은 geometry를 반복하지 -않습니다. SVG 좌표 변환, pointer gesture, Canvas rasterization과 comment UI는 -Editing owner 밖에서 조합합니다. - -```ts -const gesture = createGestureSession(); -const point = projectWebClientPointToSVG(clientPoint, viewport); -const raster = await readWebRasterFile(file); -const output = await renderWebAnnotationRaster({ document, sourceId, sourceURL, style }); +않습니다. `@interactive-os/json-document-annotation`의 `AnnotationHand`가 +도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 +하나의 공개 surface로 제공합니다. + +```tsx + crypto.randomUUID()} + rasterStyle={style} +/> ``` Gesture는 Affordance가 input-independent lifecycle로 소유하고 Pointer capture는 @@ -35,95 +40,74 @@ presentation을 번역합니다. ## Calendar editor -시간 구간 이벤트의 persistent model과 editing session은 -`@interactive-os/json-document-editing`의 `createCalendarEditor`가 소유합니다. +Calendar의 문서 모델·검증·의미 연산·projection은 +`@interactive-os/json-document-calendar-document`, 편집 lifecycle은 +`@interactive-os/json-document-editing`, 입력과 UI 조합은 +`@interactive-os/json-document-calendar`가 소유합니다. ```ts +import { validateCalendarDocument } from "@interactive-os/json-document-calendar-document"; +import { createCalendarEditor } from "@interactive-os/json-document-editing"; + +const validation = validateCalendarDocument(calendarDocument); +if (!validation.ok) throw new Error(validation.reason); const editor = createCalendarEditor(calendarDocument); editor.dispatch({ type: "event.move", eventId, start: "2026-08-03T10:00" }); -editor.dispatch({ type: "event.move-day", eventId, day: "2026-08-05" }); -editor.dispatch({ type: "event.create", start: "2026-08-03", end: "2026-08-04", allDay: true }); editor.undo(); ``` -`CalendarDocument`는 캘린더 `{ id, title, hidden, color }`와 이벤트 -`{ id, title, start, end, allDay, calendarId }`를 직렬화합니다. `color`는 -Host가 fill로 옮기는 짧은 토큰입니다. 시간 이벤트는 datetime-local, 종일 -이벤트는 exclusive-end 날짜입니다. 일·주 보기의 빈 구간 drag는 그 -start/end로 만들고, 빈 칸 클릭은 선택을 지웁니다. 빈 칸 더블클릭은 기본 -길이로 만들고 제목을 묻습니다. 블록 이동은 duration을 유지하며 가장자리는 -`event.resize`입니다. 종일 밴드는 날짜 단위로 드래그해 만들고 옮기고 -늘립니다. 월 보기 같은 날 빈 칸 클릭은 선택 해제, 더블클릭은 그날 종일 생성, -빈 칸을 다른 날로 끌면 그 날들을 덮는 종일 구간을 만들고 제목을 묻습니다. -여러 날을 덮는 종일 이벤트는 주 행을 가로지르는 막대이고, 주 경계에서 잘립니다. -Exclusive end를 마지막 점유 날짜로 바꾸는 interval projection은 Editing -`calendarIntervalLastDate`가 소유합니다. occurrence day 열거, all-day resize end, -month week clipping이 모두 같은 정본 규칙을 사용합니다. -날짜 또는 날짜·시간 문자열에서 `YYYY-MM-DD` 날짜 부분을 얻는 projection은 -Editing `calendarDatePart`가 소유합니다. 현재 시각의 오늘 날짜와 새 이벤트의 -생성 날짜도 Host의 문자열 자르기 없이 이 공개 API를 사용합니다. -Host는 `Temporal.Now`로 concrete clock을 읽고 Editing `formatCalendarInstant`로 -Calendar datetime-local minute 문자열을 만듭니다. clock source와 저장 형식의 -책임을 섞는 route-local formatter는 두지 않습니다. -Calendar collection과 id lookup은 Editing `calendarDocumentCalendars`, -`calendarDocumentCalendar`가 소유합니다. Host는 sidebar와 inspector를 조합하고 -calendar color를 UI variant로 바꾸는 시각 정책만 유지합니다. -Inspector의 repeat frequency·interval·until 변경은 Editing -`calendarRecurrenceWithFrequency`, `calendarRecurrenceWithInterval`, -`calendarRecurrenceWithUntil`이 `CalendarRecurrence` model을 만들고 보존합니다. -Host는 option copy와 recurrence 비활성화 선택만 조합합니다. -월간 42개 날짜 cell을 6개의 ISO 주 행으로 투영하는 일은 UI Primitives 날짜 값 -정본의 `calendarMonthWeeks`가 소유하며, Host는 각 행의 event layout과 DOM을 -조합합니다. -표시 cell collection의 첫 날짜부터 마지막 날짜 다음 날까지의 half-open query -범위는 UI Primitives `calendarCellInterval`이 소유합니다. 연간 12개 month grid와 -sidebar navigator가 같은 interval을 Editing occurrence query에 전달합니다. -Day와 week time grid의 ordered 날짜 cell도 UI Primitives `calendarCells`가 -소유합니다. Day는 정확한 ISO weekday를 포함한 단일 cell, week는 ISO 주의 -7개 cell을 반환합니다. 각 `CalendarCell`은 canonical date에서 투영한 `day`와 -ISO weekday를 제공하며 Host는 문자열을 해석하지 않고 이 metadata로 날짜 숫자, -header와 event grid를 조합합니다. -Inclusive UI 날짜 endpoint를 all-day event의 exclusive storage interval로 바꾸는 -projection은 Editing `calendarAllDaySpan`이 소유합니다. 빈 drag, end resize, -timed→all-day 전환, 단일 생성과 Inspector 수정이 모두 같은 정본 규칙을 사용합니다. -막대 가장자리는 종일 밴드와 같이 `event.resize`입니다. -점유 칸은 origin 이벤트 선택, 다른 날로 끌 때만 `event.move-day`입니다. -월간 span에서 누른 Web `clientX`는 `calendarKeyFromWebRow`가 주 행 bounds와 -정렬된 날짜를 사용해 origin 날짜로 투영합니다. Calendar React의 -`useCalendarPointerInteractions`가 DOM 측정과 pointer session 시작을 소유하므로 -Host는 이벤트와 날짜 목록만 연결합니다. -`+N more`는 그 날의 이벤트 목록을 열고 월 보기에 남습니다. 이 매핑은 -`interpretCalendarTimeGridPointer`, `interpretCalendarAllDayPointer`, -`interpretCalendarMonthPointer`가 소유하며 현재 선택은 입력이 아닙니다. - -선택한 occurrence의 body drag는 이 단건 pointer intent를 반복하지 않습니다. -Selection의 `resolveMaterializedSelectionDragSource`가 source snapshot을 확정하고, -Editing의 `planCalendarSelectionMove`가 anchor에서 target까지의 temporal delta를 -모든 occurrence에 동일하게 적용합니다. React Calendar binding은 Web pointer -session과 Affordance `createGestureSession`을 합성해 같은 plan으로 preview한 뒤 -`selection.move`를 한 번 dispatch합니다. 따라서 document 변경, `selectionAfter`, -undo/redo는 한 Editing transaction으로 함께 이동합니다. ResizeHandle은 이 -selection drag와 별개의 edge geometry lifecycle을 유지합니다. -연 보기는 12개 미니 월입니다. 월 이름은 월 보기로, 날짜는 일 보기로 -들어갑니다. 연간 12개 월 시작일은 UI Primitives 날짜 값 정본의 -`calendarYearMonths`가 만들고, Host는 월 이름과 grid layout 및 navigation만 -조합합니다. 보기와 날짜는 Host URL (`?view=&date=`)입니다. 픽셀 격자와 -보기 전환은 Host가 조합합니다. 정본 view membership은 Editing -`parseCalendarView`가 판별하고 URL의 invalid -값을 어떤 view로 대체할지는 Host 정책으로 남습니다. -toolbar의 현재 기간 문구는 UI Primitives -`visiblePeriodLabel`이 view 분기와 날짜 경계를 투영하고, Host가 월 이름 copy와 -week separator policy를 주입합니다. -Previous/Next 및 keyboard period 이동은 UI Primitives `shiftVisibleDate`가 -day/week/month/year의 단위와 calendar arithmetic을 소유하며, Host는 현재 view와 -direction을 전달하고 결과를 URL state에 반영합니다. -Timed event의 datetime-local에서 `HH:mm` 문구를 투영하는 일은 UI Primitives -`calendarTimeLabel`이 소유합니다. Host는 그 결과를 visual copy에 조합하고, -UI Primitives의 event-label projection도 같은 정본 값을 accessible name에 -사용합니다. Date-only와 유효하지 않은 값은 빈 문구입니다. -Month event의 accessible name은 UI Primitives `calendarEventLabel`이 all-day에는 -title, timed event에는 가능한 `HH:mm title`을 투영합니다. 이 모듈은 구조적 -event 값만 받아 UI Primitives가 Editing package에 의존하지 않도록 합니다. +`CalendarDocument`는 calendar와 interval event·recurrence를 정의합니다. +datetime-local minute과 exclusive-end all-day, calendar 참조, 반복의 +this/following/all 의미는 [Document Type 계약](/docs/api/calendar-document)을 +따릅니다. `validateCalendarDocument`와 생성자는 같은 검증을 사용합니다. +생략된 legacy 필드와 잘못된 타입은 다르게 처리합니다. + +Editing은 Document Type의 `planCalendarEventEdit`, `planCalendarEventRemoval`, +`planCalendarOccurrenceRemoval`, `planCalendarVisibility`를 실행하고 Selection과 +History를 연결합니다. occurrence 선택은 `{ eventId, occurrenceStart }`로 식별하며 +Hand는 `editor.primaryOccurrence`를 읽습니다. 직접 dispatch, 외부에서 바꾼 선택, +mount 전 선택도 Inspector·수정·삭제의 같은 대상이 됩니다. +`editor.paste(clipboard)`의 기본 목적지는 선택 회차이고, 빈 슬롯을 찍은 위치는 +Hand의 명시적 임시 paste target입니다. [Editing 프로파일](/docs/api/editing#calendar-protocol-profile-rc)에 +선택·복사·붙여넣기·History 결과와 공통 검사 근거를 연결합니다. + +문서 조회와 시간 변환은 Document Type의 공개 API를 사용합니다. + +- `calendarDocumentCalendars` / `calendarDocumentCalendar`: collection과 id lookup +- `calendarDatePart` / `calendarIntervalLastDate`: 날짜 부분과 exclusive end의 마지막 점유일 +- `calendarAllDaySpan`: inclusive UI 날짜 endpoint를 exclusive 저장 구간으로 변환 +- `formatCalendarInstant`: concrete `Temporal.Now` 값을 저장 형식으로 변환 +- `calendarRecurrenceWithFrequency` / `calendarRecurrenceWithInterval` / + `calendarRecurrenceWithUntil`: 반복 모델 변경 +- `projectCalendarOccurrences` / `calendarBusyDates`: 발생분과 점유 날짜 조회 +- `calendarTimedLayout` / `calendarAllDayLayout` / `calendarMonthWeekLayout`: + event 구간과 lane projection + +Host는 clock 인스턴스, color를 UI variant로 바꾸는 정책, copy와 layout을 조합합니다. +`CalendarMonthGrid`와 `CalendarTimeGrid`는 표시와 접근성·overflow·interaction을 +소유합니다. 월의 42개 날짜 cell과 6개 주 행은 Calendar Hands의 `calendarMonthWeeks`, +query 범위는 `calendarCellInterval`, day/week의 ordered cell은 `calendarCells`가 +만듭니다. cell의 ISO weekday metadata와 화면의 주 시작 정책을 같은 개념으로 +설명하지 않습니다. 연간 월 목록은 `calendarYearMonths`가 소유합니다. + +Toolbar의 `visiblePeriodLabel`, 날짜 이동의 `shiftVisibleDate`, 시간 문구의 +`calendarTimeLabel`, 접근 가능한 event 이름의 `calendarEventLabel`도 Calendar +Hands 책임입니다. generic UI Primitives에 Calendar 모델이나 날짜 선택 동작을 +넣지 않습니다. View와 날짜의 URL은 Host가 조합하며 view membership은 기존 +Editing `parseCalendarView`가 판별합니다. + +Calendar별 `interpretCalendarTimeGridPointer`, `interpretCalendarAllDayPointer`, +`interpretCalendarMonthPointer`와 bind 함수는 정규화된 release 값을 Calendar Intent로 +연결하는 Editing 책임입니다. generic gesture의 begin/preview/commit/cancel은 +Affordance, DOM pointer capture와 `calendarKeyFromWebRow` 같은 좌표 변환은 Web에 둡니다. +`useCalendarPointerInteractions`가 이 API들을 조합하며 자체 root 안에서 hit-test합니다. + +선택한 occurrence의 body drag는 Selection의 +`resolveMaterializedSelectionDragSource`로 대상을 캡처하고 Editing의 +`planCalendarSelectionMove`로 preview와 commit을 계획합니다. 같은 Document Type +연산을 공유하며 전체 document·selection·undo/redo가 한 Editing transaction으로 +이동합니다. Hand는 Web pointer session과 Affordance `createGestureSession`을 +조합하고, resize edge와 그룹 이동의 lifecycle은 구별합니다. ```live-demo /demo/calendar diff --git a/docs/public/history.md b/docs/public/history.md index b4540fd52..83f75f5c7 100644 --- a/docs/public/history.md +++ b/docs/public/history.md @@ -29,7 +29,10 @@ History 항목은 JSON 값이 실제로 바뀐 편집에서 생깁니다. Select 현재 편집 대상만 바꾸므로 기록을 추가하지 않습니다. 검사를 통과하지 못한 요청과 문서 값이 그대로인 요청도 되돌릴 값이 없어 기록되지 않습니다. -기본 local history는 외부 문서 변경을 받으면 비워집니다. 다른 참여자의 변경을 +기본 local history는 실제 외부 문서 변경이 있으면 비워집니다. UI 구독자가 없거나 +구독을 해제한 뒤에도 같은 정책을 따릅니다. 외부 변경 후 값이 원래 값으로 +돌아와도 이전 Undo/Redo 기록은 되살아나지 않습니다. 동일 값의 새 snapshot +reference나 문서 no-op은 기록을 지우지 않습니다. 다른 참여자의 변경을 보존하며 내 기여만 취소하려면 [Collaborative History](collaboration-history.md)의 공식 연결 API를 사용합니다. document만 바꾸는 것으로 history 의미까지 바뀌지는 않습니다. diff --git a/docs/public/intent.md b/docs/public/intent.md index 8e6c2040f..9cfd24bf7 100644 --- a/docs/public/intent.md +++ b/docs/public/intent.md @@ -55,10 +55,14 @@ type EditingResult = `EditingSnapshot`은 처리 뒤의 값과 Selection, revision, 실행 취소 상태를 묶습니다. `type`은 editor가 수행할 동작을 나타내고, 각 동작에 필요한 필드는 editor별 Intent union에서 정합니다. 성공 결과에는 snapshot이 들어 있으며 -JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. 실패하면 -문서와 Selection은 요청 전 상태를 유지합니다. +JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. -값이 바뀐 요청은 History 항목을 만들고 +외부 변경의 동기화를 마친 뒤 요청 자체가 검증·commit에서 거절되면 +문서와 Selection, History는 그 요청의 시작 상태를 유지합니다. 이미 완료된 외부 +commit은 되돌아가지 않습니다. Selection mapping/reconciliation callback의 예외와 +재시도는 [History의 동기화·복구 계약](history.md)에서 설명합니다. + +기본 local History에서 기록 대상인 값 변경 요청은 History 항목을 만들고 `change.metadata.editing.origin`에 `intent.type`을 남깁니다. Selection만 바뀐 요청은 성공 snapshot을 돌려주지만 History 항목은 만들지 않습니다. @@ -67,6 +71,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `blockId`, `mode?`, `offset?` | 블록 선택 변경 | +| `selection.select-all` | | 첫 블록 offset 0부터 마지막 블록 text 끝까지 한 번에 선택 | | `text.replace` | `blockId`, `text`, `offset?` | 블록 text 변경 | | `block.insert` | `afterId?`, `text?` | 블록 추가 | | `selection.remove` | | 선택한 블록 제거 | @@ -83,6 +88,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `rowId`, `columnId`, `mode?` | 셀 선택 변경 | +| `selection.select-all` | `topology?` | 지정한 행·열 전체를 하나의 범위로 선택 | | `selection.fill` | `value`, `topology?` | 선택한 셀 채우기 | | `cell.commit` | `rowId`, `columnId`, `value` | 한 셀의 값 확정 | | `clipboard.paste` | `clipboard`, `topology?` | Clipboard 셀 붙여넣기 | @@ -95,6 +101,7 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `nodeId`, `topology`, `mode?` | 보이는 노드 선택 변경 | +| `selection.select-all` | `topology` | 보이는 노드 전체를 하나의 범위로 선택 | | `selection.remove` | `topology` | 선택한 노드 제거 | | `clipboard.paste` | `clipboard`, `topology`, `afterId?` | 붙여넣기 | @@ -109,6 +116,8 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `selection.set` | `objectIds`, `mode?` | 객체 선택 변경 | | `selection.remove` | | 선택한 객체 제거 | | `selection.fill` | `color` | 선택한 객체 색 변경 | +| `selection.style` | `style` | 지원하는 선택 객체의 색·글자 서식·테두리를 한 번 변경 | +| `object.text` | `objectId`, `text` | 글자·도형·스티커 노트의 label 본문 변경 | | `object.translate` | `objectIds`, `dx`, `dy` | 선택한 객체 위치 이동 | | `object.resize` | `objectIds`, `dx`, `dy`, `dw`, `dh` | 선택한 객체 크기 | | `clipboard.paste` | `clipboard` | 붙여넣기 | @@ -121,6 +130,8 @@ JSON 값까지 바뀌었다면 적용된 `change`도 함께 들어 있습니다. | `type` | 필드 | 결과 | | --- | --- | --- | | `selection.set` | `itemId`, `mode?` | 항목 선택 변경 | +| `selection.select-all` | | 전체 항목을 하나의 범위로 선택 | +| `item.rename` | `itemId`, `label` | 레이블 확정; 실패 결과는 draft session에 전달 | | `selection.remove` | | 선택한 항목 제거 | | `clipboard.paste` | `clipboard`, `afterId?` | 붙여넣기 | diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 497b6a361..9e822d892 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -23,12 +23,13 @@ import { ``` Root는 React, Zod, selection, clipboard, history, DOM을 import하지 않는다. -공개 Root는 정확히 다음 23개 symbol이다. +공개 Root는 정확히 다음 25개 symbol이다. ```txt values appendSegment, applyPatch, buildPointer, createJSONDocument - jsonEqual, parentPointer, parseArrayIndex, parsePointer, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parentPointer, parseArrayIndex, parsePointer + readPointer, trackPointer, tryParsePointer types JSONAppliedChange, JSONPatchValidationResult 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/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 0f02d8174..fd11dfbf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "workspaces": [ "packages/json-document", "packages/json-document-selection", + "packages/json-document-object-document", + "packages/json-document-calendar-document", "packages/json-document-editing", "packages/json-document-react", "packages/json-document-react-hook-form", @@ -19,7 +21,9 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", + "packages/json-document-canvas", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", @@ -1124,10 +1128,22 @@ "resolved": "packages/json-document-animation-react", "link": true }, + "node_modules/@interactive-os/json-document-annotation": { + "resolved": "packages/json-document-annotation", + "link": true + }, "node_modules/@interactive-os/json-document-calendar": { "resolved": "packages/json-document-calendar", "link": true }, + "node_modules/@interactive-os/json-document-calendar-document": { + "resolved": "packages/json-document-calendar-document", + "link": true + }, + "node_modules/@interactive-os/json-document-canvas": { + "resolved": "packages/json-document-canvas", + "link": true + }, "node_modules/@interactive-os/json-document-collaboration": { "resolved": "packages/json-document-collaboration", "link": true @@ -1164,6 +1180,10 @@ "resolved": "packages/json-document-markdown-react", "link": true }, + "node_modules/@interactive-os/json-document-object-document": { + "resolved": "packages/json-document-object-document", + "link": true + }, "node_modules/@interactive-os/json-document-react": { "resolved": "packages/json-document-react", "link": true @@ -6460,12 +6480,14 @@ "version": "0.1.0-rc.0", "license": "MIT", "devDependencies": { + "@interactive-os/json-document-selection": "*", "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" }, "peerDependencies": { + "@interactive-os/json-document-selection": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1" } }, @@ -6504,12 +6526,47 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-annotation": { + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "@interactive-os/json-document": ">=3.0.0-rc.0 <4", + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-calendar": { "name": "@interactive-os/json-document-calendar", "version": "0.1.0-rc.0", "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, "devDependencies": { "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-calendar-document": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-ui-primitives-react": "*", @@ -6524,6 +6581,7 @@ }, "peerDependencies": { "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-calendar-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", @@ -6531,6 +6589,58 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-calendar-document": { + "name": "@interactive-os/json-document-calendar-document", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document": "^3.0.0" + } + }, + "packages/json-document-canvas": { + "name": "@interactive-os/json-document-canvas", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "lucide-react": "^1.33.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-file-intake": "*", + "@interactive-os/json-document-object-document": "*", + "@interactive-os/json-document-react": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-file-intake": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-collaboration": { "name": "@interactive-os/json-document-collaboration", "version": "0.2.0-rc.1", @@ -6562,6 +6672,7 @@ "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-mention": "*", "@interactive-os/json-document-rich-text-suggestion": "*", + "@interactive-os/json-document-web": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" @@ -6571,7 +6682,8 @@ "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", - "@interactive-os/json-document-rich-text-suggestion": "^0.1.0-rc.0" + "@interactive-os/json-document-rich-text-suggestion": "^0.1.0-rc.0", + "@interactive-os/json-document-web": "^0.1.0-rc.0" } }, "packages/json-document-composer-react": { @@ -6581,6 +6693,8 @@ "devDependencies": { "@interactive-os/json-document": "*", "@interactive-os/json-document-composer": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-mention": "*", "@interactive-os/json-document-rich-text-mention-react": "*", @@ -6601,6 +6715,8 @@ "peerDependencies": { "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-composer": "^0.1.0-rc.0", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention-react": "^0.1.0-rc.0", @@ -6677,11 +6793,10 @@ "name": "@interactive-os/json-document-editing", "version": "0.1.0-rc.0", "license": "MIT", - "dependencies": { - "@js-temporal/polyfill": "^0.5.1" - }, "devDependencies": { "@interactive-os/json-document": "*", + "@interactive-os/json-document-calendar-document": "*", + "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", @@ -6689,6 +6804,8 @@ }, "peerDependencies": { "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-calendar-document": "^0.1.0-rc.0", + "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-selection": "^0.1.0-rc.0" } }, @@ -6728,6 +6845,22 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-object-document": { + "name": "@interactive-os/json-document-object-document", + "version": "0.1.0-rc.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-file-intake": "*", + "@types/node": "^25.9.0", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", + "@interactive-os/json-document-file-intake": "^0.1.0-rc.0" + } + }, "packages/json-document-react": { "name": "@interactive-os/json-document-react", "version": "0.1.0-rc.0", @@ -6851,6 +6984,7 @@ "version": "0.1.0-rc.0", "license": "MIT", "devDependencies": { + "@interactive-os/json-document": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-web": "*", @@ -6863,6 +6997,7 @@ "vitest": "^4.1.7" }, "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-react": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-web": "^0.1.0-rc.0", @@ -6985,6 +7120,7 @@ "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-selection": "*", "@types/node": "^25.9.0", + "jsdom": "^29.1.1", "typescript": "^5.0.0", "vitest": "^4.1.7" }, @@ -7022,7 +7158,10 @@ "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-ajv": "*", "@interactive-os/json-document-animation-react": "*", + "@interactive-os/json-document-annotation": "*", "@interactive-os/json-document-calendar": "*", + "@interactive-os/json-document-calendar-document": "*", + "@interactive-os/json-document-canvas": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-composer": "*", "@interactive-os/json-document-composer-react": "*", @@ -7032,6 +7171,7 @@ "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-markdown-react": "*", + "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-react-hook-form": "*", "@interactive-os/json-document-rich-text": "*", diff --git a/package.json b/package.json index 81f3367ae..ef935a9b3 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "workspaces": [ "packages/json-document", "packages/json-document-selection", + "packages/json-document-object-document", + "packages/json-document-calendar-document", "packages/json-document-editing", "packages/json-document-react", "packages/json-document-react-hook-form", @@ -16,7 +18,9 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", + "packages/json-document-canvas", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", diff --git a/packages/json-document-affordance/README.md b/packages/json-document-affordance/README.md index 8a4d6313c..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. @@ -56,6 +61,20 @@ drop targets, and insertion positions without owning DOM or product color. own the reusable state that spans several events. Product selection and rename Intents remain callbacks supplied by the host. +`createRenameSession` accepts either the legacy `onCommit(key, draft): void` +or synchronous `tryCommit(key, draft): boolean`. A false result keeps the active +key and draft without publishing a finish. Updating and retrying can then +complete the edit; success or cancellation clears the draft and calls `onFinish` +once. The domain editor still owns validation and document changes: + +```ts +createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onSnapshot: renderDraft, + onFinish: restoreFocus, +}); +``` + `createBoardDragSession` owns the input-agnostic active item, drop-target preview, commit, and cancel lifecycle for Board Hands. Web pointer and HTML Drag and Drop sessions feed it; Hosts still resolve targets and dispatch the @@ -79,14 +98,28 @@ open/focus semantics remain outside this geometry contract. Usage: [Affordance](https://developer-1px.github.io/json-document/docs/affordance) -`selectAllAffordance` implements an explicit Mod+A toggle input convention: -when everything is selected it emits `clear`; otherwise it emits `select-all`. -The semantic `select-all` command itself is idempotent. Hosts choosing this -input convention consume the existing Affordance API. +`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 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. [Editing grammar integration tests](tests/conformance/editing-grammar.test.ts) -connect that mapping to KeySelection and connect `createGestureSession` to +connect both mappings to selection, cover rejected draft commits, and connect `createGestureSession` to Document's `selection.move`. Structural preview and cancellation leave committed value/history unchanged; commit dispatches the latest preview once. This proves the tested composition, not every Host callback. IME composition has a separate [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 2744c59cf..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,13 +119,21 @@ export function resolveAffordanceKey(stroke: WebKeyboardStroke): AffordancePrevi return { hand: keyboard.resolve(stroke) }; } +/** 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 }; - return { hand: { type: state.allSelected ? "clear" : "select-all" } }; + 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" } }; } export function typeaheadAffordance(input: { diff --git a/packages/json-document-affordance/src/session.ts b/packages/json-document-affordance/src/session.ts index f2a63a2d0..da75e6bca 100644 --- a/packages/json-document-affordance/src/session.ts +++ b/packages/json-document-affordance/src/session.ts @@ -79,8 +79,14 @@ export interface RenameSession { cancel(): void; } -export function createRenameSession(options: { +/** Owns the draft lifecycle. A false tryCommit retains the draft without finishing; onCommit always finishes. */ +export function createRenameSession(options: ({ readonly onCommit: (key: Key, draft: string) => void; + readonly tryCommit?: never; +} | { + readonly tryCommit: (key: Key, draft: string) => boolean; + readonly onCommit?: never; +}) & { readonly onCancel?: (key: Key, draft: string) => void; readonly onFinish?: (key: Key) => void; readonly onSnapshot?: (snapshot: RenameSessionSnapshot | null) => void; @@ -94,7 +100,11 @@ export function createRenameSession(options: { function finish(commit: boolean) { if (snapshot === null) return; const finished = snapshot; - if (commit) options.onCommit(finished.key, finished.draft); + if (commit) { + if (options.tryCommit !== undefined) { + if (!options.tryCommit(finished.key, finished.draft)) return; + } else options.onCommit(finished.key, finished.draft); + } else options.onCancel?.(finished.key, finished.draft); publish(null); options.onFinish?.(finished.key); 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/conformance/editing-grammar.test.ts b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts index 6af1531cd..43a7c7ca5 100644 --- a/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts +++ b/packages/json-document-affordance/tests/conformance/editing-grammar.test.ts @@ -1,9 +1,99 @@ -import { createDocumentEditor } from "@interactive-os/json-document-editing"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { createDocumentEditor, createOrderEditor, type OrderDocument } from "@interactive-os/json-document-editing"; import { createKeySelectionFamily, emptyKeySelection, type KeySelectionContext } from "@interactive-os/json-document-selection"; import { describe, expect, test } from "vitest"; -import { createGestureSession, selectAllAffordance, type GestureCancelReason } from "../../src/index.js"; +import { createGestureSession, createRenameSession, selectAllAffordance, type GestureCancelReason } from "../../src/index.js"; describe("editing grammar / input mapping", () => { + test.each(["metaKey", "ctrlKey"] as const)("EG-SELECT / %s+A preserve profile retains all on repeat", (modifier) => { + const editor = createDocumentEditor({ blocks: [{ id: "a", text: "A" }, { id: "b", text: "B" }] }); + const stroke = { key: "A", metaKey: false, ctrlKey: false, [modifier]: true }; + for (const allSelected of [false, true, true]) { + const hand = selectAllAffordance(stroke, { allSelected }, { repeat: "preserve" }).hand; + expect(hand).toEqual({ type: "select-all" }); + if (hand?.type === "select-all") editor.dispatch({ type: "selection.select-all" }); + expect(editor.selectedBlockIds).toEqual(["a", "b"]); + expect(editor.snapshot).toMatchObject({ canUndo: false, canRedo: false }); + } + expect(selectAllAffordance(stroke, { allSelected: true }, { repeat: "toggle" }).hand).toEqual({ type: "clear" }); + expect(selectAllAffordance({ ...stroke, key: "x" }, { allSelected: true }, { repeat: "preserve" }).hand).toBeNull(); + expect(selectAllAffordance({ key: "a", metaKey: false, ctrlKey: false }, { allSelected: true }, { repeat: "preserve" }).hand).toBeNull(); + }); + + test("EG-COMMIT / rejection retains draft; retry finishes once; cancel never commits", () => { + const attempts: string[] = []; + const finished: string[] = []; + const cancelled: string[] = []; + const published: unknown[] = []; + const session = createRenameSession({ + tryCommit(key, draft) { attempts.push(`${key}:${draft}`); return draft.length > 0; }, + onFinish: (key) => finished.push(key), + onCancel: (key, draft) => cancelled.push(`${key}:${draft}`), + onSnapshot: (snapshot) => published.push(snapshot), + }); + session.begin("a", "Alpha"); + session.update(""); + expect(session.handleKey("Enter")).toBe(true); + session.commit(); + expect(session.getSnapshot()).toEqual({ key: "a", draft: "" }); + expect(published).toEqual([{ key: "a", draft: "Alpha" }, { key: "a", draft: "" }]); + expect(finished).toEqual([]); + session.update("Beta"); + expect(session.handleKey("Enter")).toBe(true); + session.commit(); + expect(session.getSnapshot()).toBeNull(); + expect(attempts).toEqual(["a:", "a:", "a:Beta"]); + expect(finished).toEqual(["a"]); + session.begin("b", "Draft"); + expect(session.handleKey("Escape")).toBe(true); + expect(session.getSnapshot()).toBeNull(); + expect(cancelled).toEqual(["b:Draft"]); + expect(finished).toEqual(["a", "b"]); + expect(attempts).toHaveLength(3); + }); + + test("rename callback contracts remain mutually exclusive and synchronous", () => { + const commits: string[] = []; + const legacy = createRenameSession({ onCommit: (_key, draft) => commits.push(draft) }); + legacy.begin("a", "Alpha"); + legacy.commit(); + expect(commits).toEqual(["Alpha"]); + expect(legacy.getSnapshot()).toBeNull(); + // These calls are checked by the owning package's test typecheck. + // @ts-expect-error Select exactly one commit contract. + createRenameSession({ onCommit: () => {}, tryCommit: () => true }); + // @ts-expect-error A commit contract is required. + createRenameSession({}); + // @ts-expect-error Result-aware commit is synchronous. + createRenameSession({ tryCommit: async () => true }); + }); + + test("EG-COMMIT / Order rejection preserves value, selection and history until retry", () => { + const initial: OrderDocument = { items: [{ id: "a", label: "Alpha" }] }; + const editor = createOrderEditor(createJSONDocument(initial, { + validate: (candidate) => (candidate as OrderDocument).items.every((item) => item.label.length > 0) + ? { ok: true } : { ok: false, code: "schema_violation" }, + })); + const before = editor.snapshot; + let finished = 0; + const rename = createRenameSession({ + tryCommit: (itemId, label) => editor.dispatch({ type: "item.rename", itemId, label }).ok, + onFinish: () => { finished++; }, + }); + rename.begin("a", "Alpha"); + rename.update(""); + rename.handleKey("Enter"); + expect(editor.snapshot).toEqual(before); + expect(rename.getSnapshot()).toEqual({ key: "a", draft: "" }); + expect(finished).toBe(0); + rename.update("Beta"); + rename.handleKey("Enter"); + expect(rename.getSnapshot()).toBeNull(); + expect(finished).toBe(1); + expect(editor.snapshot).toMatchObject({ value: { items: [{ id: "a", label: "Beta" }] }, canUndo: true }); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { value: initial, selection: before.selection, canUndo: false } }); + }); + test.each(["metaKey", "ctrlKey"] as const)("EG-SELECT / %s+A toggle profile sends clear as a distinct intent", (modifier) => { const context: KeySelectionContext = { keys: ["a", "b"], universe: "visible:v1", universeMismatch: "clear" }; const family = createKeySelectionFamily(); diff --git a/packages/json-document-affordance/tests/plane-select.test.ts b/packages/json-document-affordance/tests/plane-select.test.ts new file mode 100644 index 000000000..2e2272c5f --- /dev/null +++ b/packages/json-document-affordance/tests/plane-select.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "vitest"; +import { createPlaneSelectProfile, type PlaneSelectContext, type PlaneSelectSelection } from "../src/index.js"; + +// A diagram consumer: no Object document, Editing, Canvas, DOM or React dependency. +const items = ["node:a", "node:b", "node:c"].map((id, index) => ({ id, x: index * 100, y: 20, width: 40, height: 40 })); +const point = { x: 10, y: 30 }; +const selected = (keys: readonly string[], primaryKey = keys.at(-1) ?? null): PlaneSelectSelection => ({ kind: "explicit", keys, primaryKey }); +const context = (keys: readonly string[] = ["node:a", "node:b"]): PlaneSelectContext => ({ items, selection: selected(keys) }); +const stroke = (key: string, mod = false) => ({ key, shiftKey: false, metaKey: mod, ctrlKey: false }); + +test("press retains a selected set, release collapses a click, and drag produces one set delta", () => { + const profile = createPlaneSelectProfile(); + expect(profile.begin(context(), { point, hitKey: "node:a" }).selection).toEqual(selected(["node:a", "node:b"], "node:a")); + expect(profile.commit({ x: 12, y: 31 })).toEqual({ selection: selected(["node:a"]), translation: null }); + profile.begin(context(), { point, hitKey: "node:a" }); + expect(profile.preview({ x: 40, y: 50 })?.translation).toEqual({ operation: "move", keys: ["node:a", "node:b"], dx: 30, dy: 20 }); + expect(profile.commit({ x: 50, y: 60 })?.translation).toEqual({ operation: "move", keys: ["node:a", "node:b"], dx: 40, dy: 30 }); + expect(profile.commit(point)).toBeNull(); +}); + +test("an unselected hit replaces the drag source; Shift activation toggles without range semantics", () => { + const profile = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: "node:c" }); + expect(profile.commit({ x: 20, y: 40 })?.translation?.keys).toEqual(["node:c"]); + expect(profile.select(context(["node:a"]), "node:c", true)).toEqual(selected(["node:a", "node:c"])); + expect(profile.select(context(), "node:b", true)).toEqual(selected(["node:a"])); + profile.begin(context(), { point, hitKey: "node:b", shiftKey: true }); + expect(profile.commit({ x: 40, y: 40 })).toEqual({ selection: selected(["node:a", "node:b"]), translation: { operation: "move", keys: ["node:a", "node:b"], dx: 30, dy: 0 } }); +}); + +test.each([false, true])("marquee is transient and uses base selection for every preview (Shift=%s)", (shiftKey) => { + const profile = createPlaneSelectProfile(); + const base = context(["node:c"]); + profile.begin(base, { point: { x: -10, y: 10 }, hitKey: null, shiftKey }); + expect(profile.preview({ x: 150, y: 70 })?.selection.keys).toEqual(shiftKey ? ["node:a", "node:b", "node:c"] : ["node:a", "node:b"]); + const narrowed = profile.preview({ x: 50, y: 70 }); + expect(narrowed?.selection.keys).toEqual(shiftKey ? ["node:a", "node:c"] : ["node:a"]); + expect(narrowed?.marquee).toEqual({ x: -10, y: 10, width: 60, height: 60 }); + expect(base.selection).toEqual(selected(["node:c"])); + expect(profile.commit({ x: 50, y: 70 })?.selection).toEqual(narrowed?.selection); + expect(profile.getPreview()).toBeNull(); +}); + +test("empty click clears even with Shift; reverse marquee intersects bounds and inside is explicit policy", () => { + const profile = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: null, shiftKey: true }); + expect(profile.commit(point)?.selection).toEqual(selected([])); + const inside = createPlaneSelectProfile({ contain: "inside" }); + for (const consumer of [profile, inside]) consumer.begin(context([]), { point: { x: 120, y: 80 }, hitKey: null }); + expect(profile.commit({ x: -10, y: 10 })?.selection.keys).toEqual(["node:a", "node:b"]); + expect(inside.commit({ x: -10, y: 10 })?.selection.keys).toEqual(["node:a"]); +}); + +test.each(["cancel", "pointer-cancel", "lost-capture", "superseded"] as const)("%s discards preview without mutating base or producing a commit", (reason) => { + const profile = createPlaneSelectProfile(); + const base = context(); + profile.begin(base, { point, hitKey: null }); profile.preview({ x: 200, y: 100 }); + profile.cancel(reason); + expect(profile.getPreview()).toBeNull(); expect(profile.commit(point)).toBeNull(); + expect(base.selection).toEqual(selected(["node:a", "node:b"])); +}); + +test("Escape cancels the inner gesture first, then clears idle selection; repeated Mod+A preserves primary", () => { + const profile = createPlaneSelectProfile(); + const base = context(); + profile.begin(base, { point, hitKey: "node:a" }); + expect(profile.keyDown(stroke("Escape"), base)).toEqual({ type: "cancel" }); + expect(profile.commit(point)).toBeNull(); + expect(profile.keyDown(stroke("Escape"), base)).toEqual({ type: "selection", selection: selected([]) }); + expect(profile.keyDown(stroke("Escape"), context([]))).toBeNull(); + expect(profile.keyDown(stroke("Escape"), context([]), true)).toEqual({ type: "cancel" }); + const all = profile.keyDown(stroke("a", true), base); + expect(all).toEqual({ type: "selection", selection: selected(items.map((item) => item.id), "node:b") }); + if (all?.type !== "selection") throw new Error("selection expected"); + expect(profile.keyDown({ ...stroke("A"), ctrlKey: true }, { items, selection: all.selection })).toEqual(all); +}); + +test("Delete targets the set, edit targets primary only, unrelated/modified keys remain unhandled", () => { + const profile = createPlaneSelectProfile(); + expect(profile.keyDown(stroke("Delete"), context())).toEqual({ type: "delete", keys: ["node:a", "node:b"] }); + for (const key of ["Enter", "F2"]) expect(profile.keyDown(stroke(key), context())).toEqual({ type: "edit", key: "node:b" }); + for (const key of ["Delete", "Enter", "F2"]) expect(profile.keyDown(stroke(key), context([]))).toBeNull(); + for (const key of ["Home", "z"]) expect(profile.keyDown(stroke(key), context())).toBeNull(); + expect(profile.keyDown(stroke("Enter", true), context())).toBeNull(); +}); + +test("Alt-copy and Shift-axis lock reproject the same source, including stationary modifier changes", () => { + const profile = createPlaneSelectProfile(); + const base = context(); + profile.begin(base, { point, hitKey: "node:a", altKey: true, shiftKey: true }); + const modifiers = Object.create({ get shiftKey() { return true; }, get altKey() { return true; } }); + expect(profile.preview({ x: 40, y: 50 }, modifiers)?.translation).toEqual({ operation: "copy", keys: ["node:a", "node:b"], dx: 30, dy: 0 }); + expect(profile.updateModifiers({ altKey: false })?.translation).toEqual({ operation: "move", keys: ["node:a", "node:b"], dx: 30, dy: 20 }); + expect(profile.updateModifiers({ altKey: true, shiftKey: true })?.translation?.operation).toBe("copy"); + expect(profile.commit({ x: 40, y: 90 })?.translation).toEqual({ operation: "copy", keys: ["node:a", "node:b"], dx: 0, dy: 60 }); + expect(base.selection).toEqual(selected(["node:a", "node:b"])); + expect(profile.updateModifiers({})).toBeNull(); +}); + +test("Alt-click, zero-distance return and cancellation never request duplication", () => { + const profile = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: "node:a", altKey: true }); + expect(profile.commit(point)?.translation).toBeNull(); + profile.begin(context(), { point, hitKey: "node:a", altKey: true }); + profile.preview({ x: 40, y: 50 }); + expect(profile.commit(point)?.translation).toBeNull(); + profile.begin(context(), { point, hitKey: "node:c", altKey: true }); + expect(profile.preview({ x: 40, y: 50 })?.translation?.keys).toEqual(["node:c"]); + profile.cancel(); expect(profile.commit({ x: 40, y: 50 })).toBeNull(); +}); + +test("nudge and duplicate target the set, and native clipboard chords remain available", () => { + const profile = createPlaneSelectProfile(); + expect(profile.keyDown(stroke("ArrowRight"), context())).toEqual({ type: "translate", keys: ["node:a", "node:b"], dx: 1, dy: 0 }); + expect(profile.keyDown({ ...stroke("ArrowUp"), shiftKey: true }, context())).toEqual({ type: "translate", keys: ["node:a", "node:b"], dx: 0, dy: -10 }); + for (const modifiers of [{ metaKey: true }, { ctrlKey: true }]) { + expect(profile.keyDown({ ...stroke("d"), ...modifiers }, context())).toEqual({ type: "duplicate", keys: ["node:a", "node:b"] }); + for (const key of ["c", "x", "v", "ArrowLeft"]) expect(profile.keyDown({ ...stroke(key), ...modifiers }, context())).toBeNull(); + } + for (const key of ["d", "ArrowLeft"]) { + expect(profile.keyDown({ ...stroke(key), altKey: true }, context())).toBeNull(); + expect(profile.keyDown(stroke(key, key === "d"), context([]))).toBeNull(); + } +}); + +test("keys and primary reconcile through Selection; geometry and input are captured, and instances are independent", () => { + const profile = createPlaneSelectProfile(); + const mutable = { items: items.map((item) => ({ ...item })), selection: selected(["missing", "node:a"], "missing") }; + const input = { point: { x: -10, y: 0 }, hitKey: null }; + profile.begin(mutable, input); + mutable.items[0]!.x = 999; input.point.x = 999; + expect(profile.commit({ x: 60, y: 80 })?.selection).toEqual(selected(["node:a"])); + const second = createPlaneSelectProfile(); + profile.begin(context(), { point, hitKey: "node:a" }); + expect(second.getPreview()).toBeNull(); + expect(second.select(context(), "missing")).toEqual(selected([])); +}); + +test("crossing threshold is latched; returning to the origin has no document delta", () => { + const profile = createPlaneSelectProfile({ dragThreshold: 5 }); + profile.begin(context(), { point, hitKey: "node:a" }); + expect(profile.preview({ x: 13, y: 34 })?.translation).toBeNull(); + profile.preview({ x: 30, y: 40 }); + expect(profile.commit(point)).toEqual({ selection: selected(["node:a", "node:b"], "node:a"), translation: null }); + profile.begin(context(), { point, hitKey: null }); profile.preview({ x: 100, y: 100 }); + expect(profile.commit(point)?.selection).toEqual(selected([])); + for (const dragThreshold of [-1, NaN, Infinity]) expect(() => createPlaneSelectProfile({ dragThreshold })).toThrow(RangeError); +}); diff --git a/packages/json-document-affordance/tests/resize.test.ts b/packages/json-document-affordance/tests/resize.test.ts new file mode 100644 index 000000000..3df08f673 --- /dev/null +++ b/packages/json-document-affordance/tests/resize.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import { commitAffordance, resizeAffordance, type ResizeEdge } from "../src/index.js"; + +const size = { width: 200, height: 100 }; +const origin = { x: 0, y: 0 }; +const edges = ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const; +const modes = [ + { shiftKey: false, altKey: false }, { shiftKey: true, altKey: false }, + { shiftKey: false, altKey: true }, { shiftKey: true, altKey: true }, +]; + +function bounds(edge: ResizeEdge, point: typeof origin, modifiers = modes[0]!, initial = size) { + const result = resizeAffordance(origin, point, edge, modifiers, initial); + expect(result.cursor).toBe(`${edge}-resize`); + const hand = result.hand; + if (hand?.type !== "resize") throw new Error("Expected a resize result"); + return { x: hand.dx, y: hand.dy, width: initial.width + hand.dw, height: initial.height + hand.dh }; +} + +describe("size-aware Resize", () => { + test.each([ + ["n", 0, -10, 200, 110], ["ne", 40, -10, 240, 110], + ["e", 40, 0, 240, 100], ["se", 40, 10, 240, 110], + ["s", 0, 10, 200, 110], ["sw", -40, 10, 240, 110], + ["w", -40, 0, 240, 100], ["nw", -40, -10, 240, 110], + ] as const)("%s resizes only its axes and fixes the opposite edge/corner", (edge, dx, dy, width, height) => { + expect(bounds(edge, { x: dx || 75, y: dy || 75 })).toEqual({ + x: edge.includes("w") ? 200 - width : 0, y: edge.includes("n") ? 100 - height : 0, width, height, + }); + }); + + test.each(edges)("%s uses the initial non-square ratio and Alt fixes the center", (edge) => { + const x = edge.includes("w") ? -40 : edge.includes("e") ? 40 : 0; + const y = edge.includes("n") ? -10 : edge.includes("s") ? 10 : 0; + const width = x === 0 ? 220 : 240, height = width / 2; + const resized = bounds(edge, { x, y }, { shiftKey: true, altKey: false }); + expect(resized.width).toBeCloseTo(width); expect(resized.height).toBeCloseTo(height); + expect(resized.x).toBeCloseTo(x === 0 ? (200 - width) / 2 : x < 0 ? 200 - width : 0); + expect(resized.y).toBeCloseTo(y === 0 ? (100 - height) / 2 : y < 0 ? 100 - height : 0); + for (const shiftKey of [false, true]) { + const centered = bounds(edge, { x, y }, { shiftKey, altKey: true }); + expect(centered.x + centered.width / 2).toBeCloseTo(100); + expect(centered.y + centered.height / 2).toBeCloseTo(50); + expect(centered.width).toBeCloseTo(shiftKey ? 200 + (width - 200) * 2 : 200 + Math.abs(x) * 2); + expect(centered.height).toBeCloseTo(shiftKey ? centered.width / 2 : 100 + Math.abs(y) * 2); + } + }); + + test.each(edges.flatMap((edge) => modes.map((mode) => ({ edge, ...mode }))))("$edge clamps without moving its anchor (Shift=$shiftKey Alt=$altKey)", ({ edge, shiftKey, altKey }) => { + const horizontal = edge.includes("e") || edge.includes("w"), vertical = edge.includes("n") || edge.includes("s"); + const result = bounds(edge, { x: edge.includes("w") ? 1000 : -1000, y: edge.includes("n") ? 1000 : -1000 }, { shiftKey, altKey }); + expect(result.width).toBeCloseTo(shiftKey ? 2 : horizontal ? 1 : 200); + expect(result.height).toBeCloseTo(shiftKey || vertical ? 1 : 100); + expect(result.x + result.width * (altKey || !horizontal ? 0.5 : edge.includes("w") ? 1 : 0)).toBeCloseTo(altKey || !horizontal ? 100 : edge.includes("w") ? 200 : 0); + expect(result.y + result.height * (altKey || !vertical ? 0.5 : edge.includes("n") ? 1 : 0)).toBeCloseTo(altKey || !vertical ? 50 : edge.includes("n") ? 100 : 0); + }); + + test.each(edges)("%s keeps a stationary input a no-op in every modifier mode", (edge) => { + for (const mode of modes) expect(commitAffordance(resizeAffordance(origin, origin, edge, mode, size))).toBeNull(); + }); + + test("a tall object's ratio, shrinking and initially sub-unit dimensions remain well-defined", () => { + expect(bounds("se", { x: -10, y: -80 }, { shiftKey: true, altKey: false }, { width: 100, height: 400 })).toEqual({ x: 0, y: 0, width: 80, height: 320 }); + const small = { width: 0.5, height: 0.25 }; + expect(bounds("nw", origin, modes[0], small)).toEqual({ x: 0, y: 0, ...small }); + expect(bounds("nw", { x: 10, y: 10 }, modes[0], small)).toEqual({ x: -0.5, y: -0.75, width: 1, height: 1 }); + }); + + test.each([0, -1, Infinity, NaN])("rejects invalid initial dimensions (%s)", (width) => { + expect(() => resizeAffordance(origin, origin, "e", undefined, { width, height: 100 })).toThrow(RangeError); + }); +}); diff --git a/packages/json-document-affordance/tsconfig.json b/packages/json-document-affordance/tsconfig.json index ee5938125..0fd47ac1b 100644 --- a/packages/json-document-affordance/tsconfig.json +++ b/packages/json-document-affordance/tsconfig.json @@ -5,6 +5,6 @@ "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, - "references": [{ "path": "../json-document-web" }], + "references": [{ "path": "../json-document-web" }, { "path": "../json-document-selection" }], "include": ["src/**/*.ts"] } diff --git a/packages/json-document-annotation/LICENSE b/packages/json-document-annotation/LICENSE new file mode 100644 index 000000000..b66a4819a --- /dev/null +++ b/packages/json-document-annotation/LICENSE @@ -0,0 +1,3 @@ +MIT License + +Copyright (c) Interactive OS contributors diff --git a/packages/json-document-annotation/README.md b/packages/json-document-annotation/README.md new file mode 100644 index 000000000..2f593fd23 --- /dev/null +++ b/packages/json-document-annotation/README.md @@ -0,0 +1,41 @@ +# @interactive-os/json-document-annotation + +`AnnotationHand` is the canonical React interaction surface for raster +annotations. Editing owns the persistent document and selector transforms; +the Hand owns tools, gesture-to-Intent orchestration, SVG projection, +transient previews, resize handles, and comment UI. + +```tsx +import { AnnotationHand } from "@interactive-os/json-document-annotation"; + + crypto.randomUUID()} + rasterStyle={rasterStyle} +/> +``` + +The Host owns the active `tool` and injects `onToolChange`, IDs, enabled tools, +copy, class names, `reactionShadow`, raster style, and the +concrete source URL. The serialized output remains an `AnnotationDocument`; +selection and history stay in the editor snapshot. + + +`useAnnotationOutput({ document, editor, sourceUrl, rasterStyle, renderImage })` +provides `structured`, `structuredDownloadUrl`, `renderedImage`, `imageError`, +`canRestore`, `save()` and `restore()`. Pass the same Core `document` instance +used to create `editor`. `save()` retains an immutable document snapshot; +`restore()` uses a Core commit and clears selection, returning whether it +succeeded. This is external document replacement, so the editor's external +history policy applies. A saved snapshot cannot be restored into a different +Core document instance. Image rendering is lazy and ignores stale completions. +The Host composes its own output tabs, copyable code display and download links. + +The Hand uses Key Selection through Editing, `useInteractionHandle` for move +and resize, Web pointer capture for creation, and the Web keyboard resolver for +Undo/Redo/Delete. Tool shortcuts are plain V/C/D/A/L/K; modified shortcuts and +IME composition do not choose a tool. Preview and commit both consume Editing's +`transformAnnotationSelector`. diff --git a/packages/json-document-annotation/package.json b/packages/json-document-annotation/package.json new file mode 100644 index 000000000..7bba7fc71 --- /dev/null +++ b/packages/json-document-annotation/package.json @@ -0,0 +1,66 @@ +{ + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "description": "Official React Annotation Hand for json-document.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/json-document-annotation" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "tag": "next" + }, + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -b tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0", + "@interactive-os/json-document": ">=3.0.0-rc.0 <4" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "@interactive-os/json-document": "*" + } +} diff --git a/packages/json-document-annotation/src/annotation-hand.tsx b/packages/json-document-annotation/src/annotation-hand.tsx new file mode 100644 index 000000000..9759107a6 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-hand.tsx @@ -0,0 +1,326 @@ +import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; +import { createGestureSession, type InteractionHandleEvent, type InteractionHandleDescriptor } from "@interactive-os/json-document-affordance"; +import { + annotationResizeHandle, + annotationSelectorBounds, + transformAnnotationSelector, + type Annotation, + type AnnotationDocument, + type AnnotationEditor, + type AnnotationPoint, + type AnnotationSource, +} from "@interactive-os/json-document-editing"; +import { createWebKeyboardAdapter, createWebPointerSession, projectWebClientPointToSVG, renderWebAnnotationRaster, webSVGViewportFromElement, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; +import { Command, Field, Toggle, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; +import { ArrowUpRight, Download, MessageSquare, MousePointer2, Pencil, SendHorizontal, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; + +export type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +type Gesture = + | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } + | { readonly type: "draw"; readonly points: ReadonlyArray } + | { readonly type: "move" | "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; + +export const annotationTools = [ + { id: "select", label: "Select", shortcut: "V", icon: MousePointer2 }, + { id: "comment", label: "Comment", shortcut: "C", icon: MessageSquare }, + { id: "draw", label: "Draw", shortcut: "D", icon: Pencil }, + { id: "arrow", label: "Arrow", shortcut: "A", icon: ArrowUpRight }, + { id: "like", label: "Like", shortcut: "L", icon: ThumbsUp }, + { id: "dislike", label: "Dislike", shortcut: "K", icon: ThumbsDown }, +] as const; + +export interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} + +export interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} + +export interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} + +const defaultLabels = { + canvas: "Raster annotation canvas", tools: "Annotation tools", instruction: "Annotation instruction", + instructionPlaceholder: "수정 요청을 입력하세요…", sendComment: "Send comment", + deleteAnnotation: "Delete annotation", downloadImage: "Download annotated image", +}; +const accent = "var(--annotation-accent)"; +const keyboard = createWebKeyboardAdapter(); +const toolKeyboard = createWebKeyboardAdapter({ defaults: false, keymap: Object.fromEntries(annotationTools.map(({ id, shortcut }) => [shortcut.toLowerCase(), id])) }); + +export function AnnotationHand(props: AnnotationHandProps) { + useSyncExternalStore(props.editor.subscribe, () => props.editor.snapshot.revision, () => props.editor.snapshot.revision); + const labels = { ...defaultLabels, ...props.labels }; const classes = props.classNames ?? {}; + const enabled = props.enabledTools ?? annotationTools.map(({ id }) => id); + const { tool, onToolChange: setTool } = props; + const [editingId, setEditingId] = useState(null); const [previewId, setPreviewId] = useState(null); + const [, redraw] = useState(0); + const [gestures] = useState(() => createGestureSession({ onBegin: rerender, onPreview: rerender, onCommit: rerender, onCancel: rerender })); + const [pointer] = useState(() => createWebPointerSession<{ readonly active: true }>()); + const document = props.editor.snapshot.value as AnnotationDocument; const selectedId = props.editor.snapshot.selection.primaryId; + const selected = document.annotations.find(({ id }) => id === selectedId) ?? null; const source = document.sources[0]!; const gesture = gestures.getActive(); + function rerender() { redraw((value) => value + 1); } + function announce(message: string) { props.onAnnouncement?.(message); } + function select(id: string | null) { props.editor.dispatch({ type: "selection.set", annotationId: id, mode: "replace" }); } + function choose(next: AnnotationTool) { setTool(next); setEditingId(null); if (selectedId !== null) select(null); } + function remove() { if (selectedId === null) return; props.editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); setEditingId(null); announce("선택한 annotation을 삭제했습니다."); } + + function canvasDown(event: PointerEvent) { + if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; + const point = eventPoint(event); if (point === null) return; if (tool === "select") return select(null); + pointer.begin(event.currentTarget, event.pointerId, { active: true }); + gestures.begin(tool === "draw" ? { type: "draw", points: [point] } : { type: "create", tool, start: point, current: point }); + } + function handleInteraction(interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") { + if (interaction.phase === "start") { + if (type === "move") { setEditingId(null); setPreviewId(null); select(annotation.id); if (tool !== "select") return; } + const start = eventPoint(event); + if (start !== null) gestures.begin({ type, id: annotation.id, start, current: start }); + return; + } + if (interaction.phase === "cancel") { gestures.cancel("pointer-cancel"); announce("진행 중인 조작을 취소했습니다."); return; } + const active = gestures.getActive(); + if (active?.type !== type || active.id !== annotation.id) return; + const current = eventPoint(event); if (current === null) return; + gestures.preview({ ...active, current }); + if (interaction.phase === "commit") commitActiveGesture(); + } + function pointerMove(event: PointerEvent) { + const gesture = gestures.getActive(); + if (gesture === null || pointer.getSnapshot()?.pointerId !== event.pointerId) return; const point = eventPoint(event); if (point === null) return; + if (gesture.type === "draw") { const last = gesture.points[gesture.points.length - 1]; if (last && distance(last, point) >= 4) gestures.preview({ ...gesture, points: [...gesture.points, point] }); } + else gestures.preview({ ...gesture, current: point }); + } + function pointerUp(event: PointerEvent) { + pointerMove(event); + if (pointer.commit(event.pointerId) !== null) commitActiveGesture(); + } + function commitActiveGesture() { + const committed = gestures.commit(); if (committed === null) return; + if (committed.type === "draw" || committed.type === "create") { + const annotation = committed.type === "draw" ? drawAnnotation(source.id, committed.points, props.createId) : createAnnotation(source.id, committed, props.createId); + if (annotation === null || !props.editor.dispatch({ type: "annotation.create", annotation }).ok) return; setTool("select"); + setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); announce(createdMessage(annotation)); return; + } + const dx = committed.current.x - committed.start.x; const dy = committed.current.y - committed.start.y; + if (committed.type === "move" && Math.hypot(dx, dy) < 4) { + const annotation = document.annotations.find(({ id }) => id === committed.id); if (annotation?.presentation.type !== "reaction") setEditingId(committed.id); return; + } + const annotation = document.annotations.find(({ id }) => id === committed.id); if (!annotation) return; + const handle = annotationResizeHandle(annotation.target.selector); + const result = committed.type === "move" ? props.editor.dispatch({ type: "annotation.move", annotationId: committed.id, dx, dy }) + : handle === null ? null : props.editor.dispatch({ type: "annotation.resize", annotationId: committed.id, handle, dx, dy }); + if (!result?.ok) return; + announce(committed.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); + } + function cancel(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { + if (pointer.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel") === null) return; + gestures.cancel(reason); announce("진행 중인 조작을 취소했습니다."); + } + function keyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing) return; + const command = keyboard.resolve(event); + if (command?.type === "undo" || command?.type === "redo") { event.preventDefault(); props.editor[command.type](); return; } + if (command?.type === "delete") { event.preventDefault(); remove(); return; } + const next = toolKeyboard.resolve(event); + if (next && enabled.includes(next)) { event.preventDefault(); choose(next); return; } + if (event.key === "Escape") { event.preventDefault(); const active = pointer.getSnapshot(); if (active) pointer.cancel(active.pointerId); gestures.cancel("cancel"); choose("select"); } + } + async function download() { + const result = await renderWebAnnotationRaster({ document, sourceId: source.id, sourceURL: props.sourceUrl, style: props.rasterStyle }); + if (!result.ok) return announce("Annotation 이미지를 만들지 못했습니다."); + const link = window.document.createElement("a"); link.href = result.dataURL; link.download = "annotation-request.png"; link.click(); announce("Annotation이 적용된 이미지를 다운로드했습니다."); + } + return
+
+ cancel(event, "lost-capture")} onPointerCancel={(event) => cancel(event, "pointer-cancel")} onPointerMove={pointerMove} onPointerUp={pointerUp} role="application" tabIndex={0} viewBox={`0 0 ${source.width} ${source.height}`}> + + {document.annotations.map((annotation, index) => setPreviewId(visible ? annotation.id : null)} />)} + {gesture?.type === "create" ? : null}{gesture?.type === "draw" ? : null} + + {document.annotations.map((annotation, index) => gesture === null && previewId === annotation.id && annotation.body.instruction.trim() && editingId !== annotation.id ? : null)} + {selected && editingId === selected.id ? cancelComment(selected)} onSave={(instruction) => saveComment(selected, instruction)} onSubmit={(instruction) => submitComment(selected, instruction)} /> : null} +
+ +
; + + function saveComment(annotation: Annotation, instruction: string) { const value = instruction.trim(); if (annotation.body.instruction !== value) props.editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: value }); setTool("select"); announce("수정 요청을 추가했습니다."); } + function submitComment(annotation: Annotation, instruction: string) { saveComment(annotation, instruction); setEditingId(null); } + function cancelComment(annotation: Annotation) { if (!annotation.body.instruction) props.editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); else select(null); setEditingId(null); } +} + +function CommentComposer(props: { annotation: Annotation; index: number; source: AnnotationSource; classNames: AnnotationHandClassNames; labels: typeof defaultLabels; onCancel: () => void; onSave: (value: string) => void; onSubmit: (value: string) => void }) { + const [draft, setDraft] = useState(props.annotation.body.instruction); const input = useRef(null); const dock = composerDock(props.annotation, props.source); + useEffect(() => setDraft(props.annotation.body.instruction), [props.annotation.id, props.annotation.body.instruction]); + useEffect(() => { const frame = requestAnimationFrame(() => input.current?.focus()); return () => cancelAnimationFrame(frame); }, [props.annotation.id]); + return
+ { if (draft.trim()) props.onSave(draft); }} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); if (draft.trim()) props.onSubmit(draft); } else if (event.key === "Escape") props.onCancel(); }} /> + props.onSubmit(draft)} onMouseDown={(event) => event.preventDefault()}> +
; +} +function CommentPreview({ annotation, index, source, className }: { annotation: Annotation; index: number; source: AnnotationSource; className?: string | undefined }) { const dock = composerDock(annotation, source); return
{annotation.body.instruction}
; } +function AnnotationShape(props: { + readonly annotation: Annotation; + readonly index: number; + readonly selected: boolean; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") => void; + readonly onPreview: (visible: boolean) => void; +}) { + const { annotation } = props; + const drag = useInteractionHandle({ + descriptor: { kind: "drag", cursor: { idle: "move", active: "grabbing" } }, + onHandle: (interaction, event) => props.onHandle(interaction, event, annotation, "move"), + }); + const selector = annotation.target.selector; + const bounds = annotationSelectorBounds(annotation.target.selector); + const common = { + fill: "none", + stroke: accent, + strokeWidth: props.selected ? 6 : 4, + vectorEffect: "non-scaling-stroke" as const, + }; + return ( + props.onPreview(false)} + onFocus={() => props.onPreview(true)} + onPointerEnter={() => props.onPreview(true)} + onPointerLeave={() => props.onPreview(false)} + {...drag.handleProps} + role="button" + tabIndex={0} + style={{ cursor: drag.cursor }} + > + {annotation.presentation.type === "marker" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "reaction" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "outline" && selector.type === "rectangle" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type === "stroke" && selector.type === "path" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} /> + ) : null} + + ) : null} + {annotation.presentation.type === "arrow" && selector.type === "arrow" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type !== "marker" && annotation.presentation.type !== "reaction" ? ( + + ) : null} + + ); +} + +function AnnotationPointHandle(props: { + readonly "aria-label": string; + readonly cx: number; + readonly cy: number; + readonly descriptor: InteractionHandleDescriptor; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: props.descriptor, onHandle: props.onHandle }); + return ; +} + +function Badge(props: { readonly index: number; readonly point: AnnotationPoint; readonly selected: boolean }) { + return ( + + + {props.index} + + ); +} + +function commentBubblePath(point: AnnotationPoint): string { + const { x, y } = point; + return `M ${x} ${y - 24} C ${x + 13.25} ${y - 24} ${x + 24} ${y - 13.25} ${x + 24} ${y} C ${x + 24} ${y + 13.25} ${x + 13.25} ${y + 24} ${x} ${y + 24} L ${x - 24} ${y + 24} L ${x - 24} ${y} C ${x - 24} ${y - 13.25} ${x - 13.25} ${y - 24} ${x} ${y - 24} Z`; +} + + +function Stroke({ points, selected, draft }: { points: ReadonlyArray; selected?: boolean; draft?: boolean }) { return ; } +function Arrow({ from, to, selected }: { from: AnnotationPoint; to: AnnotationPoint; selected: boolean }) { const a = Math.atan2(to.y - from.y, to.x - from.x); const point = (delta: number) => ({ x: to.x - 34 * Math.cos(a + delta), y: to.y - 34 * Math.sin(a + delta) }); const l = point(-Math.PI / 6), r = point(Math.PI / 6); return ; } +function Reaction(props: { readonly point: AnnotationPoint; readonly reaction: "like" | "dislike"; readonly selected: boolean; readonly draft?: boolean }) { + const Icon = props.reaction === "like" ? ThumbsUp : ThumbsDown; + return ( + + + + + + ); +} +function DraftShape({ gesture }: { gesture: Extract }) { if (gesture.tool === "like" || gesture.tool === "dislike") return ; if (gesture.tool === "arrow") return ; if (distance(gesture.start, gesture.current) < 16) return ; return ; } +function project(annotation: Annotation, gesture: Gesture | null): Annotation { if (!gesture || (gesture.type !== "move" && gesture.type !== "resize") || gesture.id !== annotation.id) return annotation; const selector = transformAnnotationSelector(annotation.target.selector, gesture.type === "move" ? { type: "move", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y } : { type: "resize", handle: annotationResizeHandle(annotation.target.selector) ?? "south-east", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y }); return selector ? { ...annotation, target: { ...annotation.target, selector } } : annotation; } +function createAnnotation(sourceId: string, gesture: Extract, id: () => string): Annotation | null { const { tool, start, current } = gesture; if (tool === "like" || tool === "dislike") return { id: id(), target: { sourceId, selector: { type: "point", ...start } }, body: { instruction: "" }, presentation: { type: "reaction", reaction: tool } }; if (tool === "comment") return { id: id(), target: { sourceId, selector: distance(start, current) < 16 ? { type: "point", ...start } : { type: "rectangle", ...rectangle(start, current) } }, body: { instruction: "" }, presentation: { type: distance(start, current) < 16 ? "marker" : "outline" } }; return distance(start, current) < 8 ? null : { id: id(), target: { sourceId, selector: { type: "arrow", from: start, to: current } }, body: { instruction: "" }, presentation: { type: "arrow" } }; } +function drawAnnotation(sourceId: string, points: ReadonlyArray, id: () => string): Annotation | null { return points.length < 2 || pathLength(points) < 16 ? null : { id: id(), target: { sourceId, selector: { type: "path", points } }, body: { instruction: "" }, presentation: { type: "stroke" } }; } +function eventPoint(event: PointerEvent): AnnotationPoint | null { const svg = event.currentTarget.ownerSVGElement ?? event.currentTarget as SVGSVGElement; const point = projectWebClientPointToSVG({ x: event.clientX, y: event.clientY }, webSVGViewportFromElement(svg)); return point && { x: point.x, y: point.y }; } +function rectangle(a: AnnotationPoint, b: AnnotationPoint) { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) }; } +function distance(a: AnnotationPoint, b: AnnotationPoint) { return Math.hypot(b.x - a.x, b.y - a.y); } +function pathLength(points: ReadonlyArray) { return points.slice(1).reduce((total, point, index) => total + distance(points[index] ?? point, point), 0); } +function pathData(points: ReadonlyArray) { const first = points[0]; if (!first) return ""; if (points.length === 2) return `M ${first.x} ${first.y} L ${points[1]!.x} ${points[1]!.y}`; const curves = points.slice(1, -1).map((point, index) => { const next = points[index + 2] ?? point; return `Q ${point.x} ${point.y} ${(point.x + next.x) / 2} ${(point.y + next.y) / 2}`; }); const last = points[points.length - 1] ?? first; return [`M ${first.x} ${first.y}`, ...curves, `L ${last.x} ${last.y}`].join(" "); } +function composerDock(annotation: Annotation, source: AnnotationSource) { const bounds = annotationSelectorBounds(annotation.target.selector); return { horizontal: bounds.x + bounds.width / 2 > source.width * .75 ? "left" : "right", vertical: bounds.y < 48 ? "below" : bounds.y > source.height - 48 ? "above" : "center", bounds }; } +function dockStyle(dock: ReturnType, source: AnnotationSource) { const left = dock.horizontal === "left" ? dock.bounds.x - 36 : dock.bounds.x + 36; const x = dock.horizontal === "left" ? "-100%" : "0"; const y = dock.vertical === "above" ? "-100%" : dock.vertical === "below" ? "0" : "-50%"; return { left: `${left / source.width * 100}%`, top: `${dock.bounds.y / source.height * 100}%`, transform: `translate(${x}, ${y})` }; } +function createdMessage(annotation: Annotation) { if (annotation.presentation.type === "reaction") return annotation.presentation.reaction === "like" ? "좋아요 스티커를 붙였습니다." : "싫어요 스티커를 붙였습니다."; return annotation.presentation.type === "marker" ? "위치 코멘트를 만들었습니다." : annotation.presentation.type === "outline" ? "영역 코멘트를 만들었습니다." : annotation.presentation.type === "stroke" ? "자유선 코멘트를 만들었습니다." : "화살표 코멘트를 만들었습니다."; } diff --git a/packages/json-document-annotation/src/annotation-output.ts b/packages/json-document-annotation/src/annotation-output.ts new file mode 100644 index 000000000..c77a4e562 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-output.ts @@ -0,0 +1,58 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { JSONDocument } from "@interactive-os/json-document"; +import type { AnnotationDocument, AnnotationEditor } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; + +export interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +export interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} + +/** Output lifecycle; the Host owns tabs, copy, links, and panel layout. */ +export function useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput { + const { document, editor, sourceUrl, rasterStyle, renderImage } = options; + useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision); + const value = editor.snapshot.value as AnnotationDocument; + const [saved, setSaved] = useState<{ owner: JSONDocument; value: AnnotationDocument } | null>(null); + const [image, setImage] = useState<{ value: AnnotationDocument; sourceUrl: string; style: WebAnnotationRasterStyle; dataURL: string | null } | null>(null); + const { stroke, fill, lineWidth, labelFont } = rasterStyle; + useEffect(() => { + if (!renderImage) return; + let current = true; + const style = { stroke, fill, lineWidth, labelFont }; + void renderWebAnnotationRaster({ document: value, sourceId: value.sources[0]!.id, sourceURL: sourceUrl, style }) + .then((result) => { if (current) setImage({ value, sourceUrl, style, dataURL: result.ok ? result.dataURL : null }); }) + .catch(() => { if (current) setImage({ value, sourceUrl, style, dataURL: null }); }); + return () => { current = false; }; + }, [value, sourceUrl, stroke, fill, lineWidth, labelFont, renderImage]); + const currentImage = image?.value === value && image.sourceUrl === sourceUrl && image.style.stroke === stroke && image.style.fill === fill && image.style.lineWidth === lineWidth && image.style.labelFont === labelFont ? image : null; + const structured = JSON.stringify(value, null, 2); + return { + structured, + structuredDownloadUrl: `data:application/json;charset=utf-8,${encodeURIComponent(structured)}`, + renderedImage: currentImage?.dataURL ?? null, + imageError: currentImage !== null && currentImage.dataURL === null, + canRestore: saved?.owner === document, + save() { setSaved({ owner: document, value }); }, + restore() { + if (saved?.owner !== document) return false; + const result = document.commit([{ op: "replace", path: "", value: saved.value }]); + if (!result.ok) return false; + editor.dispatch({ type: "selection.set", annotationId: null, mode: "replace" }); + return true; + }, + }; +} diff --git a/packages/json-document-annotation/src/index.ts b/packages/json-document-annotation/src/index.ts new file mode 100644 index 000000000..0c9557b6c --- /dev/null +++ b/packages/json-document-annotation/src/index.ts @@ -0,0 +1,4 @@ +export { AnnotationHand, annotationTools } from "./annotation-hand.js"; +export type { AnnotationHandClassNames, AnnotationHandLabels, AnnotationHandProps, AnnotationTool } from "./annotation-hand.js"; +export { useAnnotationOutput } from "./annotation-output.js"; +export type { AnnotationOutput, AnnotationOutputOptions } from "./annotation-output.js"; diff --git a/packages/json-document-annotation/tests/annotation-hand.test.tsx b/packages/json-document-annotation/tests/annotation-hand.test.tsx new file mode 100644 index 000000000..b7b98d9d8 --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-hand.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { AnnotationHand, annotationTools } from "../src/index.js"; + +const document: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; + +afterEach(cleanup); + +describe("AnnotationHand", () => { + test("publishes one descriptor for every default tool", () => { + expect(annotationTools.map(({ id, shortcut }) => [id, shortcut])).toEqual([["select", "V"], ["comment", "C"], ["draw", "D"], ["arrow", "A"], ["like", "L"], ["dislike", "K"]]); + }); + + test("renders the canonical canvas and configurable tool set", () => { + render( "next"} tool="comment" onToolChange={() => {}} rasterStyle={rasterStyle} enabledTools={["select", "comment"]} />); + expect(screen.getByRole("application", { name: "Raster annotation canvas" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Select" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Draw" })).toBeNull(); + + }); +}); + + +test("the Host controls tools; modified keys and IME do not invoke ordinary commands", () => { + const editor = createAnnotationEditor(document); + const onToolChange = vi.fn(); + const undo = vi.spyOn(editor, "undo"), redo = vi.spyOn(editor, "redo"), dispatch = vi.spyOn(editor, "dispatch"); + const props = { editor, sourceUrl: "/image.png", createId: () => "next", rasterStyle, onToolChange }; + const view = render(); + const canvas = screen.getByRole("application"); + fireEvent.click(screen.getByRole("button", { name: "Draw" })); + expect(onToolChange).toHaveBeenLastCalledWith("draw"); + expect(canvas.getAttribute("data-tool")).toBe("comment"); + view.rerender(); + expect(canvas.getAttribute("data-tool")).toBe("draw"); + onToolChange.mockClear(); dispatch.mockClear(); + for (const modifiers of [{ altKey: true }, { ctrlKey: true }, { shiftKey: true }, { isComposing: true }]) fireEvent.keyDown(canvas, { key: "c", ...modifiers }); + expect(onToolChange).not.toHaveBeenCalled(); + fireEvent.keyDown(canvas, { key: "c" }); + expect(onToolChange).toHaveBeenLastCalledWith("comment"); + fireEvent.keyDown(canvas, { key: "z", metaKey: true }); + fireEvent.keyDown(canvas, { key: "Z", ctrlKey: true, shiftKey: true }); + expect(undo).toHaveBeenCalledTimes(1); expect(redo).toHaveBeenCalledTimes(1); + fireEvent.keyDown(canvas, { key: "z", ctrlKey: true, altKey: true }); + fireEvent.keyDown(canvas, { key: "z", metaKey: true, isComposing: true }); + expect(undo).toHaveBeenCalledTimes(1); +}); diff --git a/packages/json-document-annotation/tests/annotation-output.test.tsx b/packages/json-document-annotation/tests/annotation-output.test.tsx new file mode 100644 index 000000000..ec3598f69 --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-output.test.tsx @@ -0,0 +1,62 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterResult } from "@interactive-os/json-document-web"; +import { useAnnotationOutput } from "../src/index.js"; + +vi.mock("@interactive-os/json-document-web", async (load) => ({ ...await load(), renderWebAnnotationRaster: vi.fn() })); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); +const initial: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; +function setup() { + const document = createJSONDocument(initial), editor = createAnnotationEditor(document); + return { document, editor, sourceUrl: "/image.png", rasterStyle, renderImage: false }; +} + +test("serializes only the document and restores a saved snapshot with cleared selection", () => { + const options = setup(); + const { result } = renderHook(() => useAnnotationOutput(options)); + expect(result.current.restore()).toBe(false); + act(() => result.current.save()); + act(() => { options.editor.dispatch({ type: "annotation.create", annotation: { id: "note", body: { instruction: "Inspect" }, presentation: { type: "marker" }, target: { sourceId: "image", selector: { type: "point", x: 10, y: 20 } } } }); }); + expect(options.editor.snapshot.selection.primaryId).toBe("note"); + expect(JSON.parse(result.current.structured)).toEqual(options.document.value); + expect(JSON.parse(decodeURIComponent(result.current.structuredDownloadUrl.split(",")[1]!))).toEqual(options.document.value); + expect(result.current.structured).not.toContain("primaryId"); + act(() => { expect(result.current.restore()).toBe(true); }); + expect(options.document.value).toEqual(initial); + expect(options.editor.snapshot.selection.ids).toEqual([]); +}); + +test("does not restore a snapshot from a replaced document owner", () => { + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: setup() }); + act(() => result.current.save()); + rerender(setup()); + expect(result.current.canRestore).toBe(false); + expect(result.current.restore()).toBe(false); +}); + +test("renders lazily, ignores stale raster completion, and exposes the current failure", async () => { + const pending: Array<(result: WebAnnotationRasterResult) => void> = []; + vi.mocked(renderWebAnnotationRaster).mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + const options = setup(); + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: options }); + expect(pending).toHaveLength(0); + rerender({ ...options, renderImage: true }); + expect(pending).toHaveLength(1); + rerender({ ...options, renderImage: true, sourceUrl: "/new.png" }); + expect(pending).toHaveLength(2); + await act(async () => pending[0]!({ ok: true, dataURL: "data:old" })); + expect(result.current.renderedImage).toBeNull(); + await act(async () => pending[1]!({ ok: false, code: "raster.decode-failed" })); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); + + +test("reports an unexpected raster rejection without an unhandled promise", async () => { + vi.mocked(renderWebAnnotationRaster).mockRejectedValue(new Error("canvas unavailable")); + const options = { ...setup(), renderImage: true }; + const { result } = renderHook(() => useAnnotationOutput(options)); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); diff --git a/packages/json-document-annotation/tsconfig.json b/packages/json-document-annotation/tsconfig.json new file mode 100644 index 000000000..a90816ee2 --- /dev/null +++ b/packages/json-document-annotation/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig/library-react.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "references": [ + { + "path": "../json-document" + }, + { + "path": "../json-document-affordance" + }, + { + "path": "../json-document-editing" + }, + { + "path": "../json-document-ui-primitives-react" + }, + { + "path": "../json-document-web" + } + ], + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/packages/json-document-annotation/tsconfig.test.json b/packages/json-document-annotation/tsconfig.test.json new file mode 100644 index 000000000..6a4921de1 --- /dev/null +++ b/packages/json-document-annotation/tsconfig.test.json @@ -0,0 +1 @@ +{"extends":"./tsconfig.json","compilerOptions":{"composite":false,"noEmit":true,"rootDir":".","tsBuildInfoFile":null},"include":["src/**/*.ts","src/**/*.tsx","tests/**/*.ts","tests/**/*.tsx"]} diff --git a/packages/json-document-annotation/vitest.config.ts b/packages/json-document-annotation/vitest.config.ts new file mode 100644 index 000000000..04e0964c9 --- /dev/null +++ b/packages/json-document-annotation/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ test: { environment: "jsdom" } }); diff --git a/packages/json-document-calendar-document/LICENSE b/packages/json-document-calendar-document/LICENSE new file mode 100644 index 000000000..6a984193a --- /dev/null +++ b/packages/json-document-calendar-document/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 interactive-os contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/json-document-calendar-document/README.md b/packages/json-document-calendar-document/README.md new file mode 100644 index 000000000..e3750bc15 --- /dev/null +++ b/packages/json-document-calendar-document/README.md @@ -0,0 +1,36 @@ +# @interactive-os/json-document-calendar-document + +Calendar Document Type의 RC 공개 소유자입니다. 문서 모델, JSON/Calendar 검증, +입력 독립 의미 연산과 occurrence/기간 projection을 제공합니다. 의존성은 +JSON Document와 Temporal이며 Editing, Selection, React 또는 DOM을 요구하지 않습니다. + +```ts +import { applyPatch } from "@interactive-os/json-document"; +import { + validateCalendarDocument, planCalendarEventEdit, projectCalendarOccurrences, +} from "@interactive-os/json-document-calendar-document"; + +const validation = validateCalendarDocument(document); +if (!validation.ok) throw new Error(validation.reason); +const plan = planCalendarEventEdit(document.events, { + type: "event.move", eventId: "meeting", start: "2026-08-03T10:00", +}, { allocateId: () => crypto.randomUUID(), calendarIds: new Set(document.calendars.map(calendar => calendar.id)) }); +if (plan.ok) { + const result = applyPatch(document, plan.operations); + const occurrences = projectCalendarOccurrences(plan.events, "2026-08-03", "2026-08-04"); +} +``` + +정본 [API 및 값 계약](docs/api.md)은 이 package에 둡니다. 사이트의 +[Calendar Document API](/docs/api/calendar-document), [Usage / Source](/editors#calendar-editor), +Usage의 Source 탭에서 실제 소유자와 소비 경로를 확인할 수 있습니다. + +`json-document-editing`은 이 package의 연산 결과를 Selection, Clipboard와 +History에 연결합니다. `json-document-calendar`는 Web/Affordance/React와 UI를 +조합합니다. 기존 Editing root의 문서 타입·projection export는 동일 구현의 +호환 경로이며, 새 직접 소비자는 이 package에서 import합니다. + +`tests/calendar-document.test.ts`는 editor 없는 소비를 검증합니다. +Editing의 `tests/conformance/calendar-grammar.test.ts`는 동일 연산을 사용하는 +선택·복사·붙여넣기·삭제·Undo/Redo의 공통 규칙을 검증합니다. Document Type +소유권의 확정은 wire 프로토콜의 Stable 승격이나 독립 구현 간 호환 보장이 아닙니다. diff --git a/packages/json-document-calendar-document/docs/api.md b/packages/json-document-calendar-document/docs/api.md new file mode 100644 index 000000000..1dadbe570 --- /dev/null +++ b/packages/json-document-calendar-document/docs/api.md @@ -0,0 +1,101 @@ +## Calendar Document Type 계약 · RC + +소유자: `@interactive-os/json-document-calendar-document`, 현재 `0.1.0-rc.0`. +이 package는 문서 규칙을 소유합니다. 선택·Clipboard·History는 +[Editing 프로파일](/docs/api/editing#calendar-protocol-profile-rc), 사용자 입력과 +UI는 [Calendar Hands](/docs/api/calendar)가 연결합니다. Core Stable 및 Official +Hands/Editing Grammar의 Draft 지위를 바꾸지 않습니다. + +### 모델과 검증 + +`CalendarDocument`는 `calendars`와 `events`를, `CalendarEvent`는 interval과 +recurrence를 정의합니다. `{ eventId, occurrenceStart }`인 `CalendarOccurrencePoint`는 +문서의 발생분 주소이며 selection 상태가 아닙니다. `CalendarOccurrenceInterval`은 +현재 규칙으로 해석한 `{ eventId, start, end }`입니다. + +- `validateCalendarDocument(unknown)`는 JSON과 Calendar 구조·참조·시간 규칙을 + 검사해 `{ ok: true }` 또는 `{ ok: false, code, reason }`을 반환합니다. +- `assertCalendarDocument(unknown)`는 같은 검사에 실패하면 `TypeError`를 던집니다. + 검사만 수행하며 입력을 정규화하거나 mutation하지 않습니다. legacy 수용을 + 포함하므로 validator 결과는 필수 필드가 모두 채워졌다는 TypeScript type guard가 아닙니다. +- calendar의 `id`는 고유한 비어 있지 않은 문자열, `title`은 문자열, + `hidden`은 boolean, `color`는 비어 있지 않은 문자열입니다. calendars의 생략은 + 허용하지만 null·문자열 같은 비배열 값은 거절합니다. +- event는 고유한 비어 있지 않은 `id`, 문자열 `title`, `start < end`를 갖습니다. + calendar 목록이 비어 있지 않을 때 비어 있지 않은 `calendarId`는 실제 calendar를 참조합니다. +- 기존 간단한 문서의 생략된 calendars / allDay / calendarId / recurrence / + excludeDates는 빈 목록 / timed / 미지정 / 반복 없음 / 제외 없음으로 읽습니다. + 이 호환 경로는 잘못된 타입을 생략으로 바꾸지 않습니다. + +### 시간과 반복 + +timed 값은 정확한 `YYYY-MM-DDTHH:mm` local date-time, all-day 값은 +`YYYY-MM-DD`입니다. 둘 다 종료는 exclusive입니다. 8월 1일 하루는 +`start: "2026-08-01", end: "2026-08-02"`입니다. UTC Instant, offset, timezone과 +초 단위는 현재 프로파일에 포함하지 않습니다. + +recurrence의 `freq`는 daily / weekly / monthly / yearly, `interval`은 양의 safe +integer입니다. `until: ""`은 무기한이며 나머지는 발생 시작일 기준 inclusive 날짜입니다. +`excludeDates`는 발생 시작일을 제외합니다. 월말·윤년의 시작은 Temporal constrain, +종료는 원본의 local duration을 보존하므로 시작·종료를 따로 constrain하지 않습니다. + +### 의미 연산 + +| API | 입력과 결과 | +| --- | --- | +| `planCalendarEventEdit(events, operation, options)` | create/update/move/move-day/resize/occurrence.edit를 events·JSON Patch·affectedOccurrence로 계획 | +| `planCalendarEventRemoval(events, eventIds)` | 지정한 원본 event들을 제거하는 events·Patch 계획 | +| `planCalendarOccurrenceRemoval(events, removal)` | 발생분 scope에 따른 제외·시리즈 절단·제거 계획 | +| `planCalendarVisibility(document, calendarId, hidden)` | 존재하는 calendar의 boolean 가시성 변경 계획 | + +모든 plan은 입력을 변경하거나 commit하지 않습니다. 실패하면 `{ ok: false, code, +reason? }`이며 성공 시 `operations`를 JSONDocument 또는 `applyPatch`에 적용할 수 +있습니다. `affectedOccurrence`는 변경 결과의 문서 주소입니다. 무엇을 선택하고 +어떤 Undo 단위로 묶을지는 Editing이 결정합니다. 기존 Calendar rejection code는 +호환성을 위해 유지하며, `selection.*`라는 code 이름이 Selection 의존성을 뜻하지는 않습니다. + +연산의 `events`는 검증된 현재 문서에서 가져옵니다. `calendarIds`에는 그 문서의 +calendar ID 집합을 전달합니다. 생략하면 event의 시간·반복 구조만 검사하므로 +문서 전체의 membership 검증을 대신하지 않습니다. 생성의 `defaultCalendarId`는 +호출자가 고른 기본값이고, 미지정이면 빈 문자열입니다. `allocateId`는 필요한 새 ID를 +공급하며 비어 있거나 이미 있는 ID는 거절합니다. provider 예외는 호출자에게 전파합니다. +Editing은 기존 bounded ID allocator를 주입합니다. + +| scope | 편집 | 삭제 | +| --- | --- | --- | +| this | 해당 발생분을 제외하고 독립 일정으로 분리 | 해당 발생분 제외 | +| this-and-following | 기준일 전날까지 원본을 자르고 이후 시리즈 분리 | 기준일 이후 발생분 제거 | +| all | 선택한 발생분의 변경량을 원본 시리즈에 적용 | 원본 시리즈 제거 | + +시작만 바꾸면 구간 길이를 보존하고 resize는 지정한 경계만 변경합니다. 시리즈 +이동은 유한한 until과 excludeDates를 함께 옮기며 following은 기존 종료와 이후 +제외 날짜를 보존합니다. 월/년 재기준화로 요청한 발생분을 표현할 수 없으면 +`selection.unrepresentable-series-move`로 거절합니다. allDay / calendarId / +recurrence 자체의 변경은 시리즈 속성 변경입니다. + +### Projection과 날짜 값 + +`projectCalendarOccurrences(events, rangeStart, rangeEnd)`는 `[rangeStart, rangeEnd)`와 +겹치는 발생분을 계산합니다. 범위는 date 문자열입니다. 요청 구간 근처로 seek하며 +400회 같은 lifetime cap은 없습니다. `resolveCalendarOccurrence`는 화면 밖의 주소도 +현재 recurrence와 exclusions로 해석합니다. 잘못된 조회 범위는 빈 결과입니다. + +`calendarVisibleEvents`, `calendarEventsOnDay`, `calendarEventsInMonth`, +`calendarBusyDates`는 문서 조회를, `calendarTimedLayout`, `calendarAllDayLayout`, +`calendarMonthDayLayout`, `calendarMonthWeekLayout`은 event의 구간/lane projection을 +제공합니다. DOM·CSS·표시 요소의 디자인은 포함하지 않습니다. + +`calendarDocumentCalendars` / `calendarDocumentCalendar`는 collection 조회, +`calendarDatePart` / `calendarIntervalLastDate` / `calendarAllDaySpan`은 시간 값의 +정본 변환입니다. parse/format/add/shift와 recurrence 변경 함수도 같은 owner를 +사용합니다. Calendar 화면의 cell/grid, 날짜 선택과 표시 label은 Calendar Hands에 남습니다. + +### 실제 소비와 남은 범위 + +[Calendar Usage 및 Source](/editors#calendar-editor)는 이 package의 조회·projection을 직접 소비하고, +Editing이 공개 연산 계획을 통해 변경합니다. Source에서 모델·검증·연산·projection과 +Editing·Hand의 연결을 추적할 수 있습니다. 이 문서 아래에는 전체 export signature가 이어집니다. + +timezone/DST, 서버 revision/충돌/재시도, AI command wire, 외부 calendar connector, +범용 RRULE, 대량 조회 pagination, 프로파일 버전 협상과 독립 구현 conformance는 +TBD입니다. 현재 소유권 및 로컬 RC 동작의 검증과 구분합니다. diff --git a/packages/json-document-calendar-document/package.json b/packages/json-document-calendar-document/package.json new file mode 100644 index 000000000..fc22921a0 --- /dev/null +++ b/packages/json-document-calendar-document/package.json @@ -0,0 +1,24 @@ +{ + "name": "@interactive-os/json-document-calendar-document", + "version": "0.1.0-rc.0", + "description": "Calendar Document Type: model, validation, semantic operations and projections without an editor or UI.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { "type": "git", "url": "git+https://github.com/developer-1px/json-document.git", "directory": "packages/json-document-calendar-document" }, + "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "scripts": { + "build": "tsc -b tsconfig.json", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "test": "vitest run --config vitest.config.ts", + "verify": "npm run typecheck && npm test && npm run build" + }, + "peerDependencies": { "@interactive-os/json-document": "^3.0.0" }, + "dependencies": { "@js-temporal/polyfill": "^0.5.1" }, + "devDependencies": { "@interactive-os/json-document": "*", "@types/node": "^25.9.0", "typescript": "^5.0.0", "vitest": "^4.1.7" } +} diff --git a/packages/json-document-calendar-document/src/calendar-model.ts b/packages/json-document-calendar-document/src/calendar-model.ts new file mode 100644 index 000000000..d8a8ff265 --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-model.ts @@ -0,0 +1,42 @@ +import type { JSONValue } from "@interactive-os/json-document"; + +export interface CalendarCalendar extends Record { + readonly id: string; + readonly title: string; + readonly hidden: boolean; + readonly color: string; +} + +export interface CalendarRecurrence extends Record { + readonly freq: "daily" | "weekly" | "monthly" | "yearly"; + readonly interval: number; + readonly until: string; +} + +export interface CalendarEvent extends Record { + readonly id: string; + readonly title: string; + readonly start: string; + readonly end: string; + readonly allDay: boolean; + readonly calendarId: string; + readonly recurrence: CalendarRecurrence | null; + readonly excludeDates: ReadonlyArray; +} + +export interface CalendarDocument extends Record { + readonly calendars: ReadonlyArray; + readonly events: ReadonlyArray; +} + +export interface CalendarOccurrencePoint extends Record { + readonly eventId: string; + readonly occurrenceStart: string; +} + +/** A resolved document occurrence, independent of selection or an editor. */ +export interface CalendarOccurrenceInterval { + readonly eventId: string; + readonly start: string; + readonly end: string; +} diff --git a/packages/json-document-editing/src/calendar-occurrence.ts b/packages/json-document-calendar-document/src/calendar-occurrence.ts similarity index 57% rename from packages/json-document-editing/src/calendar-occurrence.ts rename to packages/json-document-calendar-document/src/calendar-occurrence.ts index 80bb8d5df..a2e8920c2 100644 --- a/packages/json-document-editing/src/calendar-occurrence.ts +++ b/packages/json-document-calendar-document/src/calendar-occurrence.ts @@ -1,12 +1,16 @@ import { Temporal } from "@js-temporal/polyfill"; -import type { CalendarEvent, CalendarRecurrence } from "./calendar.js"; +import type { CalendarEvent, CalendarOccurrencePoint, CalendarOccurrenceInterval, CalendarRecurrence } from "./calendar-model.js"; import { addCalendarDate, calendarDatePart, calendarEventBounds, + calendarEventIntervalAt, + calendarDaysBetween, + formatCalendarDate, + formatCalendarInstant, isCalendarAllDay, + isCalendarRecurrence, parseCalendarDate, - parseCalendarInstant, } from "./calendar-validation.js"; export type CalendarOccurrence = { @@ -16,17 +20,7 @@ export type CalendarOccurrence = { }; export function calendarEventRecurrence(event: CalendarEvent): CalendarRecurrence | null { - const value = event.recurrence; - if (value === null || typeof value !== "object" || Array.isArray(value)) return null; - const freq = value.freq; - const interval = value.interval; - if ( - (freq !== "daily" && freq !== "weekly" && freq !== "monthly" && freq !== "yearly") - || typeof interval !== "number" - || interval < 1 - ) return null; - const until = typeof value.until === "string" ? value.until : ""; - return { freq, interval, until }; + return isCalendarRecurrence(event.recurrence) ? event.recurrence : null; } export function calendarRecurrenceWithFrequency( @@ -82,8 +76,21 @@ export function projectCalendarOccurrences( } const excluded = new Set(calendarEventExcludeDates(event)); const until = recurrence.until === "" ? null : parseCalendarDate(recurrence.until); - for (let index = 0; index < 400; index += 1) { - const shifted = shiftOccurrence(event, recurrence.freq, recurrence.interval * index); + const end = parseCalendarDate(calendarDatePart(event.end)); + if (end === null) continue; + // Seek by the interval end, not start, so long occurrences overlapping the + // window are retained. One preceding period covers constrained month/year ends. + const distance = recurrence.freq === "monthly" ? (from.year - end.year) * 12 + from.month - end.month + : recurrence.freq === "yearly" ? from.year - end.year + : calendarDaysBetween(end, from) / (recurrence.freq === "weekly" ? 7 : 1); + const first = Math.max(0, Math.floor(distance / recurrence.interval) - 1); + for (let index = first; ; index += 1) { + let shifted: ReturnType; + try { shifted = shiftOccurrence(event, recurrence.freq, recurrence.interval * index); } + catch (error) { + if (!(error instanceof RangeError)) throw error; + break; // Beyond the finite Temporal date domain, not a truncated result. + } if (shifted === null) break; const bounds = calendarEventBounds({ ...event, start: shifted.start, end: shifted.end }); if (bounds === null) break; @@ -97,37 +104,31 @@ export function projectCalendarOccurrences( return occurrences; } +/** Resolve against the current recurrence/exclusion rules, including off-screen occurrences. */ +export function resolveCalendarOccurrence( + events: ReadonlyArray, + point: CalendarOccurrencePoint, +): CalendarOccurrenceInterval | null { + const event = events.find((candidate) => candidate.id === point.eventId); + if (event === undefined || typeof point.occurrenceStart !== "string") return null; + const day = calendarDatePart(point.occurrenceStart); + const next = addCalendarDate(day, 1); + if (next === null) return null; + const occurrence = projectCalendarOccurrences([event], day, next).find((candidate) => candidate.start === point.occurrenceStart); + return occurrence === undefined ? null : { eventId: event.id, start: occurrence.start, end: occurrence.end }; +} + function shiftOccurrence( event: CalendarEvent, freq: CalendarRecurrence["freq"], steps: number, ): { readonly start: string; readonly end: string } | null { if (steps === 0) return { start: event.start, end: event.end }; - if (isCalendarAllDay(event)) { - const start = shiftDate(event.start, freq, steps); - const end = shiftDate(event.end, freq, steps); - if (start === null || end === null) return null; - return { start, end }; - } - const start = shiftInstant(event.start, freq, steps); - const end = shiftInstant(event.end, freq, steps); - if (start === null || end === null) return null; - return { start, end }; -} - -function shiftDate(value: string, freq: CalendarRecurrence["freq"], steps: number): string | null { - if (freq === "daily") return addCalendarDate(value, steps); - if (freq === "weekly") return addCalendarDate(value, steps * 7); - if (parseCalendarDate(value) === null) return null; - const duration = freq === "monthly" ? { months: steps } : { years: steps }; - return Temporal.PlainDate.from(value).add(duration, { overflow: "constrain" }).toString(); -} - -function shiftInstant(value: string, freq: CalendarRecurrence["freq"], steps: number): string | null { - const dateTime = parseCalendarInstant(value); - if (dateTime === null) return null; - if (freq === "daily") return dateTime.add({ days: steps }).toString({ smallestUnit: "minute" }); - if (freq === "weekly") return dateTime.add({ weeks: steps }).toString({ smallestUnit: "minute" }); - const duration = freq === "monthly" ? { months: steps } : { years: steps }; - return Temporal.PlainDateTime.from(value).add(duration, { overflow: "constrain" }).toString({ smallestUnit: "minute" }); + const bounds = calendarEventBounds(event); + if (bounds === null) return null; + const duration = freq === "daily" ? { days: steps } : freq === "weekly" ? { weeks: steps } + : freq === "monthly" ? { months: steps } : { years: steps }; + const shifted = bounds.from.add(duration, { overflow: "constrain" }); + const start = isCalendarAllDay(event) ? formatCalendarDate(shifted.toPlainDate()) : formatCalendarInstant(shifted); + return calendarEventIntervalAt(event, start); } diff --git a/packages/json-document-calendar-document/src/calendar-operation.ts b/packages/json-document-calendar-document/src/calendar-operation.ts new file mode 100644 index 000000000..bd959002b --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-operation.ts @@ -0,0 +1,245 @@ +import { buildPointer, type JSONPatchOperation } from "@interactive-os/json-document"; +import type { CalendarDocument, CalendarEvent, CalendarOccurrencePoint, CalendarRecurrence } from "./calendar-model.js"; +import { calendarEventExcludeDates, calendarEventRecurrence, resolveCalendarOccurrence } from "./calendar-occurrence.js"; +import { + addCalendarDate, calendarAllDaySpan, calendarDatePart, calendarDaysBetween, + calendarDocumentCalendars, calendarEventIntervalAt, calendarMinutesBetween, formatCalendarInstant, + parseCalendarDate, parseCalendarInstant, validateCalendarEvent, +} from "./calendar-validation.js"; + +export type CalendarEventOperation = + | { + readonly type: "event.create"; + readonly start: string; + readonly end: string; + readonly title?: string; + readonly allDay?: boolean; + readonly calendarId?: string; + readonly recurrence?: CalendarRecurrence | null; + } + | { readonly type: "event.move"; readonly eventId: string; readonly start: string } + | { readonly type: "event.resize"; readonly eventId: string; readonly edge: "start" | "end"; readonly instant: string } + | { readonly type: "event.move-day"; readonly eventId: string; readonly day: string } + | { + readonly type: "event.update"; + readonly eventId: string; + readonly title?: string; + readonly start?: string; + readonly end?: string; + readonly allDay?: boolean; + readonly calendarId?: string; + readonly recurrence?: CalendarRecurrence | null; + } + | { + readonly type: "occurrence.edit"; + readonly eventId: string; + readonly occurrenceStart: string; + readonly scope: "this" | "this-and-following" | "all"; + readonly title?: string; + readonly start?: string; + readonly end?: string; + }; + +export type CalendarOccurrenceRemoval = { + readonly eventId: string; + readonly occurrenceStart: string; + readonly scope: "this" | "this-and-following" | "all"; +}; + +export type CalendarEventPlan = { + readonly ok: true; + readonly events: ReadonlyArray; + readonly operations: ReadonlyArray; + readonly affectedOccurrence: CalendarOccurrencePoint; +} | { readonly ok: false; readonly code: string; readonly reason?: string }; + +/** Calendar's single event/series semantics, shared by commit, preview and group moves. */ +export function planCalendarEventEdit( + events: ReadonlyArray, + intent: CalendarEventOperation, + options: { readonly allocateId: () => string; readonly calendarIds?: ReadonlySet; readonly defaultCalendarId?: string }, +): CalendarEventPlan { + const index = intent.type === "event.create" ? -1 : events.findIndex((event) => event.id === intent.eventId); + const event = events[index]; + + function replace(next: CalendarEvent, selectedStart = next.start, fields?: ReadonlyArray<"start" | "end">): CalendarEventPlan { + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + return { + ok: true, + events: events.map((item, position) => position === index ? next : item), + operations: fields === undefined + ? [{ op: "replace", path: buildPointer(["events", index]), value: next }] + : fields.map((field) => ({ op: "replace", path: buildPointer(["events", index, field]), value: next[field] })), + affectedOccurrence: { eventId: next.id, occurrenceStart: selectedStart }, + }; + } + + function append(next: CalendarEvent, preceding: ReadonlyArray = [], previous?: CalendarEvent): CalendarEventPlan { + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + if (events.some((event) => event.id === next.id)) return failure("event.duplicate-id"); + if (previous !== undefined) { + const previousValidation = validateCalendarEvent(previous, options.calendarIds); + if (!previousValidation.ok) return previousValidation; + } + // Detached records own their JSON subtrees, including extension metadata. + const appended = JSON.parse(JSON.stringify(next)) as CalendarEvent; + return { + ok: true, + events: [...events.map((item, position) => position === index && previous !== undefined ? previous : item), appended], + operations: [...preceding, { op: "add", path: `/events/${events.length}`, value: appended }], + affectedOccurrence: { eventId: next.id, occurrenceStart: next.start }, + }; + } + + if (intent.type === "event.create") { + const candidate: CalendarEvent = { + id: "pending", title: intent.title ?? "Event", start: intent.start, end: intent.end, + allDay: intent.allDay ?? false, calendarId: intent.calendarId ?? options.defaultCalendarId ?? "", + recurrence: intent.recurrence ?? null, excludeDates: [], + }; + const validation = validateCalendarEvent(candidate, options.calendarIds); + if (!validation.ok) return validation; + return append({ ...candidate, id: options.allocateId() }); + } + if (event === undefined) return failure("selection.event-not-found"); + if (intent.type === "event.resize") { + if (intent.edge !== "start" && intent.edge !== "end") return failure("event.invalid-edge"); + return replace({ ...event, [intent.edge]: intent.instant }, intent.edge === "start" ? intent.instant : event.start, [intent.edge]); + } + if (intent.type === "event.move" || intent.type === "event.move-day") { + if (intent.type === "event.move" && event.allDay) return failure("event.all-day-move"); + if (intent.type === "event.move-day" && parseCalendarDate(intent.day) === null) return failure("event.invalid-day"); + const start = intent.type === "event.move" ? intent.start + : event.allDay ? intent.day : `${intent.day}T${event.start.slice(11)}`; + const interval = calendarEventIntervalAt(event, start); + return interval === null ? failure("event.invalid-instant") : replace({ ...event, ...interval }, start, ["start", "end"]); + } + if (intent.type === "event.update") { + let start = intent.start ?? event.start; + let end = intent.end ?? event.end; + if (intent.allDay === true && !event.allDay) { + start = calendarDatePart(event.start); + end = calendarAllDaySpan(start, start)?.end ?? start; + } else if (intent.allDay === false && event.allDay) { + start = `${calendarDatePart(event.start)}T09:00`; + end = `${calendarDatePart(event.start)}T10:00`; + } else if (intent.start !== undefined && intent.end === undefined) { + const interval = calendarEventIntervalAt(event, intent.start); + if (interval === null) return failure("event.invalid-instant"); + ({ start, end } = interval); + } + return replace({ ...event, start, end, allDay: intent.allDay ?? event.allDay, + title: intent.title ?? event.title, calendarId: intent.calendarId ?? event.calendarId, + recurrence: intent.recurrence === undefined ? event.recurrence : intent.recurrence }); + } + + if (intent.type !== "occurrence.edit") return failure("operation.unsupported"); + if (intent.scope !== "this" && intent.scope !== "this-and-following" && intent.scope !== "all") return failure("occurrence.invalid-scope"); + const occurrence = resolveCalendarOccurrence(events, { eventId: event.id, occurrenceStart: intent.occurrenceStart }); + if (occurrence === null) return failure("selection.stale-occurrence"); + const interval = intent.end === undefined + ? calendarEventIntervalAt({ ...event, start: occurrence.start, end: occurrence.end }, intent.start ?? occurrence.start) + : { start: intent.start ?? occurrence.start, end: intent.end }; + if (interval === null) return failure("event.invalid-instant"); + const next = { ...event, ...interval, title: intent.title ?? event.title }; + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + const recurrence = calendarEventRecurrence(event); + if (recurrence === null) return replace(next); + + if (intent.scope === "all") { + const start = shiftValue(event.start, occurrence.start, interval.start); + const end = shiftValue(event.end, occurrence.end, interval.end); + if (start === null || end === null) return failure("event.invalid-instant"); + const shifted = shiftRecurrence(event, occurrence.start, interval.start); + const plan = replace({ ...next, start, end, ...shifted }, interval.start); + if (!plan.ok) return plan; + return resolveCalendarOccurrence(plan.events, plan.affectedOccurrence)?.end === interval.end + ? plan : failure("selection.unrepresentable-series-move"); + } + const day = calendarDatePart(occurrence.start); + const id = options.allocateId(); + if (intent.scope === "this") { + const excludeDates = [...new Set([...calendarEventExcludeDates(event), day])]; + return append({ ...next, id, recurrence: null, excludeDates: [] }, [ + { op: "add", path: buildPointer(["events", index, "excludeDates"]), value: excludeDates }, + ], { ...event, excludeDates }); + } + const previous = { ...event, recurrence: { ...recurrence, until: addCalendarDate(day, -1)! } }; + const shifted = shiftRecurrence({ ...event, excludeDates: calendarEventExcludeDates(event).filter((date) => date >= day) }, occurrence.start, interval.start); + return append({ ...next, id, ...shifted }, [ + { op: "replace", path: buildPointer(["events", index, "recurrence"]), value: previous.recurrence }, + ], previous); +} + +function shiftValue(value: string, origin: string, next: string): string | null { + if (value.length === 10) { + const from = parseCalendarDate(origin), to = parseCalendarDate(next); + return from === null || to === null ? null : addCalendarDate(value, calendarDaysBetween(from, to)); + } + const start = parseCalendarInstant(value), from = parseCalendarInstant(origin), to = parseCalendarInstant(next); + return start === null || from === null || to === null ? null + : formatCalendarInstant(start.add({ minutes: calendarMinutesBetween(from, to) })); +} + +function shiftRecurrence(event: CalendarEvent, origin: string, next: string): Pick { + const recurrence = calendarEventRecurrence(event)!; + const delta = calendarDaysBetween(parseCalendarDate(calendarDatePart(origin))!, parseCalendarDate(calendarDatePart(next))!); + return { + recurrence: { ...recurrence, until: recurrence.until === "" ? "" : addCalendarDate(recurrence.until, delta)! }, + excludeDates: calendarEventExcludeDates(event).map((date) => addCalendarDate(date, delta)!), + }; +} + +export type CalendarPatchPlan = { readonly ok: true; readonly operations: ReadonlyArray } + | { readonly ok: false; readonly code: string; readonly reason?: string }; +export type CalendarEventsPlan = (Extract & { readonly events: ReadonlyArray }) + | Extract; + +/** Remove document records; choosing the next selection belongs to Editing. */ +export function planCalendarEventRemoval(events: ReadonlyArray, eventIds: ReadonlyArray): CalendarEventsPlan { + const removing = new Set(eventIds); + const knownIds = new Set(events.map((event) => event.id)); + if (removing.size === 0 || eventIds.some((id) => !knownIds.has(id))) return failure("selection.event-not-found"); + return { + ok: true, + events: events.filter((event) => !removing.has(event.id)), + operations: events.flatMap((event, index): JSONPatchOperation[] => removing.has(event.id) + ? [{ op: "remove", path: buildPointer(["events", index]) }] : []).reverse(), + }; +} + +/** Exclusion and recurrence truncation have the same meaning without an editor. */ +export function planCalendarOccurrenceRemoval(events: ReadonlyArray, removal: CalendarOccurrenceRemoval): CalendarEventsPlan { + const index = events.findIndex((event) => event.id === removal.eventId); + const event = events[index]; + if (event === undefined) return failure("selection.event-not-found"); + if (removal.scope !== "this" && removal.scope !== "this-and-following" && removal.scope !== "all") return failure("occurrence.invalid-scope"); + if (resolveCalendarOccurrence(events, removal) === null) return failure("selection.stale-occurrence"); + const recurrence = calendarEventRecurrence(event); + if (recurrence === null || removal.scope === "all") return planCalendarEventRemoval(events, [event.id]); + const day = calendarDatePart(removal.occurrenceStart); + const until = addCalendarDate(day, -1); + if (removal.scope === "this-and-following" && (until === null || until < calendarDatePart(event.start))) { + return planCalendarEventRemoval(events, [event.id]); + } + const field = removal.scope === "this" ? "excludeDates" : "recurrence"; + const value = removal.scope === "this" ? [...calendarEventExcludeDates(event), day] : { ...recurrence, until: until! }; + return { + ok: true, + events: events.map((item, position) => position === index ? { ...item, [field]: value } : item), + operations: [{ op: field === "excludeDates" ? "add" : "replace", path: buildPointer(["events", index, field]), value }], + }; +} + +export function planCalendarVisibility(document: CalendarDocument, calendarId: string, hidden: boolean): CalendarPatchPlan { + if (typeof hidden !== "boolean") return failure("calendar.invalid-hidden"); + const index = calendarDocumentCalendars(document).findIndex((calendar) => calendar.id === calendarId); + return index < 0 ? failure("calendar.not-found") : { + ok: true, operations: [{ op: "replace", path: buildPointer(["calendars", index, "hidden"]), value: hidden }], + }; +} + +function failure(code: string): { readonly ok: false; readonly code: string } { return { ok: false, code }; } diff --git a/packages/json-document-calendar-document/src/calendar-projection.ts b/packages/json-document-calendar-document/src/calendar-projection.ts new file mode 100644 index 000000000..fe3f81830 --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-projection.ts @@ -0,0 +1,284 @@ +import { Temporal } from "@js-temporal/polyfill"; +import type { CalendarDocument, CalendarEvent } from "./calendar-model.js"; +import { projectCalendarOccurrences } from "./calendar-occurrence.js"; +import { + addCalendarDate, calendarDatePart, calendarDocumentCalendars, calendarDocumentEvents, + calendarEventBounds, calendarIntervalLastDate, calendarMinutesBetween, + isCalendarAllDay, parseCalendarDate, parseCalendarInstant, +} from "./calendar-validation.js"; + +export function calendarVisibleEvents(document: CalendarDocument): ReadonlyArray { + const events = calendarDocumentEvents(document); + const hidden = new Set(calendarDocumentCalendars(document).filter((item) => item.hidden).map((item) => item.id)); + if (hidden.size === 0) return events; + return events.filter((event) => !hidden.has(event.calendarId)); +} + +export function calendarNowMarker(nowInstant: string, day: string): { readonly minutes: number } | null { + if (calendarDatePart(nowInstant) !== day) return null; + const start = parseCalendarInstant(`${day}T00:00`); + const now = parseCalendarInstant(nowInstant); + if (start === null || now === null) return null; + return { minutes: calendarMinutesBetween(start, now) }; +} + +export function calendarEventsOnDay( + events: ReadonlyArray, + day: string, +): ReadonlyArray { + const next = addCalendarDate(day, 1); + if (next === null) return []; + return projectCalendarOccurrences(events, day, next).map((item) => ({ + ...item.event, + start: item.start, + end: item.end, + })); +} + +export function calendarMonthDayLayout( + events: ReadonlyArray, + day: string, + rowLimit: number, +): { + readonly events: ReadonlyArray; + readonly hiddenCount: number; +} { + const onDay = [...calendarEventsOnDay(events, day)].sort((left, right) => { + const leftAllDay = isCalendarAllDay(left); + const rightAllDay = isCalendarAllDay(right); + if (leftAllDay !== rightAllDay) return leftAllDay ? -1 : 1; + return left.start.localeCompare(right.start); + }); + if (rowLimit < 1) return { events: [], hiddenCount: onDay.length }; + if (onDay.length <= rowLimit) return { events: onDay, hiddenCount: 0 }; + const shown = Math.max(0, rowLimit - 1); + return { events: onDay.slice(0, shown), hiddenCount: onDay.length - shown }; +} + +export function calendarBusyDates( + events: ReadonlyArray, + rangeStart: string, + rangeEnd: string, +): ReadonlySet { + const dates = new Set(); + for (const item of projectCalendarOccurrences(events, rangeStart, rangeEnd)) { + for (const day of calendarOccurrenceDays(item.start, item.end, isCalendarAllDay(item.event))) { + if (day >= rangeStart && day < rangeEnd) dates.add(day); + } + } + return dates; +} + +function calendarOccurrenceDays(start: string, end: string, allDay: boolean): ReadonlyArray { + const first = calendarDatePart(start); + const last = calendarIntervalLastDate(start, end, allDay); + const days: string[] = []; + for (let day = first; day <= last; ) { + days.push(day); + const next = addCalendarDate(day, 1); + if (next === null) break; + day = next; + } + return days; +} + +export function calendarTimedLayout( + events: ReadonlyArray, + day: string, +): ReadonlyArray<{ + readonly event: CalendarEvent; + readonly startMinutes: number; + readonly endMinutes: number; + readonly lane: number; + readonly laneCount: number; +}> { + const dayStart = parseCalendarInstant(`${day}T00:00`); + if (dayStart === null) return []; + const dayEnd = dayStart.add({ days: 1 }); + const next = addCalendarDate(day, 1); + if (next === null) return []; + const layout: Array<{ event: CalendarEvent; startMinutes: number; endMinutes: number }> = []; + for (const item of projectCalendarOccurrences(events, day, next)) { + if (isCalendarAllDay(item.event)) continue; + const bounds = calendarEventBounds({ ...item.event, start: item.start, end: item.end }); + if (bounds === null || Temporal.PlainDateTime.compare(bounds.to, dayStart) <= 0 || Temporal.PlainDateTime.compare(bounds.from, dayEnd) >= 0) continue; + const clippedStart = Temporal.PlainDateTime.compare(bounds.from, dayStart) < 0 ? dayStart : bounds.from; + const clippedEnd = Temporal.PlainDateTime.compare(bounds.to, dayEnd) > 0 ? dayEnd : bounds.to; + layout.push({ + event: { ...item.event, start: item.start, end: item.end }, + startMinutes: calendarMinutesBetween(dayStart, clippedStart), + endMinutes: calendarMinutesBetween(dayStart, clippedEnd), + }); + } + const sorted = layout.sort((left, right) => left.startMinutes - right.startMinutes || left.endMinutes - right.endMinutes); + const positioned: Array = []; + let groupStart = 0; + while (groupStart < sorted.length) { + let groupEnd = groupStart + 1; + let occupiedUntil = sorted[groupStart]!.endMinutes; + while (groupEnd < sorted.length && sorted[groupEnd]!.startMinutes < occupiedUntil) { + occupiedUntil = Math.max(occupiedUntil, sorted[groupEnd]!.endMinutes); + groupEnd += 1; + } + const laneEnds: number[] = []; + const group = sorted.slice(groupStart, groupEnd).map((item) => { + const available = laneEnds.findIndex((end) => end <= item.startMinutes); + const lane = available === -1 ? laneEnds.length : available; + laneEnds[lane] = item.endMinutes; + return { ...item, lane }; + }); + positioned.push(...group.map((item) => ({ ...item, laneCount: laneEnds.length }))); + groupStart = groupEnd; + } + return positioned; +} + +export function calendarAllDayLayout( + events: ReadonlyArray, + days: ReadonlyArray, +): ReadonlyArray<{ + readonly event: CalendarEvent; + readonly startIndex: number; + readonly span: number; + readonly lane: number; + readonly laneCount: number; +}> { + const rangeStart = days[0]; + const rangeLast = days.at(-1); + if (rangeStart === undefined || rangeLast === undefined) return []; + const rangeEnd = addCalendarDate(rangeLast, 1); + if (rangeEnd === null) return []; + const layout: Array<{ event: CalendarEvent; startIndex: number; span: number }> = []; + for (const item of projectCalendarOccurrences(events, rangeStart, rangeEnd)) { + if (!isCalendarAllDay(item.event)) continue; + const clipped = clipAllDayToDays(item.start, item.end, days); + if (clipped === null) continue; + layout.push({ + event: { ...item.event, start: item.start, end: item.end }, + startIndex: clipped.startIndex, + span: clipped.span, + }); + } + const sorted = layout.sort((left, right) => left.startIndex - right.startIndex || right.span - left.span); + const positioned = assignCalendarSpanLanes(sorted); + const laneCount = Math.max(1, positioned[0]?.laneCount ?? 0); + return positioned.map((item) => ({ ...item, laneCount })); +} + +export function calendarMonthWeekLayout( + events: ReadonlyArray, + days: ReadonlyArray, + rowLimit: number, +): { + readonly items: ReadonlyArray<{ + readonly event: CalendarEvent; + readonly startIndex: number; + readonly span: number; + readonly lane: number; + }>; + readonly hiddenCounts: ReadonlyArray; + readonly laneCount: number; +} { + const empty = { items: [], hiddenCounts: days.map(() => 0), laneCount: 0 }; + const rangeStart = days[0]; + const rangeLast = days.at(-1); + if (rangeStart === undefined || rangeLast === undefined) return empty; + const rangeEnd = addCalendarDate(rangeLast, 1); + if (rangeEnd === null) return empty; + const layout: Array<{ event: CalendarEvent; startIndex: number; span: number }> = []; + for (const item of projectCalendarOccurrences(events, rangeStart, rangeEnd)) { + const occurrence = { ...item.event, start: item.start, end: item.end }; + const clipped = isCalendarAllDay(occurrence) + ? clipAllDayToDays(item.start, item.end, days) + : clipTimedToDays(item.start, item.end, days); + if (clipped === null) continue; + layout.push({ event: occurrence, startIndex: clipped.startIndex, span: clipped.span }); + } + layout.sort((left, right) => { + if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex; + const leftAllDay = isCalendarAllDay(left.event) ? 0 : 1; + const rightAllDay = isCalendarAllDay(right.event) ? 0 : 1; + if (leftAllDay !== rightAllDay) return leftAllDay - rightAllDay; + return right.span - left.span || left.event.start.localeCompare(right.event.start); + }); + const positioned = assignCalendarSpanLanes(layout); + const covering = (index: number) => positioned.filter((item) => ( + index >= item.startIndex && index < item.startIndex + item.span + )); + const overflow = days.some((_, index) => covering(index).length > rowLimit); + const visibleLaneCount = overflow + ? Math.max(0, rowLimit - 1) + : positioned.reduce((max, item) => Math.max(max, item.lane + 1), 0); + return { + items: positioned.filter((item) => item.lane < visibleLaneCount), + hiddenCounts: days.map((_, index) => covering(index).filter((item) => item.lane >= visibleLaneCount).length), + laneCount: visibleLaneCount, + }; +} + +function assignCalendarSpanLanes( + layout: ReadonlyArray, +): ReadonlyArray { + const laneEnds: number[] = []; + const positioned = layout.map((item) => { + const available = laneEnds.findIndex((end) => end <= item.startIndex); + const lane = available === -1 ? laneEnds.length : available; + laneEnds[lane] = item.startIndex + item.span; + return { ...item, lane }; + }); + const laneCount = laneEnds.length; + return positioned.map((item) => ({ ...item, laneCount })); +} + +function clipTimedToDays( + start: string, + end: string, + days: ReadonlyArray, +): { readonly startIndex: number; readonly span: number } | null { + let startIndex = -1; + let lastIndex = -1; + for (const day of calendarOccurrenceDays(start, end, false)) { + const index = days.indexOf(day); + if (index < 0) continue; + if (startIndex < 0) startIndex = index; + lastIndex = index; + } + if (startIndex < 0 || lastIndex < startIndex) return null; + return { startIndex, span: lastIndex - startIndex + 1 }; +} + +function clipAllDayToDays( + start: string, + end: string, + days: ReadonlyArray, +): { readonly startIndex: number; readonly span: number } | null { + const first = days[0]; + const last = days.at(-1); + if (first === undefined || last === undefined) return null; + const visibleEnd = addCalendarDate(last, 1); + if (visibleEnd === null) return null; + const startDate = calendarDatePart(start); + const exclusiveEnd = calendarDatePart(end); + if (exclusiveEnd <= first || startDate >= visibleEnd) return null; + const foundStart = days.indexOf(startDate); + const foundEnd = days.indexOf(exclusiveEnd); + const startIndex = foundStart >= 0 ? foundStart : startDate < first ? 0 : -1; + const endIndex = foundEnd >= 0 ? foundEnd : exclusiveEnd >= visibleEnd ? days.length : -1; + if (startIndex < 0 || endIndex < 0 || endIndex <= startIndex) return null; + return { startIndex, span: endIndex - startIndex }; +} + +export function calendarEventsInMonth( + events: ReadonlyArray, + month: string, +): ReadonlyArray { + const start = `${month}-01`; + const startUtc = parseCalendarDate(start); + if (startUtc === null) return []; + const end = Temporal.PlainYearMonth.from(month).add({ months: 1 }).toPlainDate({ day: 1 }).toString(); + return projectCalendarOccurrences(events, start, end).map((item) => ({ + ...item.event, + start: item.start, + end: item.end, + })); +} diff --git a/packages/json-document-calendar-document/src/calendar-validation.ts b/packages/json-document-calendar-document/src/calendar-validation.ts new file mode 100644 index 000000000..268f5713f --- /dev/null +++ b/packages/json-document-calendar-document/src/calendar-validation.ts @@ -0,0 +1,219 @@ +import { Temporal } from "@js-temporal/polyfill"; +import { isJSONValue } from "@interactive-os/json-document"; +import type { CalendarCalendar, CalendarDocument, CalendarEvent, CalendarRecurrence } from "./calendar-model.js"; + +const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; +const DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/; +export function calendarDocumentCalendars(document: CalendarDocument): ReadonlyArray { + return Array.isArray(document.calendars) ? document.calendars : []; +} + +export function calendarDocumentCalendar(document: CalendarDocument, calendarId: string): CalendarCalendar | null { + return calendarDocumentCalendars(document).find((calendar) => calendar.id === calendarId) ?? null; +} + +export function calendarDocumentEvents(document: CalendarDocument): ReadonlyArray { + return Array.isArray(document.events) ? document.events : []; +} + +export function assertCalendarDocument(value: unknown): void { + const result = validateCalendarDocument(value); + if (!result.ok) throw new TypeError(result.reason); +} + +/** Validate canonical JSON and Calendar invariants without creating an Editing session. */ +export function validateCalendarDocument(value: unknown): CalendarValidationResult { + if (!isJSONValue(value) || typeof value !== "object" || value === null || Array.isArray(value)) { + return invalidCalendar("Calendar documents must be JSON objects."); + } + const document = value as unknown as CalendarDocument; + if (!Array.isArray(document.events)) return invalidCalendar("Calendar events must be an array."); + if (document.calendars !== undefined && !Array.isArray(document.calendars)) { + return invalidCalendar("Calendar calendars must be an array when present."); + } + const calendarIds = new Set(); + for (const calendar of calendarDocumentCalendars(document)) { + if (typeof calendar !== "object" || calendar === null || Array.isArray(calendar) + || typeof calendar.id !== "string" || calendar.id.length === 0) { + return invalidCalendar("Calendar ids must be nonempty strings."); + } + if (calendarIds.has(calendar.id)) return invalidCalendar(`Calendar id must be unique: ${JSON.stringify(calendar.id)}.`); + if (typeof calendar.title !== "string" || typeof calendar.hidden !== "boolean") { + return invalidCalendar("Calendar title must be a string and hidden must be a boolean."); + } + if (typeof calendar.color !== "string" || calendar.color.length === 0) { + return invalidCalendar(`Calendar color must not be empty: ${JSON.stringify(calendar.id)}.`); + } + calendarIds.add(calendar.id); + } + const ids = new Set(); + for (const event of calendarDocumentEvents(document)) { + const result = validateCalendarEvent(event, calendarIds); + if (!result.ok) return result; + if (ids.has(event.id)) return invalidCalendar(`Calendar event id must be unique: ${JSON.stringify(event.id)}.`); + ids.add(event.id); + } + return { ok: true }; +} + +function invalidCalendar(reason: string): CalendarValidationResult { + return { ok: false, code: "calendar.invalid-document", reason }; +} + +export type CalendarValidationResult = { readonly ok: true } | { + readonly ok: false; readonly code: string; readonly reason: string; +}; + +/** One domain invariant shared by construction, edit planning and clipboard ingress. */ +export function validateCalendarEvent(value: unknown, calendarIds?: ReadonlySet): CalendarValidationResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { ok: false, code: "event.invalid", reason: "Calendar events must be objects." }; + } + const event = value as Record; + if (typeof event.id !== "string" || event.id.length === 0 || typeof event.title !== "string") { + return { ok: false, code: "event.invalid", reason: "Calendar events require a nonempty id and a string title." }; + } + if (typeof event.start !== "string" || typeof event.end !== "string" + || (event.allDay !== undefined && typeof event.allDay !== "boolean")) { + return { ok: false, code: "event.invalid-instant", reason: "Calendar events require canonical temporal values." }; + } + const parse = event.allDay === true ? parseCalendarDate : parseCalendarInstant; + if (parse(event.start) === null || parse(event.end) === null) { + return { ok: false, code: "event.invalid-instant", reason: event.allDay === true + ? `All-day calendar events must use date strings: ${JSON.stringify(event.id)}.` + : `Calendar event times must be datetime-local strings: ${JSON.stringify(event.id)}.` }; + } + if (event.start >= event.end) { + return { ok: false, code: "event.invalid-interval", reason: `Calendar event must end after it starts: ${JSON.stringify(event.id)}.` }; + } + if (event.calendarId !== undefined && typeof event.calendarId !== "string") { + return { ok: false, code: "calendar.not-found", reason: "Calendar references must be strings." }; + } + if (typeof event.calendarId === "string" && event.calendarId.length > 0 + && calendarIds !== undefined && calendarIds.size > 0 && !calendarIds.has(event.calendarId)) { + return { ok: false, code: "calendar.not-found", reason: `Calendar event must belong to a calendar: ${JSON.stringify(event.id)}.` }; + } + if (event.recurrence != null && !isCalendarRecurrence(event.recurrence)) { + return { ok: false, code: "event.invalid-recurrence", reason: "Calendar recurrence requires a supported frequency, positive safe integer interval and canonical until date." }; + } + if (event.excludeDates !== undefined && (!Array.isArray(event.excludeDates) + || !event.excludeDates.every((date) => typeof date === "string" && parseCalendarDate(date) !== null))) { + return { ok: false, code: "event.invalid-exclusions", reason: "Calendar exclusions must be canonical dates." }; + } + return { ok: true }; +} + +export function isCalendarRecurrence(value: unknown): value is CalendarRecurrence { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const rule = value as Record; + return (rule.freq === "daily" || rule.freq === "weekly" || rule.freq === "monthly" || rule.freq === "yearly") + && typeof rule.interval === "number" && Number.isSafeInteger(rule.interval) && rule.interval >= 1 + && typeof rule.until === "string" && (rule.until === "" || parseCalendarDate(rule.until) !== null); +} + +export function isCalendarAllDay(event: Pick): boolean { + return event.allDay === true; +} + +export function parseCalendarInstant(value: string): Temporal.PlainDateTime | null { + if (!DATETIME.test(value)) return null; + try { + return Temporal.PlainDateTime.from(value); + } catch { + return null; + } +} + +export function formatCalendarInstant(value: Temporal.PlainDateTime): string { + return value.toString({ smallestUnit: "minute" }); +} + +export function parseCalendarDate(value: string): Temporal.PlainDate | null { + if (!DATE.test(value)) return null; + try { + return Temporal.PlainDate.from(value); + } catch { + return null; + } +} + +export function formatCalendarDate(value: Temporal.PlainDate): string { + return value.toString(); +} + +export function addCalendarDate(day: string, days: number): string | null { + const date = parseCalendarDate(day); + if (date === null) return null; + return formatCalendarDate(date.add({ days })); +} + +export function calendarAllDaySpan(originDay: string, targetDay: string): { readonly start: string; readonly end: string } | null { + if (parseCalendarDate(originDay) === null || parseCalendarDate(targetDay) === null) return null; + const start = originDay <= targetDay ? originDay : targetDay; + const last = originDay <= targetDay ? targetDay : originDay; + const end = addCalendarDate(last, 1); + if (end === null) return null; + return { start, end }; +} + +export function calendarShiftInstant(instant: string, minutes: number): string | null { + const dateTime = parseCalendarInstant(instant); + if (dateTime === null) return null; + return formatCalendarInstant(dateTime.add({ minutes })); +} + +export function calendarInstantAt(day: string, minutesFromMidnight: number): string | null { + const dateTime = parseCalendarInstant(`${day}T00:00`); + if (dateTime === null) return null; + const minutes = Math.max(0, Math.min(24 * 60, minutesFromMidnight)); + return formatCalendarInstant(dateTime.add({ minutes })); +} + +export function calendarDatePart(value: string): string { + return value.slice(0, 10); +} + +export function calendarIntervalLastDate(start: string, end: string, allDay: boolean): string { + const first = calendarDatePart(start); + let last = calendarDatePart(end); + const endInstant = parseCalendarInstant(end); + const endsAtDateBoundary = !end.includes("T") + || (endInstant !== null && endInstant.hour === 0 && endInstant.minute === 0); + if (allDay || endsAtDateBoundary) last = addCalendarDate(last, -1) ?? first; + return last < first ? first : last; +} + +export function calendarEventBounds( + event: Pick, +): { readonly from: Temporal.PlainDateTime; readonly to: Temporal.PlainDateTime } | null { + if (isCalendarAllDay(event)) { + const from = parseCalendarDate(event.start); + const to = parseCalendarDate(event.end); + if (from === null || to === null) return null; + return { from: from.toPlainDateTime(), to: to.toPlainDateTime() }; + } + const from = parseCalendarInstant(event.start); + const to = parseCalendarInstant(event.end); + if (from === null || to === null) return null; + return { from, to }; +} + +export function calendarDaysBetween(from: Temporal.PlainDate, to: Temporal.PlainDate): number { + return from.until(to, { largestUnit: "days" }).days; +} + +export function calendarMinutesBetween(from: Temporal.PlainDateTime, to: Temporal.PlainDateTime): number { + return from.until(to, { largestUnit: "minutes" }).total("minutes"); +} + +/** Move a Calendar interval without changing its local duration. */ +export function calendarEventIntervalAt( + event: Pick, + start: string, +): { readonly start: string; readonly end: string } | null { + const bounds = calendarEventBounds(event); + const next = isCalendarAllDay(event) ? parseCalendarDate(start)?.toPlainDateTime() : parseCalendarInstant(start); + if (bounds === null || next == null) return null; + const end = next.add({ minutes: calendarMinutesBetween(bounds.from, bounds.to) }); + return { start, end: isCalendarAllDay(event) ? formatCalendarDate(end.toPlainDate()) : formatCalendarInstant(end) }; +} diff --git a/packages/json-document-calendar-document/src/index.ts b/packages/json-document-calendar-document/src/index.ts new file mode 100644 index 000000000..25a270318 --- /dev/null +++ b/packages/json-document-calendar-document/src/index.ts @@ -0,0 +1,52 @@ +export type { + CalendarCalendar, CalendarDocument, CalendarEvent, CalendarRecurrence, + CalendarOccurrencePoint, CalendarOccurrenceInterval, +} from "./calendar-model.js"; +export { planCalendarEventEdit, planCalendarEventRemoval, planCalendarOccurrenceRemoval, planCalendarVisibility } from "./calendar-operation.js"; +export type { CalendarEventOperation, CalendarEventPlan, CalendarOccurrenceRemoval, CalendarPatchPlan, CalendarEventsPlan } from "./calendar-operation.js"; +export type { CalendarValidationResult } from "./calendar-validation.js"; +export { + calendarDocumentCalendars, + calendarDocumentCalendar, + calendarDocumentEvents, + assertCalendarDocument, + validateCalendarDocument, + validateCalendarEvent, + isCalendarRecurrence, + isCalendarAllDay, + parseCalendarInstant, + formatCalendarInstant, + parseCalendarDate, + formatCalendarDate, + addCalendarDate, + calendarAllDaySpan, + calendarShiftInstant, + calendarInstantAt, + calendarDatePart, + calendarIntervalLastDate, + calendarEventBounds, + calendarDaysBetween, + calendarMinutesBetween, + calendarEventIntervalAt, +} from "./calendar-validation.js"; +export { + calendarEventRecurrence, + calendarRecurrenceWithFrequency, + calendarRecurrenceWithInterval, + calendarRecurrenceWithUntil, + calendarEventExcludeDates, + projectCalendarOccurrences, + resolveCalendarOccurrence, +} from "./calendar-occurrence.js"; +export type { CalendarOccurrence } from "./calendar-occurrence.js"; +export { + calendarVisibleEvents, + calendarNowMarker, + calendarEventsOnDay, + calendarMonthDayLayout, + calendarBusyDates, + calendarTimedLayout, + calendarAllDayLayout, + calendarMonthWeekLayout, + calendarEventsInMonth, +} from "./calendar-projection.js"; diff --git a/packages/json-document-calendar-document/tests/calendar-document.test.ts b/packages/json-document-calendar-document/tests/calendar-document.test.ts new file mode 100644 index 000000000..1eaf27f90 --- /dev/null +++ b/packages/json-document-calendar-document/tests/calendar-document.test.ts @@ -0,0 +1,106 @@ +import { applyPatch } from "@interactive-os/json-document"; +import { describe, expect, test } from "vitest"; +import { + assertCalendarDocument, validateCalendarDocument, planCalendarEventEdit, + planCalendarEventRemoval, planCalendarOccurrenceRemoval, planCalendarVisibility, + projectCalendarOccurrences, calendarVisibleEvents, + type CalendarDocument, type CalendarEventOperation, +} from "../src/index.js"; + +const document = (): CalendarDocument => ({ + calendars: [{ id: "work", title: "Work", hidden: false, color: "accent" }], + events: [{ id: "a", title: "A", start: "2026-08-01T09:00", end: "2026-08-01T10:00", allDay: false, + calendarId: "work", recurrence: { freq: "daily", interval: 1, until: "2026-08-08" }, excludeDates: [] }], +}); + +describe("Calendar Document Type public contract", () => { + test("validates without editing state and preserves canonical and legacy inputs", () => { + for (const value of [document(), { events: [{ id: "legacy", title: "L", start: "2026-08-01T09:00", end: "2026-08-01T10:00" }] }]) { + const before = structuredClone(value); + expect(validateCalendarDocument(value)).toEqual({ ok: true }); + expect(() => assertCalendarDocument(value)).not.toThrow(); + expect(value).toEqual(before); + } + }); + + test.each([ + "work", null, 123, {}, [null], [123], [[]], + [{ id: 123, title: "Work", hidden: false, color: "accent" }], + [{ id: "", title: "Work", hidden: false, color: "accent" }], + [{ id: "work", title: 123, hidden: false, color: "accent" }], + [{ id: "work", title: "Work", hidden: "false", color: "accent" }], + [{ id: "work", title: "Work", hidden: false, color: "" }], + ])("rejects malformed calendar containers and records: %j", (calendars) => { + const value = { calendars, events: [] }; + expect(validateCalendarDocument(value)).toMatchObject({ ok: false, code: "calendar.invalid-document" }); + expect(() => assertCalendarDocument(value)).toThrow(TypeError); + }); + + test("validates document identity, membership and JSON extension fields", () => { + const value = document(); + for (const invalid of [ + { ...value, calendars: [...value.calendars, { ...value.calendars[0]! }] }, + { ...value, events: [...value.events, { ...value.events[0]!, recurrence: { ...value.events[0]!.recurrence! }, excludeDates: [] }] }, + { ...value, events: [{ ...value.events[0]!, calendarId: "missing" }] }, + { ...value, metadata: Number.NaN }, + ]) expect(validateCalendarDocument(invalid).ok).toBe(false); + }); + + test.each(["this", "this-and-following", "all"] as const)("plans %s edits as document operations, not selection transitions", (scope) => { + const value = document(); + const before = structuredClone(value); + const plan = planCalendarEventEdit(value.events, { + type: "occurrence.edit", eventId: "a", occurrenceStart: "2026-08-03T09:00", scope, + start: "2026-08-03T11:00", title: "Changed", + }, { allocateId: () => "new", calendarIds: new Set(["work"]) }); + expect(plan.ok).toBe(true); + if (!plan.ok) throw new Error(plan.code); + const applied = applyPatch(value, plan.operations); + expect(applied.ok).toBe(true); + if (!applied.ok) throw new Error("patch failed"); + expect((applied.value as CalendarDocument).events).toEqual(plan.events); + expect(validateCalendarDocument(applied.value).ok).toBe(true); + expect(plan.affectedOccurrence.occurrenceStart).toBe("2026-08-03T11:00"); + expect(plan).not.toHaveProperty("selectionAfter"); + const focused = projectCalendarOccurrences(plan.events, "2026-08-03", "2026-08-04") + .find((occurrence) => occurrence.event.id === plan.affectedOccurrence.eventId); + expect(focused).toMatchObject({ start: "2026-08-03T11:00", end: "2026-08-03T12:00", event: { title: "Changed" } }); + expect(value).toEqual(before); + }); + + test("rejects invalid edits, unknown operations and reused allocation identities", () => { + const value = document(); + for (const operation of [ + { type: "event.create", start: "2026-08-03T11:00", end: "2026-08-03T12:00" }, + { type: "event.update", eventId: "a", end: "2026-08-01T08:00" }, + { type: "event.typo", eventId: "a" }, + ]) expect(planCalendarEventEdit(value.events, operation as CalendarEventOperation, { allocateId: () => "a" }).ok).toBe(false); + expect(value).toEqual(document()); + }); + + test.each(["this", "this-and-following", "all"] as const)("owns %s occurrence removal without selection or history", (scope) => { + const value = document(); + const plan = planCalendarOccurrenceRemoval(value.events, { eventId: "a", occurrenceStart: "2026-08-03T09:00", scope }); + expect(plan.ok).toBe(true); + if (!plan.ok) throw new Error(plan.code); + const applied = applyPatch(value, plan.operations); + expect(applied.ok).toBe(true); + if (!applied.ok) throw new Error("patch failed"); + expect((applied.value as CalendarDocument).events).toEqual(plan.events); + const days = projectCalendarOccurrences(plan.events, "2026-08-01", "2026-08-09").map((item) => item.start.slice(8, 10)); + expect(days).toEqual(scope === "all" ? [] : scope === "this-and-following" ? ["01", "02"] : ["01", "02", "04", "05", "06", "07", "08"]); + }); + + test("owns record removal and calendar visibility", () => { + const value = document(); + expect(planCalendarEventRemoval(value.events, ["missing"]).ok).toBe(false); + expect(planCalendarEventRemoval(value.events, ["a"])).toMatchObject({ ok: true, events: [], operations: [{ op: "remove", path: "/events/0" }] }); + expect(planCalendarVisibility(value, "missing", true).ok).toBe(false); + const plan = planCalendarVisibility(value, "work", true); + if (!plan.ok) throw new Error(plan.code); + const applied = applyPatch(value, plan.operations); + if (!applied.ok) throw new Error("patch failed"); + expect(calendarVisibleEvents(applied.value as CalendarDocument)).toEqual([]); + expect(value).toEqual(document()); + }); +}); diff --git a/packages/json-document-calendar-document/tsconfig.json b/packages/json-document-calendar-document/tsconfig.json new file mode 100644 index 000000000..fa193bf21 --- /dev/null +++ b/packages/json-document-calendar-document/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig/library.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, + "references": [{ "path": "../json-document" }], + "include": ["src/**/*.ts"] +} diff --git a/packages/json-document-calendar-document/tsconfig.test.json b/packages/json-document-calendar-document/tsconfig.test.json new file mode 100644 index 000000000..47af24776 --- /dev/null +++ b/packages/json-document-calendar-document/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "composite": false, "noEmit": true, "rootDir": "../..", "types": ["node", "vitest/globals"] }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/json-document-calendar-document/vitest.config.ts b/packages/json-document-calendar-document/vitest.config.ts new file mode 100644 index 000000000..be8c3f2e7 --- /dev/null +++ b/packages/json-document-calendar-document/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineNodeProject } from "../../test/vitest.shared.js"; + +export default defineNodeProject("json-document-calendar-document"); diff --git a/packages/json-document-calendar/README.md b/packages/json-document-calendar/README.md index 8c961bb8d..77a28c1f6 100644 --- a/packages/json-document-calendar/README.md +++ b/packages/json-document-calendar/README.md @@ -2,8 +2,10 @@ React lifecycle for Calendar Hands over the canonical interval editor. The package owns editor subscription, occurrence focus, normalized gesture preview, -canonical Rename and keyboard composition, and series-scope command binding. The Editing package owns -Calendar document and intent semantics, Affordance owns the input-independent +canonical Rename and keyboard composition, and series-scope command binding. +`@interactive-os/json-document-calendar-document` owns the document model, validation, +operations and projection; Editing owns selection, Intent execution, Clipboard and History. +Affordance owns the input-independent gesture lifecycle, and Web owns Pointer Events capture and coordinate translation. Hosts keep fixtures, URL state, product copy, layout, colors, and time-grid policy. @@ -12,19 +14,47 @@ time-grid policy. const editor = createCalendarEditor(document); const calendar = useCalendarHand(editor); -calendar.dispatch({ type: "selection.set", eventIds: [eventId] }); +calendar.dispatch({ + type: "selection.set", + point: { eventId, occurrenceStart }, + topology: calendarOccurrenceTopology(document, rangeStart, rangeEnd), +}); calendar.applySelectedPatch({ title: "Planning" }); const payload = calendar.copy(); -calendar.paste(payload); +if (payload !== null) calendar.paste(payload); const titleInput = useCalendarRenameInput(calendar); -useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRemove }); +useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRename, onRemove }); ``` -The Hand resolves the currently focused occurrence as the copy/cut source and -paste target. The Host selects Web representations; Calendar schema, +The Hand derives the focused occurrence from `editor.primaryOccurrence`; direct +dispatch, external selection and selection made before mounting use the same target. +`editor.paste(payload)` defaults to that occurrence, not the recurring series origin. +`setOccurrence` supplies an explicit temporal paste cursor (including an empty slot), +scoped to the current editor revision; it never overrides the Inspector/edit selection. +Bind `cut: calendar.cut` directly to the Web clipboard surface: +the Hand accepts the payload already written by Web and removes that captured +target even if selection changes during the write. The Host selects Web representations; Calendar schema, occurrence projection, temporal placement, selection, and history remain in their canonical owners. +Pass `onResult` to `useCalendarHand` to observe domain rejection codes and +present product-owned feedback. `commitIntent` runs occurrence/rename aftercare +only on success. Web clipboard decoding/writing failures remain Web results; +observe the surface's `onResult` as well. Unexpected provider/programmer errors +are not converted into a successful edit. + +`useCalendarPointerInteractions` exposes `rootRef`. `CalendarTimeGrid` and +`CalendarMonthGrid` attach it automatically; a custom Calendar surface must +attach it to its own root. Use one interaction instance per mounted surface. +Hit tests and all-day column measurements never fall back to global document +queries. Keyboard listeners can also use the existing `target` option when a +Host embeds multiple active calendars. + +The [Calendar editing protocol profile](../json-document-editing/docs/calendar-profile.md) +defines temporal values, recurrence scopes, stale-source rejection and clipboard +compatibility. On the site it is visible under [Editing API](/docs/api/editing#calendar-protocol-profile-rc); +the Calendar Hand does not introduce a second domain protocol. + Date and time controls belong to this Calendar owner rather than the generic UI Primitive package: diff --git a/packages/json-document-calendar/package.json b/packages/json-document-calendar/package.json index 01111c04a..9529c3a7a 100644 --- a/packages/json-document-calendar/package.json +++ b/packages/json-document-calendar/package.json @@ -19,7 +19,11 @@ "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, "peerDependencies": { + "@interactive-os/json-document-calendar-document": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", @@ -28,6 +32,7 @@ "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@interactive-os/json-document-calendar-document": "*", "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-react": "*", diff --git a/packages/json-document-calendar/src/calendar-event-inspector.tsx b/packages/json-document-calendar/src/calendar-event-inspector.tsx index 9d8fc118c..f07cb7153 100644 --- a/packages/json-document-calendar/src/calendar-event-inspector.tsx +++ b/packages/json-document-calendar/src/calendar-event-inspector.tsx @@ -8,7 +8,7 @@ import { calendarRecurrenceWithInterval, calendarRecurrenceWithUntil, type CalendarCalendar, -} from "@interactive-os/json-document-editing"; +} from "@interactive-os/json-document-calendar-document"; import { Choice, Command, diff --git a/packages/json-document-calendar/src/calendar-month-grid.tsx b/packages/json-document-calendar/src/calendar-month-grid.tsx index 9dd398be1..c290a79ce 100644 --- a/packages/json-document-calendar/src/calendar-month-grid.tsx +++ b/packages/json-document-calendar/src/calendar-month-grid.tsx @@ -8,6 +8,9 @@ import { type ReactNode, type Ref, } from "react"; +import { + type CalendarOccurrenceTopologySnapshot, +} from "@interactive-os/json-document-editing"; import { calendarAllDaySpan, calendarEventsOnDay, @@ -16,8 +19,7 @@ import { calendarMonthWeekLayout, isCalendarAllDay, type CalendarEvent, - type CalendarOccurrenceTopologySnapshot, -} from "@interactive-os/json-document-editing"; +} from "@interactive-os/json-document-calendar-document"; import { selectionModeFromModifiers } from "@interactive-os/json-document-react"; import { contentInteractionAttributes, @@ -124,6 +126,7 @@ export const CalendarMonthGrid = forwardRef["scope"]; @@ -38,6 +40,7 @@ export interface CalendarSelectionDragPreview { export type CalendarHandOptions = { readonly initialOccurrence?: CalendarOccurrenceRange; readonly defaultTitle?: string; + readonly onResult?: (result: EditingResult) => void; }; export interface CalendarHand { @@ -88,23 +91,29 @@ export interface CalendarHand { undo(): void; redo(): void; copy(): CalendarClipboard | null; - cut(): EditingResult | null; + cut(clipboard?: CalendarClipboard): EditingResult | null; paste(clipboard: CalendarClipboard): EditingResult; } export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOptions = {}): CalendarHand { const snapshot = useEditingSnapshot(editor); + const optionsRef = useRef(options); + optionsRef.current = options; const selectedEvent = editor.selectedEvents[0] ?? null; const selectedOccurrences = editor.selectedOccurrences; - const [occurrence, setOccurrence] = useState( - options.initialOccurrence ?? calendarOccurrenceFromSelection(selectedEvent), - ); + // A cursor in an empty slot is an explicit paste destination, not a copy of selection. + type PasteTarget = { readonly editor: CalendarEditor; readonly revision: number; readonly range: CalendarOccurrenceRange }; + const [pasteTarget, setPasteTarget] = useState(() => options.initialOccurrence === undefined + ? null : { editor, revision: editor.snapshot.revision, range: options.initialOccurrence }); + const pasteTargetRef = useRef(pasteTarget); + pasteTargetRef.current = pasteTarget; + const primaryOccurrence = editor.primaryOccurrence; + const occurrence = primaryOccurrence !== null ? calendarOccurrenceFromSelection(primaryOccurrence) + : pasteTarget?.editor === editor && pasteTarget.revision === snapshot.revision ? pasteTarget.range : { start: null, end: null }; const [scope, setScope] = useState("this"); const [renameSnapshot, setRenameSnapshot] = useState | null>(null); - const occurrenceRef = useRef(occurrence); const scopeRef = useRef(scope); const createdRenameKeyRef = useRef(null); - occurrenceRef.current = occurrence; scopeRef.current = scope; const [renameSession] = useState(() => createRenameSession({ onCommit(key, draft) { @@ -112,15 +121,13 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt if (current === undefined) return; const next = draft.trim() || (options.defaultTitle ?? "Event"); if (next !== current.title) { - editor.dispatch(calendarUpdateIntent(current, occurrenceRef.current.start, scopeRef.current, { title: next })); - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + if (dispatch(calendarUpdateIntent(current, editor.primaryOccurrence?.start ?? null, scopeRef.current, { title: next }))) rememberSelection(); } }, onCancel(key, draft) { const fallback = options.defaultTitle ?? "Event"; if (createdRenameKeyRef.current === key && (draft.trim() === "" || draft.trim() === fallback)) { - editor.dispatch({ type: "selection.remove" }); - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + if (dispatch({ type: "selection.remove" })) rememberSelection(); } }, onFinish(key) { @@ -156,11 +163,23 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt : selectedEvent?.title ?? ""; function dispatch(intent: CalendarIntent | null): boolean { - return intent !== null && editor.dispatch(intent).ok; + return intent !== null && observe(editor.dispatch(intent)).ok; + } + + function observe(result: EditingResult): EditingResult { + optionsRef.current.onResult?.(result); + return result; } function rememberSelection(): void { - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + pasteTargetRef.current = null; + setPasteTarget(null); + } + + function setOccurrence(range: CalendarOccurrenceRange): void { + const target = { editor, revision: editor.snapshot.revision, range }; + pasteTargetRef.current = target; + setPasteTarget(target); } function commitIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): boolean { @@ -169,9 +188,8 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt return true; } - function rememberIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): void { - const committed = calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null); - setOccurrence(calendarOccurrenceAfterIntent(intent, origin, committed)); + function rememberIntent(intent: CalendarIntent | null, _origin: CalendarOccurrenceRange): void { + rememberSelection(); if (intent?.type === "event.create") { setScope("this"); const created = editor.selectedEvents[0] ?? null; @@ -185,8 +203,9 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt } function applySelectedPatch(patch: CalendarEventPatch): boolean { - if (selectedEvent === null) return false; - if (!dispatch(calendarUpdateIntent(selectedEvent, occurrence.start, scope, patch))) return false; + const current = editor.selectedEvents[0]; + if (current === undefined) return false; + if (!dispatch(calendarUpdateIntent(current, editor.primaryOccurrence?.start ?? null, scopeRef.current, patch))) return false; rememberSelection(); return true; } @@ -218,8 +237,7 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt return point?.eventId === eventId && point.occurrenceStart === occurrenceStart; } const primary = editor.primaryOccurrence ?? editor.selectedOccurrences[0] ?? null; - return (primary?.eventId === eventId && primary.start === occurrenceStart) - || (selectedEvent?.id === eventId && occurrence.start === occurrenceStart); + return primary?.eventId === eventId && primary.start === occurrenceStart; } function selectOccurrence( @@ -235,8 +253,7 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt mode, ...(topology === undefined ? {} : { topology }), })) return false; - const primary = editor.primaryOccurrence ?? editor.selectedOccurrences[0] ?? null; - setOccurrence(primary === null ? { start: null, end: null } : { start: primary.start, end: primary.end }); + rememberSelection(); return true; } @@ -258,9 +275,11 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt } function removeSelected(): boolean { - if (selectedEvent === null) return false; - const intent: CalendarIntent = selectedEvent.recurrence !== null && occurrence.start !== null - ? { type: "occurrence.remove", eventId: selectedEvent.id, occurrenceStart: occurrence.start, scope } + const current = editor.selectedEvents[0]; + if (current === undefined) return false; + const primary = editor.primaryOccurrence; + const intent: CalendarIntent = current.recurrence !== null && primary !== null + ? { type: "occurrence.remove", eventId: current.id, occurrenceStart: primary.start, scope: scopeRef.current } : { type: "selection.remove" }; if (!dispatch(intent)) return false; rememberSelection(); @@ -287,28 +306,30 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt function undo(): void { setSelectionDragPreview(null); - editor.undo(); - rememberSelection(); + if (observe(editor.undo()).ok) rememberSelection(); } function redo(): void { setSelectionDragPreview(null); - editor.redo(); - rememberSelection(); + if (observe(editor.redo()).ok) rememberSelection(); } function copy(): CalendarClipboard | null { return editor.copy(); } - function cut(): EditingResult | null { - const cut = editor.cut(); - if (cut?.result.ok) rememberSelection(); - return cut?.result ?? null; + function cut(clipboard?: CalendarClipboard): EditingResult | null { + if (clipboard !== undefined && calendarClipboardFormat.parse(clipboard) === null) return observe({ ok: false, code: "clipboard.invalid" }); + const cut = editor.cut(clipboard); + if (cut === null) return null; + const result = observe(cut.result); + if (result.ok) rememberSelection(); + return result; } function paste(clipboard: CalendarClipboard): EditingResult { - const result = editor.paste(clipboard, occurrence.start ?? selectedEvent?.start); + const target = pasteTargetRef.current; + const result = observe(editor.paste(clipboard, target?.editor === editor && target.revision === editor.snapshot.revision ? target.range.start ?? undefined : undefined)); if (result.ok) rememberSelection(); return result; } diff --git a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts index 940b88829..81138ff1f 100644 --- a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts +++ b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts @@ -1,13 +1,24 @@ -import { useRef, useState, type PointerEvent } from "react"; +import { useRef, useState, type PointerEvent, type RefObject } from "react"; import { createGestureSession } from "@interactive-os/json-document-affordance"; import { - addCalendarDate, bindCalendarAllDayIntent, bindCalendarMonthIntent, bindCalendarTimeGridIntent, - calendarEventsOnDay, calendarInstantAt, calendarShiftInstant, calendarVisibleEvents, - interpretCalendarAllDayPointer, interpretCalendarMonthPointer, interpretCalendarTimeGridPointer, - type CalendarAllDayPointerRelease, type CalendarIntent, type CalendarTimeGridHandle, + bindCalendarAllDayIntent, + bindCalendarMonthIntent, + bindCalendarTimeGridIntent, + interpretCalendarAllDayPointer, + interpretCalendarMonthPointer, + interpretCalendarTimeGridPointer, + type CalendarAllDayPointerRelease, + type CalendarTimeGridHandle, type CalendarTimeGridPointerRelease, type CalendarSelectionDragSource, } from "@interactive-os/json-document-editing"; +import { + addCalendarDate, + calendarEventsOnDay, + calendarInstantAt, + calendarShiftInstant, + calendarVisibleEvents, +} from "@interactive-os/json-document-calendar-document"; import { calendarDayDeltaFromWebWidth, calendarKeyFromWebRow, calendarMinutesFromWebGrid, createWebPointerSession, findWebPointTarget, @@ -43,6 +54,8 @@ type CalendarSelectionDragGesture = { }; export interface CalendarPointerInteractions { + /** Bind to one Calendar surface; canonical grids attach it automatically. */ + readonly rootRef: RefObject; readonly hoveredTime: { readonly day: string; readonly instant: string; readonly minutes: number } | null; instantAt(day: string, clientY: number, grid: Element): string | null; timePointerDown(event: PointerEvent, day: string, id: string | null, start: string | null, end: string | null, handle: CalendarTimeGridHandle | null): void; @@ -66,6 +79,7 @@ export interface CalendarPointerInteractions { /** Owns Calendar's Web pointer preview, commit, cancel, and resize lifecycle. */ export function useCalendarPointerInteractions(hand: CalendarHand, policy: CalendarPointerPolicy): CalendarPointerInteractions { + const rootRef = useRef(null); const [timePointer] = useState(() => createWebPointerSession()); const [allDayPointer] = useState(() => createWebPointerSession()); const [monthPointer] = useState(() => createWebPointerSession()); @@ -76,9 +90,8 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const document = hand.document; const visibleEvents = calendarVisibleEvents(document); - function remember(intent: CalendarIntent | null, start: string | null, end: string | null): void { - hand.dispatch(intent); - hand.rememberIntent(intent, { start, end }); + function pointTarget(selector: string, event: { clientX: number; clientY: number }): Element | null { + return rootRef.current === null ? null : findWebPointTarget(selector, { x: event.clientX, y: event.clientY }, rootRef.current); } function bindTime(intent: ReturnType, occurrenceStart: string | null) { @@ -97,7 +110,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen if (release.originEventId === null) return; const event = document.events.find((item) => item.id === release.originEventId); const intent = bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), event, release.originEventStart, hand.scope); - remember(intent, release.originEventStart, event?.end ?? null); + hand.commitIntent(intent, { start: release.originEventStart, end: event?.end ?? null }); } function timePointerDown(event: PointerEvent, day: string, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null, originHandle: CalendarTimeGridHandle | null): void { @@ -116,7 +129,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } function timePointerMove(event: PointerEvent): void { - const grid = findWebPointTarget('[data-calendar-grid="time"]', { x: event.clientX, y: event.clientY }); + const grid = pointTarget('[data-calendar-grid="time"]', event); const day = grid?.getAttribute("data-calendar-day"); if (grid == null || day == null) return; if (timePointer.getSnapshot()?.pointerId !== event.pointerId) { @@ -144,7 +157,11 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen if (next?.dragSource !== null && next?.dragSource !== undefined) { const originAnchor = next.dragSource.anchor.occurrenceStart; const move = interpretCalendarTimeGridPointer(next); - if (move?.type !== "event.move") return; + if (move?.type !== "event.move") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "instant", instant: originAnchor } }); const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "instant", instant: move.start } })); hand.previewSelectionDrag(gesture); @@ -168,7 +185,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen hand.setOccurrence({ start: release.targetInstant, end: release.targetInstant }); return; } - remember(bindTime(interpretCalendarTimeGridPointer(release), release.originEventStart), release.originEventStart, release.originEventEnd); + hand.commitIntent(bindTime(interpretCalendarTimeGridPointer(release), release.originEventStart), { start: release.originEventStart, end: release.originEventEnd }); } function allDayPointerDown(event: PointerEvent, originDay: string, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null, originHandle: "body" | "start" | "end" | null): void { @@ -184,7 +201,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen function allDayPointerMove(event: PointerEvent): void { if (allDayPointer.getSnapshot()?.pointerId !== event.pointerId) return; - const targetDay = findWebPointTarget("[data-calendar-allday-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-allday-day"); + const targetDay = pointTarget("[data-calendar-allday-day]", event)?.getAttribute("data-calendar-allday-day"); if (targetDay == null) return; const next = allDayPointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetDay !== state.originDay @@ -193,8 +210,14 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetDay, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { + const move = interpretCalendarAllDayPointer(next); + if (move?.type !== "event.move-day") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "day", day: next.originDay } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: targetDay } })); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: move.day } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setAllDayPreview(next); } @@ -203,13 +226,19 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = allDayPointer.commit(event.pointerId); hand.setAllDayPreview(null); if (release === null) return; - const targetDay = findWebPointTarget("[data-calendar-allday-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-allday-day"); - if (targetDay == null) return; + const targetDay = pointTarget("[data-calendar-allday-day]", event)?.getAttribute("data-calendar-allday-day"); + if (targetDay == null) { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (release.dragSource !== null) { suppressEventClick.current = true; suppressDoubleClickBriefly(); const gesture = selectionDrag.commit(); - if (gesture !== null) hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: targetDay } }); + const move = interpretCalendarAllDayPointer({ ...release, targetDay }); + hand.previewSelectionDrag(null); + if (gesture !== null && move?.type === "event.move-day") hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: move.day } }); return; } if (release.dragCandidate !== null) return; @@ -221,7 +250,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } const id = raw?.type === "event.move-day" || raw?.type === "event.resize" ? raw.eventId : null; const intent = bindCalendarAllDayIntent(raw, id === null ? undefined : document.events.find((item) => item.id === id), release.originEventStart, hand.scope); - remember(intent, release.originEventStart, release.originEventEnd); + hand.commitIntent(intent, { start: release.originEventStart, end: release.originEventEnd }); } function monthPointerDown(event: PointerEvent, fallbackDay: string, rowDays: ReadonlyArray, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null): void { @@ -242,7 +271,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen function monthPointerMove(event: PointerEvent): void { if (monthPointer.getSnapshot()?.pointerId !== event.pointerId) return; - const targetDay = findWebPointTarget("[data-calendar-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-day"); + const targetDay = pointTarget("[data-calendar-day]", event)?.getAttribute("data-calendar-day"); if (targetDay == null) return; const next = monthPointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetDay !== state.originDay @@ -251,8 +280,14 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetDay, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { + const move = interpretCalendarMonthPointer(next); + if (move?.type !== "event.move-day") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "day", day: next.originDay } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: targetDay } })); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: move.day } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setMonthPreview({ ...next, eventsOnTargetDay: [] }); } @@ -261,13 +296,19 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = monthPointer.commit(event.pointerId); hand.setMonthPreview(null); if (release === null) return; - const targetDay = findWebPointTarget("[data-calendar-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-day"); - if (targetDay == null) return; + const targetDay = pointTarget("[data-calendar-day]", event)?.getAttribute("data-calendar-day"); + if (targetDay == null) { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (release.dragSource !== null) { suppressEventClick.current = true; suppressDoubleClickBriefly(); const gesture = selectionDrag.commit(); - if (gesture !== null) hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: targetDay } }); + const move = interpretCalendarMonthPointer({ ...release, targetDay }); + hand.previewSelectionDrag(null); + if (gesture !== null && move?.type === "event.move-day") hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: move.day } }); return; } if (release.dragCandidate !== null) return; @@ -279,7 +320,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } const id = raw?.type === "event.move-day" ? raw.eventId : null; const intent = bindCalendarMonthIntent(raw, id === null ? undefined : document.events.find((item) => item.id === id), release.originEventStart, hand.scope); - remember(intent, release.originEventStart, release.originEventEnd); + hand.commitIntent(intent, { start: release.originEventStart, end: release.originEventEnd }); } function resizeTimed(id: string, edge: "start" | "end", occurrenceStart: string, origin: string, delta: number, phase: Phase): void { @@ -289,12 +330,15 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = { originInstant: origin, originEventId: id, originEventStart: occurrenceStart, originHandle: edge, targetInstant }; if (phase === "preview") return hand.setTimePreview(release); hand.setTimePreview(null); - remember(bindTime(interpretCalendarTimeGridPointer(release), occurrenceStart), occurrenceStart, targetInstant); + hand.commitIntent(bindTime(interpretCalendarTimeGridPointer(release), occurrenceStart), { start: occurrenceStart, end: targetInstant }); } function resizeAllDay(id: string, edge: "start" | "end", originDay: string, occurrenceStart: string, delta: number, phase: Phase): void { - const column = globalThis.document.querySelector("[data-calendar-allday-day]") ?? globalThis.document.querySelector("[data-calendar-week] [data-calendar-day]"); - const targetDay = addCalendarDate(originDay, calendarDayDeltaFromWebWidth(delta, column?.getBoundingClientRect().width ?? 0)); + const column = rootRef.current?.querySelector("[data-calendar-allday-day]") ?? rootRef.current?.querySelector("[data-calendar-week] [data-calendar-day]"); + if (column == null) return; + const width = column.getBoundingClientRect().width; + if (width <= 0) return; + const targetDay = addCalendarDate(originDay, calendarDayDeltaFromWebWidth(delta, width)); if (targetDay === null) return; const release = { originDay, originEventId: id, originEventStart: occurrenceStart, originHandle: edge, targetDay }; if (phase === "preview") return hand.setAllDayPreview(release); @@ -328,6 +372,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } return { + rootRef, hoveredTime, instantAt, timePointerDown, diff --git a/packages/json-document-calendar/tests/calendar-protocol.test.tsx b/packages/json-document-calendar/tests/calendar-protocol.test.tsx new file mode 100644 index 000000000..b6f51c7ca --- /dev/null +++ b/packages/json-document-calendar/tests/calendar-protocol.test.tsx @@ -0,0 +1,189 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { calendarClipboardFormat, createCalendarEditor, type CalendarDocument } from "@interactive-os/json-document-editing"; +import { createWebClipboardBinding, createWebJSONClipboardRepresentation } from "@interactive-os/json-document-web"; +import { useCalendarHand, useCalendarPointerInteractions } from "../src/index.js"; + +afterEach(cleanup); +const initial: CalendarDocument = { + calendars: [{ id: "work", title: "Work", hidden: false, color: "accent" }], + events: [{ id: "a", title: "A", start: "2026-08-01T09:00", end: "2026-08-01T10:00", allDay: false, calendarId: "work", recurrence: null, excludeDates: [] }], +}; +const policy = { hourStart: 0, hourEnd: 24, stepMinutes: 15, pixelsPerHour: 60 }; +const rect = (width: number) => ({ left: 0, right: width, top: 0, bottom: 1440, width, height: 1440, x: 0, y: 0, toJSON: () => ({}) }); +function surface(width: number, day: string) { + const root = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.calendarGrid = "time"; + grid.dataset.calendarDay = day; + grid.dataset.calendarAlldayDay = day; + grid.getBoundingClientRect = () => rect(width); + root.append(grid); + document.body.append(root); + return { root, grid }; +} + +describe("Calendar Hand protocol composition", () => { + test.each(["hand", "editor", "initial"] as const)("uses canonical occurrence after %s selection, including the README patch/paste path", (path) => { + let id = 0; + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, recurrence: { freq: "daily", interval: 1, until: "2026-08-08" } }] }, { createId: () => `new-${++id}` }); + const intent = { type: "selection.set", point: { eventId: "a", occurrenceStart: "2026-08-03T09:00" } } as const; + if (path === "initial") editor.dispatch(intent); + const { result } = renderHook(() => useCalendarHand(editor)); + if (path !== "initial") act(() => path === "hand" ? result.current.dispatch(intent) : editor.dispatch(intent)); + expect(result.current.occurrence).toEqual({ start: "2026-08-03T09:00", end: "2026-08-03T10:00" }); + expect(result.current.inspectedInterval).toEqual(result.current.occurrence); + expect(result.current.isPrimaryOccurrence("a", "2026-08-01T09:00")).toBe(false); + expect(result.current.isPrimaryOccurrence("a", "2026-08-03T09:00")).toBe(true); + act(() => { expect(result.current.applySelectedPatch({ title: "Planning" })).toBe(true); }); + expect(result.current.selectedEvent).toMatchObject({ title: "Planning", start: "2026-08-03T09:00", recurrence: null }); + const payload = result.current.copy()!; + act(() => { expect(result.current.paste(payload).ok).toBe(true); }); + expect(result.current.selectedEvent?.start).toBe("2026-08-03T09:00"); + act(() => result.current.undo()); + act(() => result.current.undo()); + expect(result.current.occurrence.start).toBe("2026-08-03T09:00"); + }); + + test("an explicit empty-slot paste target does not become a stale selected occurrence", () => { + const editor = createCalendarEditor(initial); + const { result } = renderHook(() => useCalendarHand(editor)); + act(() => result.current.setOccurrence({ start: "2026-08-04T11:00", end: "2026-08-04T12:00" })); + act(() => editor.dispatch({ type: "selection.clear" })); + act(() => editor.dispatch({ type: "selection.set", point: { eventId: "a", occurrenceStart: "2026-08-01T09:00" } })); + expect(result.current.occurrence.start).toBe("2026-08-01T09:00"); + act(() => result.current.removeSelected()); + expect(result.current.selectedEvent).toBeNull(); + expect(result.current.occurrence).toEqual({ start: null, end: null }); + }); + + test("selection and patch in one event use the live canonical occurrence", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, recurrence: { freq: "daily", interval: 1, until: "2026-08-08" } }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + act(() => { + result.current.dispatch({ type: "selection.set", point: { eventId: "a", occurrenceStart: "2026-08-03T09:00" } }); + expect(result.current.applySelectedPatch({ title: "Same event" })).toBe(true); + }); + expect(result.current.selectedEvent).toMatchObject({ title: "Same event", start: "2026-08-03T09:00", recurrence: null }); + expect((editor.snapshot.value as CalendarDocument).events[0]?.title).toBe("A"); + }); + + test.each(["time", "allDay", "month"] as const)("returning a %s drag to its origin clears preview and does not commit", (kind) => { + const sourceEvent = kind === "time" ? initial.events[0]! : { ...initial.events[0]!, start: "2026-08-01", end: "2026-08-02", allDay: true }; + const editor = createCalendarEditor({ ...initial, events: [sourceEvent] }); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + const { root, grid } = surface(100, kind === "time" ? "2026-08-01" : "2026-08-02"); + try { + result.current.pointer.rootRef.current = root; + const target = { closest: () => kind === "time" ? grid : null, focus() {}, setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} }; + const down = { button: 0, clientX: 50, clientY: 540, currentTarget: target, pointerId: 1 } as never; + act(() => { + if (kind === "time") result.current.pointer.timePointerDown(down, "2026-08-01", "a", sourceEvent.start, sourceEvent.end, "body"); + else if (kind === "allDay") result.current.pointer.allDayPointerDown(down, "2026-08-01", "a", sourceEvent.start, sourceEvent.end, "body"); + else result.current.pointer.monthPointerDown(down, "2026-08-01", ["2026-08-01"], "a", sourceEvent.start, sourceEvent.end); + }); + act(() => result.current.pointer[`${kind}PointerMove`]({ pointerId: 1, clientX: 50, clientY: 600, target: grid } as never)); + expect(result.current.hand.selectionDragPreview).not.toBeNull(); + grid.dataset.calendarDay = grid.dataset.calendarAlldayDay = "2026-08-01"; + act(() => result.current.pointer[`${kind}PointerMove`]({ pointerId: 1, clientX: 50, clientY: 540, target: grid } as never)); + expect(result.current.hand.selectionDragPreview).toBeNull(); + act(() => result.current.pointer[`${kind}PointerUp`]({ pointerId: 1, clientX: 50, clientY: 540 } as never)); + expect(result.current.hand.document.events).toEqual([sourceEvent]); + expect(editor.snapshot.canUndo).toBe(false); + } finally { root.remove(); } + }); + + test("cuts the written payload even when the writer re-enters selection", () => { + const editor = createCalendarEditor({ ...initial, events: [...initial.events, { ...structuredClone(initial.events[0]!), id: "b", title: "B" }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + const data = new Map(); + const binding = createWebClipboardBinding({ + codec: createWebJSONClipboardRepresentation(calendarClipboardFormat), + read: result.current.copy, cut: result.current.cut, paste: result.current.paste, + }); + act(() => { + expect(binding.cut({ + clipboardData: { + types: [], + getData: (type) => data.get(type) ?? "", + setData(type, value) { + data.set(type, value); + editor.dispatch({ type: "selection.set", point: { eventId: "b", occurrenceStart: "2026-08-01T09:00" } }); + }, + }, + preventDefault() {}, + }).ok).toBe(true); + }); + expect(JSON.parse(data.get(calendarClipboardFormat.mimeType)!).items[0].sourceEventId).toBe("a"); + expect(result.current.document.events.map((event) => event.id)).toEqual(["b"]); + }); + + test("reports a rejected pointer edit without success aftercare", () => { + const editor = createCalendarEditor(initial, { createId: () => "draft" }); + const onResult = vi.fn(); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor, { onResult }); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + act(() => result.current.hand.createInterval("2026-08-01T11:00", "2026-08-01T12:00")); + const before = editor.snapshot; + const occurrence = result.current.hand.occurrence; + act(() => result.current.pointer.resizeTimed("draft", "end", "2026-08-01T11:00", "2026-08-01T12:00", -120, "commit")); + expect(editor.snapshot).toEqual(before); + expect(result.current.hand.occurrence).toEqual(occurrence); + expect(result.current.hand.renaming).toBe(true); + expect(onResult).toHaveBeenLastCalledWith(expect.objectContaining({ ok: false, code: "event.invalid-interval" })); + }); + + test("keeps the edited later occurrence focused for all-scope Inspector follow-up", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, recurrence: { freq: "daily", interval: 1, until: "2026-08-08" } }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + act(() => result.current.selectOccurrence("a", "2026-08-03T09:00", "2026-08-03T10:00")); + act(() => result.current.setScope("all")); + act(() => result.current.applySelectedPatch({ start: "2026-08-03T11:00" })); + expect(result.current.inspectedInterval).toEqual({ start: "2026-08-03T11:00", end: "2026-08-03T12:00" }); + act(() => result.current.applySelectedPatch({ end: "2026-08-03T13:00" })); + expect(result.current.document.events[0]).toMatchObject({ start: "2026-08-01T11:00", end: "2026-08-01T13:00" }); + }); + + test("two overlapping Calendar instances hit-test and resize within their own roots", () => { + const allDay = { ...initial, events: [{ ...initial.events[0]!, start: "2026-08-01", end: "2026-08-02", allDay: true }] }; + const first = createCalendarEditor(allDay), second = createCalendarEditor(allDay); + const { result } = renderHook(() => { + const firstHand = useCalendarHand(first), secondHand = useCalendarHand(second); + return { first: useCalendarPointerInteractions(firstHand, policy), second: useCalendarPointerInteractions(secondHand, policy) }; + }); + const left = surface(100, "2026-08-01"), right = surface(200, "2026-08-02"); + try { + result.current.first.rootRef.current = left.root; + result.current.second.rootRef.current = right.root; + act(() => result.current.second.timePointerMove({ pointerId: 1, clientX: 50, clientY: 540, target: right.grid } as never)); + expect(result.current.second.hoveredTime?.day).toBe("2026-08-02"); + act(() => result.current.first.resizeAllDay("a", "end", "2026-08-01", "2026-08-01", 200, "commit")); + act(() => result.current.second.resizeAllDay("a", "end", "2026-08-01", "2026-08-01", 200, "commit")); + expect((first.snapshot.value as CalendarDocument).events[0]?.end).toBe("2026-08-04"); + expect((second.snapshot.value as CalendarDocument).events[0]?.end).toBe("2026-08-03"); + } finally { left.root.remove(); right.root.remove(); } + }); + + test("all-day body dragging preserves the grab offset inside a multi-day span", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, start: "2026-08-01", end: "2026-08-04", allDay: true }] }); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + const { root, grid } = surface(100, "2026-08-04"); + try { + result.current.pointer.rootRef.current = root; + const target = { focus() {}, setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} }; + act(() => result.current.pointer.allDayPointerDown({ button: 0, currentTarget: target, pointerId: 1 } as never, "2026-08-03", "a", "2026-08-01", "2026-08-04", "body")); + act(() => result.current.pointer.allDayPointerMove({ pointerId: 1, clientX: 50, clientY: 50, target: grid } as never)); + expect(result.current.hand.paintedEvents[0]?.start).toBe("2026-08-02"); + act(() => result.current.pointer.allDayPointerUp({ pointerId: 1, clientX: 50, clientY: 50 } as never)); + expect(result.current.hand.document.events[0]).toMatchObject({ start: "2026-08-02", end: "2026-08-05" }); + } finally { root.remove(); } + }); +}); diff --git a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx index 999ec4bf4..e5505b9e4 100644 --- a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx +++ b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx @@ -201,7 +201,10 @@ describe("useCalendarHand", () => { grid.dataset.calendarGrid = "time"; grid.dataset.calendarDay = "2026-08-03"; grid.getBoundingClientRect = () => ({ left: 0, right: 100, top: 0, bottom: 1440, width: 100, height: 1440, x: 0, y: 0, toJSON: () => ({}) }); - document.body.append(grid); + const root = document.createElement("div"); + root.append(grid); + document.body.append(root); + result.current.pointer.rootRef.current = root; const target = { closest: () => grid, focus: () => undefined, @@ -227,7 +230,7 @@ describe("useCalendarHand", () => { expect(result.current.hand.document.events.map((item) => item.start)).toEqual([ "2026-08-03T09:00", "2026-08-04T11:00", ]); - grid.remove(); + root.remove(); }); test("finishes an outstanding create rename when selection drag commits", () => { diff --git a/packages/json-document-calendar/tsconfig.json b/packages/json-document-calendar/tsconfig.json index fbedf710e..0cd96d404 100644 --- a/packages/json-document-calendar/tsconfig.json +++ b/packages/json-document-calendar/tsconfig.json @@ -6,6 +6,7 @@ "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ + { "path": "../json-document-calendar-document" }, { "path": "../json-document-affordance" }, { "path": "../json-document-editing" }, { "path": "../json-document-react" }, diff --git a/packages/json-document-canvas/LICENSE b/packages/json-document-canvas/LICENSE new file mode 100644 index 000000000..6a984193a --- /dev/null +++ b/packages/json-document-canvas/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 interactive-os contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/json-document-canvas/README.md b/packages/json-document-canvas/README.md new file mode 100644 index 000000000..939643f68 --- /dev/null +++ b/packages/json-document-canvas/README.md @@ -0,0 +1,25 @@ +# Canvas Hand + +`@interactive-os/json-document-canvas`는 한 장의 Object Canvas 프로파일에서 +글자·사각형·타원·스티커 노트·그리기를 완성하는 React Hand입니다. 도형과 노트의 +본문도 같은 plain-text 입력과 스타일 API로 편집합니다. 별도 Canvas editor나 +selection/history 구현을 만들지 않고 기존 `createObjectEditor`를 사용합니다. +재사용 가능한 Plane Select profile로 다중 선택·Alt 복제·Shift 축 고정·방향키 이동을, +Object Editing과 Web Clipboard로 선택 copy/cut/paste·Mod+D·Undo/Redo를 연결합니다. +외부 텍스트와 PNG/JPEG/WebP 붙여넣기, 포함된 이미지·글의 HTML 순서 보존, +반복 paste 배치와 비동기 취소도 정본 API를 사용합니다. 외부 이미지 URL은 가져오지 않습니다. + +```tsx +import { useState } from "react"; +import { createObjectEditor } from "@interactive-os/json-document-editing"; +import { CanvasHand } from "@interactive-os/json-document-canvas"; + +function Slide() { + const [editor] = useState(() => createObjectEditor({ profile: "canvas/1", width: 1280, height: 720, objects: [] })); + return ; +} +``` + +CSS와 레이아웃은 Host가 결정하고 controls는 기존 UI Primitives를 소비합니다. +슬라이드 크기와 색은 문서/제품 값입니다. [API 계약](docs/api.md)과 +[실제 Usage/Source](https://developer-1px.github.io/json-document/docs/api/canvas)를 참고하세요. diff --git a/packages/json-document-canvas/docs/api.md b/packages/json-document-canvas/docs/api.md new file mode 100644 index 000000000..801a71761 --- /dev/null +++ b/packages/json-document-canvas/docs/api.md @@ -0,0 +1,170 @@ +## Canvas Hand 계약 · RC + +`CanvasHand`는 `ObjectEditor`와 `CanvasCreationStyle`을 받아 글자·사각형·타원·스티커 노트·자유 +그리기, 이미지·텍스트 붙여넣기, 다중 선택, 선택 스타일, 집합 이동·복제·삭제, native Clipboard, primary resize, Undo/Redo, JSON 재열기를 연결합니다. +`useCanvasHand`는 같은 입력 조합을 custom UI에서 사용할 수 있게 공개합니다. + +툴바의 모든 도구·명령은 Lucide 아이콘과 공통 `Toggle`/`Command`의 `label`을 +사용합니다. label이 접근성 이름과 hover/focus 툴팁의 정본이며, 별도 툴팁이나 +버튼 구현을 두지 않습니다. 선택 도구는 `aria-pressed`, 실행 불가 명령은 +`disabled`로 상태를 전달합니다. + +```text +Host: 한 장 fixture, 크기·색상 정책, 레이아웃 + └─ Canvas Hand: 도구, 조작 preview, plain-text draft, UI 조합 + ├─ React Connector: Editing snapshot 구독 + ├─ Web Adapter: SVG 좌표, pointer capture, keyboard·Clipboard 해석 + ├─ File Intake: 붙여넣은 파일의 형식·개수·용량 정책 검사 + ├─ Affordance: createPlaneSelectProfile, gesture, resize + │ └─ Selection: key 집합·primaryKey 전이 + ├─ UI Primitives: handle, icon controls·tooltips + └─ Object Editing: Intent, ID 할당, Selection, History, 외부 내용 변환·paste 순서/취소 + ├─ Object Document Type: Canvas profile, 검증, 연산, projection, JSON + └─ Core: immutable 값과 atomic JSON Patch +``` + +이는 책임 관계이며 모든 입력이 통과하는 직렬 pipeline이 아닙니다. + +### 입력과 History + +- 도구를 고르고 클릭하면 기본 크기, 드래그하면 지정한 크기로 생성합니다. + 사각형·타원·글자·노트는 누르거나 작게 흔들리는 동안 기본 크기를 미리 표시하지 않습니다. + 시작점에서 3 문서 단위 이상 움직이면 실제 드래그 상자만 표시하며, 다시 시작점 근처로 + 돌아와도 클릭 크기로 바뀌지 않습니다. 클릭 기본 크기는 놓을 때 press 위치에만 생성합니다. + 드래그 후 시작점에 정확히 돌아와 놓으면 객체나 History를 만들지 않습니다. + 생성 후 Select로 돌아가며 새 객체를 선택합니다. 펜은 최소 두 지점이 필요합니다. +- 객체 click은 단일 선택, Shift+click은 toggle입니다. 빈 곳 click은 clear, + drag는 marquee replace, Shift+marquee는 add입니다. Mod+A를 반복해도 전체 선택을 유지합니다. + 선택된 객체 press는 집합을 유지하고 release까지 drag가 없으면 단일 선택으로 바꿉니다. +- 선택된 객체를 끌면 집합 전체가 같은 delta로 이동합니다. 마지막 객체가 위에 표시됩니다. + 선택 윤곽은 모두 그리지만 네 변·네 모서리 resize targets는 primary 하나에만 붙습니다. + Delete는 집합 전체를 한 번 삭제하며 primary resize/text 편집은 기존 선택 집합을 보존합니다. + Focus만으로 선택하지 않으며, focused 객체에서 Space/Shift+Space로 선택/toggle합니다. + focused 객체의 Enter는 그 객체를 선택하고 본문이 있는 글자·도형·노트라면 편집합니다. 슬라이드 자체의 + Enter/F2는 현재 primary를 편집하며, F2는 객체에 focus가 있어도 primary를 대상으로 합니다. +- 글자·노트는 생성 직후, 사각형·타원은 더블클릭/F2/Enter로 편집합니다. + 기존 글자·노트도 같은 더블클릭/F2/Enter를 사용합니다. 줄바꿈·IME·선택·native + 입력 Undo는 textarea에 남습니다. blur 또는 Mod+Enter가 전체 draft를 한 번 commit하고 + Escape는 draft만 버립니다. 객체의 label이 실제 문자열 값입니다. + 노트 클릭 기본 크기는 200×200이며 드래그로 자유 크기를 지정합니다. 생성과 이후 + 본문 확정은 각각 한 번의 Undo입니다. 새 노트에서 Escape하면 빈 노트는 남습니다. + 도형은 내부 중앙, 노트는 여백을 둔 상단이며 `projectObjectText`를 표시와 입력이 공유합니다. + 편집 중에도 채우기와 테두리는 유지합니다. 상자 밖 본문은 clip하며 입력 중에는 native + textarea 스크롤로 긴 내용을 편집할 수 있습니다. 자동 글자 축소나 상자 자동 확대는 하지 않습니다. +- Alt/Option+drag는 선택 집합을 복제합니다. 원본을 남기고 사본 위치를 preview하며 + release에 새 ID를 할당합니다. Alt를 도중에 누르거나 놓으면 copy/move가 전환됩니다. + Shift+drag는 큰 delta 축을 고정하며 Shift+click toggle과 구분합니다. + Mod+D 또는 아이콘 툴바의 복제는 24단위 offset으로 복제하고 사본 집합·대응 primary를 선택합니다. +- 방향키는 선택 집합을 1단위, Shift+방향키는 10단위 이동합니다. 수정 키 없는 입력만 + 처리하며 text/JSON 입력과 IME의 키보드 소유권은 보존합니다. +- 네 모서리 손잡이는 기존 사각 모양을 유지하며 네 변 전체에도 보이지 않는 resize 영역이 + 있습니다. 방향 커서로 구분하며 모서리가 변보다 우선합니다. 변은 한 축만 조절하고 반대편 + 변을, 모서리는 반대 모서리를 고정합니다. Shift는 초기 객체 비율을 유지하고, 변에서 비율을 + 유지할 때 다른 축은 중심 기준입니다. Alt/Option은 중심 기준, Shift+Alt는 중심·비율을 + 함께 고정합니다. 포인터가 멈춰 있어도 modifier 전환을 반영합니다. 최소 1 문서 단위까지 + 줄여도 고정점은 움직이지 않고 뒤집히지 않습니다. 정지한 grab은 크기·History를 바꾸지 않습니다. + [Resize 정본 계약](/docs/api/affordance)을 소비하며, 글자 크기·path 정규화 좌표·이미지 원본은 + 그대로 두고 객체 상자만 조절합니다. +- 이동·resize·생성 중에는 문서를 변경하지 않습니다. pointerup의 최종 좌표로 한 번 + commit합니다. Escape, pointercancel, capture loss, 외부 문서 변경, unmount는 preview를 + 버립니다. 다른 pointer의 release는 조작을 완료하지 못합니다. marquee 선택 preview도 + commit 전까지 Editing에 반영하지 않습니다. Escape는 gesture만 취소하고 idle에서 선택을 비웁니다. +- 선택만 바꾸거나 0 거리로 움직이면 History가 생기지 않습니다. commit된 편집은 + 한 번의 Undo로 되돌리며 삭제 Undo는 객체와 선택을 함께 복원합니다. Mod+Z/Mod+Shift+Z는 + 입력 필드 밖에서 문서 Undo/Redo를 실행합니다. + +### 선택 스타일 + +Select 도구에서 스타일을 지원하는 객체가 선택되면 팔레트 아이콘 하나가 나타납니다. +공통 Popover와 Command 툴팁을 사용하며, 선택한 종류에 필요한 속성만 엽니다. +색상 팔레트와 굵게·정렬 버튼은 즉시 확정합니다. 직접 입력한 CSS 색·글자 크기·선 굵기는 +Enter 또는 적용 아이콘으로 확정하고, Escape·바깥 클릭으로 닫으면 미확정 입력은 버립니다. +이미지만 선택한 경우에는 스타일 컨트롤이 없습니다. + +도형·노트에는 `색상`(채우기)과 `글자색`이 따로 나타납니다. 글자 크기·굵기·정렬도 +같은 선택 스타일 API로 적용합니다. 독립 글자는 기존 `색상`을 글자색으로 씁니다. + +혼합 선택은 `readObjectStyle`의 `null`을 `혼합`으로 드러냅니다. 색·크기·정렬을 임의의 +primary 값으로 표시하지 않습니다. 속성은 이를 지원하는 선택 객체에만 적용하고 전체 +선택과 primary를 보존합니다. 굵기가 모두 0인 도형에 테두리색을 고르면 2 단위로 함께 +켭니다. 투명한 테두리색이나 도형의 0 굵기로 테두리를 없앨 수 있습니다. path는 양의 +굵기가 필요하므로 path가 포함된 선택에 0을 입력하면 전체를 거절합니다. + +`useCanvasHand`의 `selectedStyle`과 `setStyle(style)`로 같은 기능을 custom UI에 연결할 +수 있습니다. `setStyle`은 `selection.style` Intent의 결과를 반환합니다. 스타일을 열거나 +적용할 때 글자 draft는 먼저 확정하고 진행 중인 gesture·paste는 취소합니다. 글자 편집 +textarea도 표시와 같은 크기·굵기·정렬을 사용합니다. 스타일 확정당 한 번의 Undo이며 +기본값·동일값은 문서와 History를 바꾸지 않습니다. + +스타일은 저장 객체에만 적용하며 `creationStyle`의 제품 생성 기본값을 변경하지 않습니다. +글자 자동 크기, 부분 문자열 서식, 상시 inspector는 이번 범위 밖입니다. + +### Native Clipboard + +선택 객체의 Mod+C/X/V 또는 브라우저 native copy/cut/paste 이벤트를 Web Clipboard +binding에 연결합니다. 구조화 MIME과 label의 `text/plain`을 함께 쓰므로 다른 Canvas +instance로 객체를 복사하거나 다른 앱에 문자열을 붙일 수 있습니다. 앱 내부 가상 +clipboard는 만들지 않습니다. 복제 버튼은 OS clipboard를 바꾸지 않는 별도 명령입니다. + +cut은 쓰기에 성공한 캡처 대상만 제거합니다. 쓰기 실패나 Editing 거절은 오류로 드러내고 +문서 삭제나 브라우저 fallback 삭제를 허용하지 않습니다. paste는 새 ID·대응 primary로 +선택합니다. Editing의 cascade placement로 24/24씩 이동하여 기존 객체와 시작점이 겹치지 않는 +첫 위치를 고릅니다. 객체 간 완전한 충돌 회피나 슬라이드 안 자동 배치는 아닙니다. +text/JSON textarea의 native clipboard는 가로채지 않습니다. + +`createCanvasClipboardBinding(editor, policy, options?)`가 이 연결의 공개 API입니다. +구조화 Object → 이미지 파일 → 이미지가 포함된 HTML → 일반 텍스트 순서로 처리하며 잘못된 Object MIME은 문자열로 +조용히 변환하지 않습니다. 외부 문자열은 한 text 객체가 되며 HTML 서식을 보존하지 않고 +줄바꿈·Unicode를 그대로 보존합니다. PNG/JPEG/WebP는 문서 내부 base64 image 객체로 넣습니다. +기본은 한 paste당 최대 4개, 파일당 10 MiB, decode 후 이미지당 16,000,000픽셀입니다. +이미지는 비율을 유지해 슬라이드 75% 상자에 맞추고 확대하지 않습니다. 후속 resize는 일반 +객체와 같은 상자 변환이며 Shift로 초기 비율을 유지할 수 있습니다. `policy.files`와 `maxImagePixels`로 입력 정책을 지정할 수 +있지만 Object 모델이 지원하지 않는 이미지 표현까지 허용되는 것은 아닙니다. + +HTML은 Web의 inert parser와 이미지 준비 API를 사용합니다. 포함된 PNG/JPEG/WebP data URL과 +글을 HTML 내부 순서대로 text/image 객체로 바꿉니다. Editing의 `createCanvasClipboard`가 +간격을 둔 세로 흐름으로 배치하고, 전체 높이가 넘으면 이미지 비율·글자 크기·간격을 함께 +줄여 상자 안에 맞춥니다. 긴 내용을 원래 글자 크기로 읽거나 CSS·Office 배치를 재현하는 +기능은 아닙니다. source가 없거나 외부·상대·blob·cid URL이면 글만 남기지 않고 전체를 거절합니다. +HTML과 native 파일이 함께 있으면 파일을 우선하고, 두 표현을 합치거나 중복 삽입하지 않습니다. + +같은 batch는 순차 decode로 준비하고 모두 성공한 경우만 한 번 삽입합니다. 연속 paste는 +Editing paste session을 통해 입력 순서대로 각각 commit/Undo를 만듭니다. 진행 상태를 표시하고 +Escape·다른 도구/편집·외부 문서/선택·unmount는 준비를 취소합니다. 늦은 완료는 문서를 바꾸거나 +오류 상태를 덮어쓰지 않습니다. `pending`, `cancel()`, `onResult`, `onPendingChange`를 공개하며 +`readRaster`에는 Web API와 호환되는 구체 환경 인스턴스를 주입할 수 있습니다. + +이미지도 기존 다중 선택·이동·복제·copy/cut/paste·삭제·Undo/Redo와 JSON 재열기를 사용합니다. +이미지용 별도 생성 도구, 외부 URL/SVG·전체 HTML layout import, 이미지 파일 export, asset 서버, +async clipboard 툴바는 아직 지원하지 않습니다. 표준 MIME이 없는 입력이나 실패는 오류로 드러냅니다. + +### JSON + +JSON 버튼은 현재 문서 문자열을 노출합니다. 이 문자열을 저장해 다시 JSON 입력에 +넣고 `JSON 열기`로 복원할 수 있습니다. 재열기는 전체 문서 교체 한 번으로 기록하고 +선택을 비우며 Undo도 가능합니다. 잘못된 JSON은 오류를 표시하고 기존 문서와 History를 +보존합니다. 새 ObjectEditor에 deserialize한 값을 넣으면 새 History 세션으로 시작합니다. +파일 시스템·서버 persistence 정책은 Host 범위입니다. + +### 범위와 Usage + +단일 슬라이드를 컨테이너에 맞춰 표시합니다. 확대/축소·페이지·팬·다중 resize·그룹·회전· +snap·레이어·PPTX·collaboration은 이번 Hand의 지원 범위가 아닙니다. +`creationStyle`은 새 객체에만 적용하는 제품 기본값이며 저장 객체의 스타일을 덮어쓰지 않습니다. +`stickyNoteColor`로 새 노트의 채우기를 지정하며 생략하면 `color`를 사용합니다. +노트·도형의 본문은 `textColor`와 `fontSize` 생성 기본값을 받습니다. + +선택은 Affordance의 [평면 Select 프로파일](/docs/api/affordance)을 소비합니다. +`selectProfile`을 주입하거나 생략하여 기본 instance를 만들 수 있습니다. instance는 Hand마다 +독립적이어야 합니다. `useCanvasHand(editor, style, selectProfile?)`의 `selection`과 `marquee`는 +현재 표시할 preview이며, `snapshot.selection`은 Editing에 확정된 선택입니다. + +```live-demo +/demo/canvas +``` + +샘플이 있는 두 번째 Host도 같은 Hand를 사용합니다. + +```live-demo +/widgets/canvas +``` diff --git a/packages/json-document-canvas/package.json b/packages/json-document-canvas/package.json new file mode 100644 index 000000000..503022652 --- /dev/null +++ b/packages/json-document-canvas/package.json @@ -0,0 +1,35 @@ +{ + "name": "@interactive-os/json-document-canvas", + "version": "0.1.0-rc.0", + "description": "Official React Canvas Hand for a fixed single-slide Object document.", + "type": "module", "license": "MIT", "sideEffects": false, + "main": "./dist/index.js", "types": "./dist/index.d.ts", + "repository": { "type": "git", "url": "git+https://github.com/developer-1px/json-document.git", "directory": "packages/json-document-canvas" }, + "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs", "LICENSE"], + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "scripts": { + "build": "tsc -b tsconfig.json", "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", "test": "vitest run --config vitest.config.ts", + "verify": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { "lucide-react": "^1.33.0" }, + "peerDependencies": { + "@interactive-os/json-document-file-intake": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-object-document": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@interactive-os/json-document-file-intake": "*", + "@interactive-os/json-document-object-document": "*", "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-web": "*", + "@interactive-os/json-document-react": "*", "@interactive-os/json-document-ui-primitives-react": "*", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^29.1.1", "react": "^19.2.5", "react-dom": "^19.2.5", "typescript": "^5.0.0", "vitest": "^4.1.7" + } +} diff --git a/packages/json-document-canvas/src/canvas-clipboard.ts b/packages/json-document-canvas/src/canvas-clipboard.ts new file mode 100644 index 000000000..2f8457d8a --- /dev/null +++ b/packages/json-document-canvas/src/canvas-clipboard.ts @@ -0,0 +1,71 @@ +import { createCanvasClipboard, createObjectPasteSession, type CanvasClipboardOptions, type EditingResult, type ObjectEditor, type ObjectPastePreparation, type ObjectSelection } from "@interactive-os/json-document-editing"; +import type { FileAcceptancePolicy } from "@interactive-os/json-document-file-intake"; +import { assertCanvasDocument } from "@interactive-os/json-document-object-document"; +import { captureWebClipboardPaste, createWebClipboardBinding, objectClipboardCodec, readWebHTMLClipboard, readWebRasterFile, readWebRasterFiles, type WebClipboardEvent, type WebFileCandidate, type WebHTMLClipboardContent } from "@interactive-os/json-document-web"; + +export interface CanvasClipboardPolicy { + readonly textColor: string; + readonly fontSize: number; + readonly files?: FileAcceptancePolicy; + readonly maxImagePixels?: number; +} + +/** Canvas input composition. File validation, native capture, content conversion, and ordered adoption retain their canonical owners. */ +export function createCanvasClipboardBinding(editor: ObjectEditor, policy: CanvasClipboardPolicy, options: { + readonly readRaster?: typeof readWebRasterFile; + readonly onResult?: (result: { readonly ok: boolean; readonly code?: string; readonly reason?: string }) => void; + readonly onPendingChange?: (pending: boolean) => void; +} = {}) { + const placement = { type: "cascade", dx: 24, dy: 24 } as const; + const session = createObjectPasteSession(editor, { placement, ...(options.onResult ? { onResult: options.onResult } : {}), ...(options.onPendingChange ? { onPendingChange: options.onPendingChange } : {}) }); + const native = createWebClipboardBinding({ + codec: objectClipboardCodec, + read: () => editor.copy(), + cut: (payload) => editor.dispatch({ type: "object.remove", objectIds: payload.objects.map((object) => object.id) }), + paste: (payload) => editor.dispatch({ type: "clipboard.paste", clipboard: payload, placement }), + }); + const files = policy.files ?? { acceptedMediaTypes: ["image/png", "image/jpeg", "image/webp"], maxFiles: 4, maxBytesPerFile: 10 * 1024 * 1024 }; + const maxPixels = policy.maxImagePixels ?? 16_000_000; + if (!Number.isFinite(maxPixels) || maxPixels <= 0) throw new TypeError("maxImagePixels must be positive and finite."); + + function contentOptions(): CanvasClipboardOptions { + const document = editor.snapshot.value; + assertCanvasDocument(document); + return { bounds: { x: 0, y: 0, width: document.width * 0.75, height: document.height * 0.75 }, textColor: policy.textColor, fontSize: policy.fontSize }; + } + async function images(candidates: readonly WebFileCandidate[], content: CanvasClipboardOptions, signal: AbortSignal): Promise { + const prepared = await readWebRasterFiles(candidates, { policy: files, maxImagePixels: maxPixels, signal, readRaster: options.readRaster ?? readWebRasterFile }); + return prepared.ok + ? { ok: true, clipboard: createCanvasClipboard({ type: "images", images: prepared.files.map(({ candidate, image }) => ({ ...image, label: candidate.name })) }, content) } + : prepared; + } + async function html(input: WebHTMLClipboardContent, content: CanvasClipboardOptions, signal: AbortSignal): Promise { + const prepared = await readWebHTMLClipboard(input, { policy: files, maxImagePixels: maxPixels, signal, readRaster: options.readRaster ?? readWebRasterFile }); + return prepared.ok ? { ok: true, clipboard: createCanvasClipboard({ type: "mixed", items: prepared.parts.map((part) => part.type === "text" ? part : { type: "image", ...part.image, label: part.candidate.name }) }, content) } : prepared; + } + return { + get pending() { return session.pending; }, + cancel: () => session.cancel(), + copy(event: WebClipboardEvent) { + session.cancel(); + const result = native.copy(event); options.onResult?.(result); return result; + }, + cut(event: WebClipboardEvent) { + session.cancel(); + const result = native.cut(event); options.onResult?.(result); return result; + }, + paste(event: WebClipboardEvent): Promise> { + const captured = captureWebClipboardPaste(event, { codec: objectClipboardCodec, files: true, html: "images", text: true }); + const controller = new AbortController(); + return session.enqueue(() => { + if (!captured.ok) return captured; + if (captured.type === "structured") return { ok: true, clipboard: captured.payload }; + const content = contentOptions(); + if (captured.type === "html") return html(captured.content, content, controller.signal); + return captured.type === "text" + ? { ok: true, clipboard: createCanvasClipboard({ type: "text", text: captured.text }, content) } + : images(captured.files, content, controller.signal); + }, () => controller.abort()); + }, + }; +} diff --git a/packages/json-document-canvas/src/canvas-hand.tsx b/packages/json-document-canvas/src/canvas-hand.tsx new file mode 100644 index 000000000..9e7647a58 --- /dev/null +++ b/packages/json-document-canvas/src/canvas-hand.tsx @@ -0,0 +1,75 @@ +import { useState, type CSSProperties } from "react"; +import { Braces, Circle, CopyPlus, MousePointer2, Pencil, RectangleHorizontal, Redo2, StickyNote, Trash2, Type, Undo2, type LucideIcon } from "lucide-react"; +import type { ObjectEditor } from "@interactive-os/json-document-editing"; +import type { PlaneSelectProfile } from "@interactive-os/json-document-affordance"; +import { serializeCanvasDocument } from "@interactive-os/json-document-object-document"; +import { Command, Field, ProductShell, Toggle, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; +import { CanvasObjectTarget, CanvasObjectView, CanvasResizeTarget, CanvasTextInput } from "./canvas-object-view.js"; +import { useCanvasHand, type CanvasCreationStyle, type CanvasTool } from "./use-canvas-hand.js"; +import { CanvasStyleControls } from "./canvas-style-controls.js"; + +export interface CanvasHandProps { + readonly editor: ObjectEditor; + readonly creationStyle: CanvasCreationStyle; + readonly className?: string; + readonly slideStyle?: CSSProperties; + readonly label?: string; + /** Optional policy instance; one profile per mounted Hand. */ + readonly selectProfile?: PlaneSelectProfile; +} + +const tools: ReadonlyArray<{ readonly id: CanvasTool; readonly label: string; readonly icon: LucideIcon }> = [ + { id: "select", label: "선택", icon: MousePointer2 }, { id: "text", label: "글자", icon: Type }, + { id: "sticky-note", label: "스티커 노트", icon: StickyNote }, + { id: "rectangle", label: "사각형", icon: RectangleHorizontal }, { id: "ellipse", label: "타원", icon: Circle }, { id: "path", label: "그리기", icon: Pencil }, +]; + +export function CanvasHand(props: CanvasHandProps) { + const hand = useCanvasHand(props.editor, props.creationStyle, props.selectProfile); + const [json, setJSON] = useState(null); + const selected = hand.objects.find((object) => object.id === hand.selection.primaryKey); + const selectedKeys = new Set(hand.selection.keys); + const copyOriginals = new Map(hand.copyOriginals.map((object) => [object.id, object])); + return ( + + {tools.map((tool) => hand.choose(tool.id)}>)} + + hand.history("undo")}> + hand.history("redo")}> + hand.duplicate()}> + + + {hand.tool === "select" && { hand.commitText(); hand.cancel(); }} />} + { hand.commitText(); hand.cancel(); setJSON(json === null ? serializeCanvasDocument(props.editor.snapshot.value as typeof hand.document) : null); }}> + }> + + {hand.objects.map((object) => + + 0 && selectedKeys.has(object.id)} + onSelect={(shiftKey) => hand.select(object.id, shiftKey)} onEdit={() => hand.editText(object.id)} onHandle={(interaction, event) => hand.interaction(interaction, event, object, "drag")} /> + )} + {copyOriginals.size > 0 && {hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => )}} + {hand.preview && } + {hand.tool === "select" && hand.objects.filter((object) => selectedKeys.has(object.id)).map((object) => + )} + {selected && hand.tool === "select" && + {!hand.draft && (["n", "e", "s", "w", "nw", "ne", "se", "sw"] as const).map((edge) => hand.interaction(interaction, event, selected, "resize", edge)} />)} + } + {hand.marquee && } + {selected && hand.draft?.id === selected.id && { hand.commitText(); hand.surface.current?.focus(); }} onCancel={() => { hand.cancel(); hand.surface.current?.focus(); }} />} + + {hand.pastePending &&

붙여넣는 중… Escape로 취소

} + {hand.error &&

{hand.error}

} + {json !== null &&
+ + setJSON(serializeCanvasDocument(hand.document))}>현재 문서 읽기 + { if (hand.openJSON(json)) setJSON(null); }}>JSON 열기 +
} +
+ ); +} diff --git a/packages/json-document-canvas/src/canvas-object-view.tsx b/packages/json-document-canvas/src/canvas-object-view.tsx new file mode 100644 index 000000000..b683f20db --- /dev/null +++ b/packages/json-document-canvas/src/canvas-object-view.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, type CSSProperties, type PointerEvent, type ReactNode } from "react"; +import { getObjectStyle, projectObjectText, type CanvasObject, type ObjectTextProjection } from "@interactive-os/json-document-object-document"; +import type { InteractionHandleEvent, ResizeEdge } from "@interactive-os/json-document-affordance"; +import { contentInteractionAttributes, Field, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; + +export function CanvasObjectView({ object, hideText = false }: { readonly object: CanvasObject; readonly hideText?: boolean }): ReactNode { + const style = getObjectStyle(object); + if (object.kind === "image") return ; + if (object.kind === "path") { + return `${object.x + point.x * object.width},${object.y + point.y * object.height}`).join(" ")} fill="none" stroke={object.color} strokeWidth={object.strokeWidth} strokeLinecap="round" strokeLinejoin="round" />; + } + const text = projectObjectText(object); + return <> + {object.kind === "ellipse" + ? + : object.kind !== "text" && } + {!hideText && text &&
{text.text + "\u200b"}
} + ; +} + +function textPresentation(value: ObjectTextProjection): CSSProperties { + return { color: value.color, fontSize: value.fontSize, fontWeight: value.fontWeight, textAlign: value.textAlign, + fontFamily: "inherit", lineHeight: 1.2, whiteSpace: "pre-wrap", overflowWrap: "anywhere", letterSpacing: "normal" }; +} + +/** The same body box and line wrapping serve display and the native textarea. */ +function CanvasTextBox({ value, children }: { readonly value: ObjectTextProjection; readonly children: ReactNode }) { + return +
+
{children}
+
+
; +} + +export function CanvasObjectTarget(props: { + readonly object: CanvasObject; + readonly selected: boolean; + readonly enabled: boolean; + readonly copying?: boolean; + readonly onSelect: (shiftKey: boolean) => void; + readonly onEdit: () => void; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: { kind: "drag" }, onHandle: props.onHandle }); + const { object } = props; + return ( + { + if (event.nativeEvent.isComposing || event.metaKey || event.ctrlKey || event.altKey) return; + if (event.key === " " || (event.key === "Enter" && !event.shiftKey)) { + event.preventDefault(); event.stopPropagation(); props.onSelect(event.shiftKey); + if (event.key === "Enter") props.onEdit(); + } + }} /> + ); +} + +export function CanvasResizeTarget(props: { + readonly object: CanvasObject; + readonly edge: ResizeEdge; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: { kind: "resize", edge: props.edge }, onHandle: props.onHandle }); + const { object, edge } = props; + const x = object.x + (edge.includes("w") ? 0 : edge.includes("e") ? object.width : object.width / 2); + const y = object.y + (edge.includes("n") ? 0 : edge.includes("s") ? object.height : object.height / 2); + if (edge.length === 1) { + const horizontal = edge === "n" || edge === "s"; + return ; + } + return ; +} + +export function CanvasTextInput(props: { + readonly object: CanvasObject; + readonly text: string; + readonly onChange: (text: string) => void; + readonly onCommit: () => void; + readonly onCancel: () => void; +}) { + const input = useRef(null); + useEffect(() => { input.current?.focus(); input.current?.select(); }, [props.object.id]); + const { object } = props; + const text = projectObjectText(object); + if (!text) return null; + return ( + + + { + event.stopPropagation(); + if (event.nativeEvent.isComposing) return; + if (event.key === "Escape") { event.preventDefault(); props.onCancel(); } + else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); props.onCommit(); } + }} /> + + ); +} diff --git a/packages/json-document-canvas/src/canvas-style-controls.tsx b/packages/json-document-canvas/src/canvas-style-controls.tsx new file mode 100644 index 000000000..00fde91b4 --- /dev/null +++ b/packages/json-document-canvas/src/canvas-style-controls.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { AlignCenter, AlignLeft, AlignRight, Bold, Check, Palette, Square, SquareDashed, type LucideIcon } from "lucide-react"; +import type { ObjectStyle, ObjectStyleSelection } from "@interactive-os/json-document-object-document"; +import { Command, Field, Popover, Toggle, ToolbarGroup } from "@interactive-os/json-document-ui-primitives-react"; + +const colors = [ + ["검정", "#253044"], ["흰색", "#ffffff"], ["파랑", "#3b82f6"], ["빨강", "#ef4444"], + ["주황", "#f59e0b"], ["초록", "#22c55e"], ["보라", "#a855f7"], ["투명", "transparent"], +] as const; +const alignments: ReadonlyArray<{ readonly value: ObjectStyle["textAlign"]; readonly label: string; readonly icon: LucideIcon }> = [ + { value: "left", label: "왼쪽 정렬", icon: AlignLeft }, { value: "center", label: "가운데 정렬", icon: AlignCenter }, { value: "right", label: "오른쪽 정렬", icon: AlignRight }, +]; + +/** Selection-only UI; the Document Type owns supported properties and mixed values. */ +export function CanvasStyleControls(props: { + readonly value: ObjectStyleSelection; + readonly onStyle: (style: Partial) => { readonly ok: boolean }; + readonly onOpen: () => void; +}) { + const [open, setOpen] = useState(false); + const value = props.value; + if (Object.keys(value).length === 0) return null; + const apply = (style: Partial) => props.onStyle(style).ok; + return ; +} + +function StyleValue(props: { + readonly label: string; + readonly value: string | number | null; + readonly color?: boolean; + readonly onApply: (value: string) => boolean; +}) { + const [draft, setDraft] = useState(props.value === null ? "" : String(props.value)); + const [error, setError] = useState(null); + function apply() { + const value = draft.trim(); + if (!value || (props.color && !CSS.supports("color", value))) { setError(props.color ? "유효한 색상을 입력하세요." : "값을 입력하세요."); return; } + if (props.onApply(value)) setError(null); + } + return
+ {props.label} + {props.color && + {colors.map(([label, color]) => { props.onApply(color); setDraft(color); setError(null); }}> + {color === "transparent" ? )} + } +
{ event.preventDefault(); apply(); }}> + { if (event.nativeEvent.isComposing) { if (event.key === "Enter") event.preventDefault(); event.stopPropagation(); } }} + aria-invalid={error !== null} onValueChange={(value) => { setDraft(value); setError(null); }} style={{ width: "100%", minWidth: 0 }} /> + + + {error && {error}} +
; +} diff --git a/packages/json-document-canvas/src/index.ts b/packages/json-document-canvas/src/index.ts new file mode 100644 index 000000000..4a7e72cf2 --- /dev/null +++ b/packages/json-document-canvas/src/index.ts @@ -0,0 +1,3 @@ +export { CanvasHand, type CanvasHandProps } from "./canvas-hand.js"; +export { createCanvasClipboardBinding, type CanvasClipboardPolicy } from "./canvas-clipboard.js"; +export { useCanvasHand, type CanvasTool, type CanvasCreationStyle } from "./use-canvas-hand.js"; diff --git a/packages/json-document-canvas/src/use-canvas-hand.ts b/packages/json-document-canvas/src/use-canvas-hand.ts new file mode 100644 index 000000000..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-collaboration/benchmarks/runtime.mjs b/packages/json-document-collaboration/benchmarks/runtime.mjs index bd4fabbab..e73a30647 100644 --- a/packages/json-document-collaboration/benchmarks/runtime.mjs +++ b/packages/json-document-collaboration/benchmarks/runtime.mjs @@ -11,6 +11,7 @@ console.log("json-document collaboration benchmark"); console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${config.warmups}`); const ingestRows = []; +const objectRows = []; for (const size of config.sizes) { const initial = { items: Array.from({ length: size }, (_, index) => ({ id: `item-${index}`, done: false })) }; const author = createCollaborationRuntime(initial, { ...runtimeOptions, actorId: "author" }); @@ -29,6 +30,16 @@ for (const size of config.sizes) { }); ingestRows.push({ size, ...ingest }); + const wide = Object.fromEntries(Array.from({ length: size }, (_, index) => [`field${index}`, index])); + const objectAuthor = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "author" }); + objectAuthor.document.commit([{ op: "replace", path: `/field${middle}`, value: -1 }]); + const objectBundle = objectAuthor.replica.exportBundle(); + const objectIngest = measure(config, "remote wide object leaf ingest", () => { + const receiver = createCollaborationRuntime(wide, { ...runtimeOptions, actorId: "receiver" }); + return () => receiver.replica.ingest(objectBundle).ok && receiver.document.value[`field${middle}`] === -1; + }); + objectRows.push({ size, ...objectIngest }); + measure(config, "export one-change bundle", () => () => ( author.replica.exportBundle().changes.length === 1 )); @@ -36,6 +47,8 @@ for (const size of config.sizes) { console.log("\nremote leaf ingest"); reportScaling(ingestRows); +console.log("\nremote wide object leaf ingest"); +reportScaling(objectRows); const ledgerSizes = (process.env.PERF_COLLABORATION_CHANGES ?? "100,1000,10000") .split(",") diff --git a/packages/json-document-collaboration/src/checkpoint.ts b/packages/json-document-collaboration/src/checkpoint.ts index 58c399592..de1edb6a8 100644 --- a/packages/json-document-collaboration/src/checkpoint.ts +++ b/packages/json-document-collaboration/src/checkpoint.ts @@ -68,22 +68,26 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { rawPayload.reason ?? "checkpoint payload must contain only JSON values", ); } + const ownedPayload = rawPayload.value as Readonly>; if ( - input.payload.kind !== "json-document-collaboration/checkpoint" - || input.payload.version !== 1 + ownedPayload.kind !== "json-document-collaboration/checkpoint" + || ownedPayload.version !== 1 ) { return invalid("checkpoint payload kind or version is unsupported"); } - const base = applyPatch(input.payload.base, []); - if (!base.ok) { - return invalid(base.reason ?? "checkpoint base must be JSON"); + const base = ownedPayload.base; + // Only missing fields still need Core's non-JSON diagnostic; present fields + // were already validated and detached with the complete payload. + if (base === undefined || ownedPayload.membership === undefined) { + const missing = applyPatch(undefined, []); + if (!missing.ok) return invalid(missing.reason!); } - const membership = prepareMembership(input.payload.membership); + const membership = prepareMembership(ownedPayload.membership!); if (!membership.ok) return membership; const bundle = prepareBundle({ - epoch: input.payload.epoch, - changes: input.payload.changes, + epoch: ownedPayload.epoch, + changes: ownedPayload.changes, }); if (!bundle.ok) return bundle; for (let index = 1; index < bundle.bundle.changes.length; index += 1) { @@ -99,7 +103,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { ); } } - if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base.value)) { + if (bundle.bundle.epoch.baseDigest !== fingerprintJSON(base!)) { return invalid("checkpoint base does not match epoch baseDigest"); } if ( @@ -115,7 +119,7 @@ export function prepareCheckpoint(input: unknown): PreparedCheckpoint { kind: "json-document-collaboration/checkpoint" as const, version: 1 as const, epoch: bundle.bundle.epoch, - base: base.value, + base: base!, membership: membership.membership, changes: bundle.bundle.changes, }); @@ -202,7 +206,7 @@ export function verifyCheckpointProof( } function prepareMembership( - input: unknown, + input: JSONValue, ): | { readonly ok: true; @@ -210,25 +214,19 @@ function prepareMembership( } | { readonly ok: false; readonly reason: string } { if (input === null) return { ok: true, membership: null }; - const validated = applyPatch(input, []); - if (!validated.ok) { - return invalid( - validated.reason ?? "checkpoint membership must contain only JSON values", - ); - } if ( - !isRecord(validated.value) - || validated.value.version !== 1 - || !Array.isArray(validated.value.members) + !isRecord(input) + || input.version !== 1 + || !Array.isArray(input.members) ) { return invalid("checkpoint membership must be null or a version 1 list"); } try { const membership = canonicalMembership( - validated.value as unknown as CollaborationMembership, + input as unknown as CollaborationMembership, ); if ( - canonicalStringify(validated.value) + canonicalStringify(input) !== canonicalStringify(membership as unknown as JSONValue) ) { return invalid("checkpoint membership must be canonical"); diff --git a/packages/json-document-collaboration/src/document-patch.ts b/packages/json-document-collaboration/src/document-patch.ts index 70e27301c..c0bb07b7a 100644 --- a/packages/json-document-collaboration/src/document-patch.ts +++ b/packages/json-document-collaboration/src/document-patch.ts @@ -8,6 +8,7 @@ interface VisibleMember { parent?: VisibleMember; key: string; children: VisibleMember[]; + readonly childrenByKey: Map | undefined; } /** Compile the visible tree transition, retaining member identity in RFC 6902 moves. */ @@ -59,8 +60,8 @@ export function patchBetweenTrees( if (staging === undefined) { let key = "__json_document_transfer__"; if (Array.isArray(root.value)) key = String(root.children.length); - else while ([...root.children, ...target.children].some((child) => child.key === key)) key += "_"; - staging = { id: "", value: [], container: "", key, children: [] }; + else while (root.childrenByKey?.has(key) || target.childrenByKey?.has(key)) key += "_"; + staging = { id: "", value: [], container: "", key, children: [], childrenByKey: undefined }; insert(staging, root, key); operations.push({ op: "add", path: pointer(staging), value: [] }); } @@ -74,12 +75,13 @@ export function patchBetweenTrees( for (const child of [...node.children]) vacate(child); const value = wanted.container === undefined ? wanted.value : Array.isArray(wanted.value) ? [] : {}; operations.push({ op: "replace", path: pointer(node), value }); - const replacement: VisibleMember = { ...wanted, children: [], key: node.key }; + const replacement: VisibleMember = { ...wanted, children: [], childrenByKey: wanted.childrenByKey && new Map(), key: node.key }; if (node.parent !== undefined) { const parent = node.parent; const index = parent.children.indexOf(node); replacement.parent = parent; parent.children[index] = replacement; + parent.childrenByKey?.set(replacement.key, replacement); } current.set(wanted.id, replacement); node = replacement; @@ -91,11 +93,11 @@ export function patchBetweenTrees( if (existing !== undefined && !attached(existing, root)) existing = undefined; const occupant = Array.isArray(node.value) ? node.children[index] - : node.children.find((entry) => entry.key === key); + : node.childrenByKey?.get(key); if (!Array.isArray(node.value) && occupant !== undefined && occupant !== existing) vacate(occupant); if (existing === undefined) { const value = child.container === undefined ? child.value : Array.isArray(child.value) ? [] : {}; - existing = { ...child, value, children: [] }; + existing = { ...child, value, children: [], childrenByKey: child.childrenByKey && new Map() }; insert(existing, node, key); current.set(child.id, existing); operations.push({ op: "add", path: pointer(existing), value }); @@ -123,6 +125,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me container: reference.kind === "container" ? reference.containerId : undefined, key, children: [], + childrenByKey: value !== null && typeof value === "object" && !Array.isArray(value) ? new Map() : undefined, }; members.set(node.id, node); if (value !== null && typeof value === "object") { @@ -131,6 +134,7 @@ function snapshot(tree: TreeState, id: string, value: JSONValue, key: string, me const member = snapshot(tree, childId, child, key, members); member.parent = node; node.children.push(member); + node.childrenByKey?.set(key, member); } } return node; @@ -155,6 +159,7 @@ function pointer(node: VisibleMember): string { return buildPointer(segments(nod function detach(node: VisibleMember): void { if (node.parent === undefined) throw new Error("cannot detach the document root"); node.parent.children.splice(node.parent.children.indexOf(node), 1); + node.parent.childrenByKey?.delete(node.key); delete node.parent; } @@ -162,5 +167,8 @@ function insert(node: VisibleMember, parent: VisibleMember, key: string): void { node.parent = parent; node.key = key; if (Array.isArray(parent.value)) parent.children.splice(Number(key), 0, node); - else parent.children.push(node); + else { + parent.children.push(node); + parent.childrenByKey!.set(key, node); + } } diff --git a/packages/json-document-collaboration/tests/unit/checkpoint.test.ts b/packages/json-document-collaboration/tests/unit/checkpoint.test.ts index 7d605c07f..93d2203ba 100644 --- a/packages/json-document-collaboration/tests/unit/checkpoint.test.ts +++ b/packages/json-document-collaboration/tests/unit/checkpoint.test.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; import { describe, expect, test, vi } from "vitest"; +import * as core from "@interactive-os/json-document"; +import { prepareCheckpoint } from "../../src/checkpoint.js"; import { compactCollaborationCheckpoint, @@ -108,6 +110,37 @@ function canonicalJSON(value: unknown): string { } describe("@interactive-os/json-document-collaboration checkpoints", () => { + test("owns the raw payload once and reuses its base and membership", () => { + const source = createCollaborationRuntime( + { rows: Array.from({ length: 1_000 }, (_, id) => ({ id })) }, + options("author", "ownership/v1", membership("author")), + ); + const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint())); + const applyPatch = vi.spyOn(core, "applyPatch"); + try { + const prepared = prepareCheckpoint(input); + expect(prepared.ok).toBe(true); + expect(applyPatch.mock.calls.filter(([value]) => value === input.payload)).toHaveLength(1); + expect(applyPatch.mock.calls.filter(([value]) => value === input.payload.base || value === input.payload.membership)).toHaveLength(0); + input.payload.base.rows[0].id = -1; + input.payload.membership.members[0].actorId = "poison"; + if (!prepared.ok) throw new Error(prepared.reason); + expect(core.readPointer(prepared.checkpoint.payload.base, "/rows/0/id")).toMatchObject({ value: 0 }); + expect(prepared.checkpoint.payload.membership).toEqual(membership("author")); + expect(Object.isFrozen(prepared.checkpoint.payload.base)).toBe(true); + } finally { applyPatch.mockRestore(); } + }); + + test.each(["base", "membership"])("preserves the missing %s JSON diagnostic and validation order", (field) => { + const source = createCollaborationRuntime(null, options("author", "missing/v1", undefined)); + const input = JSON.parse(JSON.stringify(source.replica.exportCheckpoint())); + delete input.payload[field]; + const missing = core.applyPatch(undefined, []); + expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: !missing.ok && missing.reason }); + input.payload.version = 2; + expect(prepareCheckpoint(input)).toEqual({ ok: false, reason: "checkpoint payload kind or version is unsupported" }); + }); + test("round-trips the complete same-epoch causal state", () => { const members = membership("actor-a", "actor-b"); const source = createCollaborationRuntime( diff --git a/packages/json-document-collaboration/tests/unit/document-tracking.test.ts b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts index fea4862b3..9e1329020 100644 --- a/packages/json-document-collaboration/tests/unit/document-tracking.test.ts +++ b/packages/json-document-collaboration/tests/unit/document-tracking.test.ts @@ -1,10 +1,28 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { trackPointer, type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; import { createCollaborationRuntime } from "../../src/index.js"; +import { createInitialTree } from "../../src/tree.js"; +import { patchBetweenTrees } from "../../src/document-patch.js"; const options = { epochId: "tracking/v1", ruleset: { id: "tracking", digest: "v1" } }; describe("remote structural notification", () => { + test("looks up a wide object's keys without rescanning siblings for each key", () => { + const before = Object.fromEntries(Array.from({ length: 5_000 }, (_, index) => [`field${index}`, index])); + const after = { ...before, field2500: -1 }; + const beforeTree = createInitialTree(before, "wide"); + const afterTree = createInitialTree(after, "wide"); + const find = vi.spyOn(Array.prototype, "find"); + try { + const operations = patchBetweenTrees(before, after, beforeTree, afterTree); + const siblingSearches = find.mock.contexts.filter((value) => ( + Array.isArray(value) && value.length > 100 && value[0] && "key" in value[0] + )); + expect(siblingSearches).toHaveLength(0); + expect(operations).toEqual([{ op: "replace", path: "/field2500", value: -1 }]); + } finally { find.mockRestore(); } + }); + test.each([ { name: "move then edit", initial: { items: [{ label: "a" }, { label: "b" }] }, pointer: "/items/0/label", patch: [{ op: "move", from: "/items/0", path: "/items/1" }, { op: "replace", path: "/items/1/label", value: "edited" }], expected: "/items/1/label" }, { name: "escaped object member", initial: { "a/b": { label: "a" } }, pointer: "/a~1b/label", patch: [{ op: "move", from: "/a~1b", path: "/~0" }], expected: "/~0/label" }, @@ -18,6 +36,7 @@ describe("remote structural notification", () => { { name: "object swap", initial: { a: { label: "a" }, b: { label: "b" } }, pointer: "/a/label", patch: [{ op: "move", from: "/a", path: "/temp" }, { op: "move", from: "/b", path: "/a" }, { op: "move", from: "/temp", path: "/b" }], expected: "/b/label" }, { name: "move out before replacing its parent", initial: { left: { item: { label: "a" } }, right: {} }, pointer: "/left/item/label", patch: [{ op: "move", from: "/left/item", path: "/right/item" }, { op: "replace", path: "/left", value: {} }], expected: "/right/item/label" }, { name: "root array reorder", initial: [{ label: "a" }, { label: "b" }], pointer: "/0/label", patch: [{ op: "move", from: "/0", path: "/1" }], expected: "/1/label" }, + { name: "successive key swaps and a replaced container", initial: { a: { item: { label: "a" } }, b: { label: "b" }, __json_document_transfer__: 1, __json_document_transfer___: 2 }, pointer: "/a/item/label", patch: [{ op: "move", from: "/a/item", path: "/temp" }, { op: "replace", path: "/a", value: {} }, { op: "move", from: "/b", path: "/a/new" }, { op: "move", from: "/temp", path: "/b" }, { op: "replace", path: "/a/new/label", value: "edited" }], expected: "/b/label" }, ] as const)("$name preserves the address of the same member", ({ initial, pointer, patch, expected }) => { const local = createCollaborationRuntime(initial, { ...options, actorId: "local" }); const remote = createCollaborationRuntime(initial, { ...options, actorId: "remote" }); diff --git a/packages/json-document-composer-react/README.md b/packages/json-document-composer-react/README.md index cbb26934d..eabb3b005 100644 --- a/packages/json-document-composer-react/README.md +++ b/packages/json-document-composer-react/README.md @@ -20,3 +20,52 @@ composed from the canonical suggestion packages. Product copy, styling, layout, suggestions, and concrete ports remain Host-owned. Draft/editor subscription, suggestion integration, keyboard/history execution, Web file intake, focus recovery, and submit lifecycle remain canonical across Host replacements. + +## 이미지 준비와 편집 + +`useComposer`의 `addWebFiles`, `handlePaste`, `handleFileInputChange`는 같은 파일 입력 경로를 +사용합니다. Web `readWebRasterFiles`가 PNG/JPEG/WebP를 실제로 decode하고, +Editing `createEditingPreparationQueue`가 준비 완료와 무관하게 요청 순서를 유지합니다. +일반 파일은 기존 metadata-only 첨부로 남으며 `image`가 있는 첨부만 실제 내용을 보존합니다. + +| API | 계약 | +| --- | --- | +| `isPreparingAttachments` | 아직 완료하지 않은 첨부 준비가 있음 | +| `attachmentError` | 가장 최근 실패의 `code`와 선택적 `reason`, 성공/새 입력/취소 시 초기화 | +| `cancelAttachments()` | 대기 batch를 취소하고 실제 파일 읽기를 중단; 늦은 결과는 반영하지 않음 | +| `canSubmit` | 내용이 있고 대기 중인 첨부가 없음 | +| `submit()` | 최신 draft를 사용하며, 준비 중에는 호출해도 submit port를 실행하지 않음 | +| `maxImagePixels?` | 이미지당 decode 후 픽셀 제한, 기본 16,000,000 | +| `readRaster?` | Web reader의 대체 instance 주입. 테스트·환경 연결용이며 문서 모델을 바꾸지 않음 | + +파일 수·byte 제한과 허용 media type은 기존 `config.attachments`가 결정합니다. +정책은 읽기 전에 검사하고, 실제 추가 직전 최신 첨부 수와 정책으로 다시 검사합니다. +실패한 batch는 첨부와 History를 일부만 남기지 않습니다. 추가는 최신 첨부 목록의 끝에서 +이루어지므로 준비 중 typing/caret 이동은 유지하며 현재 text selection을 덮어쓰지 않습니다. +늦은 완료가 focus를 빼앗지 않습니다. 파일 선택 창을 닫을 때만 editor focus를 복구합니다. + +Escape, `cancelAttachments`, `handleHistoryKeyDown`의 Undo/Redo, unmount는 준비를 취소합니다. +직접 `editor.undo/redo` 또는 외부 문서 교체를 수행하는 소비자는 먼저 `cancelAttachments()`를 +호출합니다. queue 자체는 문서 History나 text 삽입 위치 mapping을 대신하지 않습니다. +React Host는 `handlePaste`와 `handleHistoryKeyDown`을 감싸는 surface의 capture handler에, +`handleKeyDown`은 editor에 연결하고 상태/실패를 표시합니다. + +내부 Rich Text 구조화 MIME은 기존 binding에 먼저 위임합니다. 나머지는 파일 → 이미지가 +포함된 HTML 순으로 Web에서 동기 캡처하고 한 번만 처리합니다. 이미지가 없는 텍스트/HTML은 +기존 Rich Text 경로에 남깁니다. 이미지-only HTML은 `readWebHTMLClipboard`로 포함된 +PNG/JPEG/WebP data URL을 읽고 기존 첨부와 같은 queue·정책·취소·History를 사용합니다. +외부·상대·blob·cid source는 가져오지 않고 `raster.source-unsupported`로 실패합니다. + +글+이미지 HTML은 inline 위치를 표현할 모델이 아직 없으므로 +`composer.clipboard.mixed-unsupported`로 전체를 거절하고 draft/History를 유지합니다. +파일과 HTML 양쪽의 내용을 별개로 추가하거나 대응을 추측하지 않습니다. native 파일과 +HTML의 혼합 의미 보존 및 inline 이미지 profile은 TBD입니다. + +Usage와 Source: [Composer](/demo/composer). PNG/JPEG/WebP의 내용·미리보기·삭제·Undo/Redo와 +submit payload를 확인할 수 있습니다. 서버 upload와 OS-native Clipboard 호환성 완료를 +주장하지 않습니다. + +`useComposer` preserves Alt when translating key events to Composer interaction +meaning. Its history capture handler consumes only the default Undo/Redo chord; +Alt-modified variants leave the event and canonical history untouched. Submit +and command-menu handling retain the configured Composer policy. diff --git a/packages/json-document-composer-react/package.json b/packages/json-document-composer-react/package.json index dc60a801a..636570850 100644 --- a/packages/json-document-composer-react/package.json +++ b/packages/json-document-composer-react/package.json @@ -22,6 +22,8 @@ "peerDependencies": { "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-composer": "^0.1.0-rc.0", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention-react": "^0.1.0-rc.0", @@ -34,6 +36,8 @@ "devDependencies": { "@interactive-os/json-document": "*", "@interactive-os/json-document-composer": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-mention": "*", "@interactive-os/json-document-rich-text-mention-react": "*", diff --git a/packages/json-document-composer-react/src/attachments.ts b/packages/json-document-composer-react/src/attachments.ts new file mode 100644 index 000000000..95b809866 --- /dev/null +++ b/packages/json-document-composer-react/src/attachments.ts @@ -0,0 +1,72 @@ +import type { JSONDocument } from "@interactive-os/json-document"; +import { addComposerAttachments, createComposerAttachments, type ComposerAttachmentCandidate, type ComposerAttachmentPolicy, type ComposerDraft, type ComposerDraftCommandResult } from "@interactive-os/json-document-composer"; +import { createEditingPreparationQueue, type EditingPreparation, type EditingPreparationFailure } from "@interactive-os/json-document-editing"; +import { validateFileCandidates } from "@interactive-os/json-document-file-intake"; +import type { RichTextEditor } from "@interactive-os/json-document-rich-text"; +import { fileCandidatesFromWebFiles, readWebHTMLClipboard, readWebRasterFiles, type readWebRasterFile, type WebFileCandidate, type WebFileCandidateList, type WebHTMLClipboardContent } from "@interactive-os/json-document-web"; +import { useEffect, useRef, useState } from "react"; + +/** Composer appends attachments, so typing and caret changes do not invalidate preparation. */ +export function useComposerAttachments(document: JSONDocument, editor: RichTextEditor, options: { + readonly policy: ComposerAttachmentPolicy; + readonly createId: () => string; + readonly maxImagePixels?: number; + readonly readRaster?: typeof readWebRasterFile; +}) { + const current = useRef(options); + current.current = options; + const [isPending, setPending] = useState(false); + const [error, setError] = useState(null); + const [queue] = useState(() => createEditingPreparationQueue, ComposerDraftCommandResult>({ + apply(candidates) { + const draft = document.value as ComposerDraft; + const created = createComposerAttachments(candidates, { createId: current.current.createId, policy: current.current.policy, currentCount: draft.attachments.length }); + return created.ok ? addComposerAttachments(editor, draft, created.attachments) : created; + }, + onPendingChange: setPending, + onResult: (result) => { setError(result.ok ? null : result); }, + })); + useEffect(() => () => queue.cancel(), [queue]); + + function enqueue(prepare: (signal: AbortSignal) => EditingPreparation> | Promise>>) { + setError(null); + const controller = new AbortController(); + void queue.enqueue(() => prepare(controller.signal), () => controller.abort()); + } + + function addFiles(input: WebFileCandidateList | ReadonlyArray) { + // Snapshot File references while a paste/drop event still owns them. + const files = Array.from(input as ArrayLike); + if (files.length === 0) return; + const policy = current.current.policy; + const maxImagePixels = current.current.maxImagePixels ?? 16_000_000; + const readRaster = current.current.readRaster; + enqueue((signal) => { + const candidates = fileCandidatesFromWebFiles(files); + const accepted = validateFileCandidates(candidates, policy, { currentCount: (document.value as ComposerDraft).attachments.length }); + if (!accepted.ok) return accepted; + const images = files.filter((file) => file.type.startsWith("image/")); + if (images.length === 0) return { ok: true, value: candidates }; + return readWebRasterFiles(images, { policy, maxImagePixels, signal, ...(readRaster ? { readRaster } : {}) }).then((prepared): EditingPreparation> => { + if (!prepared.ok) return prepared; + let imageIndex = 0; + return { ok: true, value: candidates.map((candidate) => candidate.mediaType?.startsWith("image/") + ? { ...candidate, image: prepared.files[imageIndex++]!.image } + : candidate) }; + }); + }); + } + + function addHTML(content: WebHTMLClipboardContent) { + const { policy, maxImagePixels = 16_000_000, readRaster } = current.current; + enqueue((signal) => { + if (content.parts.some((part) => part.type === "text" && part.text.trim())) return { ok: false, code: "composer.clipboard.mixed-unsupported" }; + return readWebHTMLClipboard(content, { policy, maxImagePixels, signal, currentCount: (document.value as ComposerDraft).attachments.length, ...(readRaster ? { readRaster } : {}) }) + .then((prepared): EditingPreparation> => prepared.ok + ? { ok: true, value: prepared.parts.flatMap((part) => part.type === "image" ? [{ ...part.candidate, image: part.image }] : []) } + : prepared); + }); + } + + return { isPending, error, addFiles, addHTML, reportError: setError, cancel: () => { queue.cancel(); setError(null); }, hasPending: () => queue.isPending }; +} diff --git a/packages/json-document-composer-react/src/use-composer.tsx b/packages/json-document-composer-react/src/use-composer.tsx index 733a3fa1e..4b789e661 100644 --- a/packages/json-document-composer-react/src/use-composer.tsx +++ b/packages/json-document-composer-react/src/use-composer.tsx @@ -1,9 +1,7 @@ import { createJSONDocument, type JSONDocument } from "@interactive-os/json-document"; import { - addComposerAttachments, composerInteractionFromKeyStroke, composerSchema, - createComposerAttachments, createComposerDraft, hasComposerContent, insertComposerText, @@ -14,18 +12,22 @@ import { type ComposerHostPorts, type ComposerHostSuggestion, } from "@interactive-os/json-document-composer"; -import { createRichTextEditor, type RichTextEditor, type RichTextNode } from "@interactive-os/json-document-rich-text"; +import { createRichTextEditor, RICH_TEXT_CLIPBOARD_MIME, type RichTextEditor, type RichTextNode } from "@interactive-os/json-document-rich-text"; import type { RichTextSuggestionCandidate } from "@interactive-os/json-document-rich-text-suggestion"; import type { RichTextSuggestionBinding } from "@interactive-os/json-document-rich-text-suggestion-react"; -import { fileCandidatesFromWebClipboard, fileCandidatesFromWebFiles, type WebFileCandidate, type WebFileCandidateList } from "@interactive-os/json-document-web"; +import { captureWebClipboardPaste, type readWebRasterFile, type WebFileCandidate, type WebFileCandidateList } from "@interactive-os/json-document-web"; +import type { EditingPreparationFailure } from "@interactive-os/json-document-editing"; import { useCallback, useRef, useState, useSyncExternalStore, type ChangeEvent, type ClipboardEvent, type KeyboardEvent, type ReactNode } from "react"; import { ComposerReferenceAtom, type ComposerReferenceAtomProps } from "./reference-atom.js"; import { useComposerCommandMenu } from "./command-menu.js"; +import { useComposerAttachments } from "./attachments.js"; export interface UseComposerOptions { readonly id: string; readonly config: ComposerHostConfig & { readonly suggestions: ReadonlyArray }; readonly ports: ComposerHostPorts; + readonly maxImagePixels?: number; + readonly readRaster?: typeof readWebRasterFile; readonly labels: { readonly mentionSuggestions: string; readonly skillSuggestions: string; @@ -41,6 +43,10 @@ export interface ComposerBinding["attachments"]; readonly model: Model; readonly hasContent: boolean; + readonly isPreparingAttachments: boolean; + readonly attachmentError: EditingPreparationFailure | null; + readonly canSubmit: boolean; + cancelAttachments(): void; readonly commandKind: "mention" | "skill" | null; readonly commandMenu: RichTextSuggestionBinding; readonly commandOpen: boolean; @@ -77,38 +83,42 @@ export function useComposer(null); const commandMenu = useComposerCommandMenu({ id: options.id, editor, document: draft.instruction, suggestions: config.suggestions, createId: ports.createId, labels: options.labels }); const content = hasComposerContent(draft); + const intake = useComposerAttachments(document, editor, { + policy: config.attachments, createId: ports.createId, + ...(options.maxImagePixels === undefined ? {} : { maxImagePixels: options.maxImagePixels }), + ...(options.readRaster ? { readRaster: options.readRaster } : {}), + }); function submit() { - if (content) void ports.submit(draft); + const current = document.value as ComposerDraft; + if (!intake.hasPending() && hasComposerContent(current)) void ports.submit(current); } - function addCandidates(candidates: ReturnType) { - if (candidates.length === 0) return; - const created = createComposerAttachments(candidates, { createId: ports.createId, policy: config.attachments, currentCount: draft.attachments.length }); - if (!created.ok) return; - addComposerAttachments(editor, draft, created.attachments); - editorElementRef.current?.focus(); - } - - function addWebFiles(files: Parameters[0]) { - addCandidates(fileCandidatesFromWebFiles(files)); - } + const addWebFiles = intake.addFiles; function handleFileInputChange(event: ChangeEvent) { addWebFiles(event.currentTarget.files ?? []); event.currentTarget.value = ""; + editorElementRef.current?.focus(); } function handlePaste(event: ClipboardEvent) { - const candidates = fileCandidatesFromWebClipboard(event); - if (candidates.length === 0) return; - event.preventDefault(); - event.stopPropagation(); - addCandidates(candidates); + if (event.defaultPrevented) return; + const captured = captureWebClipboardPaste(event, { files: true, html: "images", delegatedMimeTypes: [RICH_TEXT_CLIPBOARD_MIME] }); + if (captured.ok && captured.type === "files") { + event.stopPropagation(); + addWebFiles(captured.files); + } else if (captured.ok && captured.type === "html") { + event.stopPropagation(); + intake.addHTML(captured.content); + } else if (!captured.ok && captured.code !== "clipboard.empty") intake.reportError(captured); } function handleKeyDown(event: KeyboardEvent) { - const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey }, config.interaction); + if (event.key === "Escape" && intake.hasPending() && !event.nativeEvent.isComposing) { + event.preventDefault(); event.stopPropagation(); intake.cancel(); return; + } + const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey, altKey: event.altKey }, config.interaction); if (commandMenu.open) { commandMenu.handleKeyDown(event); if (event.defaultPrevented) { @@ -123,11 +133,12 @@ export function useComposer) { - const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey }, config.interaction); + const interaction = composerInteractionFromKeyStroke({ key: event.key, shiftKey: event.shiftKey, commandKey: event.metaKey || event.ctrlKey, altKey: event.altKey }, config.interaction); if (interaction !== "history.undo" && interaction !== "history.redo") return; event.preventDefault(); event.stopPropagation(); event.nativeEvent.stopImmediatePropagation(); + intake.cancel(); if (interaction === "history.redo") editor.redo(); else editor.undo(); } @@ -145,6 +156,10 @@ export function useComposer fileInputRef.current?.click(), - removeAttachment: (attachmentId) => { removeComposerAttachment(editor, draft, attachmentId); }, + removeAttachment: (attachmentId) => { removeComposerAttachment(editor, document.value as ComposerDraft, attachmentId); }, selectModel: (model) => { selectComposerModel(editor, model); }, insertText: (text) => { insertComposerText(editor, text); }, chooseTrigger: (value) => { editorElementRef.current?.focus(); insertComposerText(editor, value); }, diff --git a/packages/json-document-composer-react/tests/composer-attachments.test.tsx b/packages/json-document-composer-react/tests/composer-attachments.test.tsx new file mode 100644 index 000000000..7b6128426 --- /dev/null +++ b/packages/json-document-composer-react/tests/composer-attachments.test.tsx @@ -0,0 +1,175 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { COMPOSER_HOST_PROFILE_V1, composerText, type ComposerHostConfig } from "@interactive-os/json-document-composer"; +import type { readWebRasterFile, WebRasterSourceResult } from "@interactive-os/json-document-web"; +import { useComposer } from "../src/index.js"; +import type { ClipboardEvent, KeyboardEvent } from "react"; +import { RICH_TEXT_CLIPBOARD_MIME } from "@interactive-os/json-document-rich-text"; + +afterEach(cleanup); +const file = { name: "image.png", size: 3, type: "image/png" }; +const image = { ok: true as const, dataURL: "data:image/png;base64,AQID", width: 100, height: 50 }; +const config: ComposerHostConfig<"fast"> = { + profile: COMPOSER_HOST_PROFILE_V1, models: [{ id: "fast", value: "fast", label: "Fast", description: "Model" }], suggestions: [], + attachments: { acceptedMediaTypes: ["*/*"], maxFiles: 4, maxBytesPerFile: 100 }, interaction: { submit: "enter", newline: "shift-enter" }, +}; +function waiting() { + let resolve!: (result: WebRasterSourceResult) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function setup(readRaster: typeof readWebRasterFile = vi.fn(async () => image), policy = config.attachments) { + let id = 0; + const submit = vi.fn(async () => undefined); + const hook = renderHook(() => useComposer({ id: "composer", config: { ...config, attachments: policy }, ports: { createId: () => `id-${++id}`, submit }, labels: { mentionSuggestions: "Mentions", skillSuggestions: "Skills" }, readRaster })); + return { ...hook, submit, readRaster }; +} +function key(key: string, metaKey = false) { + return { key, metaKey, preventDefault: vi.fn(), stopPropagation: vi.fn(), nativeEvent: { isComposing: false, stopImmediatePropagation: vi.fn() } } as unknown as KeyboardEvent; +} + +function htmlEvent(html: string) { + const preventDefault = vi.fn(), stopPropagation = vi.fn(); + return { clipboardData: { files: [], types: ["text/html", "text/plain"], getData: (type: string) => type === "text/html" ? html : "Fallback", setData() {} }, preventDefault, stopPropagation } as unknown as ClipboardEvent; +} + +test("PI-CONTENT: decoded images survive draft serialization, submit, removal, and Undo/Redo", async () => { + const { result, submit } = setup(); + await act(async () => { result.current.addWebFiles([file]); }); + const attachment = result.current.attachments[0]!; + expect(attachment).toMatchObject({ id: "id-4", name: file.name, image: { source: image.dataURL, width: 100, height: 50 } }); + expect(JSON.parse(JSON.stringify(result.current.draft)).attachments[0]).toEqual(attachment); + act(() => { result.current.submit(); }); expect(submit).toHaveBeenCalledWith(result.current.draft); + act(() => { result.current.removeAttachment(attachment.id); }); expect(result.current.attachments).toEqual([]); + act(() => { result.current.editor.undo(); }); expect(result.current.attachments).toEqual([attachment]); + act(() => { result.current.editor.undo(); }); expect(result.current.attachments).toEqual([]); + act(() => { result.current.editor.redo(); }); expect(result.current.attachments).toEqual([attachment]); +}); + +test("PI-TYPING/PI-ORDER: typing and caret movement continue; requests append in input order with separate Undo", async () => { + const first = waiting(), second = waiting(); + const reader = vi.fn().mockImplementationOnce(() => first.promise).mockImplementationOnce(() => second.promise); + const { result, submit } = setup(reader); + act(() => { result.current.addWebFiles([file]); result.current.addWebFiles([{ ...file, name: "second.png" }]); result.current.insertText("계속 입력"); }); + expect(result.current.isPreparingAttachments).toBe(true); expect(result.current.canSubmit).toBe(false); + const focus = result.current.editor.snapshot.selection.ranges[0]!.focus; + expect(focus.kind).toBe("text"); + if (focus.kind !== "text") return; + const caret = { ...focus, offset: 0 }; + const selection = { kind: "range" as const, ranges: [{ anchor: caret, focus: caret }], primaryIndex: 0 }; + act(() => { result.current.editor.dispatch({ type: "selection.set", selection }); result.current.submit(); }); + expect(submit).not.toHaveBeenCalled(); + await act(async () => { second.resolve(image); }); expect(result.current.attachments).toEqual([]); + await act(async () => { first.resolve(image); }); + expect(result.current.attachments.map((attachment) => attachment.name)).toEqual([file.name, "second.png"]); + expect(composerText(result.current.draft.instruction)).toBe("계속 입력"); + expect(result.current.editor.snapshot.selection).toEqual(selection); expect(result.current.canSubmit).toBe(true); + act(() => { result.current.handleHistoryKeyDown(key("z", true)); }); expect(result.current.attachments).toHaveLength(1); + act(() => { result.current.handleHistoryKeyDown(key("z", true)); }); expect(result.current.attachments).toHaveLength(0); + expect(composerText(result.current.draft.instruction)).toBe("계속 입력"); +}); + +test.each(["escape", "cancel", "undo", "unmount"])("PI-CANCEL: %s aborts preparation and cannot revive a late image", async (reason) => { + const pending = waiting(); + const reader = vi.fn(() => pending.promise); + const { result, unmount } = setup(reader); + act(() => { result.current.addWebFiles([file]); }); + const document = result.current.document; + const initial = document.value; + act(() => { + if (reason === "escape") result.current.handleKeyDown(key("Escape")); + else if (reason === "cancel") result.current.cancelAttachments(); + else if (reason === "undo") result.current.handleHistoryKeyDown(key("z", true)); + else unmount(); + }); + expect(reader.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(image); }); expect(document.value).toEqual(initial); +}); + +test("PI-FILE: failed image batch leaves neither metadata-only remnants nor History", async () => { + const reader = vi.fn().mockResolvedValueOnce(image).mockResolvedValueOnce({ ok: false, code: "raster.decode-failed" }); + const { result } = setup(reader); + await act(async () => { result.current.addWebFiles([file, file]); }); + expect(result.current.attachments).toEqual([]); expect(result.current.editor.snapshot.canUndo).toBe(false); + expect(result.current.attachmentError?.code).toBe("raster.decode-failed"); expect(result.current.isPreparingAttachments).toBe(false); +}); + +test("queued requests revalidate the latest attachment count before ID allocation", async () => { + const first = waiting(); + const reader = vi.fn().mockImplementationOnce(() => first.promise).mockResolvedValue(image); + const { result } = setup(reader, { ...config.attachments, maxFiles: 1 }); + act(() => { result.current.addWebFiles([file]); result.current.addWebFiles([{ ...file, name: "overflow.png" }]); }); + await act(async () => { first.resolve(image); }); + expect(result.current.attachments).toHaveLength(1); expect(result.current.attachmentError?.code).toBe("composer.attachments.limit"); + act(() => { result.current.removeAttachment(result.current.attachments[0]!.id); }); + await act(async () => { result.current.addWebFiles([file]); }); expect(result.current.attachments[0]!.id).toBe("id-5"); +}); + +test("file-only native paste consumes images once and delegates text/HTML to Rich Text", async () => { + const { result } = setup(); + const preventDefault = vi.fn(), stopPropagation = vi.fn(); + const event = (files: typeof file[], types: string[]) => ({ clipboardData: { files, types, getData: () => "

text

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

HTML image

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

BeforeAfter

`); + await act(async () => { result.current.handlePaste(input); }); + expect(input.preventDefault).toHaveBeenCalledOnce(); expect(input.stopPropagation).toHaveBeenCalledOnce(); + expect(result.current.attachmentError?.code).toBe("composer.clipboard.mixed-unsupported"); + expect(result.current.draft).toEqual(before); expect(result.current.editor.snapshot.revision).toBe(revision); + expect(readRaster).not.toHaveBeenCalled(); +}); + +test("HTML and file inputs use the same queue even when later preparation finishes first", async () => { + const first = waiting(), second = waiting(); + const readRaster = vi.fn().mockImplementationOnce(() => first.promise).mockImplementationOnce(() => second.promise); + const { result } = setup(readRaster); + act(() => { result.current.handlePaste(htmlEvent(`First HTML`)); result.current.addWebFiles([file]); result.current.insertText("Typing"); }); + await act(async () => { second.resolve(image); }); expect(result.current.attachments).toEqual([]); + await act(async () => { first.resolve(image); }); + expect(result.current.attachments.map((attachment) => attachment.name)).toEqual(["First HTML", file.name]); + expect(composerText(result.current.draft.instruction)).toBe("Typing"); +}); + +test("HTML observes the current attachment limit before decoding", async () => { + const { result, readRaster } = setup(); + act(() => { result.current.addWebFiles(Array.from({ length: 4 }, (_, index) => ({ name: `note-${index}`, size: 1, type: "text/plain" }))); }); + await act(async () => { result.current.handlePaste(htmlEvent(``)); }); + expect(result.current.attachmentError?.code).toBe("file-intake.limit"); expect(result.current.attachments).toHaveLength(4); + expect(readRaster).not.toHaveBeenCalled(); +}); + +test("HTML cancellation aborts its reader and ignores later completion", async () => { + const pending = waiting(), readRaster = vi.fn(() => pending.promise); + const { result } = setup(readRaster); + act(() => { result.current.handlePaste(htmlEvent(``)); result.current.cancelAttachments(); }); + expect(readRaster.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + await act(async () => { pending.resolve(image); }); expect(result.current.attachments).toEqual([]); +}); diff --git a/packages/json-document-composer-react/tests/composer-react.test.tsx b/packages/json-document-composer-react/tests/composer-react.test.tsx index cb0c06dab..dadfaad1f 100644 --- a/packages/json-document-composer-react/tests/composer-react.test.tsx +++ b/packages/json-document-composer-react/tests/composer-react.test.tsx @@ -37,6 +37,24 @@ describe("Composer React integration", () => { fireEvent.keyDown(screen.getByTestId("mod-keyboard"), { key: "Enter", ctrlKey: true }); expect(modSubmit).toHaveBeenCalledTimes(1); }); + + test.each(["metaKey", "ctrlKey"] as const)("preserves Alt through the real %s history event path", (modifier) => { + render(); + fireEvent.click(screen.getByRole("button", { name: "history-text" })); + const target = screen.getByTestId("history-keyboard"); + const draft = screen.getByTestId("history-draft"); + const inserted = draft.textContent; + expect(inserted).toContain("hello"); + expect(fireEvent.keyDown(target, { key: "z", [modifier]: true, altKey: true })).toBe(true); + expect(draft.textContent).toBe(inserted); + expect(fireEvent.keyDown(target, { key: "z", [modifier]: true })).toBe(false); + expect(draft.textContent).not.toContain("hello"); + const undone = draft.textContent; + expect(fireEvent.keyDown(target, { key: "Z", [modifier]: true, shiftKey: true, altKey: true })).toBe(true); + expect(draft.textContent).toBe(undone); + expect(fireEvent.keyDown(target, { key: "Z", [modifier]: true, shiftKey: true })).toBe(false); + expect(draft.textContent).toBe(inserted); + }); }); function hostConfig(model: Model, submit: "enter" | "mod-enter"): ComposerHostConfig { @@ -57,7 +75,7 @@ function ComposerHostHarness(props: { readonly host: strin ports: { createId: () => `${props.host}-${++id}`, submit: props.submit }, labels: { mentionSuggestions: `${props.host} mentions`, skillSuggestions: `${props.host} skills` }, }); - return
+ return
{JSON.stringify(composer.document.value)}
; diff --git a/packages/json-document-composer-react/tsconfig.json b/packages/json-document-composer-react/tsconfig.json index 3340baf95..4fd7db963 100644 --- a/packages/json-document-composer-react/tsconfig.json +++ b/packages/json-document-composer-react/tsconfig.json @@ -8,6 +8,8 @@ "references": [ { "path": "../json-document" }, { "path": "../json-document-composer" }, + { "path": "../json-document-editing" }, + { "path": "../json-document-file-intake" }, { "path": "../json-document-rich-text" }, { "path": "../json-document-rich-text-mention" }, { "path": "../json-document-rich-text-mention-react" }, diff --git a/packages/json-document-composer/README.md b/packages/json-document-composer/README.md index 32b8262fd..508b8d4d8 100644 --- a/packages/json-document-composer/README.md +++ b/packages/json-document-composer/README.md @@ -9,3 +9,34 @@ those validated candidates into Composer context attachments. `resolveComposerSuggestions(trigger, suggestions)` owns trigger-aware matching of a product-configured suggestion catalog. React menu lifecycle and atom projection live in `@interactive-os/json-document-composer-react`. + +## 이미지 첨부 + +`ComposerAttachment`와 `ComposerAttachmentCandidate`의 선택적 `image`는 File Intake의 +`RasterImageContent`입니다. 기존 metadata-only 첨부는 그대로 유효합니다. +`createComposerAttachments`는 image source·치수와 media type을 검사한 뒤 ID를 할당하고, +`addComposerAttachments`는 한 batch를 기존 Rich Text editor의 한 편집/Undo로 추가합니다. +이미지 내용은 같은 draft JSON에 남으므로 별도의 임시 blob URL에 의존하지 않습니다. + +```ts +const prepared = createComposerAttachments([ + { name: "screenshot.png", size: fileSize, mediaType: "image/png", image: decodedImage }, +], { policy, createId, currentCount: draft.attachments.length }); +if (prepared.ok) addComposerAttachments(editor, draft, prepared.attachments); +``` + +`image`가 없는 첨부는 파일 이름·크기·형식 정보뿐입니다. 실제 byte 저장이나 서버 업로드가 +완료된 파일이라고 해석하지 않습니다. Clipboard HTML의 글+이미지 변환과 이미지 asset +저장소 연결은 TBD입니다. 실제 Usage·Source는 [Composer](/demo/composer)에 있습니다. + +`composerInteractionFromKeyStroke(stroke, policy)` preserves the existing +`commandKey` input (Meta or Control) and accepts optional `altKey` alongside +`shiftKey`. Omitted modifiers are false. Its keyboard compatibility boundary +uses `@interactive-os/json-document-web`'s pure default resolver for Undo/Redo: +Mod+Z undoes, Mod+Shift+Z redoes, and Alt-modified variants return `null`. +Composer still owns Escape and the configured Enter submit/newline meaning. +The keyboard dependency is confined to `interaction.ts`; draft model, schema, +and commands do not interpret Web events. No DOM environment is required. + +Usage: [Composer](https://developer-1px.github.io/json-document/demo/composer). +The React integration passes all modifier facts to this boundary. diff --git a/packages/json-document-composer/package.json b/packages/json-document-composer/package.json index b0d1612e0..49ec3d430 100644 --- a/packages/json-document-composer/package.json +++ b/packages/json-document-composer/package.json @@ -21,6 +21,7 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document-web": "^0.1.0-rc.0", "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-file-intake": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-mention": "^0.1.0-rc.0", @@ -28,6 +29,7 @@ "@interactive-os/json-document-rich-text": "^0.1.0-rc.0" }, "devDependencies": { + "@interactive-os/json-document-web": "*", "@interactive-os/json-document": "*", "@interactive-os/json-document-file-intake": "*", "@interactive-os/json-document-rich-text-mention": "*", diff --git a/packages/json-document-composer/src/commands.ts b/packages/json-document-composer/src/commands.ts index 8891ead72..76317127e 100644 --- a/packages/json-document-composer/src/commands.ts +++ b/packages/json-document-composer/src/commands.ts @@ -5,7 +5,7 @@ import { type RichTextNode, type RichTextPoint, } from "@interactive-os/json-document-rich-text"; -import { validateFileCandidates } from "@interactive-os/json-document-file-intake"; +import { assertRasterImageContent, validateFileCandidates } from "@interactive-os/json-document-file-intake"; import { insertRichTextMention } from "@interactive-os/json-document-rich-text-mention"; import { COMPOSER_MENTION_NODE, COMPOSER_PROFILE_V1, COMPOSER_SKILL_NODE, type ComposerAttachment, type ComposerAttachmentCandidate, type ComposerDraft, type ComposerReference, type ComposerTrigger } from "./model.js"; import type { ComposerAttachmentPolicy } from "./host-config.js"; @@ -23,7 +23,18 @@ export function createComposerAttachments( ): ComposerAttachmentResult { const validated = validateFileCandidates(candidates, options.policy, options.currentCount === undefined ? {} : { currentCount: options.currentCount }); if (!validated.ok) return { ok: false, code: composerAttachmentError(validated.code), candidate: validated.candidate }; - const attachments: ComposerAttachment[] = validated.candidates.map((candidate) => ({ id: options.createId(), kind: candidate.mediaType?.startsWith("image/") ? "image" : "document", ...candidate })); + for (const candidate of candidates) { + if (candidate.image === undefined) continue; + try { + assertRasterImageContent(candidate.image); + if (!candidate.image.source.startsWith(`data:${candidate.mediaType};base64,`)) throw new TypeError("Attachment media type does not match its image."); + } catch { return { ok: false, code: "composer.attachments.invalid", candidate }; } + } + const attachments: ComposerAttachment[] = validated.candidates.map((candidate) => ({ + ...candidate, + id: options.createId(), kind: candidate.mediaType?.startsWith("image/") ? "image" : "document", + ...(candidate.image ? { image: { source: candidate.image.source, width: candidate.image.width, height: candidate.image.height } } : {}), + })); return { ok: true, attachments }; } diff --git a/packages/json-document-composer/src/interaction.ts b/packages/json-document-composer/src/interaction.ts index d37f422e3..d1eed3e43 100644 --- a/packages/json-document-composer/src/interaction.ts +++ b/packages/json-document-composer/src/interaction.ts @@ -1,19 +1,32 @@ +import { createWebKeyboardAdapter } from "@interactive-os/json-document-web"; import type { ComposerInteractionPolicy } from "./host-config.js"; +const keyboard = createWebKeyboardAdapter(); + export interface ComposerKeyStroke { readonly key: string; readonly shiftKey?: boolean; readonly commandKey?: boolean; + readonly altKey?: boolean; } export type ComposerInteraction = "dismiss" | "history.redo" | "history.undo" | "newline" | "submit"; +/** Uses the Web default history keymap, then applies Composer submit/newline policy. */ export function composerInteractionFromKeyStroke( stroke: ComposerKeyStroke, policy: ComposerInteractionPolicy, ): ComposerInteraction | null { if (stroke.key === "Escape") return "dismiss"; - if (stroke.commandKey && stroke.key.toLowerCase() === "z") return stroke.shiftKey ? "history.redo" : "history.undo"; + const command = keyboard.resolve({ + key: stroke.key, + shiftKey: stroke.shiftKey ?? false, + metaKey: stroke.commandKey ?? false, + ctrlKey: false, + altKey: stroke.altKey ?? false, + }); + if (command?.type === "undo") return "history.undo"; + if (command?.type === "redo") return "history.redo"; if (stroke.key !== "Enter") return null; const submits = policy.submit === "mod-enter" ? stroke.commandKey === true : stroke.commandKey !== true && stroke.shiftKey !== true; if (submits) return "submit"; diff --git a/packages/json-document-composer/src/model.ts b/packages/json-document-composer/src/model.ts index dc0891b74..dedce4cd7 100644 --- a/packages/json-document-composer/src/model.ts +++ b/packages/json-document-composer/src/model.ts @@ -1,5 +1,5 @@ import type { JSONValue } from "@interactive-os/json-document"; -import type { FileCandidate } from "@interactive-os/json-document-file-intake"; +import type { FileCandidate, RasterImageContent } from "@interactive-os/json-document-file-intake"; import type { RichTextDocument } from "@interactive-os/json-document-rich-text"; import { RICH_TEXT_MENTION_NODE, type RichTextMention } from "@interactive-os/json-document-rich-text-mention"; @@ -11,15 +11,17 @@ export type ComposerReference = | ({ readonly kind: "mention" } & RichTextMention) | { readonly kind: "skill"; readonly id: string; readonly label: string }; -export interface ComposerAttachment extends Record { +export type ComposerAttachment = Record & { readonly id: string; readonly kind: "document" | "image"; readonly name: string; readonly size: number; readonly mediaType: string | null; -} + /** Absent for metadata-only attachments. Presence retains actual embedded raster content. */ + readonly image?: RasterImageContent; +}; -export type ComposerAttachmentCandidate = FileCandidate; +export type ComposerAttachmentCandidate = FileCandidate & { readonly image?: RasterImageContent }; export interface ComposerDraft extends Record { readonly id: string; diff --git a/packages/json-document-composer/tests/composer.test.ts b/packages/json-document-composer/tests/composer.test.ts index 7594cb172..0f0d4fe44 100644 --- a/packages/json-document-composer/tests/composer.test.ts +++ b/packages/json-document-composer/tests/composer.test.ts @@ -111,6 +111,41 @@ describe("Composer domain", () => { expect((document.value as typeof draft).attachments).toEqual([]); }); + test("owns image content and preserves it through JSON and History", () => { + const image = { source: "data:image/png;base64,AAAA", width: 64, height: 32 }; + const created = createComposerAttachments( + [{ name: "brief.png", size: 3, mediaType: "image/png", image }], + { createId: () => "image-1", policy: { acceptedMediaTypes: ["image/*"], maxFiles: 2, maxBytesPerFile: 100 } }, + ); + expect(created.ok).toBe(true); + if (!created.ok) return; + image.width = 1; + expect(created.attachments[0]?.image?.width).toBe(64); + const draft = createComposerDraft({ id: "draft", instructionId: "instruction", paragraphId: "paragraph", model: "fast" }); + const document = createJSONDocument(draft); + const editor = createRichTextEditor({ document, pointer: "/instruction", schema: composerSchema }); + expect(addComposerAttachments(editor, draft, created.attachments).ok).toBe(true); + expect(JSON.parse(JSON.stringify(document.value)).attachments[0].image).toEqual({ source: image.source, width: 64, height: 32 }); + expect(editor.undo().ok).toBe(true); + expect((document.value as typeof draft).attachments).toEqual([]); + expect(editor.redo().ok).toBe(true); + expect((document.value as typeof draft).attachments).toEqual(created.attachments); + }); + + test.each([ + { source: "https://example.com/image.png", width: 64, height: 32 }, + { source: "data:image/jpeg;base64,AAAA", width: 64, height: 32 }, + { source: "data:image/png;base64,AAAA", width: 0, height: 32 }, + ])("rejects an invalid image batch before allocating IDs: %j", (image) => { + let ids = 0; + const result = createComposerAttachments([ + { name: "valid.txt", size: 1, mediaType: "text/plain" }, + { name: "invalid.png", size: 3, mediaType: "image/png", image }, + ], { createId: () => String(++ids), policy: { acceptedMediaTypes: ["*/*"], maxFiles: null, maxBytesPerFile: null } }); + expect(result).toMatchObject({ ok: false, code: "composer.attachments.invalid" }); + expect(ids).toBe(0); + }); + test("resolves product-configured Composer interaction meaning", () => { const policy = { submit: "enter", newline: "shift-enter" } as const; expect(composerInteractionFromKeyStroke({ key: "Enter" }, policy)).toBe("submit"); @@ -120,3 +155,16 @@ describe("Composer domain", () => { expect(composerInteractionFromKeyStroke({ key: "Escape" }, policy)).toBe("dismiss"); }); }); + + +describe("Composer default history keyboard", () => { + for (const commandKey of [false, true]) for (const shiftKey of [false, true]) for (const altKey of [false, true]) { + test(`history command=${commandKey} shift=${shiftKey} alt=${altKey}`, () => { + for (const key of ["z", "Z"]) { + const stroke = { key, commandKey, shiftKey, altKey }; + expect(composerInteractionFromKeyStroke(stroke, { submit: "enter", newline: "shift-enter" })) + .toBe(commandKey && !altKey ? shiftKey ? "history.redo" : "history.undo" : null); + } + }); + } +}); diff --git a/packages/json-document-composer/tsconfig.json b/packages/json-document-composer/tsconfig.json index 04c4097d3..074937d34 100644 --- a/packages/json-document-composer/tsconfig.json +++ b/packages/json-document-composer/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, "references": [ { "path": "../json-document" }, + { "path": "../json-document-web" }, { "path": "../json-document-file-intake" }, { "path": "../json-document-rich-text-mention" }, { "path": "../json-document-rich-text-suggestion" }, diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index f92b0d229..7c288fd96 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -1,5 +1,12 @@ # @interactive-os/json-document-editing +The [EditingSession contract](../../standards/editing-session.md) separates common +state, observation, recovery and history-owner invariants from the current +TypeScript binding and local-history policies, with behavior evidence at each +owner. Implementations must preserve the applicable contract; domain clipboard, +input defaults and complete Hands profiles retain their own decisions. This is +not a Stable release declaration for every export in this package. + `EditingSession` observes snapshots by JSON value, not reference identity. Fresh-copy JSONDocument implementations retain local history until an actual external value change. Undo reverses each operation against its sequential @@ -66,14 +73,62 @@ 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 uniqueness across all writers. Environments without `crypto.randomUUID` fail explicitly with `editing.id-provider-unavailable`; no weak random fallback is used. -The session subscribes to its document only while it has observers. The last -unsubscribe releases that connection; later reads catch up with external state. +`createEditingIdAllocator(existingIds, createId, subject)` reads an iterable of +occupied IDs once and returns a function that reserves each newly allocated ID. +Use one allocator for a batch; the five structural editors share this owner. +Each call tries the injected provider at most 100 times before throwing +`createId did not produce a unique id`. The allocator covers its local +reservation set, not cross-replica uniqueness; the provider still owns that. + +The last UI unsubscribe releases the session's document and external-history +observation connections. Local undo/redo validity is independent of UI subscriptions: +a one-shot change marker retains no session, history stack or UI callback and +releases itself on the next document change. Later reads invalidate local history +even if external edits returned the value to the previous snapshot. Fresh-copy +snapshots and document no-ops do not invalidate history. This does not replay every +unobserved intermediate selection or guarantee identical revision counts. Unsubscribe is idempotent: calling an old release again cannot remove a new subscription that reuses the same callback. `DocumentEditor` moves existing blocks with JSON Patch `move`, preserving their @@ -114,6 +169,26 @@ no-op, canceled, preview, and remote-presence changes do not create local docume history. Native text selection remains input/editor-owned and connects through an explicit edit lease rather than becoming a structural selection variant. +`selection.select-all` replaces the complete range selection in one publication. +Document selects from the first block's offset 0 to the last block's text end; +Order selects the full item order. Tree requires `topology` and selects its +visible IDs. Sheet uses its document axes or the supplied `topology` row/column +order. An empty universe clears selection. Repeating the operation preserves +content and document Undo/Redo; it still publishes one selection revision under +the existing session contract. Tree Copy/Cut still includes selected nodes' +descendants, and Sheet Copy/Cut still uses the primary rectangle. + +```ts +documentEditor.dispatch({ type: "selection.select-all" }); +orderEditor.dispatch({ type: "selection.select-all" }); +treeEditor.dispatch({ type: "selection.select-all", topology: { visibleIds } }); +sheetEditor.dispatch({ type: "selection.select-all", topology: { rowIds, columnIds } }); +``` + +[Whole-selection conformance cases](tests/conformance/select-all.test.ts) cover +empty, single, repeated, reordered and filtered targets, one publication, and +document history retention. Usage: the Document, Order, Tree and Sheet demos. + `Database` keeps typed property schema and records in canonical JSON while its saved Table views own property order, visibility, width, sort, and filter. The editor projects each saved view into a visible record/property topology for @@ -141,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 @@ -171,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 @@ -224,3 +310,11 @@ preserves document values and existing Undo/Redo records; Undo after an edit restores the recorded offset range. These contracts are exercised by [Document editor tests](tests/document-editor.test.ts) and the existing [Document Usage](https://developer-1px.github.io/json-document/demo). + + +Annotation selection preserves its public `{ kind: "annotation", ids, primaryId }` +shape and the order in which IDs were selected. Membership and primary fallback +are owned by Key Selection; document reconciliation retains surviving IDs in +that order. Undo/Redo restores both the document and the associated selection. +`transformAnnotationSelector`, `annotationSelectorBounds`, and +`annotationResizeHandle` are the canonical geometry APIs for preview and commit. diff --git a/packages/json-document-editing/benchmarks/editors.mjs b/packages/json-document-editing/benchmarks/editors.mjs index ea4b9d7a3..453fc6cd4 100644 --- a/packages/json-document-editing/benchmarks/editors.mjs +++ b/packages/json-document-editing/benchmarks/editors.mjs @@ -1,5 +1,5 @@ import { benchmarkConfig, measure, reportScaling } from "../../../benchmarks/measure.mjs"; -import { createDatabaseEditor, createSheetEditor, createTreeEditor } from "../dist/index.js"; +import { createDatabaseEditor, createSheetEditor, createTreeEditor, createObjectEditor } from "../dist/index.js"; const config = benchmarkConfig("PERF_EDITING_ITEMS"); console.log("json-document editing benchmark"); @@ -8,6 +8,17 @@ console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${c const workloads = new Map(); for (const size of config.sizes) { console.log(`\nitems=${size}`); + const objects = Array.from({ length: size }, (_, index) => ({ + id: `object-${index}`, label: "Object", x: 0, y: 0, width: 1, height: 1, color: "subtle", + })); + const copies = Math.min(size, 1_000); + record("object batch paste", size, measure(config, "object batch paste", () => { + let sequence = 0; + const editor = createObjectEditor({ objects }, { createId: () => `copy-${sequence++}` }); + const clipboard = { type: "application/vnd.interactive-os.objects+json", objects: objects.slice(0, copies), text: "" }; + return () => editor.dispatch({ type: "clipboard.paste", clipboard }).ok + && editor.snapshot.value.objects.length === size + copies; + })); const treeDocument = { nodes: Array.from({ length: size }, (_, index) => ({ id: `node-${index}`, parentId: index === 0 ? null : "node-0", 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/annotation-selection.ts b/packages/json-document-editing/src/annotation-selection.ts new file mode 100644 index 000000000..97f7ec86f --- /dev/null +++ b/packages/json-document-editing/src/annotation-selection.ts @@ -0,0 +1,30 @@ +import { createKeySelectionFamily, type KeySelection, type KeySelectionContext } from "@interactive-os/json-document-selection"; +import type { AnnotationDocument, AnnotationIntent, AnnotationSelection } from "./annotation.js"; + +const family = createKeySelectionFamily(); + +// Annotation exposes insertion order. The domain supplies that traversal order; +// the Key family alone owns membership, primary fallback, and reconciliation. +function context(selection: AnnotationSelection, document: AnnotationDocument): KeySelectionContext { + const rank = new Map(selection.ids.map((id, index) => [id, index])); + return { + keys: document.annotations.map(({ id }) => id).sort((a, b) => (rank.get(a) ?? rank.size) - (rank.get(b) ?? rank.size)), + universe: document.id, + universeMismatch: "clear", + }; +} +function toKey(selection: AnnotationSelection): KeySelection { + return { kind: "explicit", keys: selection.ids, primaryKey: selection.primaryId }; +} +function fromKey(selection: KeySelection, domain: KeySelectionContext): AnnotationSelection { + return { kind: "annotation", ids: family.targets(selection, domain), primaryId: selection.primaryKey }; +} +export function reconcileAnnotationSelection(selection: AnnotationSelection, document: AnnotationDocument): AnnotationSelection { + const domain = context(selection, document); + return fromKey(family.reconcile(toKey(selection), domain).state, domain); +} +export function transitionAnnotationSelection(selection: AnnotationSelection, intent: Extract, document: AnnotationDocument): AnnotationSelection { + const domain = context(selection, document); + const command = intent.annotationId === null ? { type: "clear" as const } : { type: intent.mode, keys: [intent.annotationId], primaryKey: intent.annotationId }; + return fromKey(family.transition(toKey(selection), command, domain).state, domain); +} diff --git a/packages/json-document-editing/src/annotation.ts b/packages/json-document-editing/src/annotation.ts index 212ab8dec..fdbf660d8 100644 --- a/packages/json-document-editing/src/annotation.ts +++ b/packages/json-document-editing/src/annotation.ts @@ -3,6 +3,7 @@ import { resolveDocumentSource, type EditingDocumentSource } from "./document-so import type { EditingHistoryOptions } from "./history.js"; import { createEditingSession, type EditingResult, type EditingSnapshot } from "./session.js"; import { assertAnnotation, assertAnnotationDocument } from "./annotation-validation.js"; +import { reconcileAnnotationSelection, transitionAnnotationSelection } from "./annotation-selection.js"; export const ANNOTATION_PROFILE_V1 = "urn:interactive-os:json-document:annotation:1" as const; export interface AnnotationPoint extends Record { readonly x: number; readonly y: number } @@ -38,9 +39,7 @@ export function createAnnotationEditor(source: EditingDocumentSource annotation.id)); - const ids = selection.ids.filter((id) => available.has(id)); - return selectionFor(ids, selection.primaryId !== null && available.has(selection.primaryId) ? selection.primaryId : ids.at(-1) ?? null); + return reconcileAnnotationSelection(selection, value as AnnotationDocument); }, }); const value = () => session.snapshot.value as AnnotationDocument; @@ -48,11 +47,7 @@ export function createAnnotationEditor(source: EditingDocumentSource item.id === intent.annotationId)) return failure("annotation.not-found"); - const current = session.snapshot.selection.ids; - const ids = intent.mode === "toggle" && intent.annotationId !== null - ? current.includes(intent.annotationId) ? current.filter((id) => id !== intent.annotationId) : [...current, intent.annotationId] - : intent.annotationId === null ? [] : [intent.annotationId]; - return success(session.select(selectionFor(ids, ids.includes(intent.annotationId ?? "") ? intent.annotationId : ids.at(-1) ?? null))); + return success(session.select(transitionAnnotationSelection(session.snapshot.selection, intent, value()))); } if (intent.type === "annotation.create") { try { assertAnnotation(intent.annotation, new Set(value().sources.map((item) => item.id))); } catch (error) { return failure("annotation.invalid", message(error)); } @@ -70,7 +65,9 @@ export function createAnnotationEditor(source: EditingDocumentSource session.undo(), redo: () => session.redo(), subscribe: (listener) => session.subscribe(listener) }; } -function move(selector: AnnotationSelector, dx: number, dy: number): AnnotationSelector { +export type AnnotationSelectorTransform = + | { readonly type: "move"; readonly dx: number; readonly dy: number } + | { readonly type: "resize"; readonly handle: "end" | "south-east"; readonly dx: number; readonly dy: number }; + +export interface AnnotationBounds extends AnnotationPoint { readonly width: number; readonly height: number } + +export function transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null { + if (transform.type === "resize") return resize(selector, transform.handle, transform.dx, transform.dy); + const { dx, dy } = transform; const point = (p: AnnotationPoint) => ({ x: p.x + dx, y: p.y + dy }); if (selector.type === "point" || selector.type === "rectangle") return { ...selector, ...point(selector) }; if (selector.type === "path") return { ...selector, points: selector.points.map(point) }; return { ...selector, from: point(selector.from), to: point(selector.to) }; } + +export function annotationSelectorBounds(selector: AnnotationSelector): AnnotationBounds { + if (selector.type === "arrow") return rectangleFromPoints(selector.from, selector.to); + if (selector.type === "rectangle") return { x: selector.x, y: selector.y, width: selector.width, height: selector.height }; + if (selector.type === "path") { + const xs = selector.points.map(({ x }) => x); const ys = selector.points.map(({ y }) => y); + const x = Math.min(...xs); const y = Math.min(...ys); + return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }; + } + return { x: selector.x, y: selector.y, width: 0, height: 0 }; +} + +export function annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null { + if (selector.type === "arrow") return "end"; + if (selector.type === "rectangle" || selector.type === "path") return "south-east"; + return null; +} + function resize(selector: AnnotationSelector, handle: "end" | "south-east", dx: number, dy: number): AnnotationSelector | null { if (handle === "south-east" && selector.type === "rectangle") return { ...selector, width: Math.max(1, selector.width + dx), height: Math.max(1, selector.height + dy) }; if (handle === "south-east" && selector.type === "path") { - const xs = selector.points.map((point) => point.x); const ys = selector.points.map((point) => point.y); - const x = Math.min(...xs); const y = Math.min(...ys); const width = Math.max(1, Math.max(...xs) - x); const height = Math.max(1, Math.max(...ys) - y); + const bounds = annotationSelectorBounds(selector); + const { x, y } = bounds; + const width = Math.max(1, bounds.width), height = Math.max(1, bounds.height); const scaleX = Math.max(1, width + dx) / width; const scaleY = Math.max(1, height + dy) / height; return { ...selector, points: selector.points.map((point) => ({ x: x + (point.x - x) * scaleX, y: y + (point.y - y) * scaleY })) }; } if (handle === "end" && selector.type === "arrow") { const to = { x: selector.to.x + dx, y: selector.to.y + dy }; return to.x === selector.from.x && to.y === selector.from.y ? null : { ...selector, to }; } return null; } +function rectangleFromPoints(start: AnnotationPoint, end: AnnotationPoint): AnnotationBounds { return { x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), width: Math.abs(end.x - start.x), height: Math.abs(end.y - start.y) }; } function selectionFor(ids: ReadonlyArray, primaryId: string | null = ids.at(-1) ?? null): AnnotationSelection { return { kind: "annotation", ids, primaryId }; } function success(snapshot: EditingSnapshot): EditingResult { return { ok: true, snapshot }; } function failure(code: string, reason?: string): EditingResult { return { ok: false, code, ...(reason === undefined ? {} : { reason }) }; } 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 5e6bb9203..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, @@ -18,64 +17,39 @@ import { } from "./session.js"; import { cutEditingClipboard, type EditingClipboardCut } from "./clipboard.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +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: createUniqueId(events, createId), - 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: createUniqueId(events, createId), - 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: createUniqueId(events, createId), - 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,42 +399,49 @@ 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; const dateAnchor = parseCalendarDate(calendarDatePart(clipboard.anchorOccurrenceStart)); if (dateAnchor === null) return failure("clipboard.invalid"); - const existing = [...value().events]; + const existing = value().events; + const allocateId = createEditingIdAllocator(existing.map((event) => event.id), createId, "calendar event"); const pasted: CalendarEvent[] = []; for (const item of clipboard.items) { const source = item.event; @@ -762,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: createUniqueId([...existing, ...pasted], createId), 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 })), @@ -780,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, @@ -792,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, @@ -867,355 +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 createUniqueId(events: ReadonlyArray, createId: () => string): string { - const existing = new Set(events.map((event) => event.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique calendar event id"); -} - 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/clipboard.ts b/packages/json-document-editing/src/clipboard.ts index 567162eb2..63e8933ca 100644 --- a/packages/json-document-editing/src/clipboard.ts +++ b/packages/json-document-editing/src/clipboard.ts @@ -19,9 +19,3 @@ export function cutEditingClipboard( export function isClipboardRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } - -export function isClipboardJSONValue(value: unknown): boolean { - if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true; - if (Array.isArray(value)) return value.every(isClipboardJSONValue); - return isClipboardRecord(value) && Object.values(value).every(isClipboardJSONValue); -} diff --git a/packages/json-document-editing/src/database.ts b/packages/json-document-editing/src/database.ts index 666da66f9..19c330654 100644 --- a/packages/json-document-editing/src/database.ts +++ b/packages/json-document-editing/src/database.ts @@ -1,5 +1,6 @@ import { buildPointer, + isJSONValue, jsonEqual, type JSONPatchOperation, type JSONValue, @@ -13,7 +14,7 @@ import { import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import type { EditingHistoryOptions } from "./history.js"; import { reconcileRangeSelection } from "./range-selection.js"; -import { isClipboardJSONValue, isClipboardRecord } from "./clipboard.js"; +import { isClipboardRecord } from "./clipboard.js"; import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds } from "./topology.js"; import { acceptsDatabaseValue, defaultDatabaseValue } from "./database-property-value.js"; import { assertDatabaseDocument, assertDatabaseView } from "./database-validation.js"; @@ -121,10 +122,10 @@ export interface DatabaseClipboard extends Record { export const databaseClipboardFormat = { mimeType: "application/vnd.interactive-os.database+json" as const, parse(value: unknown): DatabaseClipboard | null { - if (!isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; + if (!isJSONValue(value) || !isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; if (!Array.isArray(value.cells) || value.cells.length === 0 || !Array.isArray(value.cells[0])) return null; const width = value.cells[0].length; - return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width && row.every(isClipboardJSONValue)) + return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width) ? value as DatabaseClipboard : null; }, }; @@ -341,6 +342,7 @@ function paste( topology?: DatabaseTopology, index?: DatabaseIndex, ): EditingResult { + if (!isJSONValue(clipboard)) return failure("clipboard.invalid"); const focus = session.snapshot.selection.focus; if (focus === null) return failure("selection.empty"); if (clipboard.cells.length === 0 || clipboard.cells.some((row) => row.length === 0)) { diff --git a/packages/json-document-editing/src/document.ts b/packages/json-document-editing/src/document.ts index acda405f9..9eedb681b 100644 --- a/packages/json-document-editing/src/document.ts +++ b/packages/json-document-editing/src/document.ts @@ -1,6 +1,6 @@ import { type JSONPatchOperation, type JSONValue } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { createEditingSession, type EditingResult, type EditingSession, type EditingSnapshot } from "./session.js"; @@ -8,6 +8,7 @@ import { collapsedRangeSelection, emptyRangeSelection, reconcileRangeSelection, + replaceRangeSelection, selectRangePoint, } from "./range-selection.js"; import { lineInterval, lineTopology } from "./topology.js"; @@ -62,6 +63,7 @@ export const documentClipboardFormat = { }; export type DocumentIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly blockId: string; readonly mode?: "replace" | "extend" | "toggle"; readonly offset?: number } | { readonly type: "text.replace"; readonly blockId: string; readonly text: string; readonly offset?: number } | { readonly type: "block.insert"; readonly afterId?: string; readonly text?: string } @@ -113,6 +115,14 @@ export function createDocumentEditor(source: EditingDocumentSource { const blocks = value().blocks; + if (intent.type === "selection.select-all") { + const first = blocks[0]; + const last = blocks.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first && last ? { anchor: pointAt(first, 0), focus: pointAt(last, last.text.length) } : null, + (left, right) => left.blockId === right.blockId && left.offset === right.offset); + return success(session.select(asDocumentSelection(selection))); + } if (intent.type === "selection.set") { const index = blocks.findIndex((block) => block.id === intent.blockId); if (index < 0) return failure("selection.block-not-found"); @@ -143,7 +153,7 @@ export function createDocumentEditor(source: EditingDocumentSource block.id === intent.afterId); if (intent.afterId !== undefined && afterIndex < 0) return failure("insert.target-not-found"); - const block: DocumentBlock = { id: createUniqueId(blocks, createId), text: intent.text ?? "" }; + const block: DocumentBlock = { id: createEditingIdAllocator(blocks.map((block) => block.id), createId, "block")(), text: intent.text ?? "" }; const index = afterIndex + 1; return session.apply({ operations: [{ op: "add", path: `/blocks/${index}`, value: block }], @@ -269,26 +279,13 @@ function rangesFor(blocks: ReadonlyArray): DocumentSelection { return { kind: "range", ranges: blocks.map((block) => ({ anchor: pointAt(block), focus: pointAt(block) })), primaryIndex: blocks.length === 0 ? null : 0 }; } -function createUniqueId(blocks: ReadonlyArray, createId: () => string): string { - const existing = new Set(blocks.map((block) => block.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique block id"); -} - function cloneBlocksWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, ): DocumentBlock[] { - const occupied = [...existing]; - return source.map((block) => { - const copy = { ...block, id: createUniqueId(occupied, createId) }; - occupied.push(copy); - return copy; - }); + const allocateId = createEditingIdAllocator(existing.map((block) => block.id), createId, "block"); + return source.map((block) => ({ ...block, id: allocateId() })); } function success(snapshot: EditingSnapshot): EditingResult { diff --git a/packages/json-document-editing/src/history-invalidation.ts b/packages/json-document-editing/src/history-invalidation.ts new file mode 100644 index 000000000..e33a47370 --- /dev/null +++ b/packages/json-document-editing/src/history-invalidation.ts @@ -0,0 +1,20 @@ +import { jsonEqual, type JSONAppliedChange, type JSONDocument } from "@interactive-os/json-document"; + +/** A one-shot change marker. The document retains no editor, history or UI callback. */ +export function observeHistoryInvalidation( + document: JSONDocument, + pendingOwnChange?: JSONAppliedChange, +): { readonly changed: boolean } { + const marker = { changed: false }; + const release = document.subscribe((change) => { + // A reentrant commit may return before its queued notification is delivered. + // Earlier queued changes precede this history entry; start after its own change. + if (pendingOwnChange) { + if (jsonEqual(change, pendingOwnChange)) pendingOwnChange = undefined; + return; + } + marker.changed = true; + release(); + }); + return marker; +} diff --git a/packages/json-document-editing/src/identity.ts b/packages/json-document-editing/src/identity.ts index a4bbb09ea..d2ef11623 100644 --- a/packages/json-document-editing/src/identity.ts +++ b/packages/json-document-editing/src/identity.ts @@ -4,3 +4,22 @@ export function createEditingId(prefix: string): string { if (typeof provider?.randomUUID !== "function") throw new TypeError("editing.id-provider-unavailable"); return `${prefix}-${provider.randomUUID()}`; } + +/** Reserve collision-free IDs across one editing batch. Reads existing IDs once. */ +export function createEditingIdAllocator( + existingIds: Iterable, + createId: () => string, + subject: string, +): () => string { + const occupied = new Set(existingIds); + return () => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const id = createId(); + if (!occupied.has(id)) { + occupied.add(id); + return id; + } + } + throw new Error(`createId did not produce a unique ${subject} id`); + }; +} diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index 470f7fcdd..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,26 +28,21 @@ 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 } from "./identity.js"; +export { createEditingId, createEditingIdAllocator } from "./identity.js"; export type { EditingHistory, EditingHistoryOptions, EditingHistoryResult, EditingHistoryStatus } from "./history.js"; export { createSheetEditor, sheetClipboardFormat } from "./sheet.js"; 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,12 +74,12 @@ export { calendarShiftInstant, formatCalendarInstant, isCalendarAllDay, - parseCalendarView, -} from "./calendar-validation.js"; -export { ANNOTATION_PROFILE_V1, createAnnotationEditor } from "./annotation.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 { Annotation, + AnnotationBounds, AnnotationDocument, AnnotationEditor, AnnotationIntent, @@ -80,6 +87,7 @@ export type { AnnotationPresentation, AnnotationSelection, AnnotationSelector, + AnnotationSelectorTransform, AnnotationSource, } from "./annotation.js"; export type { @@ -199,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/invert-patch.ts b/packages/json-document-editing/src/invert-patch.ts index a077bd7f1..7fc9fdf7b 100644 --- a/packages/json-document-editing/src/invert-patch.ts +++ b/packages/json-document-editing/src/invert-patch.ts @@ -13,7 +13,7 @@ import { export function invertEditingPatch(document: JSONDocument, operations: ReadonlyArray): ReadonlyArray | null { const isolated = operations.length > 1 || operations.some((op) => op.op === "move" || op.op === "copy"); const working = isolated ? createJSONDocument(document.value) : document; - let inverse: JSONPatchOperation[] = []; + const inverse: JSONPatchOperation[] = []; for (const operation of operations) { if (tryParsePointer(operation.path) === null) return null; let step: JSONPatchOperation[] = []; @@ -67,9 +67,9 @@ export function invertEditingPatch(document: JSONDocument, operations: ReadonlyA step = [{ op: operation.op === "replace" ? "replace" : "add", path: operation.path, value: previous.value }]; if (isolated && !working.commit([operation]).ok) return null; } else if (isolated && !working.commit([operation]).ok) return null; - inverse = [...step, ...inverse]; + for (let index = step.length - 1; index >= 0; index -= 1) inverse.push(step[index]!); } - return inverse; + return inverse.reverse(); } function insertionPath(document: JSONDocument, path: string): string | null { 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 a764eb65f..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 { @@ -14,24 +12,14 @@ import { type EditingSnapshot, } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +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,33 +283,20 @@ 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), }; } -function createUniqueId(objects: ReadonlyArray, createId: () => string): string { - const existing = new Set(objects.map((object) => object.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique object id"); -} - function cloneObjectsWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, ): DocumentObject[] { - const occupied = [...existing]; - return source.map((object) => { - const copy = { ...object, id: createUniqueId(occupied, createId) }; - occupied.push(copy); - return copy; - }); + const allocateId = createEditingIdAllocator([...existing, ...source].map((object) => object.id), createId, "object"); + return source.map((object) => ({ ...object, id: allocateId() })); } function selectionFor( diff --git a/packages/json-document-editing/src/order.ts b/packages/json-document-editing/src/order.ts index d02695352..57727ca21 100644 --- a/packages/json-document-editing/src/order.ts +++ b/packages/json-document-editing/src/order.ts @@ -3,13 +3,14 @@ import { type JSONValue, } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { collapsedRangeSelection, emptyRangeSelection, reconcileRangeSelection, + replaceRangeSelection, selectRangePoint, type RangeSelectionState, } from "./range-selection.js"; @@ -64,6 +65,7 @@ export const orderClipboardFormat = { }; export type OrderIntent = + | { readonly type: "selection.select-all" } | { readonly type: "selection.set"; readonly itemId: string; @@ -117,6 +119,14 @@ export function createOrderEditor( function dispatch(intent: OrderIntent): EditingResult { const items = value().items; + if (intent.type === "selection.select-all") { + const first = items[0]; + const last = items.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first && last ? { anchor: { itemId: first.id }, focus: { itemId: last.id } } : null, + (left, right) => left.itemId === right.itemId); + return success(session.select(asOrderSelection(selection))); + } if (intent.type === "selection.set") { if (!items.some((item) => item.id === intent.itemId)) return failure("selection.item-not-found"); const point: OrderPoint = { itemId: intent.itemId }; @@ -222,26 +232,13 @@ function rangesFor(items: ReadonlyArray): OrderSelection { }; } -function createUniqueId(items: ReadonlyArray, createId: () => string): string { - const existing = new Set(items.map((item) => item.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique order item id"); -} - function cloneItemsWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, ): OrderItem[] { - const occupied = [...existing]; - return source.map((item) => { - const copy = { ...item, id: createUniqueId(occupied, createId) }; - occupied.push(copy); - return copy; - }); + const allocateId = createEditingIdAllocator(existing.map((item) => item.id), createId, "order item"); + return source.map((item) => ({ ...item, id: allocateId() })); } function success(snapshot: EditingSnapshot): EditingResult { 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/src/range-selection.ts b/packages/json-document-editing/src/range-selection.ts index 33b9b73fa..34a0dbbc7 100644 --- a/packages/json-document-editing/src/range-selection.ts +++ b/packages/json-document-editing/src/range-selection.ts @@ -19,11 +19,7 @@ export function selectRangePoint( mode: RangeSelectionMode, sameTarget: (left: Point, right: Point) => boolean, ): RangeSelectionState { - const topology: OrderedTopology = { - equals: sameTarget, - interval: (anchor, focus) => sameTarget(anchor, focus) ? [anchor] : [anchor, focus], - reconcilePoint: (candidate) => candidate, - }; + const topology = pointTopology(sameTarget); const family = createRangeSelectionFamily(); return family.transition(current, mode === "replace" ? { type: "collapse", point } @@ -32,6 +28,25 @@ export function selectRangePoint( : { type: "toggle-point", point }, { topology }).state; } +/** Replace all ranges in one transition; a missing domain range clears selection. */ +export function replaceRangeSelection( + current: RangeSelectionState, + range: SelectionRange | null, + sameTarget: (left: Point, right: Point) => boolean, +): RangeSelectionState { + return createRangeSelectionFamily().transition(current, + range === null ? { type: "clear" } : { type: "replace-range", range }, + { topology: pointTopology(sameTarget) }).state; +} + +function pointTopology(equals: (left: Point, right: Point) => boolean): OrderedTopology { + return { + equals, + interval: (anchor, focus) => equals(anchor, focus) ? [anchor] : [anchor, focus], + reconcilePoint: (candidate) => candidate, + }; +} + export function collapsedRangeSelection(point: Point): RangeSelectionState { return collapsed(point); } diff --git a/packages/json-document-editing/src/session.ts b/packages/json-document-editing/src/session.ts index 66c939ce3..cecdf2bce 100644 --- a/packages/json-document-editing/src/session.ts +++ b/packages/json-document-editing/src/session.ts @@ -8,6 +8,7 @@ import { type JSONValue, } from "@interactive-os/json-document"; import type { SelectionHistoryEntry } from "@interactive-os/json-document-selection"; +import { observeHistoryInvalidation } from "./history-invalidation.js"; import { invertEditingPatch } from "./invert-patch.js"; import type { EditingHistoryOptions, EditingHistoryResult, EditingHistoryStatus } from "./history.js"; @@ -68,6 +69,7 @@ export function createEditingSession(options: Editi let undoStack: HistoryEntry[] = []; let redoStack: HistoryEntry[] = []; let activeHistoryGroup: string | undefined; + let historyInvalidation: { readonly changed: boolean } | undefined; let isCommitting = false; let observedValue = document.value; let unsubscribeDocument: (() => void) | null = null; @@ -87,7 +89,8 @@ export function createEditingSession(options: Editi let isNotifying = false; function ownSelection(value: Selection): Selection { - // JSON Document owns detachment and immutable JSON values, including selection. + // Selection families may alias anchor/focus/points. JSON serialization lowers + // that graph to a tree before Core validates and owns the immutable snapshot. return createJSONDocument(clone(value)).value as Selection; } @@ -142,10 +145,12 @@ export function createEditingSession(options: Editi } const nextHistory = options.history?.status(); const historyChanged = nextHistory?.revision !== observedHistory?.revision; + const localHistoryChanged = historyInvalidation?.changed === true; const latest = document.value; if (jsonEqual(observedValue, latest)) { - if (!historyChanged) return; + if (!historyChanged && !localHistoryChanged) return; observedHistory = nextHistory; + if (localHistoryChanged) clearLocalHistory(); } else { const before = observedValue; const replay = options.mapSelection && change !== undefined ? applyPatch(before, change.applied) : null; @@ -155,9 +160,7 @@ export function createEditingSession(options: Editi observedValue = latest; observedHistory = nextHistory; selection = nextSelection; - undoStack = []; - redoStack = []; - activeHistoryGroup = undefined; + clearLocalHistory(); } revision += 1; change = undefined; @@ -167,6 +170,18 @@ export function createEditingSession(options: Editi } } + function clearLocalHistory(): void { + undoStack = []; + redoStack = []; + activeHistoryGroup = undefined; + historyInvalidation = undefined; + } + + function trackLocalHistory(followedByChange: boolean, pendingOwnChange?: JSONAppliedChange): void { + if (options.history || undoStack.length + redoStack.length === 0) return; + historyInvalidation = followedByChange ? { changed: true } : observeHistoryInvalidation(document, pendingOwnChange); + } + function commit( operations: ReadonlyArray, metadata: Readonly>, @@ -182,7 +197,7 @@ export function createEditingSession(options: Editi // reentrant document write needs a replay to recover the earlier value. const replay = notifications > 1 ? applyPatch(before, result.change.applied) : null; observedValue = replay?.ok ? replay.value : document.value; - return result; + return { ...result, followedByChange: notifications > 1, pendingOwnChange: notifications === 0 ? result.change : undefined }; } finally { release(); isCommitting = false; @@ -221,7 +236,7 @@ export function createEditingSession(options: Editi return { ok: true, snapshot: publish() }; } - const inverse = options.history ? [] : invertEditingPatch(document, plan.operations); + const inverse = options.history || plan.history === "ignore" ? [] : invertEditingPatch(document, plan.operations); if (inverse === null) { const validation = document.validatePatch(plan.operations); return validation.ok ? { ok: false, code: "history.inverse-unavailable" } : validation; @@ -229,8 +244,8 @@ export function createEditingSession(options: Editi const result = commit(plan.operations, { editing: { origin: plan.origin, - selectionBefore: clone(beforeSelection), - selectionAfter: clone(selectionAfter), + selectionBefore: beforeSelection, + selectionAfter, }, }); if (!result.ok) return result; @@ -255,18 +270,19 @@ export function createEditingSession(options: Editi }; const previous = undoStack.at(-1); if (previous && plan.historyGroup !== undefined && activeHistoryGroup === plan.historyGroup && previous.group === plan.historyGroup) { - undoStack = [...undoStack.slice(0, -1), { + undoStack[undoStack.length - 1] = { ...entry, forward: [...previous.forward, ...entry.forward], inverse: [...entry.inverse, ...previous.inverse], selectionBefore: previous.selectionBefore, - }]; + }; } else { - undoStack = [...undoStack, entry]; + undoStack.push(entry); } activeHistoryGroup = plan.historyGroup; redoStack = []; } + if (result.change.applied.length > 0) trackLocalHistory(result.followedByChange, result.pendingOwnChange); return { ok: true, snapshot: publishCommit(), change: result.change }; } @@ -274,12 +290,13 @@ export function createEditingSession(options: Editi const operations = direction === "undo" ? entry.inverse : entry.forward; const nextSelection = direction === "undo" ? entry.selectionBefore : entry.selectionAfter; const result = commit(operations, { - editing: { origin: direction, selectionAfter: clone(nextSelection) }, + editing: { origin: direction, selectionAfter: nextSelection }, }); if (!result.ok) return result; selection = nextSelection; revision += 1; activeHistoryGroup = undefined; + if (result.change.applied.length > 0) trackLocalHistory(result.followedByChange, result.pendingOwnChange); return { ok: true, snapshot: currentSnapshot(), change: result.change }; } @@ -350,8 +367,8 @@ export function createEditingSession(options: Editi if (!entry) return { ok: false, code: "history.empty" }; const result = restore(entry, "undo"); if (result.ok) { - undoStack = undoStack.slice(0, -1); - redoStack = [...redoStack, entry]; + undoStack.pop(); + redoStack.push(entry); return { ...result, snapshot: publishCommit() }; } return result; @@ -364,8 +381,8 @@ export function createEditingSession(options: Editi if (!entry) return { ok: false, code: "history.empty" }; const result = restore(entry, "redo"); if (result.ok) { - redoStack = redoStack.slice(0, -1); - undoStack = [...undoStack, entry]; + redoStack.pop(); + undoStack.push(entry); return { ...result, snapshot: publishCommit() }; } return result; diff --git a/packages/json-document-editing/src/sheet.ts b/packages/json-document-editing/src/sheet.ts index e531ea0fa..a055e9042 100644 --- a/packages/json-document-editing/src/sheet.ts +++ b/packages/json-document-editing/src/sheet.ts @@ -1,5 +1,6 @@ import { buildPointer, + isJSONValue, type JSONPatchOperation, type JSONValue, } from "@interactive-os/json-document"; @@ -11,8 +12,8 @@ import { } from "./session.js"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; import type { EditingHistoryOptions } from "./history.js"; -import { reconcileRangeSelection } from "./range-selection.js"; -import { cutEditingClipboard, isClipboardJSONValue, isClipboardRecord } from "./clipboard.js"; +import { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js"; +import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { gridCellsInRange, gridPointIndex, gridPointKey, gridRangeBounds, type GridTopology } from "./topology.js"; import { assertSheetDocument, assertUniqueSheetIds } from "./sheet-validation.js"; import { @@ -74,15 +75,16 @@ export interface SheetClipboard extends Record { export const sheetClipboardFormat = { mimeType: "application/vnd.interactive-os.sheet+json" as const, parse(value: unknown): SheetClipboard | null { - if (!isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; + if (!isJSONValue(value) || !isClipboardRecord(value) || value.type !== this.mimeType || typeof value.text !== "string") return null; if (!Array.isArray(value.cells) || value.cells.length === 0 || !Array.isArray(value.cells[0])) return null; const width = value.cells[0].length; - return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width && row.every(isClipboardJSONValue)) + return width > 0 && value.cells.every((row) => Array.isArray(row) && row.length === width) ? value as SheetClipboard : null; }, }; export type SheetIntent = + | { readonly type: "selection.select-all"; readonly topology?: SheetTopology } | { readonly type: "selection.set"; readonly rowId: string; @@ -196,6 +198,18 @@ export function createSheetEditor(source: EditingDocumentSource, } function dispatch(intent: SheetIntent): EditingResult { + if (intent.type === "selection.select-all") { + const { rowIds, columnIds } = resolveTopology(value(), intent.topology, index()); + const firstRow = rowIds[0]; + const firstColumn = columnIds[0]; + const lastRow = rowIds.at(-1); + const lastColumn = columnIds.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + firstRow !== undefined && firstColumn !== undefined && lastRow !== undefined && lastColumn !== undefined + ? { anchor: { rowId: firstRow, columnId: firstColumn }, focus: { rowId: lastRow, columnId: lastColumn } } + : null, sameSheetPoint); + return success(session.select(withPrimaryAliases(selection))); + } if (intent.type === "selection.set") { const point = resolvePoint(value(), intent.rowId, intent.columnId, index()); if (point === null) return failure("selection.cell-not-found"); @@ -291,6 +305,7 @@ function paste( topology?: SheetTopology, index?: SheetIndex, ): EditingResult { + if (!isJSONValue(clipboard)) return failure("clipboard.invalid"); const focus = session.snapshot.selection.focus; if (focus === null) return failure("selection.empty"); if (clipboard.cells.length === 0 || clipboard.cells.some((row) => row.length === 0)) { diff --git a/packages/json-document-editing/src/tree.ts b/packages/json-document-editing/src/tree.ts index e9e9565e2..a723aafc9 100644 --- a/packages/json-document-editing/src/tree.ts +++ b/packages/json-document-editing/src/tree.ts @@ -3,9 +3,9 @@ import { type JSONValue, } from "@interactive-os/json-document"; import { resolveDocumentSource, type EditingDocumentSource } from "./document-source.js"; -import { createEditingId } from "./identity.js"; +import { createEditingId, createEditingIdAllocator } from "./identity.js"; import type { EditingHistoryOptions } from "./history.js"; -import { reconcileRangeSelection } from "./range-selection.js"; +import { reconcileRangeSelection, replaceRangeSelection } from "./range-selection.js"; import { cutEditingClipboard, isClipboardRecord } from "./clipboard.js"; import { createRangeSelectionFamily, @@ -75,6 +75,7 @@ export const treeClipboardFormat = { }; export type TreeIntent = + | { readonly type: "selection.select-all"; readonly topology: TreeTopology } | { readonly type: "selection.set"; readonly nodeId: string; @@ -171,6 +172,14 @@ export function createTreeEditor( function dispatch(intent: TreeIntent): EditingResult { const topology = resolveTopology(intent.topology); + if (intent.type === "selection.select-all") { + const first = topology.visibleIds[0]; + const last = topology.visibleIds.at(-1); + const selection = replaceRangeSelection(session.snapshot.selection, + first !== undefined && last !== undefined ? { anchor: { nodeId: first }, focus: { nodeId: last } } : null, + (left, right) => left.nodeId === right.nodeId); + return success(session.select(asTreeSelection(selection))); + } if (intent.type === "selection.set") { if (!(topologyCache.get(topology) as TreeTopologyIndex).visible.has(intent.nodeId)) { return failure("selection.node-not-visible"); @@ -346,34 +355,22 @@ function rangesFor(nodes: ReadonlyArray): TreeSelection { }; } -function createUniqueId(nodes: ReadonlyArray, createId: () => string): string { - const existing = new Set(nodes.map((node) => node.id)); - for (let attempt = 0; attempt < 100; attempt += 1) { - const id = createId(); - if (!existing.has(id)) return id; - } - throw new Error("createId did not produce a unique tree node id"); -} - function cloneNodesWithUniqueIds( source: ReadonlyArray, existing: ReadonlyArray, createId: () => string, rootParentId: string | null, ): TreeNode[] { - const occupied = [...existing]; + const allocateId = createEditingIdAllocator(existing.map((node) => node.id), createId, "tree node"); const idMap = new Map(); const copied = source.map((node) => { - const id = createUniqueId(occupied, createId); + const id = allocateId(); idMap.set(node.id, id); - const copy = { ...node, id }; - occupied.push(copy); - return copy; + return { ...node, id }; }); - const sourceIds = new Set(source.map((node) => node.id)); return copied.map((node, index) => { const original = source[index]!; - const parentId = original.parentId !== null && sourceIds.has(original.parentId) + const parentId = original.parentId !== null ? idMap.get(original.parentId) ?? rootParentId : rootParentId; return { ...node, parentId }; diff --git a/packages/json-document-editing/tests/annotation-editor.test.ts b/packages/json-document-editing/tests/annotation-editor.test.ts index d20c7d704..2fd77a2ab 100644 --- a/packages/json-document-editing/tests/annotation-editor.test.ts +++ b/packages/json-document-editing/tests/annotation-editor.test.ts @@ -1,8 +1,12 @@ +import { createJSONDocument } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { ANNOTATION_PROFILE_V1, + annotationResizeHandle, + annotationSelectorBounds, assertAnnotationDocument, createAnnotationEditor, + transformAnnotationSelector, type Annotation, type AnnotationDocument, } from "../src/index.js"; @@ -77,4 +81,60 @@ describe("Annotation editor", () => { expect(editor.dispatch({ type: "annotation.resize", annotationId: "path", handle: "south-east", dx: 2, dy: 3 }).ok).toBe(true); expect(editor.dispatch({ type: "annotation.resize", annotationId: "arrow", handle: "end", dx: 5, dy: -2 }).ok).toBe(true); }); + + test("projects preview geometry through the same selector contract used by commits", () => { + const transform = { type: "resize", handle: "south-east", dx: 10, dy: -20 } as const; + const projected = transformAnnotationSelector(rectangle.target.selector, transform); + const editor = createAnnotationEditor(document([rectangle])); + editor.dispatch({ type: "annotation.resize", annotationId: rectangle.id, handle: transform.handle, dx: transform.dx, dy: transform.dy }); + expect((editor.snapshot.value as AnnotationDocument).annotations[0]!.target.selector).toEqual(projected); + expect(annotationSelectorBounds(rectangle.target.selector)).toEqual({ x: 20, y: 30, width: 40, height: 50 }); + expect(annotationResizeHandle(rectangle.target.selector)).toBe("south-east"); + expect(annotationResizeHandle(point.target.selector)).toBeNull(); + }); +}); + + +describe("Annotation Key selection compatibility", () => { + test("keeps insertion order and primary across toggles, document edits, undo and redo", () => { + const editor = createAnnotationEditor(document([point, rectangle, arrow])); + editor.dispatch({ type: "selection.set", annotationId: "arrow", mode: "replace" }); + editor.dispatch({ type: "selection.set", annotationId: "point", mode: "toggle" }); + editor.dispatch({ type: "selection.set", annotationId: "rect", mode: "toggle" }); + const selected = { kind: "annotation", ids: ["arrow", "point", "rect"], primaryId: "rect" }; + expect(editor.snapshot.selection).toEqual(selected); + expect(editor.snapshot.canUndo).toBe(false); + editor.dispatch({ type: "annotation.move", annotationId: "rect", dx: 5, dy: 8 }); + expect(editor.snapshot.selection.ids).toEqual(["rect"]); + editor.undo(); + expect(editor.snapshot.selection).toEqual(selected); + editor.redo(); + expect(editor.snapshot.selection.ids).toEqual(["rect"]); + editor.undo(); + editor.dispatch({ type: "selection.set", annotationId: "rect", mode: "toggle" }); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: ["arrow", "point"], primaryId: "point" }); + const before = editor.snapshot; + expect(editor.dispatch({ type: "selection.set", annotationId: "missing", mode: "toggle" }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + editor.dispatch({ type: "selection.set", annotationId: null, mode: "toggle" }); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: [], primaryId: null }); + }); + + test("reconciles external removal without reordering surviving selections", () => { + const core = createJSONDocument(document([point, rectangle, arrow])); + const editor = createAnnotationEditor(core); + for (const annotationId of ["arrow", "rect", "point"]) editor.dispatch({ type: "selection.set", annotationId, mode: "toggle" }); + core.commit([{ op: "replace", path: "/annotations", value: [rectangle, arrow, point] }]); + expect(editor.snapshot.selection.ids).toEqual(["arrow", "rect", "point"]); + core.commit([{ op: "remove", path: "/annotations/2" }]); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: ["arrow", "rect"], primaryId: "rect" }); + }); + + test("rejects degenerate arrow preview and commit without changing history or selection", () => { + const editor = createAnnotationEditor(document([arrow])); + const before = editor.snapshot; + expect(transformAnnotationSelector(arrow.target.selector, { type: "resize", handle: "end", dx: -10, dy: -10 })).toBeNull(); + expect(editor.dispatch({ type: "annotation.resize", annotationId: "arrow", handle: "end", dx: -10, dy: -10 }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + }); }); 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/clipboard-surface.test.ts b/packages/json-document-editing/tests/clipboard-surface.test.ts index 2932ed872..b2af9b921 100644 --- a/packages/json-document-editing/tests/clipboard-surface.test.ts +++ b/packages/json-document-editing/tests/clipboard-surface.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import { applyPatch, type JSONValue } from "@interactive-os/json-document"; import { createDatabaseEditor, createDocumentEditor, @@ -6,8 +7,35 @@ import { createOrderEditor, createSheetEditor, createTreeEditor, + sheetClipboardFormat, + databaseClipboardFormat, } from "../src/index.js"; +describe.each([sheetClipboardFormat, databaseClipboardFormat])("$mimeType JSON boundary", (format) => { + const cycle: unknown[] = []; + cycle.push(cycle); + const invalidValues = [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]; + + test.each(invalidValues.map((value, index) => ({ value, index })))("rejects non-JSON cell $index as Core does", ({ value }) => { + expect(applyPatch(null, [{ op: "replace", path: "", value: value as JSONValue }]).ok).toBe(false); + expect(format.parse({ type: format.mimeType, cells: [[value]], text: "x" })).toBeNull(); + }); + + test("rejects holes in either matrix dimension and does not invoke cell accessors", () => { + let reads = 0; + const cell = Object.defineProperty({}, "value", { enumerable: true, get: () => { reads++; return 1; } }); + for (const cells of [Array(1), [Array(1)], [[cell]]]) { + expect(format.parse({ type: format.mimeType, cells, text: "x" })).toBeNull(); + } + expect(reads).toBe(0); + }); + + test("preserves valid nested JSON without normalization", () => { + const payload = { type: format.mimeType, cells: [[{ "a/b~": [1, true, null] }]], text: "x" }; + expect(format.parse(payload)).toBe(payload); + }); +}); + describe("editing clipboard surface", () => { test("every domain editor copies a structured payload and a text projection", () => { const document = createDocumentEditor({ blocks: [{ id: "a", text: "A" }] }); 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/conformance/select-all.test.ts b/packages/json-document-editing/tests/conformance/select-all.test.ts new file mode 100644 index 000000000..2a32cde45 --- /dev/null +++ b/packages/json-document-editing/tests/conformance/select-all.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "vitest"; +import { + createDocumentEditor, createOrderEditor, createTreeEditor, createSheetEditor, + type EditingSnapshot, type EditingResult, +} from "../../src/index.js"; +import type { JSONValue } from "@interactive-os/json-document"; + +function assertSelectionTransition( + editor: { + readonly snapshot: EditingSnapshot; + subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; + }, + selectAll: () => EditingResult, + expected: unknown, +) { + const before = editor.snapshot; + const seen: Selection[] = []; + const release = editor.subscribe((snapshot) => seen.push(snapshot.selection)); + try { + for (let repeat = 0; repeat < 3; repeat++) { + expect(selectAll().ok).toBe(true); + expect(editor.snapshot.selection).toEqual(expected); + expect(editor.snapshot).toMatchObject({ value: before.value, canUndo: before.canUndo, canRedo: before.canRedo }); + // One final selection per dispatch, with no first/last intermediate state. + expect(seen).toHaveLength(repeat + 1); + expect(seen[repeat]).toEqual(expected); + } + } finally { release(); } +} + +const empty = { kind: "range", ranges: [], primaryIndex: null }; +const range = (anchor: JSONValue, focus: JSONValue) => ({ kind: "range", ranges: [{ anchor, focus }], primaryIndex: 0 }); + +test.each([0, 1, 3])("Document select-all covers %i blocks, including text endpoints", (count) => { + const blocks = [{ id: "a", text: "Alpha" }, { id: "b", text: "Beta" }, { id: "c", text: "Gamma" }].slice(0, count); + const editor = createDocumentEditor({ blocks }); + const first = blocks[0], last = blocks.at(-1); + const expected = first && last ? range({ blockId: first.id, offset: 0 }, { blockId: last.id, offset: last.text.length }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.selectedBlockIds).toEqual(blocks.map((block) => block.id)); + if (first) { + editor.dispatch({ type: "text.replace", blockId: first.id, text: "Changed" }); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.redo().ok).toBe(true); + expect(editor.snapshot.canUndo).toBe(true); + } +}); + +test.each([0, 1, 3])("Order select-all covers %i items and preserves history", (count) => { + const items = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }].slice(0, count); + const editor = createOrderEditor({ items }); + const first = items[0], last = items.at(-1); + const expected = first && last ? range({ itemId: first.id }, { itemId: last.id }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.selectedItemIds).toEqual(items.map((item) => item.id)); + if (first) { + editor.dispatch({ type: "item.rename", itemId: first.id, label: "Changed" }); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all" }), expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test.each([[], ["b"], ["b", "a"]].map((visibleIds) => ({ visibleIds })))("Tree select-all uses exactly the visible topology $visibleIds", ({ visibleIds }) => { + const editor = createTreeEditor({ nodes: [ + { id: "a", parentId: null, label: "A" }, + { id: "hidden", parentId: "a", label: "Hidden" }, + { id: "b", parentId: null, label: "B" }, + ] }); + const topology = { visibleIds }; + const first = visibleIds[0], last = visibleIds.at(-1); + const expected = first && last ? range({ nodeId: first }, { nodeId: last }) : empty; + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all", topology }), expected); + expect(editor.selectedNodeIdsIn(topology)).toEqual(visibleIds); + if (visibleIds.includes("a")) { + // Selection scope remains visible; existing Copy/Cut descendant closure is unchanged. + expect(editor.copy(topology)?.nodes.map((node) => node.id)).toEqual(["a", "hidden", "b"]); + expect(editor.cut(topology)?.result.ok).toBe(true); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, () => editor.dispatch({ type: "selection.select-all", topology }), expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test.each([ + { rowIds: [], columnIds: ["x"] }, { rowIds: ["a"], columnIds: [] }, + { rowIds: ["b"], columnIds: ["y"] }, { rowIds: ["b", "a"], columnIds: ["y", "x"] }, +])("Sheet select-all respects declared axes $rowIds / $columnIds", (topology) => { + const editor = createSheetEditor({ + columns: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], + rows: [{ id: "a", cells: { x: 1, y: 2 } }, { id: "b", cells: { x: 3, y: 4 } }, { id: "hidden", cells: { x: 5, y: 6 } }], + }); + const anchor = topology.rowIds[0] && topology.columnIds[0] + ? { rowId: topology.rowIds[0], columnId: topology.columnIds[0] } : null; + const focus = anchor ? { rowId: topology.rowIds.at(-1)!, columnId: topology.columnIds.at(-1)! } : null; + const expected = { ...(anchor ? range(anchor, focus) : empty), anchor, focus }; + const select = () => editor.dispatch({ type: "selection.select-all", topology }); + assertSelectionTransition(editor, select, expected); + expect(editor.selectedCellsIn(topology).map(({ rowId, columnId }) => ({ rowId, columnId }))).toEqual( + topology.rowIds.flatMap((rowId) => topology.columnIds.map((columnId) => ({ rowId, columnId }))), + ); + if (anchor) { + expect(editor.dispatch({ type: "selection.fill", value: 0, topology }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + assertSelectionTransition(editor, select, expected); + expect(editor.redo().ok).toBe(true); + } +}); + +test("Sheet omitted topology covers all cells; empty documents clear selection", () => { + const sheet = createSheetEditor({ columns: [{ id: "x", label: "X" }], rows: [{ id: "a", cells: { x: 1 } }, { id: "b", cells: { x: 2 } }] }); + expect(sheet.dispatch({ type: "selection.select-all" }).ok).toBe(true); + expect(sheet.selectedCells).toHaveLength(2); + const emptySheet = createSheetEditor({ rows: [], columns: [] }); + assertSelectionTransition(emptySheet, () => emptySheet.dispatch({ type: "selection.select-all" }), { ...empty, anchor: null, focus: null }); + const tree = createTreeEditor({ nodes: [] }); + assertSelectionTransition(tree, () => tree.dispatch({ type: "selection.select-all", topology: { visibleIds: [] } }), empty); +}); diff --git a/packages/json-document-editing/tests/database-editor.test.ts b/packages/json-document-editing/tests/database-editor.test.ts index 734d3b7d1..613cd6dcd 100644 --- a/packages/json-document-editing/tests/database-editor.test.ts +++ b/packages/json-document-editing/tests/database-editor.test.ts @@ -1,3 +1,4 @@ +import type { JSONValue } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { acceptsDatabaseValue, @@ -36,6 +37,28 @@ const initial: DatabaseDocument = { }; describe("Database editor", () => { + + test("rejects non-JSON paste before cloning and preserves selection, redo, and publication", () => { + const editor = createDatabaseEditor(initial); + expect(editor.dispatch({ type: "cell.commit", recordId: "r1", propertyId: "score", value: 9 }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + const before = editor.snapshot; + let publications = 0; + const unsubscribe = editor.subscribe(() => { publications++; }); + const cycle: unknown[] = []; + cycle.push(cycle); + for (const value of [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]) { + expect(editor.dispatch({ + type: "clipboard.paste", + clipboard: { type: "application/vnd.interactive-os.database+json", cells: [[value as JSONValue]], text: "x" }, + })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(editor.snapshot).toEqual(before); + } + expect(publications).toBe(0); + expect(editor.snapshot.canRedo).toBe(true); + expect(editor.redo().ok).toBe(true); + unsubscribe(); + }); test("owns the canonical property value semantics", () => { const [, , score, status, done] = initial.schema.properties; expect(defaultDatabaseValue(score!)).toBe(0); diff --git a/packages/json-document-editing/tests/identity.test.ts b/packages/json-document-editing/tests/identity.test.ts index 5b6c88da0..53da91a07 100644 --- a/packages/json-document-editing/tests/identity.test.ts +++ b/packages/json-document-editing/tests/identity.test.ts @@ -1,6 +1,6 @@ import { createJSONDocument, type JSONDocument, type JSONValue } from "@interactive-os/json-document"; import { describe, expect, test, vi } from "vitest"; -import { createDocumentEditor, createOrderEditor, createObjectEditor, createTreeEditor, createCalendarEditor, createEditingId } from "../src/index.js"; +import { createDocumentEditor, createOrderEditor, createObjectEditor, createTreeEditor, createCalendarEditor, createEditingId, createEditingIdAllocator } from "../src/index.js"; interface Case { readonly name: string; @@ -31,6 +31,45 @@ const cases: Case[] = [ ]; describe("default domain identities", () => { + test("reads 50,000 existing IDs once while reserving 1,000 batch IDs", () => { + let reads = 0; + function* existingIds() { + for (let index = 0; index < 50_000; index++) { reads++; yield `existing-${index}`; } + } + let sequence = 0; + const allocateId = createEditingIdAllocator(existingIds(), () => `copy-${sequence++}`, "object"); + const allocated = Array.from({ length: 1_000 }, allocateId); + expect(reads).toBe(50_000); + expect(new Set(allocated).size).toBe(1_000); + }); + + test("reserves new IDs and gives each allocation exactly 100 collision attempts", () => { + const createId = vi.fn().mockReturnValueOnce("occupied").mockReturnValueOnce("copy").mockReturnValue("copy"); + const allocateId = createEditingIdAllocator(["occupied"], createId, "tree node"); + expect(allocateId()).toBe("copy"); + createId.mockClear(); + expect(allocateId).toThrow("createId did not produce a unique tree node id"); + expect(createId).toHaveBeenCalledTimes(100); + createId.mockReturnValue("next"); + expect(allocateId()).toBe("next"); + }); + + 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 { + const document = createJSONDocument(initial); + expect(insert(document)).toBe(true); + const before = document.value; + randomUUID.mockClear(); + // 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(); } + }); + test.each(cases)("$name does not reuse IDs across independent or recreated editors", ({ initial, pointer, insert }) => { const ids: JSONValue[] = []; for (let instance = 0; instance < 3; instance++) { 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/tests/session-history.test.ts b/packages/json-document-editing/tests/session-history.test.ts index c32b9a233..d436e4a4c 100644 --- a/packages/json-document-editing/tests/session-history.test.ts +++ b/packages/json-document-editing/tests/session-history.test.ts @@ -1,8 +1,76 @@ -import { createJSONDocument } from "@interactive-os/json-document"; -import { describe, expect, test } from "vitest"; +import { createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; +import { describe, expect, test, vi } from "vitest"; import { createEditingSession } from "../src/session.js"; describe("selection-aware editing history", () => { + test("lowers aliased selection points while metadata and history remain detached", () => { + const document = createJSONDocument({ n: 0 }); + const point = { offset: 1 }; + const session = createEditingSession({ document, selection: { anchor: point, focus: point } }); + const retained = session.snapshot; + const after = { offset: 2 }; + const result = session.apply({ + operations: [{ op: "replace", path: "/n", value: 1 }], + selectionAfter: { anchor: after, focus: after }, origin: "edit", + }); + expect(result.ok).toBe(true); + point.offset = 9; + after.offset = 9; + expect(retained.selection).toEqual({ anchor: { offset: 1 }, focus: { offset: 1 } }); + expect(result.ok && result.change?.metadata?.editing).toMatchObject({ + selectionBefore: { anchor: { offset: 1 }, focus: { offset: 1 } }, + selectionAfter: { anchor: { offset: 2 }, focus: { offset: 2 } }, + }); + expect(session.undo()).toMatchObject({ ok: true, snapshot: { selection: retained.selection } }); + expect(session.redo()).toMatchObject({ ok: true, snapshot: { selection: { anchor: { offset: 2 } } } }); + }); + + test("ignored local history skips inverse reads but preserves canonical failures and redo", () => { + const inner = createJSONDocument({ n: 0, ignored: 0 }); + const at = vi.fn(inner.at); + const document = { ...inner, get value() { return inner.value; }, at }; + const session = createEditingSession({ document, selection: null }); + session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "record" }); + session.undo(); + at.mockClear(); + const operations: JSONPatchOperation[] = [ + { op: "replace", path: "/ignored", value: 1 }, + { op: "replace", path: "/ignored", value: 2 }, + ]; + expect(session.apply({ operations, selectionAfter: null, origin: "ignore", history: "ignore" }).ok).toBe(true); + expect(at).not.toHaveBeenCalled(); + const before = session.snapshot; + const invalid: JSONPatchOperation[] = [{ op: "remove", path: "/missing" }, { op: "test", path: "/n", value: 99 }]; + expect(session.apply({ operations: invalid, selectionAfter: null, origin: "ignore", history: "ignore" })) + .toEqual(inner.validatePatch(invalid)); + expect(session.snapshot).toEqual(before); + expect(session.redo().ok).toBe(true); + expect(inner.value).toEqual({ n: 1, ignored: 2 }); + }); + + test("preserves step order in a large mixed inverse and a long private history", () => { + const document = createJSONDocument({ source: { value: "kept" }, destination: "overwritten", values: Array(300).fill(0) }); + const session = createEditingSession({ document, selection: null }); + const initial = document.value; + const operations: JSONPatchOperation[] = [ + { op: "move", from: "/source", path: "/destination" }, + ...Array.from({ length: 300 }, (_, index): JSONPatchOperation => ({ op: "replace", path: `/values/${index}`, value: index + 1 })), + ]; + expect(session.apply({ operations, selectionAfter: null, origin: "batch" }).ok).toBe(true); + const changed = document.value; + expect(session.undo().ok).toBe(true); + expect(document.value).toEqual(initial); + expect(session.redo().ok).toBe(true); + expect(document.value).toEqual(changed); + for (let value = 1; value <= 200; value++) { + expect(session.apply({ operations: [{ op: "replace", path: "/values/0", value: value + 1 }], selectionAfter: null, origin: "history" }).ok).toBe(true); + } + for (let index = 0; index < 200; index++) expect(session.undo().ok).toBe(true); + expect(document.value).toEqual(changed); + for (let index = 0; index < 200; index++) expect(session.redo().ok).toBe(true); + expect(document.at("/values/0")).toMatchObject({ ok: true, value: 201 }); + }); + test.each([false, true])("reconciles external selection once before publication (observed=%s)", (observed) => { const document = createJSONDocument({ text: "long" }); let reconciles = 0; @@ -255,3 +323,97 @@ describe("selection-aware editing history", () => { expect(session.reconcile((selection) => selection).revision).toBe(snapshot.revision); }); }); + + +describe("local history is independent of UI observation", () => { + test.each(["active", "absent", "released", "resubscribed"])("invalidates undo and redo after an external value round trip (%s)", (observation) => { + for (const direction of ["undo", "redo"] as const) { + const document = createJSONDocument({ n: 0 }); + const session = createEditingSession({ document, selection: 0 }); + const release = observation === "absent" ? () => {} : session.subscribe(() => {}); + expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: 1, origin: "local" }).ok).toBe(true); + if (direction === "redo") expect(session.undo().ok).toBe(true); + if (observation === "released" || observation === "resubscribed") release(); + const before = session.snapshot; + expect(before[direction === "undo" ? "canUndo" : "canRedo"]).toBe(true); + expect(document.commit([{ op: "replace", path: "/n", value: 2 }]).ok).toBe(true); + expect(document.commit([{ op: "replace", path: "/n", value: direction === "undo" ? 1 : 0 }]).ok).toBe(true); + const again = observation === "resubscribed" ? session.subscribe(() => {}) : () => {}; + expect(session.snapshot).toMatchObject({ value: before.value, canUndo: false, canRedo: false }); + expect(session[direction]()).toMatchObject({ ok: false, code: "history.empty" }); + expect(document.value).toEqual(before.value); + release(); + again(); + } + }); + + test("fresh copies and document no-ops retain unobserved history", () => { + const inner = createJSONDocument({ n: 0 }); + const document = { ...inner, get value() { return structuredClone(inner.value); } }; + const session = createEditingSession({ document, selection: null }); + expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "local" }).ok).toBe(true); + inner.commit([{ op: "replace", path: "/n", value: 1 }]); + expect(session.snapshot).toMatchObject({ revision: 1, canUndo: true }); + expect(session.undo().ok).toBe(true); + inner.commit([{ op: "replace", path: "/n", value: 0 }]); + expect(session.snapshot.canRedo).toBe(true); + expect(session.redo().ok).toBe(true); + }); + + test("catches an external round trip authored by a commit subscriber", () => { + const document = createJSONDocument({ n: 0 }); + let written = false; + document.subscribe(() => { + if (written) return; + written = true; + document.commit([{ op: "replace", path: "/n", value: 2 }]); + document.commit([{ op: "replace", path: "/n", value: 1 }]); + }); + const session = createEditingSession({ document, selection: null }); + expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "local" }).ok).toBe(true); + expect(session.snapshot).toMatchObject({ value: { n: 1 }, canUndo: false, canRedo: false }); + expect(session.undo()).toMatchObject({ ok: false, code: "history.empty" }); + }); +}); + + +test("retains an editor-authored step during an external notification", () => { + const document = createJSONDocument({ n: 0 }); + const session = createEditingSession({ document, selection: null }); + session.apply({ operations: [{ op: "replace", path: "/n", value: 1 }], selectionAfter: null, origin: "initial" }); + const release = session.subscribe((snapshot) => { + if ((snapshot.value as { n: number }).n === 2) { + expect(session.apply({ operations: [{ op: "replace", path: "/n", value: 3 }], selectionAfter: null, origin: "follow-up" }).ok).toBe(true); + } + }); + document.commit([{ op: "replace", path: "/n", value: 2 }]); + release(); + expect(session.snapshot).toMatchObject({ value: { n: 3 }, canUndo: true }); + expect(session.undo()).toMatchObject({ ok: true, snapshot: { value: { n: 2 } } }); +}); + +test("keeps only a one-shot history marker after UI release", () => { + const inner = createJSONDocument({ n: 0 }); + let connections = 0; + const document = { ...inner, get value() { return inner.value; }, + subscribe(listener: Parameters[0]) { + connections++; + const release = inner.subscribe(listener); + return () => { connections--; release(); }; + }, + }; + const session = createEditingSession({ document, selection: null }); + expect(connections).toBe(0); + const release = session.subscribe(() => {}); + for (const n of [1, 2, 3]) { + session.apply({ operations: [{ op: "replace", path: "/n", value: n }], selectionAfter: null, origin: "local" }); + expect(connections).toBe(2); + } + release(); + expect(connections).toBe(1); + document.commit([{ op: "replace", path: "/n", value: 4 }]); + expect(connections).toBe(0); + document.commit([{ op: "replace", path: "/n", value: 3 }]); + expect(session.snapshot.canUndo).toBe(false); + expect(connections).toBe(0); +}); diff --git a/packages/json-document-editing/tests/sheet-editor.test.ts b/packages/json-document-editing/tests/sheet-editor.test.ts index 84c9efb17..a25b3ca6a 100644 --- a/packages/json-document-editing/tests/sheet-editor.test.ts +++ b/packages/json-document-editing/tests/sheet-editor.test.ts @@ -1,3 +1,4 @@ +import type { JSONValue } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { createSheetEditor, type SheetDocument } from "../src/index.js"; @@ -15,6 +16,28 @@ const initial: SheetDocument = { }; describe("sheet editing vertical slice", () => { + + test("rejects non-JSON paste before cloning and preserves selection, redo, and publication", () => { + const editor = createSheetEditor(initial); + expect(editor.dispatch({ type: "cell.commit", rowId: "r1", columnId: "score", value: 9 }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + const before = editor.snapshot; + let publications = 0; + const unsubscribe = editor.subscribe(() => { publications++; }); + const cycle: unknown[] = []; + cycle.push(cycle); + for (const value of [NaN, Infinity, new Date(0), Array(1), cycle, { nested: undefined }]) { + expect(editor.dispatch({ + type: "clipboard.paste", + clipboard: { type: "application/vnd.interactive-os.sheet+json", cells: [[value as JSONValue]], text: "x" }, + })).toMatchObject({ ok: false, code: "clipboard.invalid" }); + expect(editor.snapshot).toEqual(before); + } + expect(publications).toBe(0); + expect(editor.snapshot.canRedo).toBe(true); + expect(editor.redo().ok).toBe(true); + unsubscribe(); + }); test("selects a rectangular range and copies row-major JSON with TSV", () => { const editor = createSheetEditor(initial); 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-react-hook-form/benchmarks/form.mjs b/packages/json-document-react-hook-form/benchmarks/form.mjs index f9e2517bb..20d58b430 100644 --- a/packages/json-document-react-hook-form/benchmarks/form.mjs +++ b/packages/json-document-react-hook-form/benchmarks/form.mjs @@ -23,11 +23,43 @@ console.log(`items=${config.sizes.join(",")} rounds=${config.rounds} warmups=${c const externalRows = []; const submitRows = []; +const rerenderRows = []; +const batchRows = []; for (const size of config.sizes) { const initial = { items: Array.from({ length: size }, (_, index) => ({ id: `item-${index}`, done: false })) }; const middle = Math.floor(size / 2); console.log(`\nitems=${size}`); + const rerender = await measureAsync(config, "unchanged rerender", async () => { + const documentState = createJSONDocument(initial); + const hook = renderHook(() => useReactHookFormConnector(documentState)); + return async () => { + await act(async () => { hook.rerender(); }); + return hook.result.current.snapshot.value === documentState.value; + }; + }); + cleanup(); + rerenderRows.push({ size, ...rerender }); + + const count = Math.min(size, 5_000); + const batch = await measureAsync(config, `external ${count} leaf sync`, async () => { + const documentState = createJSONDocument(initial); + const hook = renderHook(() => useReactHookFormConnector(documentState)); + return async () => { + let committed; + await act(async () => { + committed = documentState.commit(Array.from({ length: count }, (_, index) => ({ + op: "replace", path: `/items/${index}/done`, value: true, + }))); + }); + const synced = hook.result.current.form.getValues(`items.${count - 1}.done`) === true; + hook.unmount(); + cleanup(); + return committed?.ok === true && synced; + }; + }); + batchRows.push({ size, ...batch }); + const external = await measureAsync(config, "external leaf sync", async () => { const documentState = createJSONDocument(initial); const hook = renderHook(() => useReactHookFormConnector(documentState)); @@ -63,3 +95,7 @@ console.log("\nexternal leaf sync"); reportScaling(externalRows); console.log("\nwhole form submit"); reportScaling(submitRows); +console.log("\nunchanged rerender"); +reportScaling(rerenderRows); +console.log("\nexternal batch sync"); +reportScaling(batchRows); diff --git a/packages/json-document-react-hook-form/src/index.ts b/packages/json-document-react-hook-form/src/index.ts index 71e477f1f..5f9910ca8 100644 --- a/packages/json-document-react-hook-form/src/index.ts +++ b/packages/json-document-react-hook-form/src/index.ts @@ -74,9 +74,10 @@ export function useJSONDocumentForm< changeSource?: Pick, ): JSONDocumentFormBinding { const snapshot = useEditingSnapshot(session); + const defaultValues = useMemo(() => cloneFormValues(snapshot.value), [snapshot.value]); const form = useForm({ ...options.form, - defaultValues: cloneFormValues(snapshot.value) as DefaultValues, + defaultValues: defaultValues as DefaultValues, }); const [result, setResult] = useState | null>(null); const canonicalValue = useRef(snapshot.value); @@ -159,7 +160,7 @@ function syncAppliedChange( } function syncPointers(operations: ReadonlyArray): string[] | null { - const pointers: string[] = []; + const pointers = new Set(); for (const operation of operations) { if (operation.op === "test") continue; if (operation.path === "") return null; @@ -169,18 +170,20 @@ function syncPointers(operations: ReadonlyArray): string[] | if (structural) segments.pop(); const pointer = buildPointer(segments); if (pointer === "") return null; - pointers.push(pointer); + pointers.add(pointer); if ((operation.op === "move" || operation.op === "copy") && operation.from !== "") { const from = parsePointer(operation.from); from.pop(); if (from.length === 0) return null; - pointers.push(buildPointer(from)); + pointers.add(buildPointer(from)); } } - return pointers.filter((pointer, index) => ( - pointers.indexOf(pointer) === index - && !pointers.some((other) => other !== pointer && pointer.startsWith(`${other}/`)) - )); + return [...pointers].filter((pointer) => { + for (let end = pointer.lastIndexOf("/"); end > 0; end = pointer.lastIndexOf("/", end - 1)) { + if (pointers.has(pointer.slice(0, end))) return false; + } + return true; + }); } function fieldPath(pointer: string): string | null { diff --git a/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx b/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx index c83664950..8e7b46668 100644 --- a/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx +++ b/packages/json-document-react-hook-form/tests/react-hook-form-connector.test.tsx @@ -1,11 +1,13 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, test } from "vitest"; +import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { createJSONDocument, type JSONDocument } from "@interactive-os/json-document"; +import { createEditingSession } from "@interactive-os/json-document-editing"; +import { createFormControl } from "react-hook-form"; import { useReactConnector } from "@interactive-os/json-document-react"; import { createZodValidator } from "@interactive-os/json-document-zod"; import * as z from "zod/v4"; -import { useReactHookFormConnector } from "../src/index.js"; +import { useJSONDocumentForm, useReactHookFormConnector } from "../src/index.js"; interface ProfileForm { profile: { @@ -17,6 +19,98 @@ interface ProfileForm { afterEach(cleanup); describe("React Hook Form Connector", () => { + test("does not clone an unchanged snapshot on rerender or selection updates", () => { + const document = createJSONDocument({ items: Array.from({ length: 10_000 }, (_, id) => ({ id })) }); + const session = createEditingSession({ document, selection: null }); + const hook = renderHook(() => useJSONDocumentForm(session)); + const stringify = vi.spyOn(JSON, "stringify"); + try { + for (let index = 0; index < 10; index++) hook.rerender(); + act(() => { session.select(null); }); + expect(stringify.mock.calls.filter(([value]) => value === document.value)).toHaveLength(0); + act(() => { hook.result.current.form.setValue("items.0.id", 99); }); + expect(document.at("/items/0/id")).toMatchObject({ value: 0 }); + } finally { stringify.mockRestore(); } + }); + + test("initializes replaced form controls and follows session/document replacement", () => { + const first = createProfileDocument(); + const second = createJSONDocument({ profile: { title: "Second", role: "editor" } }); + const controls = [createFormControl().formControl, createFormControl().formControl]; + const hook = renderHook(({ document, formControl }) => useReactHookFormConnector(document, { + form: { formControl }, + }), { initialProps: { document: first, formControl: controls[0]! } }); + act(() => { hook.result.current.form.setValue("profile.title", "Local"); }); + hook.rerender({ document: first, formControl: controls[0]! }); + expect(hook.result.current.form.getValues("profile.title")).toBe("Local"); + hook.rerender({ document: first, formControl: controls[1]! }); + expect(hook.result.current.form.getValues("profile.title")).toBe("Draft"); + hook.rerender({ document: second, formControl: controls[1]! }); + expect(hook.result.current.form.getValues()).toEqual(second.value); + act(() => { first.commit([{ op: "replace", path: "/profile/title", value: "Old source" }]); }); + expect(hook.result.current.form.getValues("profile.title")).toBe("Second"); + }); + + test("deduplicates a large pointer batch and retains unrelated drafts", () => { + const document = createJSONDocument({ + fields: Object.fromEntries(Array.from({ length: 1_000 }, (_, index) => [`field${index}`, 0])), + draft: "original", + }); + const session = createEditingSession({ document, selection: null }); + const source = { at: vi.fn(document.at), subscribe: document.subscribe }; + const hook = renderHook(() => useJSONDocumentForm(session, {}, source)); + act(() => { hook.result.current.form.setValue("draft", "local"); }); + const some = vi.spyOn(Array.prototype, "some"); + try { + act(() => { + document.commit(Array.from({ length: 2_000 }, (_, index) => ({ + op: "replace" as const, path: `/fields/field${index % 1_000}`, value: index, + }))); + }); + expect(some.mock.contexts.filter((value) => ( + Array.isArray(value) && value.length > 100 && typeof value[0] === "string" && value[0].startsWith("/fields/") + ))).toHaveLength(0); + } finally { some.mockRestore(); } + expect(source.at).toHaveBeenCalledTimes(1_000); + expect(hook.result.current.form.getValues("fields.field999")).toBe(1_999); + expect(hook.result.current.form.getValues("draft")).toBe("local"); + }); + + test("syncs ancestors, structural move/copy parents and escaped/root fallbacks", () => { + const document = createJSONDocument({ left: [{ n: 1 }], right: [{ n: 2 }], draft: "original", "a/b": { n: 0 } }); + const session = createEditingSession({ document, selection: null }); + const source = { at: vi.fn(document.at), subscribe: document.subscribe }; + const hook = renderHook(() => useJSONDocumentForm(session, {}, source)); + act(() => { hook.result.current.form.setValue("draft", "local"); }); + act(() => { + document.commit([ + { op: "replace", path: "/left/0/n", value: 3 }, + { op: "move", from: "/left/0", path: "/right/1" }, + { op: "copy", from: "/right/0", path: "/left/0" }, + ]); + }); + expect(source.at.mock.calls.map(([path]) => path)).toEqual(["/right", "/left"]); + expect(hook.result.current.form.getValues("right")).toEqual([{ n: 2 }, { n: 3 }]); + expect(hook.result.current.form.getValues("draft")).toBe("local"); + act(() => { document.commit([{ op: "replace", path: "/a~1b/n", value: 4 }]); }); + expect(hook.result.current.form.getValues()).toEqual(document.value); + source.at.mockClear(); + act(() => { document.commit([{ op: "replace", path: "", value: { title: "root" } }]); }); + expect(source.at).not.toHaveBeenCalled(); + expect(hook.result.current.form.getValues()).toEqual({ title: "root" }); + }); + + test("keeps JSON conversion for Date and omitted optional values in form payloads", async () => { + const document = createJSONDocument({ rows: [] }); + const hook = renderHook(() => useReactHookFormConnector<{ rows: Array<{ date: Date; optional?: string | undefined }> }>(document)); + act(() => { + hook.result.current.form.setValue("rows", [{ date: new Date("2026-01-01T00:00:00Z"), optional: undefined }]); + }); + await act(async () => { await hook.result.current.submit(); }); + expect(hook.result.current.result).toMatchObject({ ok: true }); + expect(document.value).toEqual({ rows: [{ date: "2026-01-01T00:00:00.000Z" }] }); + }); + test("keeps drafts local, then commits all submitted fields as one history entry", async () => { const document = createProfileDocument(); render(); diff --git a/packages/json-document-rich-text-react/README.md b/packages/json-document-rich-text-react/README.md index 45b863d5d..65c3b7626 100644 --- a/packages/json-document-rich-text-react/README.md +++ b/packages/json-document-rich-text-react/README.md @@ -6,6 +6,11 @@ scope whose copy text comes from the canonical Rich Text model projection. Official React renderer and `contenteditable` surface for the json-document Rich Text v1 profile. +An editor's `pointer` may bind the root, a nested JSON Pointer, or its URI +fragment form. The surface reads that snapshot through Core's `readPointer` +without cloning the document. Escaped keys and fragment addresses retain the +same rendering, change observation, and history behavior as ordinary pointers. + `RichTextRenderer` renders canonical semantic HTML. `RichTextEditorSurface` connects that rendering to the official editor, DOM Selection, `beforeinput`, IME, Clipboard, and history integration. ```tsx diff --git a/packages/json-document-rich-text-react/package.json b/packages/json-document-rich-text-react/package.json index 09f3af62c..65e5fd1b5 100644 --- a/packages/json-document-rich-text-react/package.json +++ b/packages/json-document-rich-text-react/package.json @@ -29,12 +29,14 @@ "verify": "npm run typecheck && npm test && npm run build" }, "peerDependencies": { + "@interactive-os/json-document": "^3.0.0", "@interactive-os/json-document-react": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text": "^0.1.0-rc.0", "@interactive-os/json-document-rich-text-web": "^0.1.0-rc.0", "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@interactive-os/json-document": "*", "@interactive-os/json-document-react": "*", "@interactive-os/json-document-rich-text": "*", "@interactive-os/json-document-rich-text-web": "*", diff --git a/packages/json-document-rich-text-react/src/render-store.ts b/packages/json-document-rich-text-react/src/render-store.ts index c2ae9222e..3303777cd 100644 --- a/packages/json-document-rich-text-react/src/render-store.ts +++ b/packages/json-document-rich-text-react/src/render-store.ts @@ -1,3 +1,4 @@ +import { buildPointer, parsePointer, readPointer, type JSONValue } from "@interactive-os/json-document"; import { appliedOperationsFor, hasRichTextContent, @@ -22,7 +23,7 @@ export interface RichTextRenderStore { } export function createRichTextRenderStore(editor: RichTextEditor): RichTextRenderStore { - const pointer = editor.pointer ?? ""; + const pointer = buildPointer(parsePointer(editor.pointer ?? "")); let document = documentAtPointer(editor.snapshot.value, pointer); let blockIds: ReadonlyArray = document.content.map((node) => node.id); let placeholderBlockId: string | null = null; @@ -231,14 +232,10 @@ function relativeOperations( }); } -function documentAtPointer(value: unknown, pointer: string): RichTextDocument { - if (pointer === "") return value as RichTextDocument; - let current = value; - for (const segment of pointer.slice(1).split("/").map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))) { - if (current === null || typeof current !== "object") throw new TypeError(`Rich Text document was not found at ${JSON.stringify(pointer)}.`); - current = (current as Readonly>)[segment]; - } - return current as RichTextDocument; +function documentAtPointer(value: JSONValue, pointer: string): RichTextDocument { + const result = readPointer(value, pointer); + if (!result.ok) throw new TypeError(`Rich Text document was not found at ${JSON.stringify(pointer)}.`); + return result.value as RichTextDocument; } function contentStructureChanged( diff --git a/packages/json-document-rich-text-react/tests/render-locality.test.tsx b/packages/json-document-rich-text-react/tests/render-locality.test.tsx index f147f77b5..b53a8bf42 100644 --- a/packages/json-document-rich-text-react/tests/render-locality.test.tsx +++ b/packages/json-document-rich-text-react/tests/render-locality.test.tsx @@ -2,7 +2,7 @@ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -import { createJSONDocument } from "@interactive-os/json-document"; +import { buildPointer, createJSONDocument } from "@interactive-os/json-document"; import { createRichTextBlockFixture, createRichTextEditor, @@ -20,6 +20,32 @@ import { import { createRichTextRenderStore } from "../src/render-store.js"; describe("Rich Text React locality", () => { + it.each([false, true])("observes nested edits, history, and external changes (fragment: %s)", (uriFragment) => { + const key = "a/b~ #한"; + const value = createRichTextBlockFixture(3, { idPrefix: "nested" }); + const document = createJSONDocument({ [key]: value }); + const pointer = buildPointer([key], { uriFragment }); + const editor = createRichTextEditor({ document, pointer, selection: collapsed("nested-text-1", 1) }); + const store = createRichTextRenderStore(editor); + let changed = 0; + const unsubscribe = store.subscribeNode("nested-text-1", () => { changed++; }); + const untouched = store.getNode("nested-0"); + expect(editor.dispatch({ type: "text.insert", text: "y" }).ok).toBe(true); + expect(changed).toBe(1); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "xy" }); + expect(store.getNode("nested-0")).toBe(untouched); + expect(lastRenderStoreBlockScan()).toBe(1); + expect(editor.undo().ok).toBe(true); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "x" }); + expect(editor.redo().ok).toBe(true); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "xy" }); + expect(document.commit([{ op: "replace", path: buildPointer([key, "content", 1, "content", 0, "text"]), value: "remote" }]).ok).toBe(true); + expect(store.getNode("nested-text-1")).toMatchObject({ text: "remote" }); + expect(store.getNode("nested-0")).toBe(untouched); + expect(editor.pointer).toBe(pointer); + unsubscribe(); + }); + it("catches up after disconnected structural and leaf edits", () => { const editor = createRichTextEditor({ document: createJSONDocument(createRichTextBlockFixture(3, { idPrefix: "offline" })), @@ -61,6 +87,15 @@ describe("Rich Text React locality", () => { expect(createRichTextRenderStore(editor).getBlockIds()).toEqual(["move-1", "move-2", "move-0"]); expect([...container.querySelectorAll("p[data-rich-text-node-id]")].map((node) => node.getAttribute("data-rich-text-node-id"))).toEqual(["move-1", "move-2", "move-0"]); await act(async () => root.unmount()); + // UI subscriptions are gone; the one-shot local History marker remains. + expect(active).toBe(1); + const valueAfterMove = inner.value; + expect(inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "external" }]).ok).toBe(true); + expect(active).toBe(0); + expect(inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "x" }]).ok).toBe(true); + expect(inner.value).toEqual(valueAfterMove); + expect(editor.snapshot.canUndo).toBe(false); + expect(editor.undo()).toMatchObject({ ok: false, code: "history.empty" }); expect(active).toBe(0); }); diff --git a/packages/json-document-rich-text-react/tests/render.test.tsx b/packages/json-document-rich-text-react/tests/render.test.tsx index 9367e425b..ccba76092 100644 --- a/packages/json-document-rich-text-react/tests/render.test.tsx +++ b/packages/json-document-rich-text-react/tests/render.test.tsx @@ -1,9 +1,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { buildPointer, createJSONDocument } from "@interactive-os/json-document"; import * as richTextReact from "../src/index.js"; import { RichTextEditorSurface, RichTextRenderer } from "../src/index.js"; import { createRichTextSchema, + createRichTextEditor, + createRichTextBlockFixture, richTextSchemaV1, type RichTextDocument, type RichTextEditor, @@ -18,6 +21,26 @@ describe("public surface", () => { }); describe("RichTextRenderer", () => { + it.each([false, true])("renders the same nested document through an escaped pointer (fragment: %s)", (uriFragment) => { + const key = "a/b~ #한"; + const value = createRichTextBlockFixture(2, { idPrefix: "nested" }); + const editor = createRichTextEditor({ + document: createJSONDocument({ [key]: value }), + pointer: buildPointer([key], { uriFragment }), + }); + const html = renderToStaticMarkup(); + expect(html).toContain('data-rich-text-node-id="nested-0"'); + expect(html).toContain('data-rich-text-node-id="nested-1"'); + }); + + it("renders the root URI fragment", () => { + const editor = createRichTextEditor({ + document: createJSONDocument(createRichTextBlockFixture(1, { idPrefix: "root" })), + pointer: "#", + }); + expect(renderToStaticMarkup()).toContain('data-rich-text-node-id="root-0"'); + }); + it("renders every official container with DOM mapping identifiers", () => { const html = renderToStaticMarkup( { 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/src/contenteditable.ts b/packages/json-document-rich-text-web/src/contenteditable.ts index 348db05f8..b77a22a81 100644 --- a/packages/json-document-rich-text-web/src/contenteditable.ts +++ b/packages/json-document-rich-text-web/src/contenteditable.ts @@ -4,9 +4,15 @@ import type { RichTextSelection, } from "@interactive-os/json-document-rich-text"; import { createRichTextNodeId } from "@interactive-os/json-document-rich-text"; -import { createWebClipboardBinding, isWebEditingHostTarget, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; +import { createWebClipboardBinding, createWebKeyboardAdapter, isWebEditingHostTarget, type WebClipboardData, type WebClipboardEvent } from "@interactive-os/json-document-web"; import { createRichTextClipboardCodec, createRichTextClipboardRepresentations } from "./clipboard.js"; +// Preserve this binding's historical Alt acceptance alongside the shared Mod-z defaults. +const keyboard = createWebKeyboardAdapter({ keymap: { + "Mod-Alt-z": { type: "undo" }, + "Mod-Alt-Shift-z": { type: "redo" }, +} }); + export interface RichTextContentEditableBinding { isComposing(): boolean; syncSelection(): RichTextSelection | null; @@ -174,9 +180,10 @@ export function createRichTextContentEditableBinding(options: { report("text.delete", editor.dispatch({ type: "text.delete", direction, unit: "character" })); return; } - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { + const command = keyboard.resolve(event); + if (command?.type === "undo" || command?.type === "redo") { event.preventDefault(); - report(event.shiftKey ? "redo" : "undo", event.shiftKey ? editor.redo() : editor.undo()); + report(command.type, command.type === "redo" ? editor.redo() : editor.undo()); } }; 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-rich-text-web/tests/history-keyboard.test.ts b/packages/json-document-rich-text-web/tests/history-keyboard.test.ts new file mode 100644 index 000000000..d45cfae89 --- /dev/null +++ b/packages/json-document-rich-text-web/tests/history-keyboard.test.ts @@ -0,0 +1,86 @@ +import { createJSONDocument } from "@interactive-os/json-document"; +import { createRichTextEditor, type RichTextDocument, type RichTextSelection } from "@interactive-os/json-document-rich-text"; +import { afterEach, expect, test } from "vitest"; +import { createRichTextContentEditableBinding } from "../src/index.js"; + +const initial: RichTextDocument = { + profile: "urn:interactive-os:json-document:rich-text:1", id: "doc", type: "doc", + content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "Alpha", marks: [] }] }], +}; +const range = (anchor: number, focus = anchor): RichTextSelection => ({ + kind: "range", primaryIndex: 0, ranges: [{ + anchor: { kind: "text", nodeId: "t", offset: anchor, affinity: "forward" }, + focus: { kind: "text", nodeId: "t", offset: focus, affinity: "forward" }, + }], +}); +const disposers: (() => void)[] = []; +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); + document.body.replaceChildren(); +}); +function fixture(backward = false) { + const editor = createRichTextEditor({ document: createJSONDocument(initial) }); + const root = document.createElement("article"); + root.contentEditable = "true"; + root.dataset.richTextContainerId = "doc"; + root.innerHTML = '

Alpha

'; + document.body.append(root); + const text = root.querySelector("span")!.firstChild!; + document.getSelection()!.setBaseAndExtent(text, backward ? 4 : 2, text, backward ? 2 : 4); + const actions: string[] = []; + const binding = createRichTextContentEditableBinding({ root, editor, onAction: (action) => actions.push(action) }); + disposers.push(() => binding.destroy()); + root.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: "X" })); + expect(editor.snapshot).toMatchObject({ + value: { content: [{ content: [{ id: "t", text: "AlXa" }] }] }, + selection: range(3), canUndo: true, canRedo: false, + }); + return { root, editor, actions }; +} +function key(target: HTMLElement, options: KeyboardEventInit) { + const event = new KeyboardEvent("keydown", { key: "z", bubbles: true, cancelable: true, ...options }); + target.dispatchEvent(event); + return event; +} + +test.each(["metaKey", "ctrlKey"] as const)("%s Undo/Redo restores directed selection through the real binding", (modifier) => { + for (const backward of [false, true]) for (const moveAfterUndo of [false, true]) { + const { root, editor, actions } = fixture(backward); + const before = backward ? range(4, 2) : range(2, 4); + expect(key(root, { [modifier]: true }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ value: initial, selection: before, canUndo: false, canRedo: true }); + if (moveAfterUndo) { + editor.dispatch({ type: "selection.set", selection: range(0) }); + expect(editor.snapshot).toMatchObject({ selection: range(0), canUndo: false, canRedo: true }); + } + expect(key(root, { [modifier]: true, shiftKey: true, key: "Z" }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ + value: { content: [{ content: [{ id: "t", text: "AlXa" }] }] }, + selection: range(3), canUndo: true, canRedo: false, + }); + expect(actions.slice(-2)).toEqual(["undo", "redo"]); + } +}); + +test.each(["metaKey", "ctrlKey"] as const)("%s preserves legacy Alt and composing modifier acceptance", (modifier) => { + const { root, editor } = fixture(); + expect(key(root, { [modifier]: true, altKey: true, isComposing: true }).defaultPrevented).toBe(true); + expect(editor.snapshot).toMatchObject({ value: initial, selection: range(2, 4), canRedo: true }); + expect(key(root, { [modifier]: true, altKey: true, shiftKey: true }).defaultPrevented).toBe(true); + expect(editor.snapshot.selection).toEqual(range(3)); +}); + +test("unmatched keys and nested controls never consume the outer history", () => { + const { root, editor } = fixture(); + const before = editor.snapshot; + for (const options of [{}, { altKey: true }, { metaKey: true, key: "x" }]) { + expect(key(root, options).defaultPrevented).toBe(false); + } + for (const tag of ["input", "textarea", "select", "div"]) { + const nested = document.createElement(tag); + if (tag === "div") nested.setAttribute("contenteditable", "true"); + root.append(nested); + expect(key(nested, { metaKey: true }).defaultPrevented).toBe(false); + } + expect(editor.snapshot).toEqual(before); +}); diff --git a/packages/json-document-rich-text/src/diff.ts b/packages/json-document-rich-text/src/diff.ts index 8480943cc..133ab9f8a 100644 --- a/packages/json-document-rich-text/src/diff.ts +++ b/packages/json-document-rich-text/src/diff.ts @@ -1,6 +1,5 @@ import { buildPointer, parsePointer, type JSONPatchOperation, type Pointer } from "@interactive-os/json-document"; import { hasRichTextContent, isRichTextText, type RichTextDocument, type RichTextNode } from "./model.js"; -import { detachedValue } from "./path.js"; export function diffRichText( before: RichTextDocument, @@ -10,7 +9,7 @@ export function diffRichText( if (before === after) return []; const operations = diffNode(before, after, parsePointer(rootPointer)); return operations.length === 0 && before !== after - ? [{ op: "replace", path: rootPointer, value: detachedValue(after) }] + ? [{ op: "replace", path: rootPointer, value: after }] : operations; } @@ -21,7 +20,7 @@ function diffNode( ): JSONPatchOperation[] { if (before === after) return []; if (before.id !== after.id || before.type !== after.type) { - return [{ op: "replace", path: buildPointer(segments), value: detachedValue(after) }]; + return [{ op: "replace", path: buildPointer(segments), value: after }]; } const operations: JSONPatchOperation[] = []; if (isRichTextText(before) && isRichTextText(after)) { @@ -29,14 +28,14 @@ function diffNode( operations.push({ op: "replace", path: buildPointer([...segments, "text"]), value: after.text }); } if (JSON.stringify(before.marks) !== JSON.stringify(after.marks)) { - operations.push({ op: "replace", path: buildPointer([...segments, "marks"]), value: detachedValue(after.marks) }); + operations.push({ op: "replace", path: buildPointer([...segments, "marks"]), value: after.marks }); } return operations; } const beforeRecord = before as { readonly attrs?: import("@interactive-os/json-document").JSONValue }; const afterRecord = after as { readonly attrs?: import("@interactive-os/json-document").JSONValue }; if (JSON.stringify(beforeRecord.attrs) !== JSON.stringify(afterRecord.attrs) && afterRecord.attrs !== undefined) { - operations.push({ op: "replace", path: buildPointer([...segments, "attrs"]), value: detachedValue(afterRecord.attrs) }); + operations.push({ op: "replace", path: buildPointer([...segments, "attrs"]), value: afterRecord.attrs }); } if (!hasRichTextContent(before) || !hasRichTextContent(after)) return operations; operations.push(...diffContent(before.content, after.content, [...segments, "content"])); @@ -59,7 +58,7 @@ function diffContent( const sharedBefore = beforeIds.filter((id) => afterSet.has(id)); const sharedAfter = afterIds.filter((id) => beforeSet.has(id)); if (!sameIds(sharedBefore, sharedAfter)) { - return [{ op: "replace", path: buildPointer(segments), value: detachedValue(after) }]; + return [{ op: "replace", path: buildPointer(segments), value: after }]; } const operations: JSONPatchOperation[] = []; for (let index = before.length - 1; index >= 0; index -= 1) { @@ -69,7 +68,7 @@ function diffContent( const remaining = new Map(before.filter((node) => afterSet.has(node.id)).map((node) => [node.id, node])); after.forEach((node, index) => { if (!beforeSet.has(node.id)) { - operations.push({ op: "add", path: buildPointer([...segments, index]), value: detachedValue(node) }); + operations.push({ op: "add", path: buildPointer([...segments, index]), value: node }); return; } const previous = remaining.get(node.id); diff --git a/packages/json-document-rich-text/src/editor-validation.ts b/packages/json-document-rich-text/src/editor-validation.ts index 0ba2ad3b7..5d7cbeb7d 100644 --- a/packages/json-document-rich-text/src/editor-validation.ts +++ b/packages/json-document-rich-text/src/editor-validation.ts @@ -1,4 +1,4 @@ -import { createJSONDocument, type JSONDocument, type JSONValue, type Pointer } from "@interactive-os/json-document"; +import { readPointer, type JSONDocument, type JSONValue, type Pointer } from "@interactive-os/json-document"; import { getActiveRichTextInstrument } from "./instrument.js"; import { hasRichTextContent, isRichTextDocument, type RichTextDocument, type RichTextNode } from "./model.js"; import type { RichTextSchema } from "./schema.js"; @@ -13,8 +13,9 @@ export function readRichTextDocument(document: JSONDocument, pointer: Pointer): } export function readRichTextSnapshot(value: JSONValue, pointer: Pointer): RichTextDocument { - if (pointer === "" && isRichTextDocument(value)) return value; - return readRichTextDocument(createJSONDocument(value), pointer); + const result = readPointer(value, pointer); + if (!result.ok || !isRichTextDocument(result.value)) throw new TypeError(`Rich Text document was not found at ${JSON.stringify(pointer)}.`); + return result.value; } export function validateLocalOrFallback(next: RichTextDocument, path: ReadonlyArray, schema: RichTextSchema): ReturnType { diff --git a/packages/json-document-rich-text/src/editor.ts b/packages/json-document-rich-text/src/editor.ts index f7cebe8ab..5006b16a5 100644 --- a/packages/json-document-rich-text/src/editor.ts +++ b/packages/json-document-rich-text/src/editor.ts @@ -1,6 +1,8 @@ import { buildPointer, - createJSONDocument, + applyPatch, + readPointer, + isJSONValue, parsePointer, type JSONDocument, type JSONPatchOperation, @@ -41,7 +43,6 @@ import { diffRichText } from "./diff.js"; import { containerContentSegments, contentSegments, - detachedValue, nodeAtPath, replaceContentAtPath, replaceNodeAtPath, @@ -113,10 +114,11 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd const initialValidation = indexValidatedRichText(initial, schema); if (!initialValidation.ok) throw new TypeError(initialValidation.reason); const initialTopology = richTextTopology(initial); + const patchPointer = buildPointer(parsePointer(pointer)); let previousDocument = initial; function observeChange(change: import("@interactive-os/json-document").JSONAppliedChange): void { const next = readRichTextDocument(options.document, pointer); - seedRichTextTopology(previousDocument, next, change.applied, pointer); + seedRichTextTopology(previousDocument, next, change.applied, patchPointer); rememberAppliedOperations(next, change.applied); previousDocument = next; } @@ -180,14 +182,13 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd return pasteClipboard(intent.clipboard); }, apply(operations, applyOptions) { - const candidate = createJSONDocument(options.document.value); - const applied = candidate.commit(operations); + const applied = applyPatch(options.document.value, operations); if (!applied.ok) return applied; - const located = candidate.at(pointer); + const located = readPointer(applied.value, pointer); const validation = validateRichText(located.ok ? located.value : undefined, { schema }); if (!validation.ok) return validation; const nextSelection = asRichTextSelection(selectionFamily.reconcile(session.snapshot.selection, { - topology: richTextTopology(readRichTextDocument(candidate, pointer)), + topology: richTextTopology(readRichTextSnapshot(applied.value, pointer)), }).state); return session.apply({ operations, @@ -218,12 +219,14 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd ranges.forEach((range, rangeIndex) => { const anchor = range.anchor as Extract; const focus = range.focus as Extract; - grouped.set(anchor.nodeId, [...(grouped.get(anchor.nodeId) ?? []), { + const replacements = grouped.get(anchor.nodeId) ?? []; + grouped.set(anchor.nodeId, replacements); + replacements.push({ start: Math.min(anchor.offset, focus.offset), end: Math.max(anchor.offset, focus.offset), rangeIndex, affinity: focus.affinity, - }]); + }); }); const topology = richTextTopology(before); const operations: import("@interactive-os/json-document").JSONPatchOperation[] = []; @@ -232,7 +235,8 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd const currentNode = located === null ? null : nodeAtPath(before, located.path); if (located === null || currentNode === null || !isRichTextText(currentNode)) return failure("rich-text.point-not-found"); let nextText = currentNode.text; - for (const replacement of [...replacements].sort((left, right) => right.start - left.start)) { + replacements.sort((left, right) => right.start - left.start); + for (const replacement of replacements) { if (!validTextOffset(nextText, replacement.start) || !validTextOffset(nextText, replacement.end)) return failure("rich-text.invalid-offset"); nextText = nextText.slice(0, replacement.start) + text + nextText.slice(replacement.end); } @@ -240,10 +244,15 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd const validation = validateRichTextNodeAt(before, located.path, nextNode, { schema }); if (!validation.ok) return failure(validation.code); operations.push({ op: "replace", path: absolutePath(pointer, [...contentSegments(located.path), "text"]), value: nextText }); - for (const replacement of replacements) { - const shift = replacements - .filter((candidate) => candidate.start < replacement.start) - .reduce((total, candidate) => total + text.length - (candidate.end - candidate.start), 0); + let shift = 0; + let groupShift = 0; + for (let index = replacements.length - 1; index >= 0; index -= 1) { + const replacement = replacements[index]!; + if (replacement.start !== replacements[index + 1]?.start) { + shift += groupShift; + groupShift = 0; + } + groupShift += text.length - (replacement.end - replacement.start); const point: RichTextPoint = { kind: "text", nodeId, @@ -304,7 +313,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd } function pasteClipboard(clipboard: RichTextClipboard): EditingResult { - if (clipboard.type !== RICH_TEXT_CLIPBOARD_MIME || clipboard.slice.profile !== schema.profile) { + if (!isJSONValue(clipboard) || clipboard.type !== RICH_TEXT_CLIPBOARD_MIME || clipboard.slice.profile !== schema.profile) { return failure("rich-text.clipboard-invalid"); } const ranges = session.snapshot.selection.ranges; @@ -442,7 +451,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd : { id: node.id, type: "heading", attrs: { level: Number(attrs!.level) as 1 | 2 | 3 | 4 | 5 | 6 }, content: node.content }) as RichTextNode; const validation = validateRichTextNodeAt(current, located.path, nextNode, { schema }); if (!validation.ok) return failure(validation.code); - operations.push({ op: "replace", path: absolutePath(pointer, contentSegments(located.path)), value: detachedValue(nextNode) }); + operations.push({ op: "replace", path: absolutePath(pointer, contentSegments(located.path)), value: nextNode }); } if (operations.length === 0) return success(session.snapshot); return commitOperations(session.snapshot.selection, "rich-text.block.set-type", undefined, operations); @@ -543,7 +552,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd session.snapshot.selection, "rich-text.node.set-attrs", undefined, - [{ op: "replace", path: absolutePath(pointer, [...contentSegments(located.path), "attrs"]), value: detachedValue(attrs) }], + [{ op: "replace", path: absolutePath(pointer, [...contentSegments(located.path), "attrs"]), value: attrs }], ); } @@ -602,7 +611,7 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd collapsedAtPoint(caret), "rich-text.text.delete", "rich-text.typing", - [{ op: "replace", path: absolutePath(pointer, containerContentSegments(parentPath)), value: detachedValue(content) }], + [{ op: "replace", path: absolutePath(pointer, containerContentSegments(parentPath)), value: content }], { path: parentPath }, ); } @@ -682,19 +691,19 @@ export function createRichTextEditor(options: RichTextEditorOptions): RichTextEd if (first === undefined) return failure("rich-text.point-not-found"); if (removeIndex >= 0 && removeIndex < index) { operations.push({ op: "remove", path: `${contentPath}/${removeIndex}` }); - operations.push({ op: "replace", path: `${contentPath}/${index - 1}`, value: detachedValue(first) }); + operations.push({ op: "replace", path: `${contentPath}/${index - 1}`, value: first }); for (let offset = 1; offset < replacements.length; offset += 1) { const added = replacements[offset]; if (added === undefined) continue; - operations.push({ op: "add", path: `${contentPath}/${index - 1 + offset}`, value: detachedValue(added) }); + operations.push({ op: "add", path: `${contentPath}/${index - 1 + offset}`, value: added }); } } else { if (removeIndex > index) operations.push({ op: "remove", path: `${contentPath}/${removeIndex}` }); - operations.push({ op: "replace", path: `${contentPath}/${index}`, value: detachedValue(first) }); + operations.push({ op: "replace", path: `${contentPath}/${index}`, value: first }); for (let offset = 1; offset < replacements.length; offset += 1) { const added = replacements[offset]; if (added === undefined) continue; - operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: detachedValue(added) }); + operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: added }); } } return commitOperations(selectionAfter, origin, undefined, operations); @@ -858,10 +867,6 @@ function collapsedAt(nodeId: string, offset: number): RichTextSelection { })); } -function detached(document: RichTextDocument): RichTextDocument { - return JSON.parse(JSON.stringify(document)) as RichTextDocument; -} - interface LocatedRichTextNode { readonly node: RichTextNode; readonly parent: (RichTextNode | RichTextDocument) & { readonly content: ReadonlyArray } | null; @@ -994,7 +999,11 @@ function groupIntervals(intervals: ReadonlyArray): ReadonlyArray<{ readonly intervals: ReadonlyArray<{ readonly from: number; readonly to: number }>; }> { const grouped = new Map>(); - for (const interval of intervals) grouped.set(interval.nodeId, [...(grouped.get(interval.nodeId) ?? []), { from: interval.from, to: interval.to }]); + for (const interval of intervals) { + const values = grouped.get(interval.nodeId) ?? []; + grouped.set(interval.nodeId, values); + values.push({ from: interval.from, to: interval.to }); + } return [...grouped].map(([nodeId, values]) => { const sorted = values.sort((left, right) => left.from - right.from || left.to - right.to); const merged: Array<{ from: number; to: number }> = []; @@ -1017,11 +1026,15 @@ function markedSegments( ): ReadonlyArray { const boundaries = [...new Set([0, node.text.length, ...intervals.flatMap((interval) => [interval.from, interval.to])])].sort((a, b) => a - b); const nodes: RichTextNode[] = []; + let intervalIndex = 0; for (let index = 0; index < boundaries.length - 1; index += 1) { const from = boundaries[index]!; const to = boundaries[index + 1]!; if (from === to) continue; - const selected = intervals.some((interval) => from >= interval.from && to <= interval.to); + // groupIntervals supplies sorted, disjoint ranges; each is visited once. + while (intervals[intervalIndex] && intervals[intervalIndex]!.to <= from) intervalIndex += 1; + const interval = intervals[intervalIndex]; + const selected = interval !== undefined && from >= interval.from && to <= interval.to; let marks = [...node.marks]; if (selected) { marks = marks.filter((candidate) => candidate.type !== mark.type); @@ -1113,19 +1126,15 @@ function removeSelectedValue( inputOwnership: "borrowed", }); if (!normalized.ok) return { ok: false, code: normalized.code }; + const order = logicalPointOrder(document); const ranges = selection.ranges.map((range) => { - const start = earlierPoint(document, range.anchor, range.focus); + const start = order(range.anchor) <= order(range.focus) ? range.anchor : range.focus; const point = mapPointAfterRemoval(document, normalized.value, start, intervals); return { anchor: point, focus: point }; }); return { ok: true, value: normalized.value, selection: { ...selection, ranges } as RichTextSelection }; } -function earlierPoint(document: RichTextDocument, left: RichTextPoint, right: RichTextPoint): RichTextPoint { - const order = logicalPointOrder(document); - return (order(left) <= order(right)) ? left : right; -} - function logicalPointOrder(document: RichTextDocument): (point: RichTextPoint) => number { const positions = new Map(); let sequence = 0; @@ -1416,10 +1425,10 @@ function siblingReplacementOps( const contentPath = absolutePath(rootPointer, containerContentSegments(parentPath)); const first = replacements[0]!; const operations: import("@interactive-os/json-document").JSONPatchOperation[] = [ - { op: "replace", path: `${contentPath}/${index}`, value: detachedValue(first) }, + { op: "replace", path: `${contentPath}/${index}`, value: first }, ]; for (let offset = 1; offset < replacements.length; offset += 1) { - operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: detachedValue(replacements[offset]!) }); + operations.push({ op: "add", path: `${contentPath}/${index + offset}`, value: replacements[offset]! }); } return operations; } @@ -1466,7 +1475,7 @@ function planInsertNode( operations: [{ op: "add", path: absolutePath(rootPointer, [...containerContentSegments(container.path), point.offset]), - value: detachedValue(node), + value: node, }], nodes: [node], parentPath: container.path, @@ -1480,7 +1489,7 @@ function planInsertNode( const contentPath = absolutePath(rootPointer, containerContentSegments(parentPath)); if (point.offset === 0) { return { - operations: [{ op: "add", path: `${contentPath}/${index}`, value: detachedValue(node) }], + operations: [{ op: "add", path: `${contentPath}/${index}`, value: node }], nodes: [node], parentPath, selection: pointAfterInsertedAt(parentIdFromPath(document, parentPath), index, node, point.affinity), @@ -1488,7 +1497,7 @@ function planInsertNode( } if (point.offset === located.node.text.length) { return { - operations: [{ op: "add", path: `${contentPath}/${index + 1}`, value: detachedValue(node) }], + operations: [{ op: "add", path: `${contentPath}/${index + 1}`, value: node }], nodes: [node], parentPath, selection: pointAfterInsertedAt(parentIdFromPath(document, parentPath), index + 1, node, point.affinity), @@ -1498,9 +1507,9 @@ function planInsertNode( const right = { ...located.node, id: createId(), text: located.node.text.slice(point.offset) }; return { operations: [ - { op: "replace", path: `${contentPath}/${index}`, value: detachedValue(left) }, - { op: "add", path: `${contentPath}/${index + 1}`, value: detachedValue(node) }, - { op: "add", path: `${contentPath}/${index + 2}`, value: detachedValue(right) }, + { op: "replace", path: `${contentPath}/${index}`, value: left }, + { op: "add", path: `${contentPath}/${index + 1}`, value: node }, + { op: "add", path: `${contentPath}/${index + 2}`, value: right }, ], nodes: [left, node, right], parentPath, diff --git a/packages/json-document-rich-text/src/path.ts b/packages/json-document-rich-text/src/path.ts index d0a3b0cd2..3cac3db72 100644 --- a/packages/json-document-rich-text/src/path.ts +++ b/packages/json-document-rich-text/src/path.ts @@ -1,4 +1,3 @@ -import type { JSONValue } from "@interactive-os/json-document"; import { getActiveRichTextInstrument } from "./instrument.js"; import { hasRichTextContent, @@ -64,7 +63,3 @@ export function replaceContentAtPath( } return replaceNodeAtPath(document, path, { ...container, content } as RichTextNode); } - -export function detachedValue(value: Value): Value { - return JSON.parse(JSON.stringify(value)) as Value; -} diff --git a/packages/json-document-rich-text/src/validation.ts b/packages/json-document-rich-text/src/validation.ts index 23666bcd6..5c306cac4 100644 --- a/packages/json-document-rich-text/src/validation.ts +++ b/packages/json-document-rich-text/src/validation.ts @@ -1,4 +1,4 @@ -import type { JSONValue, Pointer } from "@interactive-os/json-document"; +import { isJSONValue, type Pointer } from "@interactive-os/json-document"; import { getActiveRichTextInstrument } from "./instrument.js"; import { RICH_TEXT_PROFILE_V1, @@ -192,13 +192,14 @@ function validateAttrs( const names = Object.keys(specs); if (names.length === 0) return "attrs" in owner ? fail("rich-text.schema-violation", "Unexpected attrs.", `${pointer}/attrs`) : { ok: true }; if (!isJSONObject(owner.attrs)) return fail("rich-text.schema-violation", "Required attrs object is missing.", `${pointer}/attrs`); + if (!isJSONValue(owner.attrs)) return fail("rich-text.schema-violation", "Attrs must contain JSON values.", `${pointer}/attrs`); for (const [name, spec] of Object.entries(specs)) { const value = owner.attrs[name]; if (value === undefined) { if (spec.required && spec.default === undefined) return fail("rich-text.schema-violation", `Missing attr ${name}.`, `${pointer}/attrs/${name}`); continue; } - if (!isJSONValue(value) || !spec.validate(value)) return fail("rich-text.schema-violation", `Invalid attr ${name}.`, `${pointer}/attrs/${name}`); + if (!spec.validate(value)) return fail("rich-text.schema-violation", `Invalid attr ${name}.`, `${pointer}/attrs/${name}`); } for (const name of Object.keys(owner.attrs)) if (specs[name] === undefined) return fail("rich-text.schema-violation", `Unknown attr ${name}.`, `${pointer}/attrs/${name}`); return { ok: true }; @@ -218,11 +219,4 @@ function isJSONObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isJSONValue(value: unknown): value is JSONValue { - if (value === null || typeof value === "string" || typeof value === "boolean") return true; - if (typeof value === "number") return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJSONValue); - return isJSONObject(value) && Object.values(value).every(isJSONValue); -} - export { RICH_TEXT_PROFILE_V1 }; diff --git a/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts b/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts index d3a85ed14..5ea210d4c 100644 --- a/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts +++ b/packages/json-document-rich-text/tests/conformance/editing-grammar.test.ts @@ -1,6 +1,6 @@ import { createJSONDocument } from "@interactive-os/json-document"; import { createRangeSelectionFamily } from "@interactive-os/json-document-selection"; -import { expect } from "vitest"; +import { expect, test } from "vitest"; import { editingGrammar } from "../../../json-document-editing/tests/conformance/editing-grammar.js"; import { createRichTextEditor, type RichTextClipboard, type RichTextDocument, @@ -19,6 +19,28 @@ const range = (anchor: number, focus = anchor): RichTextSelection => ({ kind: "range", ranges: [{ anchor: point(anchor), focus: point(focus) }], primaryIndex: 0, }); +test.each([ + { backward: false, moveAfterUndo: false }, { backward: true, moveAfterUndo: false }, + { backward: false, moveAfterUndo: true }, { backward: true, moveAfterUndo: true }, +])("EG-HISTORY / replacement restores directed range ($backward), redo survives selection ($moveAfterUndo)", ({ backward, moveAfterUndo }) => { + const editor = createRichTextEditor({ document: createJSONDocument(initial) }); + const before = backward ? range(4, 2) : range(2, 4); + expect(editor.dispatch({ type: "selection.set", selection: before }).ok).toBe(true); + expect(editor.dispatch({ type: "text.insert", text: "X" }).ok).toBe(true); + const after = { ...initial, content: [ + { id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "AlXa", marks: [] }] }, + initial.content[1], + ] }; + expect(editor.snapshot).toMatchObject({ value: after, selection: range(3), canUndo: true, canRedo: false }); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { value: initial, selection: before, canUndo: false, canRedo: true } }); + if (moveAfterUndo) { + expect(editor.dispatch({ type: "selection.set", selection: range(0) })).toMatchObject({ + ok: true, snapshot: { value: initial, selection: range(0), canUndo: false, canRedo: true }, + }); + } + expect(editor.redo()).toMatchObject({ ok: true, snapshot: { value: after, selection: range(3), canUndo: true, canRedo: false } }); +}); + editingGrammar("Rich Text v1 / inline slice / local history", () => { const document = createJSONDocument(initial); let id = 0; diff --git a/packages/json-document-rich-text/tests/editor-protocol.test.ts b/packages/json-document-rich-text/tests/editor-protocol.test.ts index aa077444a..749653b6d 100644 --- a/packages/json-document-rich-text/tests/editor-protocol.test.ts +++ b/packages/json-document-rich-text/tests/editor-protocol.test.ts @@ -168,6 +168,13 @@ describe("Rich Text extension protocol", () => { expect(editor.dispatch({ type: "text.insert", text: "!" }).ok).toBe(true); expect(inner.at("/content/0/content/0/text")).toMatchObject({ ok: true, value: "a!" }); unsubscribe(); + // UI observation is released; only the history invalidation marker remains. + expect(active).toBe(1); + inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "external" }]); + expect(active).toBe(0); + inner.commit([{ op: "replace", path: "/content/0/content/0/text", value: "a!" }]); + expect(editor.snapshot.canUndo).toBe(false); + expect(editor.undo()).toMatchObject({ ok: false, code: "history.empty" }); expect(active).toBe(0); }); diff --git a/packages/json-document-rich-text/tests/editor.test.ts b/packages/json-document-rich-text/tests/editor.test.ts index 561b45991..9da3656a1 100644 --- a/packages/json-document-rich-text/tests/editor.test.ts +++ b/packages/json-document-rich-text/tests/editor.test.ts @@ -32,6 +32,60 @@ const initial: RichTextDocument = { }; describe("Official Rich Text editor", () => { + it("preserves caret offsets for unsorted, coincident and overlapping replacements", () => { + const replacements = [{ start: 6, end: 6 }, { start: 1, end: 3 }, { start: 1, end: 1 }, { start: 4, end: 5 }]; + const document = createJSONDocument({ + ...initial, content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "abcdefgh", marks: [] }] }], + }); + const ranges = replacements.map(({ start, end }) => ({ anchor: point("t", start), focus: point("t", end) })); + const editor = createRichTextEditor({ document, selection: { kind: "range", ranges, primaryIndex: 2 } }); + const reconciled = editor.snapshot.selection; + const before = document.value; + const text = "XY"; + let expected = "abcdefgh"; + for (const replacement of [...replacements].sort((left, right) => right.start - left.start)) { + expected = expected.slice(0, replacement.start) + text + expected.slice(replacement.end); + } + expect(editor.dispatch({ type: "text.insert", text }).ok).toBe(true); + expect(document.at("/content/0/content/0/text")).toMatchObject({ value: expected }); + expect(editor.snapshot.selection.ranges.map((range) => range.focus.offset)).toEqual(replacements.map((replacement) => ( + replacement.start + text.length + replacements.filter((other) => other.start < replacement.start) + .reduce((shift, other) => shift + text.length - (other.end - other.start), 0) + ))); + expect(editor.undo()).toMatchObject({ ok: true, snapshot: { selection: reconciled } }); + expect(document.value).toEqual(before); + }); + + it("marks the union of overlapping and separated ranges without changing unselected segments", () => { + const document = createJSONDocument({ + ...initial, content: [{ id: "p", type: "paragraph", content: [{ id: "t", type: "text", text: "abcdefghij", marks: [] }] }], + }); + const ranges = [[6, 8], [1, 3], [2, 4]].map(([from, to]) => ({ anchor: point("t", from!), focus: point("t", to!) })); + const editor = createRichTextEditor({ document, createId: ids(), selection: { kind: "range", ranges, primaryIndex: 0 } }); + const before = document.value; + expect(editor.dispatch({ type: "mark.toggle", mark: { type: "strong" } }).ok).toBe(true); + const content = (document.value as RichTextDocument).content[0] as RichTextParagraph; + expect(content.content.map((node) => [(node as RichTextText).text, (node as RichTextText).marks])).toEqual([ + ["a", []], ["bcd", [{ type: "strong" }]], ["ef", []], ["gh", [{ type: "strong" }]], ["ij", []], + ]); + expect(editor.undo().ok).toBe(true); + expect(document.value).toEqual(before); + }); + + it("owns inserted payloads and retained change values after internal planning", () => { + const document = createJSONDocument(initial); + const editor = createRichTextEditor({ document }); + const node = { id: "external", type: "paragraph" as const, content: [{ id: "external-text", type: "text" as const, text: "safe", marks: [] }] }; + const inserted = editor.dispatch({ type: "node.insert", point: { kind: "child", nodeId: "document-1", offset: 1, affinity: "forward" }, node }); + expect(inserted.ok).toBe(true); + node.content[0]!.text = "poison"; + expect(document.at("/content/1/content/0/text")).toMatchObject({ value: "safe" }); + expect(inserted.ok && inserted.change?.applied[0]).toMatchObject({ value: { content: [{ text: "safe" }] } }); + expect(editor.undo().ok).toBe(true); + expect(editor.redo().ok).toBe(true); + expect(document.at("/content/1/content/0/text")).toMatchObject({ value: "safe" }); + }); + it("uses a child boundary for an empty first block and splits at both text boundaries canonically", () => { const empty = createJSONDocument({ profile: "urn:interactive-os:json-document:rich-text:1", diff --git a/packages/json-document-rich-text/tests/local-edit.test.ts b/packages/json-document-rich-text/tests/local-edit.test.ts index f6c3f07cf..57679e434 100644 --- a/packages/json-document-rich-text/tests/local-edit.test.ts +++ b/packages/json-document-rich-text/tests/local-edit.test.ts @@ -1,4 +1,4 @@ -import { createJSONDocument } from "@interactive-os/json-document"; +import { createJSONDocument, readPointer } from "@interactive-os/json-document"; import { describe, expect, it } from "vitest"; import { createRichTextBlockFixture, @@ -9,6 +9,29 @@ import { } from "../src/index.js"; describe("Official Rich Text local edit costs", () => { + it.each([1_000, 10_000])("reuses the nested snapshot topology across external leaf edits (%s blocks)", (size) => { + const document = createJSONDocument({ "a/b": createRichTextBlockFixture(size) }); + const editor = createRichTextEditor({ document, pointer: "#/a~1b" }); + const retained = editor.snapshot.value; + const topology = editor.topology; + const instrument = createRichTextInstrument(); + runWithRichTextInstrument(instrument, () => { + expect(editor.topology).toBe(topology); + expect(editor.snapshot.value).toBe(retained); + expect(editor.topology).toBe(topology); + }); + expect(instrument.snapshot().topologyCreates).toBe(0); + const release = editor.subscribe(() => {}); + runWithRichTextInstrument(instrument, () => { + expect(document.commit([{ op: "replace", path: "/a~1b/content/0/content/0/text", value: "external" }]).ok).toBe(true); + expect(editor.snapshot.value).toBe(document.value); + }); + expect(instrument.snapshot().topologyCreates).toBe(0); + expect(instrument.snapshot().topologyVisits).toBeLessThan(16); + expect(readPointer(retained, "/a~1b/content/0/content/0/text")).toMatchObject({ value: "x" }); + release(); + }); + it("indexes topology during editor create in the same walk as validation", () => { const size = 256; const instrument = createRichTextInstrument(); diff --git a/packages/json-document-rich-text/tests/schema.test.ts b/packages/json-document-rich-text/tests/schema.test.ts index 7b2a50041..1953e360b 100644 --- a/packages/json-document-rich-text/tests/schema.test.ts +++ b/packages/json-document-rich-text/tests/schema.test.ts @@ -1,7 +1,8 @@ -import { createJSONDocument } from "@interactive-os/json-document"; +import { createJSONDocument, type JSONValue } from "@interactive-os/json-document"; import { describe, expect, it } from "vitest"; import { createRichTextSchema, + createRichTextEditor, createRichTextTopology, normalizeRichText, richTextSchemaV1, @@ -47,6 +48,55 @@ const canonical: RichTextDocument = { }; describe("Official Rich Text schema", () => { + it("keeps normalization values and patch payloads independently mutable", () => { + const input = { + profile: canonical.profile, id: "doc", type: "doc", + content: [{ id: "p", type: "paragraph", content: [ + { id: "a", type: "text", text: "A", marks: [] }, + { id: "b", type: "text", text: "B", marks: [] }, + ] }], + }; + const normalized = normalizeRichText(input); + if (!normalized.ok) throw new Error(normalized.reason); + const operation = normalized.operations.find((operation) => operation.path === "/content/0/content"); + expect(operation).toMatchObject({ op: "replace", value: [{ text: "AB" }] }); + if (operation?.op !== "replace") throw new Error("missing normalization patch"); + (operation.value as Array<{ text: string }>)[0]!.text = "patch-only"; + expect(normalized.value.content[0]).toMatchObject({ content: [{ text: "AB" }] }); + input.content[0]!.content[0]!.text = "input-only"; + expect(normalized.value.content[0]).toMatchObject({ content: [{ text: "AB" }] }); + }); + + it("rejects non-JSON extension attrs before normalization or history changes", () => { + const schema = createRichTextSchema({ profile: "urn:example:json-attrs:1", nodes: { + "com.example/data": { group: "block", atom: true, attrs: { value: { required: true, validate: () => true } }, content: null, allowedMarks: "none" }, + } }); + const document = createJSONDocument({ profile: schema.profile, id: "doc", type: "doc", content: [ + { id: "data", type: "com.example/data", attrs: { value: null } }, + ] }); + const editor = createRichTextEditor({ document, schema }); + const before = editor.snapshot; + let publications = 0; + const unsubscribe = editor.subscribe(() => { publications++; }); + const cycle: unknown[] = []; + cycle.push(cycle); + for (const value of [NaN, Infinity, new Date(0), Array(1), cycle]) { + const attrs = { value: value as JSONValue }; + expect(editor.dispatch({ type: "node.set-attrs", nodeId: "data", attrs })) + .toMatchObject({ ok: false, code: "rich-text.schema-violation" }); + expect(editor.dispatch({ type: "clipboard.paste", clipboard: { + type: "application/vnd.interactive-os.rich-text+json", text: "", html: "", + slice: { profile: schema.profile, openStart: 0, openEnd: 0, content: [{ id: "copy", type: "com.example/data", attrs }] }, + } })).toMatchObject({ ok: false, code: "rich-text.clipboard-invalid" }); + expect(editor.snapshot).toEqual(before); + } + expect(publications).toBe(0); + expect(editor.dispatch({ type: "node.set-attrs", nodeId: "data", attrs: { value: { nested: [1, null] } } }).ok).toBe(true); + expect(editor.undo().ok).toBe(true); + expect(editor.snapshot.value).toEqual(before.value); + unsubscribe(); + }); + it("reports a schema failure when local validation cannot resolve the parent type", () => { const nodes = Object.fromEntries(Object.entries(richTextSchemaV1.nodes).filter(([type]) => type !== "blockquote")); const options = { schema: { ...richTextSchemaV1, nodes } }; 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..8766e35e2 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 @@ -146,20 +150,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 @@ -191,7 +213,8 @@ The host owns: - DOM/canvas geometry and hit testing; - external plain-text interpretation and product-specific paste policy; - 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. The module does not access `window`, `document`, or `navigator` during import, so non-browser tooling can load it safely. @@ -239,3 +262,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-zod/src/database-document.ts b/packages/json-document-zod/src/database-document.ts index 16c807a21..422e7866a 100644 --- a/packages/json-document-zod/src/database-document.ts +++ b/packages/json-document-zod/src/database-document.ts @@ -1,4 +1,4 @@ -import type { JSONValue } from "@interactive-os/json-document"; +import { buildPointer, type JSONValue } from "@interactive-os/json-document"; import { acceptsDatabaseValue, defaultDatabaseValue, @@ -76,7 +76,7 @@ export function databaseDocumentFromZod( return failure( "schema_violation", issue?.message ?? "Record failed Zod validation.", - issue === undefined ? `/${index}` : recordPointer(index, issue.path), + issue === undefined ? `/${index}` : buildPointer([index, ...issue.path.map(String)]), ); } @@ -228,14 +228,6 @@ function displayName(key: string): string { return key.length === 0 ? key : `${key[0]!.toUpperCase()}${key.slice(1)}`; } -function recordPointer(index: number, path: ReadonlyArray): string { - return `/${[index, ...path].map((segment) => escapePointerToken(String(segment))).join("/")}`; -} - -function escapePointerToken(token: string): string { - return token.replace(/~/g, "~0").replace(/\//g, "~1"); -} - function failure( code: string, reason?: string, diff --git a/packages/json-document-zod/src/index.ts b/packages/json-document-zod/src/index.ts index 208f1c162..2fb3abd3d 100644 --- a/packages/json-document-zod/src/index.ts +++ b/packages/json-document-zod/src/index.ts @@ -1,6 +1,7 @@ -import type { - JSONPatchValidationResult, - JSONValue, +import { + buildPointer, + type JSONPatchValidationResult, + type JSONValue, } from "@interactive-os/json-document"; import type { ZodType } from "zod/v4"; @@ -37,17 +38,7 @@ export function createZodValidator( ok: false, code, reason: issue.message, - pointer: issuePathToPointer(issue.path), + pointer: buildPointer(issue.path.map(String)), }; }; } - -function issuePathToPointer(path: ReadonlyArray): string { - return path.length === 0 - ? "" - : `/${path.map((segment) => escapePointerToken(String(segment))).join("/")}`; -} - -function escapePointerToken(token: string): string { - return token.replace(/~/g, "~0").replace(/\//g, "~1"); -} diff --git a/packages/json-document/README.md b/packages/json-document/README.md index f42aa210e..d4247e27b 100644 --- a/packages/json-document/README.md +++ b/packages/json-document/README.md @@ -106,13 +106,13 @@ Initial value와 patch payload, metadata, exposed document value/change는 docum ## 공개 root -Root는 23개 public symbol만 공개합니다. +Root의 공개 계약은 `public-contract.json`으로 검사합니다. ```txt values applyPatch, createJSONDocument appendSegment, buildPointer, parentPointer, parsePointer - jsonEqual, parseArrayIndex, trackPointer, tryParsePointer + isJSONValue, jsonEqual, parseArrayIndex, readPointer, trackPointer, tryParsePointer types JSONValue, Pointer, JSONPatchOperation @@ -129,6 +129,30 @@ history와 clipboard는 optional editing companion이 조합하고, framework bi ## 순수 core +`isJSONValue(value: unknown): value is JSONValue`는 Core의 JSON tree 제약을 +검사합니다. 값을 복제하거나 정규화하지 않습니다. 유한하지 않은 숫자, 희소 배열, +접근자·symbol 속성, 비표준 객체, 순환 또는 공유 객체 참조는 거절합니다. +도메인 schema의 추가 조건은 각 도메인이 검사합니다. + +`readPointer(value: JSONValue, pointer: Pointer): ReadResult`는 `document.at`과 +동일한 주소 해석을 값에 직접 적용합니다. 일반 Pointer와 URI fragment를 지원하고, +실패는 `invalid_pointer` 또는 `path_not_found`로 반환합니다. 입력은 이미 유효한 +JSON이어야 하며, 반환한 값은 원본 참조입니다. 입력을 복제·동결하거나 소유하지 +않으므로 immutable snapshot을 읽을 때도 참조 동일성이 유지됩니다. + +```ts +import { isJSONValue, readPointer } from "@interactive-os/json-document"; + +const input: unknown = { "a/b~": [{ title: "Draft" }] }; +if (isJSONValue(input)) { + const result = readPointer(input, "#/a~1b~0/0/title"); + // { ok: true, path: "#/a~1b~0/0/title", value: "Draft" } +} +``` + +실행 가능한 Usage와 구현 source는 site의 `/connectors/react`에서 확인할 수 +있습니다. 이 stateless API는 `JSONDocument`의 여섯 멤버를 늘리지 않습니다. + `applyPatch`는 schema, session, UI 없이 ordered atomic JSON Patch를 적용합니다. ```ts diff --git a/packages/json-document/benchmarks/core.mjs b/packages/json-document/benchmarks/core.mjs index 46556baf8..4a7e66cd4 100644 --- a/packages/json-document/benchmarks/core.mjs +++ b/packages/json-document/benchmarks/core.mjs @@ -75,6 +75,54 @@ for (const size of sizes) { commitBudgetPerTenThousandMs * (size / 10_000), ); + const batchSize = Math.min(size, 1_000); + const batchDocument = createJSONDocument(initial); + let batchDone = false; + const batchOperations = Array.from({ length: batchSize }, (_, index) => ({ + op: "replace", + path: `/items/${Math.floor(index * size / batchSize)}/done`, + value: batchDone, + })); + measure(`commit ${batchSize} leaf replaces`, () => { + batchDone = !batchDone; + for (const operation of batchOperations) operation.value = batchDone; + const result = batchDocument.commit(batchOperations); + return result.ok && result.change.applied.length === batchSize; + }); + measure(`commit ${batchSize} equivalent leaf replaces`, () => { + const result = batchDocument.commit(batchOperations); + return result.ok && result.change.applied.length === 0; + }); + + const rootDocument = createJSONDocument(Object.fromEntries( + Array.from({ length: size }, (_, index) => [`field-${index}`, false]), + )); + const rootOperations = Array.from({ length: batchSize }, (_, index) => ({ + op: "add", path: `/field-${index}`, value: false, + })); + let rootDone = false; + measure(`commit ${batchSize} root object writes`, () => { + rootDone = !rootDone; + for (const operation of rootOperations) operation.value = rootDone; + const result = rootDocument.commit(rootOperations); + return result.ok && result.change.applied.length === batchSize; + }); + + const structuralDocument = createJSONDocument(initial); + const appended = Array.from({ length: batchSize }, (_, index) => ({ + op: "add", path: "/items/-", value: { id: `added-${index}`, done: false }, + })); + const removed = Array.from({ length: batchSize }, (_, index) => ({ + op: "remove", path: `/items/${size - index - 1}`, + })); + let structuralDone = false; + measure(`commit ${batchSize} appends and descending removes`, () => { + structuralDone = !structuralDone; + for (const operation of appended) operation.value.done = structuralDone; + const result = structuralDocument.commit([...appended, ...removed]); + return result.ok && result.change.applied.length === batchSize * 2; + }); + const queryDocument = createJSONDocument(initial); measure("query direct item", () => { const result = queryDocument.query(`$.items[${middle}].id`); diff --git a/packages/json-document/public-contract.json b/packages/json-document/public-contract.json index 114acf97f..4f10136e4 100644 --- a/packages/json-document/public-contract.json +++ b/packages/json-document/public-contract.json @@ -5,10 +5,12 @@ "applyPatch", "buildPointer", "createJSONDocument", + "isJSONValue", "jsonEqual", "parentPointer", "parseArrayIndex", "parsePointer", + "readPointer", "trackPointer", "tryParsePointer" ], diff --git a/packages/json-document/src/application/document/index.ts b/packages/json-document/src/application/document/index.ts index d8ceb0dac..43703a62a 100644 --- a/packages/json-document/src/application/document/index.ts +++ b/packages/json-document/src/application/document/index.ts @@ -3,10 +3,12 @@ export { appendSegment, applyPatch, buildPointer, + isJSONValue, jsonEqual, parentPointer, parseArrayIndex, parsePointer, + readPointer, trackPointer, tryParsePointer, } from "./protocol.js"; diff --git a/packages/json-document/src/application/document/protocol.ts b/packages/json-document/src/application/document/protocol.ts index b0aaa7034..98bfc9672 100644 --- a/packages/json-document/src/application/document/protocol.ts +++ b/packages/json-document/src/application/document/protocol.ts @@ -8,12 +8,15 @@ import { trackPointer as trackPointerInternal, tryParsePointer as tryParsePointerInternal, jsonEqual as jsonEqualInternal, + isJSONValue as isJSONValueInternal, + readPointer as readPointerInternal, } from "../../domain/json-document/index.js"; import type { JSONPatchOperation, JSONPatchResult, JSONValue, Pointer, + ReadResult, } from "./contract.js"; export function applyPatch( @@ -35,6 +38,16 @@ export function jsonEqual(left: unknown, right: unknown): boolean { return jsonEqualInternal(left, right); } +/** Tests Core's JSON tree constraints without cloning or normalizing the input. */ +export function isJSONValue(value: unknown): value is JSONValue { + return isJSONValueInternal(value); +} + +/** Reads a JSON value by Pointer, preserving the selected value's identity. */ +export function readPointer(value: JSONValue, pointer: Pointer): ReadResult { + return readPointerInternal(value, pointer); +} + export function tryParsePointer(pointer: Pointer): string[] | null { return tryParsePointerInternal(pointer); } diff --git a/packages/json-document/src/domain/json-document/create.ts b/packages/json-document/src/domain/json-document/create.ts index d3dabc355..75c985c95 100644 --- a/packages/json-document/src/domain/json-document/create.ts +++ b/packages/json-document/src/domain/json-document/create.ts @@ -7,6 +7,7 @@ import { parsePointer, queryJSONPath, readAt, + readPointer, type JSONAppliedChange, type JSONPatchValidationResult, type JSONChangeMetadata, @@ -80,20 +81,7 @@ export function createJSONDocumentState( return state; }, at(pointer: string): ReadResult { - let segments: string[]; - try { - segments = parsePointer(pointer); - } catch (error) { - return failure( - "invalid_pointer", - error instanceof Error ? error.message : "invalid pointer", - pointer, - ); - } - const result = readAt(state, segments); - return result.ok - ? Object.freeze({ ok: true, path: pointer, value: result.value as JSONValue }) - : failure("path_not_found", `path not found: ${pointer}`, pointer); + return readPointer(state, pointer); }, query(jsonPath: string): QueryResult { try { @@ -128,12 +116,10 @@ export function createJSONDocumentState( const metadata = ownMetadata(commitOptions?.metadata); if (!metadata.ok) return metadata; - const local = localCommitEffect(state, operations); const result = prepare(operations); if (!result.ok) return result; - const unchanged = local === "noop" || (local === "unknown" && jsonEqual(state, result.value)); - if (unchanged) { + if (isUnchangedCommit(state, result.value, result.change.applied)) { return Object.freeze({ ok: true, change: createChange([], metadata.value), @@ -183,78 +169,39 @@ export function createJSONDocumentState( } } -function localCommitEffect( - state: JSONValue, +function isUnchangedCommit( + before: JSONValue, + after: JSONValue, operations: ReadonlyArray, -): "noop" | "changed" | "unknown" { - if (operations.length === 0) return "noop"; - if (operations.length === 1 && operations[0]?.op === "add") { - return singleAddEffect(state, operations[0]); - } - const seen: string[] = []; - let changed = false; - for (const operation of operations) { - if (operation === undefined || typeof operation !== "object" || operation === null) return "unknown"; - if (operation.op === "replace") { - if (typeof operation.path !== "string") return "unknown"; - if (operation.path === "") { - if (operations.length !== 1) return "unknown"; - return jsonEqual(state, operation.value) ? "noop" : "changed"; +): boolean { + if (before === after) return true; + if (operations.length === 1) { + const operation = operations[0]!; + if (operation.op === "remove") return false; + if (operation.op === "add") { + const segments = parsePointer(operation.path); + if (segments.length > 0) { + const parent = readAt(before, segments.slice(0, -1)); + if (parent.ok && Array.isArray(parent.value)) return false; } - if (overlapsLocalPath(seen, operation.path)) return "unknown"; - seen.push(operation.path); - let segments: string[]; - try { - segments = parsePointer(operation.path); - } catch { - return "unknown"; - } - const current = readAt(state, segments); - if (!current.ok) return "unknown"; - if (!jsonEqual(current.value, operation.value)) changed = true; - continue; - } - if (operation.op === "add" || operation.op === "remove") { - if (typeof operation.path !== "string" || operation.path === "") return "unknown"; - // A later operation can cancel a structural mutation or overwrite an - // object add. Only the final value establishes a multi-operation effect. - if (operations.length > 1) return "unknown"; - changed = true; - continue; + const current = readAt(before, segments); + return current.ok && jsonEqual(current.value, operation.value); } - return "unknown"; - } - return changed ? "changed" : "noop"; -} - -function overlapsLocalPath(seen: ReadonlyArray, path: string): boolean { - return seen.some((existing) => ( - existing === path - || existing.startsWith(`${path}/`) - || path.startsWith(`${existing}/`) - )); -} - -function singleAddEffect( - state: JSONValue, - operation: Extract, -): "noop" | "changed" | "unknown" { - if (typeof operation.path !== "string") return "unknown"; - let segments: string[]; - try { - segments = parsePointer(operation.path); - } catch { - return "unknown"; } - if (segments.length === 0) { - return jsonEqual(state, operation.value) ? "noop" : "changed"; + // Successful replacements can only change their target subtrees. Comparing + // those paths in the final value also covers repeated and overlapping paths, + // without pairwise overlap checks or a walk through unchanged array siblings. + if (operations.every((operation) => operation.op === "replace" || operation.op === "test")) { + return operations.every((operation) => { + if (operation.op === "test") return true; + const segments = parsePointer(operation.path); + const previous = readAt(before, segments); + const current = readAt(after, segments); + return previous.ok === current.ok + && (!previous.ok || (current.ok && jsonEqual(previous.value, current.value))); + }); } - const parent = readAt(state, segments.slice(0, -1)); - if (!parent.ok || Array.isArray(parent.value)) return "changed"; - const current = readAt(state, segments); - return current.ok && jsonEqual(current.value, operation.value) - ? "noop" - : "changed"; + return jsonEqual(before, after); } const OK: JSONPatchValidationResult = Object.freeze({ ok: true }); diff --git a/packages/json-document/src/domain/json-document/index.ts b/packages/json-document/src/domain/json-document/index.ts index 1d6bd538d..6268f6538 100644 --- a/packages/json-document/src/domain/json-document/index.ts +++ b/packages/json-document/src/domain/json-document/index.ts @@ -2,7 +2,7 @@ export { createJSONDocumentState, } from "./create.js"; -export { jsonEqual } from "../../foundation/json/index.js"; +export { isJSONValue, jsonEqual } from "../../foundation/json/index.js"; export { appendSegment, @@ -11,6 +11,7 @@ export { parentPointer, parseArrayIndex, parsePointer, + readPointer, trackPointer, tryParsePointer, } from "../../foundation/protocol/index.js"; diff --git a/packages/json-document/src/foundation/json/index.ts b/packages/json-document/src/foundation/json/index.ts index 48314fe75..4d8a17e7a 100644 --- a/packages/json-document/src/foundation/json/index.ts +++ b/packages/json-document/src/foundation/json/index.ts @@ -1,3 +1,4 @@ export { cloneJsonSerializable } from "./clone.js"; export { jsonEqual } from "./equal.js"; +export { isJSONValue } from "./serializable.js"; export { cloneTrustedPlainJson } from "./trusted-clone.js"; diff --git a/packages/json-document/src/foundation/json/serializable.ts b/packages/json-document/src/foundation/json/serializable.ts index f2b031093..d5e6dcd59 100644 --- a/packages/json-document/src/foundation/json/serializable.ts +++ b/packages/json-document/src/foundation/json/serializable.ts @@ -1,5 +1,11 @@ import { buildPointer } from "../pointer/core.js"; import { isJsonArrayIndexKey } from "./classification.js"; +import type { JSONValue } from "../protocol/contract.js"; + +/** Tests the Core JSON value boundary without invoking getters or normalizing data. */ +export function isJSONValue(value: unknown): value is JSONValue { + return jsonSerializableErrorFast(value) === null; +} export function jsonSerializableError(value: unknown): string | null { return jsonSerializableErrorFast(value) === null ? null : jsonSerializableErrorDetailed(value); diff --git a/packages/json-document/src/foundation/json/shared-array.ts b/packages/json-document/src/foundation/json/shared-array.ts index 1adf3f9b9..1f9e22741 100644 --- a/packages/json-document/src/foundation/json/shared-array.ts +++ b/packages/json-document/src/foundation/json/shared-array.ts @@ -17,9 +17,9 @@ export function denseArrayCopies(): number { return denseCopies; } -/** Internal ownership check; public reflection may materialize a dense snapshot. */ -export function isSharedArray(value: object): boolean { - return overlays.has(value); +/** Internal overlay metadata, without materializing a dense snapshot. */ +export function getSharedArrayOverlay(value: object): SharedArrayOverlay | undefined { + return overlays.get(value); } export function replaceArrayIndex( diff --git a/packages/json-document/src/foundation/jsonpath/evaluate.ts b/packages/json-document/src/foundation/jsonpath/evaluate.ts index 61e80fab2..f8bdfe8ef 100644 --- a/packages/json-document/src/foundation/jsonpath/evaluate.ts +++ b/packages/json-document/src/foundation/jsonpath/evaluate.ts @@ -1,11 +1,12 @@ // foundation/jsonpath/evaluate — Query AST + JSON 입력 → Match[] (Pointer + value). // RFC 9535 §2 의 normalized 의미. Pointer 는 RFC 6901. +import { appendSegment } from "../pointer/core.js"; import type { Query, Segment, Selector, FilterExpr, Comparable, FilterQuery, FunctionExpr, Match } from "./ast.js"; import { jsonEqual } from "../json/equal.js"; import { evaluateArrayRegexFilter, evaluateArrayWildcardField } from "./fast.js"; import { evaluateSimpleQuery, evaluateSinglePathQuery } from "./simple.js"; -import { compiledRegex, escapeSeg, normalizeSliceIndex, objectHasOwn } from "./support.js"; +import { compiledRegex, normalizeSliceIndex, objectHasOwn } from "./support.js"; /** root JSON 입력에 query 적용 → matches. 결과 순서: RFC 9535 정합 (DFS). */ export function evaluate(query: Query, root: unknown): Match[] { @@ -54,7 +55,7 @@ function visitDescendants(m: Match, cb: (n: Match) => void): void { } } else { for (const k of Object.keys(m.value as Record)) { - visitDescendants({ pointer: m.pointer + "/" + escapeSeg(k), value: (m.value as Record)[k] }, cb); + visitDescendants({ pointer: appendSegment(m.pointer, k), value: (m.value as Record)[k] }, cb); } } } @@ -64,7 +65,7 @@ function applySelector(sel: Selector, m: Match, root: unknown): Match[] { if (m.value === null || typeof m.value !== "object" || Array.isArray(m.value)) return []; const obj = m.value as Record; if (!objectHasOwn.call(obj, sel.name)) return []; - return [{ pointer: m.pointer + "/" + escapeSeg(sel.name), value: obj[sel.name] }]; + return [{ pointer: appendSegment(m.pointer, sel.name), value: obj[sel.name] }]; } if (sel.kind === "index") { if (!Array.isArray(m.value)) return []; @@ -91,7 +92,7 @@ function applySelector(sel: Selector, m: Match, root: unknown): Match[] { return m.value.map((v, i) => ({ pointer: m.pointer + "/" + i, value: v })); } const obj = m.value as Record; - return Object.keys(obj).map((k) => ({ pointer: m.pointer + "/" + escapeSeg(k), value: obj[k] })); + return Object.keys(obj).map((k) => ({ pointer: appendSegment(m.pointer, k), value: obj[k] })); } if (sel.kind === "filter") { if (m.value === null || typeof m.value !== "object") return []; @@ -104,7 +105,7 @@ function applySelector(sel: Selector, m: Match, root: unknown): Match[] { } else { const obj = m.value as Record; for (const k of Object.keys(obj)) { - const cm = { pointer: m.pointer + "/" + escapeSeg(k), value: obj[k] }; + const cm = { pointer: appendSegment(m.pointer, k), value: obj[k] }; if (evalFilter(sel.expr, cm, root)) out.push(cm); } } diff --git a/packages/json-document/src/foundation/jsonpath/fast.ts b/packages/json-document/src/foundation/jsonpath/fast.ts index 1b3f1ca2c..4309163cf 100644 --- a/packages/json-document/src/foundation/jsonpath/fast.ts +++ b/packages/json-document/src/foundation/jsonpath/fast.ts @@ -1,5 +1,6 @@ +import { appendSegment } from "../pointer/core.js"; import type { FilterExpr, Match, Query } from "./ast.js"; -import { compiledRegex, escapeSeg, objectHasOwn, plainRegexLiteral } from "./support.js"; +import { compiledRegex, objectHasOwn, plainRegexLiteral } from "./support.js"; interface ArrayWildcardFieldQuery { arrayName: string; @@ -16,8 +17,8 @@ export function evaluateArrayWildcardField(query: Query, root: unknown): Match[] const array = rootObject[simple.arrayName]; if (!Array.isArray(array)) return null; - const rootPointer = "/" + escapeSeg(simple.arrayName); - const fieldPointer = "/" + escapeSeg(simple.fieldName); + const rootPointer = appendSegment("", simple.arrayName); + const fieldPointer = appendSegment("", simple.fieldName); const matches = new Array(array.length); let matchCount = 0; for (let index = 0; index < array.length; index += 1) { @@ -70,7 +71,7 @@ export function evaluateArrayRegexFilter(query: Query, root: unknown): Match[] | const regex = literal === null ? compiledRegex(filter.pattern, filter.full) : null; if (literal === null && regex === null) return []; - const arrayPointer = "/" + escapeSeg(arraySelector.name); + const arrayPointer = appendSegment("", arraySelector.name); const matches = new Array(array.length); let matchCount = 0; for (let index = 0; index < array.length; index += 1) { @@ -122,8 +123,8 @@ export function matchArrayWildcardFieldPointers(query: Query, root: unknown): st const array = rootObject[simple.arrayName]; if (!Array.isArray(array)) return null; - const rootPointer = "/" + escapeSeg(simple.arrayName); - const fieldPointer = "/" + escapeSeg(simple.fieldName); + const rootPointer = appendSegment("", simple.arrayName); + const fieldPointer = appendSegment("", simple.fieldName); const pointers = new Array(array.length); let pointerCount = 0; for (let index = 0; index < array.length; index += 1) { diff --git a/packages/json-document/src/foundation/jsonpath/simple.ts b/packages/json-document/src/foundation/jsonpath/simple.ts index 0caa7456c..ca200b953 100644 --- a/packages/json-document/src/foundation/jsonpath/simple.ts +++ b/packages/json-document/src/foundation/jsonpath/simple.ts @@ -1,6 +1,7 @@ +import { appendSegment } from "../pointer/core.js"; import type { Match, Query, Selector } from "./ast.js"; import { matchArrayWildcardFieldPointers } from "./fast.js"; -import { escapeSeg, normalizeSliceIndex, objectHasOwn } from "./support.js"; +import { normalizeSliceIndex, objectHasOwn } from "./support.js"; export function evaluateSinglePathQuery(query: Query, root: unknown): Match[] | null { if (query.segments.length === 0) return [{ pointer: "", value: root }]; @@ -17,7 +18,7 @@ export function evaluateSinglePathQuery(query: Query, root: unknown): Match[] | const object = value as Record; if (!objectHasOwn.call(object, selector.name)) return []; value = object[selector.name]; - pointer += "/" + escapeSeg(selector.name); + pointer = appendSegment(pointer, selector.name); continue; } @@ -100,7 +101,7 @@ function applySimpleSelector( const object = value as Record; if (!objectHasOwn.call(object, selector.name)) return true; nextValues?.push(object[selector.name]); - nextPointers.push(pointer + "/" + escapeSeg(selector.name)); + nextPointers.push(appendSegment(pointer, selector.name)); return true; } case "index": { @@ -144,7 +145,7 @@ function applySimpleSelector( for (let index = 0; index < keys.length; index += 1) { const key = keys[index]!; nextValues?.push(object[key]); - nextPointers.push(pointer + "/" + escapeSeg(key)); + nextPointers.push(appendSegment(pointer, key)); } return true; } @@ -165,7 +166,7 @@ function applySimpleMatchSelector( const object = value as Record; if (!objectHasOwn.call(object, selector.name)) return true; next.push({ - pointer: match.pointer + "/" + escapeSeg(selector.name), + pointer: appendSegment(match.pointer, selector.name), value: object[selector.name], }); return true; @@ -209,7 +210,7 @@ function applySimpleMatchSelector( const keys = Object.keys(object); for (let index = 0; index < keys.length; index += 1) { const key = keys[index]!; - next.push({ pointer: match.pointer + "/" + escapeSeg(key), value: object[key] }); + next.push({ pointer: appendSegment(match.pointer, key), value: object[key] }); } return true; } diff --git a/packages/json-document/src/foundation/jsonpath/support.ts b/packages/json-document/src/foundation/jsonpath/support.ts index c3a043ecd..d38c217ec 100644 --- a/packages/json-document/src/foundation/jsonpath/support.ts +++ b/packages/json-document/src/foundation/jsonpath/support.ts @@ -3,10 +3,6 @@ const regexCache = new Map(); export const objectHasOwn = Object.prototype.hasOwnProperty; -export function escapeSeg(s: string): string { - return s.replace(/~/g, "~0").replace(/\//g, "~1"); -} - export function plainRegexLiteral(pattern: string): string | null { for (let index = 0; index < pattern.length; index += 1) { switch (pattern[index]) { diff --git a/packages/json-document/src/foundation/patch/fast/apply.ts b/packages/json-document/src/foundation/patch/fast/apply.ts index bb679e9a9..5e557e9cc 100644 --- a/packages/json-document/src/foundation/patch/fast/apply.ts +++ b/packages/json-document/src/foundation/patch/fast/apply.ts @@ -1,24 +1,8 @@ import { jsonSerializableError } from "../../json/serializable.js"; -import { - copyRootObject, - copyRootObjectKeyPrefix, - copyRootObjectKeys, - objectHasOwn, - removedRootKeysMatchSuffix, -} from "../object.js"; +import { copyRootObject, objectHasOwn } from "../object.js"; import { validateOperationShape } from "../apply.js"; import { applySequentialReplacePatch } from "../sequential-replace.js"; -import { - applyAppendOnlyAddPatch, - applySameArrayStructuralPatch, - applyTailRemovePatch, -} from "./array.js"; -import { - applyIndependentReplacePatch, - applySameArrayElementReplacePatch, - applySameArrayFieldReplacePatch, - applySameArrayNestedReplacePatch, -} from "./replace.js"; +import { applySameArrayStructuralPatch } from "./array.js"; import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; type FastPatchSuccess = Extract; @@ -29,39 +13,15 @@ type FastPatchStrategy = ( valuesTrusted: boolean, ) => FastPatchResult; -const rootObjectReplaceWhenValuesTrusted: FastPatchStrategy = (state, ops, valuesTrusted) => - valuesTrusted - ? applyRootObjectReplacePatch(state, ops, true) - : { handled: false }; - -export const trustedStrategies: readonly FastPatchStrategy[] = [ - applyAppendOnlyAddPatch, - applyTailRemovePatch, - applyRootObjectRemovePatch, - applyRootObjectAddPatch, - applySameArrayFieldReplacePatch, - applySameArrayNestedReplacePatch, - rootObjectReplaceWhenValuesTrusted, - applySameArrayElementReplacePatch, - applyIndependentReplacePatch, +const strategies: readonly FastPatchStrategy[] = [ + applyRootObjectPatch, applySequentialReplacePatch, applySameArrayStructuralPatch, ]; -export const validatedStrategies: readonly FastPatchStrategy[] = [ - applyRootObjectRemovePatch, - applyRootObjectAddPatch, - applyRootObjectReplacePatch, - applySameArrayFieldReplacePatch, - applySameArrayNestedReplacePatch, - applySameArrayElementReplacePatch, - applySequentialReplacePatch, -]; - export function applyFastPatchStrategies( state: unknown, ops: ReadonlyArray, - strategies: readonly FastPatchStrategy[], valuesTrusted: boolean, ): FastPatchSuccess | null { for (const strategy of strategies) { @@ -71,238 +31,77 @@ export function applyFastPatchStrategies( return null; } -function applyRootObjectRemovePatch( +function applyRootObjectPatch( state: unknown, ops: ReadonlyArray, + valuesTrusted: boolean, ): FastPatchResult { + const first = ops[0]; if ( ops.length < 2 || state === null || typeof state !== "object" || Array.isArray(state) - || !firstFlatRootObjectOperationIs(ops, "remove") - ) { - return { handled: false }; - } + || (first?.op !== "add" && first?.op !== "remove") + ) return { handled: false }; + const firstKey = flatRootObjectKey(first); + if (firstKey === null) return { handled: false }; const source = state as Record; - const sourceKeys = Object.keys(source); - let matchesSourceKeyOrder = ops.length === sourceKeys.length; - let removedKeys: Record | null = null; - let matchesReverseSuffix = ops.length <= sourceKeys.length; - const applied = new Array(ops.length); + const next = first.op === "add" ? copyRootObject(source) : null; + const keys = next === null ? Object.keys(source) : []; + let matchesKeyOrder = ops.length === keys.length; + let matchesReverseSuffix = ops.length <= keys.length; + let removedKeys: Set | null = null; for (let index = 0; index < ops.length; index += 1) { if (!(index in ops)) return { handled: false }; const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || op.op !== "remove" - || typeof op.path !== "string" - || op.path === "" - || op.path[0] !== "/" - || op.path.includes("~") - || op.path.indexOf("/", 1) !== -1 - ) { - return { handled: false }; - } - - const key = op.path.slice(1); - if (matchesSourceKeyOrder && key === sourceKeys[index]) { - matchesReverseSuffix = false; - applied[index] = op; - continue; - } - matchesSourceKeyOrder = false; - if (matchesReverseSuffix && key === sourceKeys[sourceKeys.length - index - 1]) { - applied[index] = op; - continue; - } - matchesReverseSuffix = false; - if (removedKeys === null) { - removedKeys = Object.create(null) as Record; - for (let seenIndex = 0; seenIndex < index; seenIndex += 1) { - removedKeys[ops[seenIndex]!.path.slice(1)] = true; + const key = index === 0 ? firstKey : flatRootObjectKey(op); + if (key === null || op.op !== first.op) return { handled: false }; + + if (op.op === "add" && next !== null) { + if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; + if (key === "__proto__") { + Object.defineProperty(next, key, { value: op.value, enumerable: true, configurable: true, writable: true }); + } else { + next[key] = op.value; } - } - if (!objectHasOwn.call(source, key) || objectHasOwn.call(removedKeys, key)) return { handled: false }; - removedKeys[key] = true; - applied[index] = op; - } - - if (ops.length === sourceKeys.length) return { handled: true, state: {}, applied }; - const keepCount = sourceKeys.length - ops.length; - if (removedKeys === null || removedRootKeysMatchSuffix(sourceKeys, keepCount, removedKeys)) { - return { - handled: true, - state: copyRootObjectKeyPrefix(source, sourceKeys, keepCount), - applied, - }; - } - if (ops.length * 2 < sourceKeys.length) { - const next = copyRootObjectKeys(source, sourceKeys); - for (let index = 0; index < ops.length; index += 1) { - delete next[ops[index]!.path.slice(1)]; - } - return { handled: true, state: next, applied }; - } - - const next: Record = {}; - for (const key of sourceKeys) { - if (objectHasOwn.call(removedKeys, key)) continue; - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: source[key], - enumerable: true, - configurable: true, - writable: true, - }); - } else { - next[key] = source[key]; - } - } - - return { handled: true, state: next, applied }; -} - -function applyRootObjectAddPatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if ( - ops.length < 2 - || state === null - || typeof state !== "object" - || Array.isArray(state) - || !firstFlatRootObjectOperationIs(ops, "add") - ) return { handled: false }; - - let next: Record | null = null; - const applied = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || op.op !== "add" - || typeof op.path !== "string" - || op.path === "" - || op.path[0] !== "/" - || op.path.includes("~") - || op.path.indexOf("/", 1) !== -1 - ) { - return { handled: false }; - } - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - const key = op.path.slice(1); - if (next === null) next = copyRootObject(state as Record); - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); } else { - next[key] = op.value; + matchesKeyOrder &&= key === keys[index]; + matchesReverseSuffix &&= key === keys[keys.length - index - 1]; + // Ordered removals need neither membership checks nor a deletion set. + if (matchesKeyOrder || matchesReverseSuffix) continue; + removedKeys ??= new Set(ops.slice(0, index).map((seen) => seen.path.slice(1))); + if (!objectHasOwn.call(source, key) || removedKeys.has(key)) return { handled: false }; + removedKeys.add(key); } - applied[index] = op; } - return next === null - ? { handled: false } - : { handled: true, state: next, applied }; -} + const applied = ops.slice(); + if (next !== null) return { handled: true, state: next, applied }; -function applyRootObjectReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if ( - ops.length < 2 - || state === null - || typeof state !== "object" - || Array.isArray(state) - || !firstFlatRootObjectOperationIs(ops, "replace") - ) return { handled: false }; - - const source = state as Record; - const sourceKeys = Object.keys(source); - let matchesSourceKeyOrder = ops.length === sourceKeys.length; - const orderedNext: Record | null = matchesSourceKeyOrder ? {} : null; - const applied = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || op.op !== "replace" - || typeof op.path !== "string" - || op.path[0] !== "/" - || op.path.includes("~") - || op.path.indexOf("/", 1) !== -1 - ) { - return { handled: false }; - } - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - const key = op.path.slice(1); - if (matchesSourceKeyOrder) { - if (key !== "" && key === sourceKeys[index]) { - if (key === "__proto__") { - Object.defineProperty(orderedNext, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - orderedNext![key] = op.value; - } - applied[index] = op; - continue; - } - matchesSourceKeyOrder = false; - } - - if (key === "" || !objectHasOwn.call(state, key)) return { handled: false }; - applied[index] = op; + const keepCount = keys.length - ops.length; + if (removedKeys === null || keys.slice(keepCount).every((key) => removedKeys.has(key))) { + return { handled: true, state: copyRootObject(source, keys.slice(0, keepCount)), applied }; } - - if (matchesSourceKeyOrder && orderedNext !== null) return { handled: true, state: orderedNext, applied }; - - const next = copyRootObjectKeys(source, sourceKeys); - const replaceOps = ops as ReadonlyArray>; - for (let index = 0; index < replaceOps.length; index += 1) { - const op = replaceOps[index]!; - const key = op.path.slice(1); - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - next[key] = op.value; - } + if (ops.length * 2 < keys.length) { + const retained = copyRootObject(source, keys); + for (const key of removedKeys) delete retained[key]; + return { handled: true, state: retained, applied }; } - return { handled: true, state: next, applied }; + return { + handled: true, + state: copyRootObject(source, keys.filter((key) => !removedKeys.has(key))), + applied, + }; } -function firstFlatRootObjectOperationIs( - ops: ReadonlyArray, - operation: "add" | "remove" | "replace", -): boolean { - if (!(0 in ops)) return false; - const first = ops[0]!; - return validateOperationShape(first) === null - && first.op === operation - && typeof first.path === "string" - && first.path.length > 1 - && first.path[0] === "/" - && !first.path.includes("~") - && first.path.indexOf("/", 1) === -1; +function flatRootObjectKey(op: JSONPatchOperation): string | null { + if ( + validateOperationShape(op) !== null + || op.path[0] !== "/" + || op.path.includes("~") + || op.path.indexOf("/", 1) !== -1 + ) return null; + return op.path.slice(1); } diff --git a/packages/json-document/src/foundation/patch/fast/array.ts b/packages/json-document/src/foundation/patch/fast/array.ts index ab90786d7..4a6d7ee26 100644 --- a/packages/json-document/src/foundation/patch/fast/array.ts +++ b/packages/json-document/src/foundation/patch/fast/array.ts @@ -1,663 +1,179 @@ import { jsonSerializableError } from "../../json/serializable.js"; import { cloneTrustedPlainJson } from "../../json/trusted-clone.js"; -import { appendSegment, type Pointer } from "../../pointer/core.js"; +import { appendSegment } from "../../pointer/core.js"; import { getValueAt, parseSafe } from "../container.js"; -import { appendArrayIndexPath, arrayLocation, arrayRemoveLocation } from "../path.js"; +import { arrayLocation } from "../path.js"; import { replaceValueAtSegments } from "../replace-value.js"; import { validateOperationShape } from "../apply.js"; import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; -type SameArrayStructuralItem = - | { op: "add"; path: Pointer; index: number | "-"; value: unknown } - | { op: "remove"; path: Pointer; index: number } - | { op: "copy"; from: Pointer; path: Pointer; fromIndex: number; index: number | "-" } - | { op: "move"; from: Pointer; path: Pointer; fromIndex: number; index: number | "-" }; - -export function applyAppendOnlyAddPatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let parent: Pointer | null = null; - let appendPath: Pointer | null = null; - const values = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - op === null - || typeof op !== "object" - || op.op !== "add" - || typeof op.path !== "string" - || !("value" in op) - || !op.path.endsWith("/-") - ) { - return { handled: false }; - } - - if (appendPath === null) { - appendPath = op.path; - parent = op.path.slice(0, -2); - } else if (op.path !== appendPath) { - return { handled: false }; - } - - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - values[index] = op.value; - } - - if (parent === null) return { handled: false }; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - - const initialLength = current.value.length; - const stateWithArray = replaceValueAtSegments( - state, - parsedParent.segs, - 0, - current.value.concat(values), - ); - if (stateWithArray === null) return { handled: false }; - - const applied = new Array(values.length); - for (let index = 0; index < values.length; index += 1) { - applied[index] = { - op: "add", - path: appendArrayIndexPath(parent, initialLength + index), - value: values[index], - }; - } - - return { - handled: true, - state: stateWithArray, - applied, - }; -} - -export function applyTailRemovePatch( - state: unknown, - ops: ReadonlyArray, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let parent: Pointer | null = null; - let parentSegments: string[] | null = null; - let currentArray: unknown[] | null = null; - let initialLength = 0; - const applied = new Array(ops.length); - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - op === null - || typeof op !== "object" - || op.op !== "remove" - || typeof op.path !== "string" - || op.path === "" - ) { - return { handled: false }; - } - - const location = arrayRemoveLocation(op.path); - if (location === null) return { handled: false }; - - if (parent === null) { - parent = location.parent; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); - if (!current.ok || !Array.isArray(current.value) || ops.length > current.value.length) { - return { handled: false }; - } - parentSegments = parsedParent.segs; - currentArray = current.value; - initialLength = current.value.length; - } else if (parent !== location.parent) { - return { handled: false }; - } - - if (location.index !== initialLength - index - 1) return { handled: false }; - applied[index] = { op: "remove", path: op.path }; - } - - if (parentSegments === null || currentArray === null) return { handled: false }; - const stateWithArray = replaceValueAtSegments( - state, - parentSegments, - 0, - currentArray.slice(0, initialLength - ops.length), - ); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} +type ArrayItem = + | { op: "add"; index: number; value: unknown } + | { op: "remove"; index: number } + | { op: "copy" | "move"; fromIndex: number; index: number }; +/** Prepare addresses and canonical operations once, then copy the array once. */ export function applySameArrayStructuralPatch( state: unknown, - ops: ReadonlyArray, + operations: ReadonlyArray, valuesTrusted = false, ): FastPatchResult { - if (ops.length < 1) return { handled: false }; - - const increasingAddFast = applyIncreasingArrayAddOpsPatch(state, ops, valuesTrusted); - if (increasingAddFast !== null) return increasingAddFast; - - let parent: string | null = null; - const items: SameArrayStructuralItem[] = []; - - for (let index = 0; index < ops.length; index++) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if ( - validateOperationShape(op) !== null - || ( - op.op !== "add" - && op.op !== "remove" - && op.op !== "copy" - && op.op !== "move" - ) - || op.path === "" - ) { - return { handled: false }; - } - const location = arrayLocation(op.path); - if (!location) return { handled: false }; - if (parent === null) { - parent = location.parent; - } else if (location.parent !== parent) { - return { handled: false }; - } - if (op.op === "add") { - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - items.push({ op: "add", path: op.path, index: location.index, value: op.value }); - } else if (op.op === "remove") { - if (location.index === "-") return { handled: false }; - items.push({ op: "remove", path: op.path, index: location.index }); - } else { - const fromLocation = arrayLocation(op.from); - if (!fromLocation || fromLocation.parent !== parent || fromLocation.index === "-") { - return { handled: false }; - } - items.push({ - op: op.op, - from: op.from, - path: op.path, - fromIndex: fromLocation.index, - index: location.index, - }); - } - } - - if (parent === null) return { handled: false }; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); + const first = operations[0]; + if (first === undefined || validateOperationShape(first) !== null) return { handled: false }; + const location = arrayLocation(first.path); + if (location === null) return { handled: false }; + const parent = location.parent; + const parsed = parseSafe(parent); + if (!("ok" in parsed)) return { handled: false }; + const current = getValueAt(state, parsed.segs); if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - const parsedIncreasingAddFast = applyIncreasingArrayAddPatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (parsedIncreasingAddFast !== null) return parsedIncreasingAddFast; - - const nonDecreasingRemoveFast = applyNonDecreasingArrayRemovePatch( - state, - parsedParent.segs, - current.value, - items, - ); - if (nonDecreasingRemoveFast !== null) return nonDecreasingRemoveFast; - - const nonIncreasingAddFast = applyNonIncreasingArrayAddPatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (nonIncreasingAddFast !== null) return nonIncreasingAddFast; - - const nonIncreasingCopyFast = applyNonIncreasingArrayCopyPatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (nonIncreasingCopyFast !== null) return nonIncreasingCopyFast; - - const appendThenRemoveFast = applyAppendThenNonDecreasingRemovePatch( - state, - parent, - parsedParent.segs, - current.value, - items, - ); - if (appendThenRemoveFast !== null) return appendThenRemoveFast; - - const single = applySingleStructuralItem(state, parent, parsedParent.segs, current.value, items); - if (single !== null) return single; - - const next = current.value.slice(); + const items: ArrayItem[] = []; const applied: JSONPatchOperation[] = []; - for (const item of items) { - if (item.op === "add") { - const index = item.index === "-" ? next.length : item.index; - if (index < 0 || index > next.length) return { handled: false }; - if (index === next.length) next.push(item.value); - else next.splice(index, 0, item.value); - applied.push({ op: "add", path: appendSegment(parent, index), value: item.value }); - continue; - } - - if (item.op === "remove") { - if (item.index < 0 || item.index >= next.length) return { handled: false }; - if (item.index === next.length - 1) next.pop(); - else next.splice(item.index, 1); - applied.push({ op: "remove", path: item.path }); - continue; - } - - if (item.op === "copy") { - if (item.fromIndex < 0 || item.fromIndex >= next.length) return { handled: false }; - const index = item.index === "-" ? next.length : item.index; - if (index < 0 || index > next.length) return { handled: false }; - const value = cloneTrustedPlainJson(next[item.fromIndex]); - if (index === next.length) next.push(value); - else next.splice(index, 0, value); - applied.push({ op: "copy", from: item.from, path: appendSegment(parent, index) }); - continue; - } - - if (item.fromIndex < 0 || item.fromIndex >= next.length) return { handled: false }; - if (item.index === "-") { - const [value] = next.splice(item.fromIndex, 1); - const index = next.length; - next.push(value); - applied.push({ op: "move", from: item.from, path: appendSegment(parent, index) }); - continue; - } - - const index = item.index; - if (index < 0 || index >= next.length) return { handled: false }; - if (item.fromIndex === index) { - applied.push({ op: "move", from: item.from, path: appendSegment(parent, index) }); - continue; - } - if (Math.abs(item.fromIndex - index) === 1) { - const value = next[item.fromIndex]; - next[item.fromIndex] = next[index]; - next[index] = value; - } else { - const [value] = next.splice(item.fromIndex, 1); - if (index < 0 || index > next.length) return { handled: false }; - next.splice(index, 0, value); - } - applied.push({ op: "move", from: item.from, path: appendSegment(parent, index) }); - } - - const stateWithArray = replaceValueAtSegments(state, parsedParent.segs, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -function applySingleStructuralItem( - state: unknown, - parent: string, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length !== 1) return null; - const item = items[0]!; - if (item.op === "add") { - const index = item.index === "-" ? current.length : item.index; - if (index < 0 || index > current.length) return { handled: false }; - if (index !== current.length) return null; - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, current.concat([item.value])); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied: [{ op: "add", path: appendSegment(parent, index), value: item.value }] }; - } - if (item.op === "remove") { - if (item.index < 0 || item.index >= current.length) return { handled: false }; - if (item.index !== current.length - 1) return null; - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, current.slice(0, item.index)); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied: [{ op: "remove", path: item.path }] }; - } - if (item.op !== "copy") return null; - if (item.fromIndex < 0 || item.fromIndex >= current.length) return { handled: false }; - const index = item.index === "-" ? current.length : item.index; - if (index < 0 || index > current.length) return { handled: false }; - if (index !== current.length) return null; - const value = cloneTrustedPlainJson(current[item.fromIndex]); - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, current.concat([value])); - return stateWithArray === null - ? { handled: false } - : { - handled: true, - state: stateWithArray, - applied: [{ op: "copy", from: item.from, path: appendSegment(parent, index) }], - }; -} - -function applyIncreasingArrayAddOpsPatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted: boolean, -): FastPatchResult | null { - if (ops.length < 2) return null; - const first = ops[0]; - if ( - first === undefined - || validateOperationShape(first) !== null - || first.op !== "add" - || first.path === "" - || first.path.endsWith("/-") - ) { - return null; - } - - const firstLocation = arrayLocation(first.path); - if (firstLocation === null || firstLocation.index === "-") return null; - - const parent = firstLocation.parent; - const start = firstLocation.index; - const values = new Array(ops.length); - const applied = new Array(ops.length); - - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; + let length = current.value.length; + for (let opIndex = 0; opIndex < operations.length; opIndex += 1) { + if (!(opIndex in operations)) return { handled: false }; + const operation = operations[opIndex]!; if ( - validateOperationShape(op) !== null - || op.op !== "add" - || op.path === "" - || op.path.endsWith("/-") - ) { - return null; + validateOperationShape(operation) !== null + || (operation.op !== "add" && operation.op !== "remove" && operation.op !== "copy" && operation.op !== "move") + ) return { handled: false }; + const target = operation.path === first.path ? location : arrayLocation(operation.path); + if (target === null || target.parent !== parent) return { handled: false }; + if (operation.op === "remove" && target.index === "-") return { handled: false }; + const lastIndex = length - (operation.op === "remove" || operation.op === "move" ? 1 : 0); + const index = target.index === "-" ? lastIndex : target.index; + if (index < 0 || index > lastIndex) return { handled: false }; + + if (operation.op === "add") { + if (!valuesTrusted && jsonSerializableError(operation.value) !== null) return { handled: false }; + items.push({ op: "add", index, value: operation.value }); + length += 1; + } else if (operation.op === "remove") { + items.push({ op: "remove", index }); + length -= 1; + } else { + const from = arrayLocation(operation.from); + if (from === null || from.parent !== parent || from.index === "-" || from.index >= length) return { handled: false }; + items.push({ op: operation.op, index, fromIndex: from.index }); + if (operation.op === "copy") length += 1; } - - const location = arrayLocation(op.path); - if (location === null || location.index === "-" || location.parent !== parent) return null; - if (location.index !== start + index) return null; - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - values[index] = op.value; - applied[index] = { - op: "add", - path: appendSegment(parent, location.index), - value: op.value, - }; + applied.push(target.index === "-" ? { ...operation, path: appendSegment(parent, index) } : operation); } - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - const current = getValueAt(state, parsedParent.segs); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - if (start < 0 || start > current.value.length) return { handled: false }; - - const next = start === current.value.length - ? current.value.concat(values) - : current.value.slice(0, start).concat(values, current.value.slice(start)); - const stateWithArray = replaceValueAtSegments(state, parsedParent.segs, 0, next); + const next = applyContiguousArrayAdd(current.value, items) + ?? applyNonIncreasingArrayInsert(current.value, items) + ?? applyAppendThenArrayRemove(current.value, items) + ?? applySequentialArrayItems(current.value, items); + const stateWithArray = replaceValueAtSegments(state, parsed.segs, 0, next); return stateWithArray === null ? { handled: false } : { handled: true, state: stateWithArray, applied }; } -function applyIncreasingArrayAddPatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 1) return null; - - let start = -1; - const values = new Array(items.length); - const applied = new Array(items.length); - - for (let index = 0; index < items.length; index += 1) { - const item = items[index]!; - if (item.op !== "add" || item.index === "-") return null; - if (index === 0) { - start = item.index; - if (start < 0 || start > current.length) return { handled: false }; - } else if (item.index !== start + index) { - return null; - } - values[index] = item.value; - applied[index] = { - op: "add", - path: appendSegment(parent, start + index), - value: item.value, - }; +function applyContiguousArrayAdd( + current: unknown[], + items: ReadonlyArray, +): unknown[] | null { + const start = items[0]!.index; + const values: unknown[] = []; + for (const item of items) { + if (item.op !== "add" || item.index !== start + values.length) return null; + values.push(item.value); } - - const next = start === current.length + if (start === 0) return values.concat(current); + return start === current.length ? current.concat(values) : current.slice(0, start).concat(values, current.slice(start)); - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; } -function applyNonIncreasingArrayAddPatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - +function applyNonIncreasingArrayInsert( + current: unknown[], + items: ReadonlyArray, +): unknown[] | null { let previousIndex = Number.POSITIVE_INFINITY; - const buckets = new Array(current.length + 1); - const applied = new Array(items.length); - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op !== "add" || item.index === "-") return null; - if (item.index > previousIndex) return null; - if (item.index < 0 || item.index > current.length) return { handled: false }; - - const bucket = buckets[item.index]; - if (bucket === undefined) buckets[item.index] = [item.value]; - else bucket.push(item.value); - applied[itemIndex] = { - op: "add", - path: appendSegment(parent, item.index), - value: item.value, - }; - previousIndex = item.index; - } - - return insertBuckets(state, parentSegments, current, buckets, items.length, applied); -} - -function applyNonIncreasingArrayCopyPatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - - let previousIndex = Number.POSITIVE_INFINITY; - let previousMinimumInsertIndex = Number.POSITIVE_INFINITY; - const buckets = new Array(current.length + 1); - const applied = new Array(items.length); - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op !== "copy" || item.index === "-") return null; - if (item.index > previousIndex) return null; - if (item.index < 0 || item.index > current.length) return { handled: false }; - if (item.fromIndex < 0 || item.fromIndex >= current.length) return { handled: false }; - if (item.fromIndex >= previousMinimumInsertIndex) return null; - - const value = cloneTrustedPlainJson(current[item.fromIndex]); - const bucket = buckets[item.index]; - if (bucket === undefined) buckets[item.index] = [value]; - else bucket.push(value); - applied[itemIndex] = { - op: "copy", - from: item.from, - path: appendSegment(parent, item.index), - }; - previousIndex = item.index; - if (item.index < previousMinimumInsertIndex) previousMinimumInsertIndex = item.index; - } - - return insertBuckets(state, parentSegments, current, buckets, items.length, applied); -} - -function insertBuckets( - state: unknown, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - buckets: ReadonlyArray, - insertCount: number, - applied: ReadonlyArray, -): FastPatchResult { - const next = new Array(current.length + insertCount); - let write = 0; - for (let index = 0; index <= current.length; index += 1) { - const bucket = buckets[index]; - if (bucket !== undefined) { - for (let bucketIndex = bucket.length - 1; bucketIndex >= 0; bucketIndex -= 1) { - next[write] = bucket[bucketIndex]; - write += 1; - } - } - if (index < current.length) { - next[write] = current[index]; - write += 1; - } - } - - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -function applyNonDecreasingArrayRemovePatch( - state: unknown, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - - let previousIndex = -1; - const removedIndexes = new Array(items.length); - const applied = new Array(items.length); - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op !== "remove") return null; - if (item.index < previousIndex) return null; - - const sourceIndex = item.index + itemIndex; - if (item.index < 0 || sourceIndex >= current.length) return { handled: false }; - removedIndexes[itemIndex] = sourceIndex; - applied[itemIndex] = { op: "remove", path: item.path }; + const values: unknown[] = []; + for (const item of items) { + if ((item.op !== "add" && item.op !== "copy") || item.index > previousIndex) return null; + // A copy can use the original array only while preceding insertions have + // not shifted its source. Otherwise the sequential executor resolves it. + if (item.op === "copy" && (item.fromIndex >= previousIndex || item.fromIndex >= current.length)) return null; + values.push(item.op === "add" ? item.value : cloneTrustedPlainJson(current[item.fromIndex])); previousIndex = item.index; } - const next = new Array(current.length - items.length); - let removeIndex = 0; + const next = new Array(current.length + items.length); + let read = 0; let write = 0; - for (let index = 0; index < current.length; index += 1) { - if (removeIndex < removedIndexes.length && index === removedIndexes[removeIndex]) { - removeIndex += 1; - continue; - } - next[write] = current[index]; - write += 1; + for (let index = items.length - 1; index >= 0; index -= 1) { + while (read < items[index]!.index) next[write++] = current[read++]; + next[write++] = values[index]; } - - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; + while (read < current.length) next[write++] = current[read++]; + return next; } -function applyAppendThenNonDecreasingRemovePatch( - state: unknown, - parent: Pointer, - parentSegments: ReadonlyArray, - current: ReadonlyArray, - items: ReadonlyArray, -): FastPatchResult | null { - if (items.length < 2) return null; - +function applyAppendThenArrayRemove( + current: unknown[], + items: ReadonlyArray, +): unknown[] | null { const values: unknown[] = []; const removedIndexes: number[] = []; - const applied = new Array(items.length); - let removing = false; - let previousRemoveIndex = -1; - - for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { - const item = items[itemIndex]!; - if (item.op === "add") { - if (removing) return null; - const expectedAppendIndex = current.length + values.length; - if (item.index !== "-" && item.index !== expectedAppendIndex) return null; + let previousIndex = -1; + let descending = false; + for (const item of items) { + if (item.op === "add" && removedIndexes.length === 0 && item.index === current.length + values.length) { values.push(item.value); - applied[itemIndex] = { - op: "add", - path: appendSegment(parent, expectedAppendIndex), - value: item.value, - }; continue; } - if (item.op !== "remove") return null; - removing = true; - if (item.index < previousRemoveIndex) return null; - const sourceIndex = item.index + removedIndexes.length; - if (item.index < 0 || sourceIndex >= current.length) return { handled: false }; + if (removedIndexes.length === 1) descending = item.index < previousIndex; + if (removedIndexes.length > 0 && (descending ? item.index >= previousIndex : item.index < previousIndex)) return null; + const sourceIndex = item.index + (descending ? 0 : removedIndexes.length); + if (sourceIndex >= current.length) return null; removedIndexes.push(sourceIndex); - applied[itemIndex] = { op: "remove", path: item.path }; - previousRemoveIndex = item.index; + previousIndex = item.index; } + if (descending) removedIndexes.reverse(); - if (values.length === 0 || removedIndexes.length === 0) return null; - - const next = new Array(current.length - removedIndexes.length + values.length); + const keepCount = current.length - removedIndexes.length; + if (removedIndexes[0] === keepCount) { + const prefix = current.slice(0, keepCount); + return values.length === 0 ? prefix : prefix.concat(values); + } + const next = new Array(keepCount + values.length); let removeIndex = 0; let write = 0; for (let index = 0; index < current.length; index += 1) { - if (removeIndex < removedIndexes.length && index === removedIndexes[removeIndex]) { - removeIndex += 1; - continue; - } - next[write] = current[index]; - write += 1; - } - for (let index = 0; index < values.length; index += 1) { - next[write] = values[index]; - write += 1; + if (index === removedIndexes[removeIndex]) removeIndex += 1; + else next[write++] = current[index]; } + for (const value of values) next[write++] = value; + return next; +} - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; +function applySequentialArrayItems( + current: unknown[], + items: ReadonlyArray, +): unknown[] { + const next = current.slice(); + for (const item of items) { + if (item.op === "remove") { + next.splice(item.index, 1); + } else if (item.op === "move") { + if (item.fromIndex === item.index) continue; + if (Math.abs(item.fromIndex - item.index) === 1) { + const value = next[item.fromIndex]; + next[item.fromIndex] = next[item.index]; + next[item.index] = value; + } else { + const [value] = next.splice(item.fromIndex, 1); + next.splice(item.index, 0, value); + } + } else { + const value = item.op === "add" ? item.value : cloneTrustedPlainJson(next[item.fromIndex]); + next.splice(item.index, 0, value); + } + } + return next; } diff --git a/packages/json-document/src/foundation/patch/fast/replace.ts b/packages/json-document/src/foundation/patch/fast/replace.ts deleted file mode 100644 index a3c4c73f0..000000000 --- a/packages/json-document/src/foundation/patch/fast/replace.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { replaceArrayIndex } from "../../json/shared-array.js"; -import { jsonSerializableError } from "../../json/serializable.js"; -import type { Pointer } from "../../pointer/core.js"; -import { getValueAt, parseSafe } from "../container.js"; -import { objectHasOwn } from "../object.js"; -import { - arrayRemoveLocation, - arrayFieldText, - indexDirection, - parseArrayFieldPath, - parseFirstArrayNestedPath, - parseKnownArrayNestedIndex, - parseKnownArrayFieldIndex, -} from "../path.js"; -import { replaceValueAtSegments } from "../replace-value.js"; -import { validateOperationShape } from "../apply.js"; -import type { FastPatchResult, JSONPatchOperation } from "../contract.js"; -import type { ArrayFieldPath, ArrayFieldText } from "../path.js"; - -interface ReplaceTree { - value?: unknown; - children: Map; -} - -export function applySameArrayFieldReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let arrayPath: Pointer | null = null; - let arraySegments: string[] | null = null; - let field: string | null = null; - let fieldText: ArrayFieldText | null = null; - let arrayValue: unknown[] | null = null; - const updateIndexes = new Array(ops.length); - const updateValues = new Array(ops.length); - const applied = new Array(ops.length); - let previousUpdateIndex: number | null = null; - let monotonicDirection: -1 | 0 | 1 = 0; - let hasRepeatedOrNonMonotonicIndex = false; - - for (let opIndex = 0; opIndex < ops.length; opIndex += 1) { - if (!(opIndex in ops)) return { handled: false }; - const op = ops[opIndex]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - const knownIndex = fieldText === null ? null : parseKnownArrayFieldIndex(op.path, fieldText); - let location: ArrayFieldPath | null; - if (knownIndex === null) { - location = parseArrayFieldPath(op.path); - } else { - if (arrayPath === null || field === null) return { handled: false }; - location = { arrayPath, index: knownIndex, key: field }; - } - if (location === null) return { handled: false }; - if (field === null) { - field = location.key; - fieldText = arrayFieldText(op.path); - } else if (field !== location.key) return { handled: false }; - - if (arrayValue === null) { - arrayPath = location.arrayPath; - const parsedArray = parseSafe(arrayPath); - if (!("ok" in parsedArray)) return { handled: false }; - arraySegments = parsedArray.segs; - const current = getValueAt(state, arraySegments); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - arrayValue = current.value; - } else if (arrayPath !== location.arrayPath) { - return { handled: false }; - } - - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - if (arrayValue === null || location.index < 0 || location.index >= arrayValue.length) return { handled: false }; - const row = arrayValue[location.index]; - if (row === null || typeof row !== "object" || Array.isArray(row)) return { handled: false }; - if (!objectHasOwn.call(row, location.key)) return { handled: false }; - if (previousUpdateIndex !== null) { - const direction = indexDirection(previousUpdateIndex, location.index); - if (direction === 0) { - hasRepeatedOrNonMonotonicIndex = true; - } else if (monotonicDirection === 0) { - monotonicDirection = direction; - } else if (direction !== monotonicDirection) { - hasRepeatedOrNonMonotonicIndex = true; - } - } - previousUpdateIndex = location.index; - updateIndexes[opIndex] = location.index; - updateValues[opIndex] = op.value; - applied[opIndex] = op; - } - - if (arraySegments === null || field === null || arrayValue === null) return { handled: false }; - const next = applyIndexedReplacements(arrayValue, updateIndexes, updateValues, (source, rowIndex, value) => ( - replaceRowField(source, rowIndex, field, value) - ), hasRepeatedOrNonMonotonicIndex); - const stateWithArray = replaceValueAtSegments(state, arraySegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -function replaceRowField( - source: unknown[], - rowIndex: number, - field: string, - value: unknown, -): unknown { - const row = source[rowIndex] as Record; - const replaced = { ...row }; - if (field === "__proto__") { - Object.defineProperty(replaced, field, { - value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - replaced[field] = value; - } - return replaced; -} - -export function applySameArrayNestedReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let arrayPath: Pointer | null = null; - let arraySegments: string[] | null = null; - let prefixText: string | null = null; - let suffixText: string | null = null; - let suffixSegments: string[] | null = null; - let arrayValue: unknown[] | null = null; - const updateIndexes = new Array(ops.length); - const updateValues = new Array(ops.length); - const applied = new Array(ops.length); - - for (let opIndex = 0; opIndex < ops.length; opIndex += 1) { - if (!(opIndex in ops)) return { handled: false }; - const op = ops[opIndex]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - - let rowIndex: number; - if (arrayPath === null) { - const location = parseFirstArrayNestedPath(state, op.path); - if (location === null) return { handled: false }; - arrayPath = location.arrayPath; - arraySegments = location.arraySegments; - prefixText = location.prefixText; - suffixText = location.suffixText; - suffixSegments = location.suffixSegments; - const current = getValueAt(state, location.arraySegments); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - arrayValue = current.value; - rowIndex = location.index; - } else { - if (suffixSegments === null || prefixText === null || suffixText === null) return { handled: false }; - const parsedIndex = parseKnownArrayNestedIndex( - op.path, - arrayPath, - suffixSegments, - prefixText, - suffixText, - ); - if (parsedIndex === null) return { handled: false }; - rowIndex = parsedIndex; - } - - if (arrayValue === null || rowIndex < 0 || rowIndex >= arrayValue.length) return { handled: false }; - updateIndexes[opIndex] = rowIndex; - updateValues[opIndex] = op.value; - applied[opIndex] = op; - } - - if (arraySegments === null || suffixSegments === null || arrayValue === null) return { handled: false }; - const replacedRows: unknown[] = []; - for (let index = 0; index < ops.length; index += 1) { - const replaced = replaceValueAtSegments(arrayValue[updateIndexes[index]!], suffixSegments, 0, updateValues[index]); - if (replaced === null) return { handled: false }; - replacedRows[index] = replaced; - } - const next = applyIndexedReplacements(arrayValue, updateIndexes, replacedRows, (source, rowIndex, value) => value); - const stateWithArray = replaceValueAtSegments(state, arraySegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -export function applySameArrayElementReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - let parent: Pointer | null = null; - let parentSegments: string[] | null = null; - let currentArray: unknown[] | null = null; - const updateIndexes = new Array(ops.length); - const updateValues = new Array(ops.length); - const applied = new Array(ops.length); - - for (let index = 0; index < ops.length; index += 1) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - const location = arrayRemoveLocation(op.path); - if (location === null) return { handled: false }; - if (parent === null) { - parent = location.parent; - const parsedParent = parseSafe(parent); - if (!("ok" in parsedParent)) return { handled: false }; - parentSegments = parsedParent.segs; - const current = getValueAt(state, parentSegments); - if (!current.ok || !Array.isArray(current.value)) return { handled: false }; - currentArray = current.value; - } else if (parent !== location.parent) { - return { handled: false }; - } - - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - if (currentArray === null || location.index < 0 || location.index >= currentArray.length) return { handled: false }; - updateIndexes[index] = location.index; - updateValues[index] = op.value; - applied[index] = op; - } - - if (parentSegments === null || currentArray === null) return { handled: false }; - const next = applyIndexedReplacements(currentArray, updateIndexes, updateValues, (_source, _rowIndex, value) => value); - const stateWithArray = replaceValueAtSegments(state, parentSegments, 0, next); - return stateWithArray === null - ? { handled: false } - : { handled: true, state: stateWithArray, applied }; -} - -export function applyIndependentReplacePatch( - state: unknown, - ops: ReadonlyArray, - valuesTrusted = false, -): FastPatchResult { - if (ops.length < 2) return { handled: false }; - - const items: Array<{ op: JSONPatchOperation; path: Pointer; segments: string[]; value: unknown }> = []; - for (let index = 0; index < ops.length; index++) { - if (!(index in ops)) return { handled: false }; - const op = ops[index]!; - if (validateOperationShape(op) !== null || op.op !== "replace" || op.path === "") return { handled: false }; - const parsed = parseSafe(op.path); - if (!("ok" in parsed)) return { handled: false }; - if (!getValueAt(state, parsed.segs).ok) return { handled: false }; - if (!valuesTrusted && jsonSerializableError(op.value) !== null) return { handled: false }; - items.push({ op, path: op.path, segments: parsed.segs, value: op.value }); - } - - if (!hasIndependentPaths(items)) return { handled: false }; - return { handled: true, state: applyReplaceTree(state, buildReplaceTree(items)), applied: items.map((item) => item.op) }; -} - -function buildReplaceTree(items: ReadonlyArray<{ segments: string[]; value: unknown }>): ReplaceTree { - const root: ReplaceTree = { children: new Map() }; - for (const item of items) { - let node = root; - for (const segment of item.segments) { - let child = node.children.get(segment); - if (!child) { - child = { children: new Map() }; - node.children.set(segment, child); - } - node = child; - } - node.value = item.value; - } - return root; -} - -function applyIndexedReplacements( - source: unknown[], - indexes: ReadonlyArray, - values: ReadonlyArray, - replace: (source: unknown[], rowIndex: number, value: unknown) => unknown, - lastWriteWins = false, -): unknown[] { - let next: unknown[] = source; - const seen = new Set(); - const start = lastWriteWins ? indexes.length - 1 : 0; - const step = lastWriteWins ? -1 : 1; - for (let index = start; lastWriteWins ? index >= 0 : index < indexes.length; index += step) { - const rowIndex = indexes[index]!; - if (lastWriteWins && seen.has(rowIndex)) continue; - seen.add(rowIndex); - next = replaceArrayIndex(next, rowIndex, replace(source, rowIndex, values[index])); - } - return next; -} - -function applyReplaceTree(value: unknown, tree: ReplaceTree): unknown { - if (tree.children.size === 0) return tree.value; - if (Array.isArray(value)) { - let next: unknown[] = value; - for (const [segment, child] of tree.children) { - const index = Number(segment); - next = replaceArrayIndex(next, index, applyReplaceTree( - Array.isArray(next) ? next[index] : undefined, - child, - )); - } - return next; - } - const next = { ...(value as Record) }; - for (const [segment, child] of tree.children) { - next[segment] = applyReplaceTree(next[segment], child); - } - return next; -} - -function hasIndependentPaths(paths: ReadonlyArray<{ path: string }>): boolean { - const sorted = paths.map((item) => item.path).sort(); - for (let index = 1; index < sorted.length; index++) { - const previous = sorted[index - 1]!; - const current = sorted[index]!; - if (current === previous || current.startsWith(`${previous}/`)) return false; - } - return true; -} diff --git a/packages/json-document/src/foundation/patch/object.ts b/packages/json-document/src/foundation/patch/object.ts index 77a364c84..2bae975da 100644 --- a/packages/json-document/src/foundation/patch/object.ts +++ b/packages/json-document/src/foundation/patch/object.ts @@ -1,32 +1,11 @@ export const objectHasOwn = Object.prototype.hasOwnProperty; -export function copyRootObject(source: Record): Record { - return copyRootObjectKeys(source, Object.keys(source)); -} - -export function copyRootObjectKeys( +export function copyRootObject( source: Record, - keys: ReadonlyArray, -): Record { - return copyRootObjectKeyPrefix(source, keys, keys.length); -} - -export function copyRootObjectKeyPrefix( - source: Record, - keys: ReadonlyArray, - end: number, + keys: ReadonlyArray = Object.keys(source), ): Record { const next: Record = {}; - if (!objectHasOwn.call(source, "__proto__")) { - for (let index = 0; index < end; index += 1) { - const key = keys[index]!; - next[key] = source[key]; - } - return next; - } - - for (let index = 0; index < end; index += 1) { - const key = keys[index]!; + for (const key of keys) { if (key !== "__proto__") { next[key] = source[key]; continue; @@ -40,14 +19,3 @@ export function copyRootObjectKeyPrefix( } return next; } - -export function removedRootKeysMatchSuffix( - keys: ReadonlyArray, - keepCount: number, - removedKeys: Record, -): boolean { - for (let index = keepCount; index < keys.length; index += 1) { - if (!objectHasOwn.call(removedKeys, keys[index]!)) return false; - } - return true; -} diff --git a/packages/json-document/src/foundation/patch/path.ts b/packages/json-document/src/foundation/patch/path.ts index 417265647..ed2ec5d2f 100644 --- a/packages/json-document/src/foundation/patch/path.ts +++ b/packages/json-document/src/foundation/patch/path.ts @@ -1,170 +1,11 @@ -import { buildPointer, parentPointer, type Pointer } from "../pointer/core.js"; +import type { Pointer } from "../pointer/core.js"; import { parseArrayIndex } from "../pointer/array-index.js"; -import { getValueAt, parseSafe } from "./container.js"; - -export interface ArrayFieldPath { - arrayPath: Pointer; - index: number; - key: string; -} - -interface ArrayNestedPath { - arrayPath: Pointer; - arraySegments: string[]; - index: number; - prefixText: string; - suffixText: string; - suffixSegments: string[]; -} - -export interface ArrayFieldText { - prefixText: string; - suffixText: string; -} export function arrayLocation(path: Pointer): { parent: Pointer; index: number | "-" } | null { - const parent = parentPointer(path); - if (parent === null) return null; - const parsed = parseSafe(path); - if (!("ok" in parsed)) return null; - const segment = parsed.segs[parsed.segs.length - 1]; - if (segment === undefined) return null; + if (path[0] !== "/") return null; + const slash = path.lastIndexOf("/"); + const parent = path.slice(0, slash); + const segment = path.slice(slash + 1); const index = segment === "-" ? "-" : parseArrayIndex(segment); return index === null ? null : { parent, index }; } - -export function arrayRemoveLocation(path: Pointer): { parent: Pointer; index: number } | null { - const simple = parseSimpleArrayElementPath(path); - if (simple !== null) return simple; - - const location = arrayLocation(path); - return location === null || location.index === "-" - ? null - : { parent: location.parent, index: location.index }; -} - -export function appendArrayIndexPath(parent: Pointer, index: number): Pointer { - return parent === "" ? `/${index}` : `${parent}/${index}`; -} - -export function indexDirection(previous: number, current: number): -1 | 0 | 1 { - return current > previous ? 1 : current < previous ? -1 : 0; -} - -export function parseArrayFieldPath(path: Pointer): ArrayFieldPath | null { - const simple = parseSimpleArrayFieldPath(path); - if (simple !== null) return simple; - - const parsed = parseSafe(path); - if (!("ok" in parsed) || parsed.segs.length < 2) return null; - const key = parsed.segs[parsed.segs.length - 1]!; - const index = parseArrayIndex(parsed.segs[parsed.segs.length - 2]!); - return index === null - ? null - : { arrayPath: buildPointer(parsed.segs.slice(0, -2)), index, key }; -} - -export function arrayFieldText(path: Pointer): ArrayFieldText | null { - const keySlash = path.lastIndexOf("/"); - if (keySlash <= 0) return null; - const indexSlash = path.lastIndexOf("/", keySlash - 1); - return indexSlash < 0 - ? null - : { - prefixText: path.slice(0, indexSlash + 1), - suffixText: path.slice(keySlash), - }; -} - -export function parseKnownArrayFieldIndex(path: Pointer, text: ArrayFieldText): number | null { - if (!path.startsWith(text.prefixText) || !path.endsWith(text.suffixText)) return null; - const indexEnd = path.length - text.suffixText.length; - const indexText = path.slice(text.prefixText.length, indexEnd); - return indexText.includes("/") ? null : parseArrayIndex(indexText); -} - -export function parseFirstArrayNestedPath(state: unknown, path: Pointer): ArrayNestedPath | null { - const parsed = parseSafe(path); - if (!("ok" in parsed) || parsed.segs.length < 3) return null; - - for (let index = 0; index < parsed.segs.length - 1; index += 1) { - const rowIndex = parseArrayIndex(parsed.segs[index]!); - if (rowIndex === null) continue; - - const arraySegments = parsed.segs.slice(0, index); - const current = getValueAt(state, arraySegments); - if (!current.ok || !Array.isArray(current.value)) continue; - - const arrayPath = buildPointer(arraySegments); - const suffixSegments = parsed.segs.slice(index + 1); - return { - arrayPath, - arraySegments, - index: rowIndex, - prefixText: arrayPath === "" ? "/" : `${arrayPath}/`, - suffixText: buildPointer(suffixSegments), - suffixSegments, - }; - } - - return null; -} - -export function parseKnownArrayNestedIndex( - path: Pointer, - arrayPath: Pointer, - suffixSegments: string[], - prefixText: string, - suffixText: string, -): number | null { - const knownIndex = parseKnownArrayNestedIndexText(path, prefixText, suffixText); - if (knownIndex !== null) return knownIndex; - - const parsed = parseSafe(path); - if (!("ok" in parsed) || parsed.segs.length < suffixSegments.length + 2) return null; - - const arraySegmentsLength = parsed.segs.length - suffixSegments.length - 1; - for (let index = 0; index < suffixSegments.length; index += 1) { - if (parsed.segs[arraySegmentsLength + 1 + index] !== suffixSegments[index]) return null; - } - - const arraySegments = parsed.segs.slice(0, arraySegmentsLength); - if (buildPointer(arraySegments) !== arrayPath) return null; - - return parseArrayIndex(parsed.segs[arraySegmentsLength]!); -} - -function parseKnownArrayNestedIndexText( - path: Pointer, - prefixText: string, - suffixText: string, -): number | null { - if (!path.startsWith(prefixText) || !path.endsWith(suffixText)) return null; - const indexEnd = path.length - suffixText.length; - const indexText = path.slice(prefixText.length, indexEnd); - return indexText.includes("/") ? null : parseArrayIndex(indexText); -} - -function parseSimpleArrayFieldPath(path: Pointer): ArrayFieldPath | null { - if (path === "" || path[0] !== "/" || path.includes("~")) return null; - const keySlash = path.lastIndexOf("/"); - if (keySlash <= 0) return null; - const indexSlash = path.lastIndexOf("/", keySlash - 1); - if (indexSlash < 0) return null; - - const index = parseArrayIndex(path.slice(indexSlash + 1, keySlash)); - if (index === null) return null; - - return { arrayPath: path.slice(0, indexSlash), index, key: path.slice(keySlash + 1) }; -} - -function parseSimpleArrayElementPath(path: Pointer): { parent: Pointer; index: number } | null { - if (path === "" || path[0] !== "/" || path.includes("~")) return null; - const indexSlash = path.lastIndexOf("/"); - if (indexSlash < 0) return null; - - const index = parseArrayIndex(path.slice(indexSlash + 1)); - return index === null - ? null - : { parent: path.slice(0, indexSlash), index }; -} diff --git a/packages/json-document/src/foundation/patch/sequential-replace.ts b/packages/json-document/src/foundation/patch/sequential-replace.ts index 0afd16111..187662555 100644 --- a/packages/json-document/src/foundation/patch/sequential-replace.ts +++ b/packages/json-document/src/foundation/patch/sequential-replace.ts @@ -1,4 +1,5 @@ import { jsonSerializableError } from "../json/serializable.js"; +import { replaceArrayIndex } from "../json/shared-array.js"; import { parseArrayIndex } from "../pointer/array-index.js"; import { validateOperationShape } from "./apply.js"; import { parseSafe } from "./container.js"; @@ -7,16 +8,6 @@ import { objectHasOwn } from "./object.js"; type ReplaceOperation = Extract; -interface PreparedSequentialReplace { - operation: ReplaceOperation; - segments: string[]; -} - -interface SequentialReplaceRun { - state: unknown; - applied: ReplaceOperation[]; -} - /** * Applies a multi-operation, non-root replace batch through one private COW * draft. Unsupported or invalid batches decline so the reference executor @@ -27,53 +18,36 @@ export function applySequentialReplacePatch( operations: ReadonlyArray, valuesTrusted = false, ): FastPatchResult { - const run = runSequentialReplaceBatch(state, operations, valuesTrusted); - return run === null - ? { handled: false } - : { handled: true, state: run.state, applied: run.applied }; -} - -function runSequentialReplaceBatch( - state: unknown, - operations: ReadonlyArray, - valuesTrusted: boolean, -): SequentialReplaceRun | null { - if (operations.length < 2) return null; + if (operations.length < 2) return { handled: false }; - const prepared = new Array(operations.length); const applied = new Array(operations.length); + const draftContainers = new WeakSet(); + let draft = state; for (let index = 0; index < operations.length; index += 1) { - if (!(index in operations)) return null; + if (!(index in operations)) return { handled: false }; const operation = operations[index]!; if ( validateOperationShape(operation) !== null || operation.op !== "replace" - || operation.path === "" + || operation.path[0] !== "/" ) { - return null; + return { handled: false }; } - if (!valuesTrusted && jsonSerializableError(operation.value) !== null) return null; + if (!valuesTrusted && jsonSerializableError(operation.value) !== null) return { handled: false }; const parsed = parseSafe(operation.path); - if (!("ok" in parsed)) return null; - prepared[index] = { operation, segments: parsed.segs }; - applied[index] = operation; - } - - const draftContainers = new WeakSet(); - let draft = state; - for (let index = 0; index < prepared.length; index += 1) { - const item = prepared[index]!; + if (!("ok" in parsed)) return { handled: false }; const replaced = replaceDraftValue( draft, - item.segments, - item.operation, + parsed.segs, + operation, draftContainers, ); - if (replaced === null) return null; + if (replaced === null) return { handled: false }; draft = replaced; + applied[index] = operation; } - return { state: draft, applied }; + return { handled: true, state: draft, applied }; } function replaceDraftValue( @@ -82,42 +56,41 @@ function replaceDraftValue( operation: ReplaceOperation, draftContainers: WeakSet, ): unknown | null { - if (segments.length === 0) return null; - const root = ensureDraftContainer(state, draftContainers); - if (root === null) return null; - - let current = root; - for (let index = 0; index < segments.length - 1; index += 1) { - const segment = segments[index]!; - const child = readDraftChild(current, segment); + const parents: Array<{ container: DraftContainer; key: number | string; value: unknown }> = []; + let current = state; + for (const segment of segments) { + if (current === null || typeof current !== "object") return null; + const container = current as DraftContainer; + const child = readDraftChild(container, segment); if (!child.ok) return null; - const childDraft = ensureDraftContainer(child.value, draftContainers); - if (childDraft === null) return null; - if (childDraft !== child.value) writeDraftChild(current, child.key, childDraft); - current = childDraft; + parents.push({ container, key: child.key, value: child.value }); + current = child.value; } - const target = readDraftChild(current, segments[segments.length - 1]!); - if (!target.ok) return null; - writeDraftChild(current, target.key, operation.value); - return root; + let next = operation.value; + for (let index = parents.length - 1; index >= 0; index -= 1) { + const { container, key, value } = parents[index]!; + if (value === next) { + next = container; + } else if (Array.isArray(container)) { + next = replaceArrayIndex(container, key as number, next); + } else { + const draft = draftContainers.has(container) ? container : { ...container }; + draftContainers.add(draft); + Object.defineProperty(draft, key, { + value: next, + enumerable: true, + configurable: true, + writable: true, + }); + next = draft; + } + } + return next; } type DraftContainer = unknown[] | Record; -function ensureDraftContainer( - value: unknown, - draftContainers: WeakSet, -): DraftContainer | null { - if (value === null || typeof value !== "object") return null; - if (draftContainers.has(value)) return value as DraftContainer; - const draft: DraftContainer = Array.isArray(value) - ? value.slice() - : { ...(value as Record) }; - draftContainers.add(draft); - return draft; -} - function readDraftChild( container: DraftContainer, segment: string, @@ -131,25 +104,3 @@ function readDraftChild( if (!objectHasOwn.call(container, segment)) return { ok: false }; return { ok: true, key: segment, value: container[segment] }; } - -function writeDraftChild( - container: DraftContainer, - key: number | string, - value: unknown, -): void { - if (Array.isArray(container)) { - container[key as number] = value; - return; - } - const property = key as string; - if (property === "__proto__") { - Object.defineProperty(container, property, { - value, - enumerable: true, - configurable: true, - writable: true, - }); - return; - } - container[property] = value; -} diff --git a/packages/json-document/src/foundation/patch/trusted.ts b/packages/json-document/src/foundation/patch/trusted.ts index f1b91a291..c582834ff 100644 --- a/packages/json-document/src/foundation/patch/trusted.ts +++ b/packages/json-document/src/foundation/patch/trusted.ts @@ -1,7 +1,7 @@ import { jsonSerializableError } from "../json/serializable.js"; import { applyOpRaw, validateOperationPointers, validateOperationShape } from "./apply.js"; import { normalizeAppliedOp, normalizeOp } from "./container.js"; -import { validatedStrategies, applyFastPatchStrategies, trustedStrategies } from "./fast/apply.js"; +import { applyFastPatchStrategies } from "./fast/apply.js"; import { fail, ok } from "./result.js"; import { applyTrustedValueMutation } from "./value.js"; import type { @@ -20,7 +20,7 @@ export function applyTrustedPatch( const singleValueFast = applySingleTrustedValuePatch(state, ops, valuesTrusted); if (singleValueFast !== null) return singleValueFast as TrustedApplyResult; - const fast = applyFastPatchStrategies(state, ops, trustedStrategies, valuesTrusted); + const fast = applyFastPatchStrategies(state, ops, valuesTrusted); if (fast !== null) return { state: fast.state as T, result: ok, applied: fast.applied }; let cur: unknown = state; @@ -52,23 +52,6 @@ export function applyTrustedPatch( return { state: cur as T, result: ok, applied: normalized }; } -export function applyValidatedPatch( - state: T, - ops: ReadonlyArray, -): TrustedApplyResult { - if (!Array.isArray(ops)) return { state, result: fail("invalid_pointer", "patch must be an array"), applied: [] }; - - if (ops.length === 1 && 0 in ops) { - const single = applyValidatedSingleTrustedValuePatch(state, ops[0]!); - if (single !== null) return single as TrustedApplyResult; - } - - const fast = applyFastPatchStrategies(state, ops, validatedStrategies, true); - if (fast !== null) return { state: fast.state as T, result: ok, applied: fast.applied }; - - return applyTrustedPatch(state, ops, { valuesTrusted: true }); -} - function applySingleTrustedValuePatch( state: unknown, ops: ReadonlyArray, @@ -102,27 +85,3 @@ function applySingleTrustedValuePatch( return { state: applied.state, result: ok, applied: [normalized] }; } - -function applyValidatedSingleTrustedValuePatch( - state: unknown, - op: JSONPatchOperation, -): TrustedApplyResult | null { - if (op === null || typeof op !== "object" || (op.op !== "add" && op.op !== "replace") || typeof op.path !== "string" || !("value" in op)) { - return null; - } - const pointerError = validateOperationPointers(op); - if (pointerError) { - return { - state, - result: fail(pointerError.error, `op[0]: ${pointerError.reason}`, pointerError.pointer), - applied: [], - }; - } - const normalized = op.op === "add" && op.path.endsWith("/-") ? normalizeOp(op, state) : op; - if (normalized.op !== "add" && normalized.op !== "replace") return null; - const applied = applyTrustedValueMutation(state, normalized); - if ("error" in applied) { - return { state, result: fail(applied.error, applied.reason ? `op[0]: ${applied.reason}` : "op[0]", applied.pointer), applied: [] }; - } - return { state: applied.state, result: ok, applied: [normalized] }; -} diff --git a/packages/json-document/src/foundation/patch/value.ts b/packages/json-document/src/foundation/patch/value.ts index eabcbdf12..0e80fbcdd 100644 --- a/packages/json-document/src/foundation/patch/value.ts +++ b/packages/json-document/src/foundation/patch/value.ts @@ -46,18 +46,7 @@ function applySingleSegmentTrustedValueMutation( if (op.op === "replace" && !objectHasOwn.call(state, key)) { return { error: "path_not_found", reason: `object key: ${key}`, pointer: op.path }; } - const next = { ...(state as Record) }; - if (key === "__proto__") { - Object.defineProperty(next, key, { - value: op.value, - enumerable: true, - configurable: true, - writable: true, - }); - } else { - next[key] = op.value; - } - return { state: next }; + return { state: { ...(state as Record), [key]: op.value } }; } const verb = op.op === "add" ? "set" : "replace"; diff --git a/packages/json-document/src/foundation/protocol/apply.ts b/packages/json-document/src/foundation/protocol/apply.ts index b8f9c8dc6..a72c6cbae 100644 --- a/packages/json-document/src/foundation/protocol/apply.ts +++ b/packages/json-document/src/foundation/protocol/apply.ts @@ -3,13 +3,10 @@ import { cloneTrustedPlainJson, } from "../json/index.js"; import type { JSONPatchOperation as AppliedPatchOperation } from "../patch/contract.js"; -import { - applyValidatedPatch, - applyTrustedPatch, -} from "../patch/trusted.js"; +import { applyTrustedPatch } from "../patch/trusted.js"; import { parseArrayIndex } from "../pointer/array-index.js"; -import { parsePointer } from "../pointer/core.js"; -import { isSharedArray } from "../json/shared-array.js"; +import { parentPointer, parsePointer, readAt } from "../pointer/core.js"; +import { getSharedArrayOverlay } from "../json/shared-array.js"; import type { JSONAppliedChange, JSONPatchFailure, @@ -38,16 +35,7 @@ export function applyProtocolPatch( operations as ReadonlyArray, ); if (!result.result.ok) { - return freezeFailure({ - ok: false, - code: result.result.code, - ...(result.result.reason === undefined - ? {} - : { reason: result.result.reason }), - ...(result.result.pointer === undefined - ? {} - : { pointer: result.result.pointer }), - }); + return freezeFailure(result.result); } const ownedValue = operations.length === 0 @@ -75,16 +63,7 @@ export function applyOwnedProtocolPatch( operations as ReadonlyArray, ); if (!prepared.result.ok) { - return freezeFailure({ - ok: false, - code: prepared.result.code, - ...(prepared.result.reason === undefined - ? {} - : { reason: prepared.result.reason }), - ...(prepared.result.pointer === undefined - ? {} - : { pointer: prepared.result.pointer }), - }); + return freezeFailure(prepared.result); } // Canonical operations own their payloads. Replaying only these validated @@ -97,7 +76,7 @@ export function applyOwnedProtocolPatch( && typeof operation.value === "object" )); const validated = replayRequired - ? applyValidatedPatch(value, applied as ReadonlyArray) + ? applyTrustedPatch(value, applied as ReadonlyArray, { valuesTrusted: true }) : prepared; const ownedValue = validated.result.ok ? freezeOwnedState(validated.state as JSONValue, applied) @@ -166,7 +145,9 @@ function freezeAlongOperations( value: JSONValue, operations: ReadonlyArray, ): boolean { - const paths: string[][] = []; + const containers = new Set(); + const parent = parentPointer(operations.find((operation) => operation.op !== "test")?.path ?? ""); + const sameParent = operations.every((operation) => operation.op === "test" || parentPointer(operation.path) === parent); for (const operation of operations) { if (operation.op === "test") continue; if ( @@ -175,29 +156,35 @@ function freezeAlongOperations( ) { return false; } + let segments: string[]; try { - paths.push(parsePointer(operation.path)); + segments = parsePointer(operation.path); } catch { return false; } + // Array insertion/removal may shift another operation's final address. + // Sibling-only writes are safe: every inserted/replaced payload is owned. + if (operation.op !== "replace" && !sameParent) { + const target = readAt(value, segments.slice(0, -1)); + if (target.ok && Array.isArray(target.value)) return false; + } + if (!freezeAlongPath(value, segments, containers)) return false; } - for (const segments of paths) { - if (!freezeAlongPath(value, segments)) return false; - } - if (value !== null && typeof value === "object" && !isSharedArray(value) && !Object.isFrozen(value)) { + if (value !== null && typeof value === "object") containers.add(value); + // Freeze each shared ancestor once, after all paths succeed. A fallback must + // never mistake a partially frozen ancestor for a fully frozen subtree. + for (const container of containers) { freezeInspections += 1; - Object.freeze(value); + if (!getSharedArrayOverlay(container) && !Object.isFrozen(container)) Object.freeze(container); } return true; } -function freezeAlongPath(root: JSONValue, segments: ReadonlyArray): boolean { - const stack: object[] = []; +function freezeAlongPath(root: JSONValue, segments: ReadonlyArray, containers: Set): boolean { let current: JSONValue = root; for (const segment of segments) { if (current === null || typeof current !== "object") return false; - freezeInspections += 1; - stack.push(current); + containers.add(current); if (Array.isArray(current)) { const index = parseArrayIndex(segment); if (index === null || index >= current.length) return false; @@ -211,17 +198,19 @@ function freezeAlongPath(root: JSONValue, segments: ReadonlyArray): bool } } freezeJSON(current); - for (let index = stack.length - 1; index >= 0; index -= 1) { - const container = stack[index]!; - if (!isSharedArray(container) && !Object.isFrozen(container)) Object.freeze(container); - } return true; } function freezeJSON(value: T): T { if (value === null || typeof value !== "object") return value; freezeInspections += 1; - if (isSharedArray(value) || Object.isFrozen(value)) return value; + const overlay = getSharedArrayOverlay(value); + if (overlay !== undefined) { + freezeJSON(overlay.base as JSONValue); + for (const child of overlay.replacements.values()) freezeJSON(child as JSONValue); + return value; + } + if (Object.isFrozen(value)) return value; for (const child of Object.values(value)) freezeJSON(child as JSONValue); Object.freeze(value); return value; diff --git a/packages/json-document/src/foundation/protocol/index.ts b/packages/json-document/src/foundation/protocol/index.ts index 9a03c4271..21e0b4172 100644 --- a/packages/json-document/src/foundation/protocol/index.ts +++ b/packages/json-document/src/foundation/protocol/index.ts @@ -2,6 +2,8 @@ import { trackPointer as trackPointerInternal } from "../patch/track.js"; import type { Pointer } from "../pointer/core.js"; import type { JSONPatchOperation, JSONValue } from "./contract.js"; +export { readPointer } from "./read.js"; + export { applyOwnedProtocolPatch, applyProtocolPatch, diff --git a/packages/json-document/src/foundation/protocol/read.ts b/packages/json-document/src/foundation/protocol/read.ts new file mode 100644 index 000000000..78e4bf5a8 --- /dev/null +++ b/packages/json-document/src/foundation/protocol/read.ts @@ -0,0 +1,21 @@ +import { parsePointer, readAt, type Pointer } from "../pointer/core.js"; +import type { JSONValue, ReadResult } from "./contract.js"; + +/** Resolves a pointer without cloning, freezing, or taking ownership of the value. */ +export function readPointer(value: JSONValue, pointer: Pointer): ReadResult { + let segments: string[]; + try { + segments = parsePointer(pointer); + } catch (error) { + return Object.freeze({ + ok: false, + code: "invalid_pointer", + reason: error instanceof Error ? error.message : "invalid pointer", + pointer, + }); + } + const result = readAt(value, segments); + return result.ok + ? Object.freeze({ ok: true, path: pointer, value: result.value as JSONValue }) + : Object.freeze({ ok: false, code: "path_not_found", reason: `path not found: ${pointer}`, pointer }); +} diff --git a/packages/json-document/tests/conformance/json-document.test.ts b/packages/json-document/tests/conformance/json-document.test.ts index bcdba4b7e..852ffc17e 100644 --- a/packages/json-document/tests/conformance/json-document.test.ts +++ b/packages/json-document/tests/conformance/json-document.test.ts @@ -408,6 +408,27 @@ test("a leaf replace keeps unrelated sibling identity and does not emit a root r change: { applied: [] }, }); }); +test.each([ + { operations: [ + { op: "replace", path: "/row/text", value: "temporary" }, + { op: "test", path: "/row/text", value: "temporary" }, + { op: "replace", path: "/row/text", value: "before" }, + ] }, + { operations: [ + { op: "replace", path: "/row", value: { transient: 0 } }, + { op: "replace", path: "/row/transient", value: 1 }, + { op: "replace", path: "/row", value: { text: "before" } }, + ] }, +] satisfies Array<{ operations: JSONPatchOperation[] }>)("canceling replacements compare final values at overlapping paths (%#)", ({ operations }) => { + const document = createJSONDocument({ row: { text: "before" } }); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + expect(document.commit(operations)).toEqual({ ok: true, change: { applied: [] } }); + expect(document.value).toBe(before); + expect(notifications).toBe(0); +}); + test("an equivalent object add stays a no-op while an array add remains a change", () => { const document = createJSONDocument({ item: { title: "Draft" }, values: ["same"] }); const notifications: unknown[] = []; diff --git a/packages/json-document/tests/conformance/json-primitives.test.ts b/packages/json-document/tests/conformance/json-primitives.test.ts index 7086e63fb..aa89bd52f 100644 --- a/packages/json-document/tests/conformance/json-primitives.test.ts +++ b/packages/json-document/tests/conformance/json-primitives.test.ts @@ -1,7 +1,37 @@ import { describe, expect, it } from "vitest"; -import { jsonEqual, parseArrayIndex } from "@interactive-os/json-document"; +import { applyPatch, createJSONDocument, isJSONValue, jsonEqual, parseArrayIndex, readPointer } from "@interactive-os/json-document"; describe("canonical JSON primitives", () => { + it("uses the same JSON tree boundary as Core without normalizing or invoking accessors", () => { + const cycle: unknown[] = []; + cycle.push(cycle); + const shared = {}; + let reads = 0; + const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => { reads++; return 1; } }); + const candidates: unknown[] = [ + null, true, 1, "", [], { "a/b~": [1, null] }, Object.create(null), + undefined, NaN, Infinity, new Date(0), Array(1), cycle, [shared, shared], + { value: undefined }, accessor, { [Symbol("key")]: 1 }, + ]; + for (const candidate of candidates) { + expect(isJSONValue(candidate)).toBe(applyPatch(candidate, []).ok); + } + expect(reads).toBe(0); + expect(isJSONValue({ nested: { title: "Draft" } })).toBe(true); + }); + + it("reads the same locations and failures as document.at while borrowing the value", () => { + const value = { "a/b~": [{ title: "Draft" }], "": true }; + const document = createJSONDocument(value); + for (const pointer of ["", "#", "/", "/a~1b~0/0", "#/a~1b~0/0/title", "/a~1b~0/01", "/missing", "/toString", "/bad~2", "#/%ZZ"]) { + expect(readPointer(value, pointer)).toEqual(document.at(pointer)); + } + const selected = readPointer(value, "#/a~1b~0/0"); + expect(selected.ok && selected.value).toBe(value["a/b~"][0]); + expect(Object.isFrozen(value)).toBe(false); + expect(Object.isFrozen(value["a/b~"][0])).toBe(false); + }); + it("compares JSON values without depending on object key order", () => { expect(jsonEqual({ alpha: 1, beta: [true] }, { beta: [true], alpha: 1 })).toBe(true); expect(jsonEqual({ alpha: 1 }, { alpha: 2 })).toBe(false); diff --git a/packages/json-document/tests/foundation/array-patch.test.ts b/packages/json-document/tests/foundation/array-patch.test.ts new file mode 100644 index 000000000..204df9a20 --- /dev/null +++ b/packages/json-document/tests/foundation/array-patch.test.ts @@ -0,0 +1,105 @@ +import { applyPatch, createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; +import { expect, test } from "vitest"; + +test.each([ + { name: "repeated insertions", operations: [ + { op: "add", path: "/items/2", value: "a" }, + { op: "add", path: "/items/2", value: "b" }, + ], expected: [0, 1, "b", "a", 2, 3, 4] }, + { name: "descending mixed insertions", operations: [ + { op: "add", path: "/items/4", value: "tail" }, + { op: "copy", from: "/items/0", path: "/items/2" }, + { op: "add", path: "/items/0", value: "head" }, + ], expected: ["head", 0, 1, 0, 2, 3, "tail", 4] }, + { name: "copies with shifted sources", operations: [ + { op: "copy", from: "/items/0", path: "/items/2" }, + { op: "copy", from: "/items/3", path: "/items/1" }, + ], expected: [0, 2, 1, 0, 2, 3, 4] }, + { name: "increasing removals", operations: [ + { op: "remove", path: "/items/0" }, + { op: "remove", path: "/items/1" }, + { op: "remove", path: "/items/1" }, + ], expected: [1, 4] }, + { name: "descending removals", operations: [ + { op: "remove", path: "/items/4" }, + { op: "remove", path: "/items/2" }, + { op: "remove", path: "/items/0" }, + ], expected: [1, 3] }, + { name: "unordered removals", operations: [ + { op: "remove", path: "/items/1" }, + { op: "remove", path: "/items/2" }, + { op: "remove", path: "/items/0" }, + ], expected: [2, 4] }, + { name: "appends followed by removals", operations: [ + { op: "add", path: "/items/-", value: "a" }, + { op: "add", path: "/items/6", value: "b" }, + { op: "remove", path: "/items/0" }, + { op: "remove", path: "/items/1" }, + ], expected: [1, 3, 4, "a", "b"] }, + { name: "moves including append and adjacent swap", operations: [ + { op: "move", from: "/items/0", path: "/items/-" }, + { op: "move", from: "/items/4", path: "/items/1" }, + { op: "move", from: "/items/3", path: "/items/2" }, + ], expected: [1, 0, 3, 2, 4] }, + { name: "removing an appended item", operations: [ + { op: "add", path: "/items/-", value: "temporary" }, + { op: "remove", path: "/items/5" }, + ], expected: [0, 1, 2, 3, 4] }, +] satisfies Array<{ name: string; operations: JSONPatchOperation[]; expected: Array }>)( + "array batches preserve sequential meaning: $name", + ({ operations, expected }) => { + const initial = { items: [0, 1, 2, 3, 4] }; + const document = createJSONDocument(initial); + const before = document.value; + expect(document.validatePatch(operations)).toEqual({ ok: true }); + expect(document.value).toBe(before); + expect(document.commit(operations)).toMatchObject({ ok: true }); + expect(document.value).toEqual({ items: expected }); + const result = applyPatch(initial, operations); + expect(result).toMatchObject({ ok: true, value: { items: expected } }); + if (result.ok) { + expect(result.change.applied).toHaveLength(operations.length); + expect(result.change.applied.every((operation) => !operation.path.endsWith("/-"))).toBe(true); + expect(applyPatch(initial, result.change.applied)).toMatchObject({ ok: true, value: { items: expected } }); + } + expect(initial.items).toEqual([0, 1, 2, 3, 4]); + expect(before).toEqual(initial); + }, +); + +test("copies can read earlier inserted values without sharing mutable payloads", () => { + const payload = { label: "inserted" }; + const document = createJSONDocument({ "a/b": [0, 1] }); + const result = document.commit([ + { op: "add", path: "/a~1b/0", value: payload }, + { op: "copy", from: "/a~1b/0", path: "/a~1b/1" }, + { op: "copy", from: "/a~1b/1", path: "/a~1b/-" }, + ]); + expect(result).toMatchObject({ ok: true }); + payload.label = "caller mutation"; + const items = (document.value as { "a/b": unknown[] })["a/b"]; + expect(items).toEqual([{ label: "inserted" }, { label: "inserted" }, 0, 1, { label: "inserted" }]); + expect(items[0]).not.toBe(items[1]); + expect(items[1]).not.toBe(items[4]); + expect(Object.isFrozen(items[1])).toBe(true); +}); + +test.each(["/items/01", "/items/99", "/items/-", "#/items/0"])( + "array fast paths preserve rollback and failure precedence (%s)", + (path) => { + const document = createJSONDocument({ items: [0, 1, 2] }); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + const operations = [ + { op: "add", path: "/items/-", value: 3 }, + { op: "remove", path }, + { op: "add", path: "/items/-", value: undefined }, + ] as unknown as JSONPatchOperation[]; + const expected = { ok: false, pointer: path, code: path[0] === "#" ? "invalid_pointer" : "path_not_found" }; + expect(document.validatePatch(operations)).toMatchObject(expected); + expect(document.commit(operations)).toMatchObject(expected); + expect(document.value).toBe(before); + expect(notifications).toBe(0); + }, +); diff --git a/packages/json-document/tests/foundation/object-patch.test.ts b/packages/json-document/tests/foundation/object-patch.test.ts new file mode 100644 index 000000000..1b64e5147 --- /dev/null +++ b/packages/json-document/tests/foundation/object-patch.test.ts @@ -0,0 +1,64 @@ +import { applyPatch, buildPointer, createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; +import { expect, test } from "vitest"; + +const keys = ["0", "1", "a", "b", "c", "d", "e", "", "__proto__"]; + +test.each([ + keys, + [...keys].reverse(), + ["0", "1", ...keys.slice(2).reverse()], + ["__proto__", "", "c", "e"], + ["0", "c"], + ["0", "1", "b", "d", "e"], + ["", "__proto__"], +].map((removed) => ({ removed })))("root removals retain key order and own properties: $removed", ({ removed }) => { + const initial = Object.fromEntries(keys.map((key) => [key, { key }])); + const operations = removed.map((key): JSONPatchOperation => ({ op: "remove", path: buildPointer([key]) })); + const retained = keys.filter((key) => !removed.includes(key)); + const expected = Object.fromEntries(retained.map((key) => [key, { key }])); + const document = createJSONDocument(initial); + expect(document.commit(operations)).toEqual({ ok: true, change: { applied: operations } }); + expect(document.value).toEqual(expected); + expect(Object.keys(document.value as object)).toEqual(retained); + expect(Object.getPrototypeOf(document.value)).toBe(Object.prototype); + expect(applyPatch(initial, operations)).toMatchObject({ ok: true, value: expected }); + expect(Object.keys(initial)).toEqual(keys); +}); + +test("root additions preserve repeated and special keys without owning caller payloads", () => { + const payload = { n: 1 }; + const document = createJSONDocument({ a: 0 }); + const operations: JSONPatchOperation[] = [ + { op: "add", path: "/__proto__", value: { n: 0 } }, + { op: "add", path: "/", value: 2 }, + { op: "add", path: "/__proto__", value: payload }, + { op: "add", path: "/a", value: 3 }, + ]; + const result = document.commit(operations); + expect(result).toEqual({ ok: true, change: { applied: operations } }); + payload.n = 99; + expect(document.value).toEqual(JSON.parse('{"a":3,"__proto__":{"n":1},"":2}')); + expect(Object.getPrototypeOf(document.value)).toBe(Object.prototype); + expect(Object.keys(document.value as object)).toEqual(["a", "__proto__", ""]); + expect(applyPatch({ a: 0 }, result.ok ? result.change.applied : [])).toMatchObject({ ok: true, value: document.value }); +}); + +test.each([["a", "b"], ["d", "c"]])("duplicate removal after an ordered prefix stays atomic (%s, %s)", (first, second) => { + const initial = { a: 0, b: 1, c: 2, d: 3 }; + const document = createJSONDocument(initial); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + const operations = [ + { op: "remove", path: `/${first}` }, + { op: "remove", path: `/${second}` }, + { op: "remove", path: `/${first}` }, + { op: "add", path: "/later", value: undefined }, + ] as unknown as JSONPatchOperation[]; + const expected = { ok: false, code: "path_not_found", pointer: `/${first}`, reason: `op[2]: object key: ${first}` }; + expect(applyPatch(initial, operations)).toEqual(expected); + expect(document.validatePatch(operations)).toEqual(expected); + expect(document.commit(operations)).toEqual(expected); + expect(document.value).toBe(before); + expect(notifications).toBe(0); +}); diff --git a/packages/json-document/tests/foundation/owned-freeze.test.ts b/packages/json-document/tests/foundation/owned-freeze.test.ts index c5752d062..e123ddd85 100644 --- a/packages/json-document/tests/foundation/owned-freeze.test.ts +++ b/packages/json-document/tests/foundation/owned-freeze.test.ts @@ -1,4 +1,4 @@ -import { applyPatch, createJSONDocument } from "@interactive-os/json-document"; +import { applyPatch, createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document"; import { expect, test } from "vitest"; import { @@ -58,6 +58,34 @@ test("a leaf replace freeze inspects the changed path, not every sibling", () => expect(large.inspections).toBe(small.inspections); }); +test("a flat replacement batch inspects its common ancestor only once for freezing", () => { + const document = createJSONDocument(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [i, 0]))); + resetOwnedPatchFreezeInspections(); + expect(document.commit(Array.from({ length: 1_000 }, (_, i) => ({ + op: "replace", path: `/${i}`, value: 1, + }))).ok).toBe(true); + expect(ownedPatchFreezeInspections()).toBe(1); + expect(isDeepFrozen(document.value)).toBe(true); +}); + +test.each([ + { op: "remove", path: "/deleted" }, + { op: "replace", path: "", value: { last: { n: 1 } } }, + { op: "copy", from: "/first", path: "/copied" }, +] satisfies JSONPatchOperation[])("a freeze fallback after $op does not skip later changed nodes", (operation) => { + const document = createJSONDocument({ first: { n: 0 }, deleted: true, last: { n: 0 } }); + const before = document.value; + const result = document.commit([ + { op: "replace", path: "/first/n", value: 1 }, + operation, + { op: "replace", path: "/last/n", value: 2 }, + ]); + expect(result.ok).toBe(true); + expect(isDeepFrozen(document.value)).toBe(true); + expect(Reflect.set((document.value as { last: object }).last, "n", 99)).toBe(false); + expect(before).toEqual({ first: { n: 0 }, deleted: true, last: { n: 0 } }); +}); + test("a leaf replace does not dense-copy a large sibling array", () => { const items = Array.from({ length: 10_000 }, (_, item) => ({ id: `item-${item}`, title: "Draft" })); const document = createJSONDocument({ items }); @@ -76,6 +104,22 @@ test("a leaf replace does not dense-copy a large sibling array", () => { expect(denseArrayCopies()).toBe(0); }); +test.each(([ + [{ op: "replace", path: "/items/0/n", value: 1 }, { op: "remove", path: "/deleted" }], + [{ op: "copy", from: "/items/0", path: "/items/-" }], + [{ op: "add", path: "/items/50/n", value: 1 }, { op: "add", path: "/items/0", value: { n: 0 } }], + [{ op: "replace", path: "/items/50/n", value: 1 }, { op: "remove", path: "/items/0" }], +] satisfies JSONPatchOperation[][]).map((prefix) => ({ prefix })))("mixed array patches freeze shifted values, overlays and copied bases: $prefix", ({ prefix }) => { + const initial = { items: Array.from({ length: 64 }, () => ({ n: 0 })), deleted: true }; + const document = createJSONDocument(initial); + const before = document.value; + resetDenseArrayCopies(); + expect(document.commit([...prefix, { op: "replace", path: "/items/1/n", value: 2 }]).ok).toBe(true); + expect(denseArrayCopies()).toBe(0); + expect(isDeepFrozen(document.value)).toBe(true); + expect(before).toEqual(initial); +}); + test("a batch of leaf replaces does not walk the whole value or dense-copy the array", () => { const items = Array.from({ length: 10_000 }, (_, item) => ({ id: `item-${item}`, title: "Draft" })); const document = createJSONDocument({ items }); @@ -122,6 +166,83 @@ test("a root replace still freezes the whole owned tree", () => { expect(isDeepFrozen(result.value)).toBe(true); }); +test("overlapping array replacements preserve payloads, snapshots and untouched siblings", () => { + const document = createJSONDocument({ + items: Array.from({ length: 128 }, (_, id) => ({ id, nested: { text: "before" } })), + }); + const before = document.value; + const untouched = document.at("/items/11"); + const payload = { id: 10, nested: { text: "injected" } }; + const operations: JSONPatchOperation[] = [ + { op: "replace", path: "/items/10", value: payload }, + { op: "replace", path: "/items/10/nested/text", value: "first" }, + { op: "replace", path: "/items/10/nested/text", value: "last" }, + { op: "replace", path: "/items/80/nested/text", value: null }, + ]; + + resetDenseArrayCopies(); + resetOwnedPatchFreezeInspections(); + expect(document.validatePatch(operations)).toEqual({ ok: true }); + expect(document.value).toBe(before); + expect(document.commit(operations)).toEqual({ ok: true, change: { applied: operations } }); + expect(denseArrayCopies()).toBe(0); + expect(ownedPatchFreezeInspections()).toBeLessThan(100); + expect(document.at("/items/10/nested/text")).toMatchObject({ ok: true, value: "last" }); + expect(document.at("/items/80/nested/text")).toMatchObject({ ok: true, value: null }); + expect(document.at("/items/11")).toEqual(untouched); + const after = document.value as { items: Array<{ nested: { text: string | null } }> }; + expect(after.items[11]).toBe((before as typeof after).items[11]); + expect((before as typeof after).items[10]!.nested.text).toBe("before"); + expect(payload.nested.text).toBe("injected"); + expect(Object.isFrozen(payload.nested)).toBe(false); + expect(isDeepFrozen(after)).toBe(true); +}); + +test("replace batches preserve escaped, empty and __proto__ object keys", () => { + const initial = JSON.parse('{"__proto__":{"value":0},"a/b":{"~":1},"":2}'); + const operations: JSONPatchOperation[] = [ + { op: "replace", path: "/__proto__/value", value: 3 }, + { op: "replace", path: "/a~1b/~0", value: null }, + { op: "replace", path: "/", value: 4 }, + ]; + const result = applyPatch(initial, operations); + expect(result).toEqual({ + ok: true, + value: JSON.parse('{"__proto__":{"value":3},"a/b":{"~":null},"":4}'), + change: { applied: operations }, + }); + expect(initial.__proto__.value).toBe(0); + if (result.ok) expect(Object.getPrototypeOf(result.value)).toBe(Object.prototype); +}); + +test.each(["/missing", "/items/01/text", "#/items/0/text"])( + "a failed replace batch stays atomic and preserves the first failure (%s)", + (path) => { + const initial = { items: Array.from({ length: 64 }, () => ({ text: "before" })) }; + const document = createJSONDocument(initial); + const before = document.value; + let notifications = 0; + document.subscribe(() => { notifications += 1; }); + const operations = [ + { op: "replace", path: "/items/0/text", value: "first" }, + { op: "replace", path, value: "invalid" }, + { op: "replace", path: "/items/1/text", value: undefined }, + ] as unknown as JSONPatchOperation[]; + const expected = { + ok: false, + code: path[0] === "#" ? "invalid_pointer" : "path_not_found", + pointer: path, + }; + expect(applyPatch(initial, operations)).toMatchObject(expected); + expect(document.validatePatch(operations)).toMatchObject(expected); + expect(document.commit(operations)).toMatchObject(expected); + expect(document.value).toBe(before); + expect(document.at("/items/0/text")).toMatchObject({ ok: true, value: "before" }); + expect(notifications).toBe(0); + expect(initial.items[0]!.text).toBe("before"); + }, +); + function applyOwnedAndCount(size: number, index: number) { const items = Array.from({ length: size }, (_, item) => ({ id: `item-${item}`, title: "Draft" })); const document = createJSONDocument({ items }); diff --git a/packages/json-document/tests/package/smoke.mjs b/packages/json-document/tests/package/smoke.mjs index fa2eb1e75..5474e172e 100644 --- a/packages/json-document/tests/package/smoke.mjs +++ b/packages/json-document/tests/package/smoke.mjs @@ -123,8 +123,8 @@ try { if (packageJson.dependencies !== undefined) { throw new Error("The v3 kernel must not publish runtime dependencies."); } - if (rootValueExports.length !== 10 || rootTypeExports.length !== 13) { - throw new Error("The root contract must contain exactly 10 values and 13 types."); + if (rootValueExports.length !== 12 || rootTypeExports.length !== 13) { + throw new Error("The root contract must contain exactly 12 values and 13 types."); } const packResult = JSON.parse(run( diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs index 90ebc9c78..b2846d1a1 100644 --- a/scripts/ci-plan.mjs +++ b/scripts/ci-plan.mjs @@ -31,6 +31,7 @@ const packageBrowserSpecs = new Map([ ["@interactive-os/json-document-ajv", ["site/tests/browser/connectors/ajv.spec.ts"]], ["@interactive-os/json-document-contenteditable", ["site/tests/browser/adapters/contenteditable.spec.ts"]], ["@interactive-os/json-document-database", ["site/tests/browser/database-demo.spec.ts"]], + ["@interactive-os/json-document-annotation", ["site/tests/browser/annotation-demo.spec.ts", "site/tests/browser/copy-selection-closure.spec.ts"]], ["@interactive-os/json-document-calendar", [ "site/tests/browser/calendar-app.spec.ts", "site/tests/browser/calendar-launch.spec.ts", @@ -73,6 +74,7 @@ const routeBrowserSpecs = new Map([ ["connectors/tanstack-table", ["site/tests/browser/connectors/tanstack-table.spec.ts"]], ["connectors/zod", ["site/tests/browser/connectors/zod.spec.ts"]], ["database-demo", ["site/tests/browser/database-demo.spec.ts"]], + ["annotation-demo", ["site/tests/browser/annotation-demo.spec.ts", "site/tests/browser/copy-selection-closure.spec.ts"]], ["document-demo", ["site/tests/browser/document-demo.spec.ts"]], ["editing-demos", ["site/tests/browser/editing-demos.spec.ts"]], ["rich-text-demo", ["site/tests/browser/rich-text-demo.spec.ts"]], @@ -88,6 +90,7 @@ const firstKitWorkspaces = new Set([ "@interactive-os/json-document-react", "@interactive-os/json-document-zod", "@interactive-os/json-document-database", + "@interactive-os/json-document-annotation", "@interactive-os/json-document-calendar", "@interactive-os/json-document-file-intake", "@interactive-os/json-document-rich-text-suggestion", diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 719f2ea10..5efb07146 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -40,7 +40,7 @@ test("기반 패키지 변경은 모든 역방향 소비자를 선택한다", () const plan = createPlan(["packages/json-document/src/index.ts"]); assert.equal(plan.full, false); - assert.equal(plan.packageWorkspaces.length, 26); + assert.equal(plan.packageWorkspaces.length, 27); assert.equal(plan.standards, true); assert.equal(plan.externalKit, true); assert.deepEqual(plan.browserSpecs, ["site/tests/browser"]); @@ -109,6 +109,7 @@ test("선택기가 반환하는 모든 browser 경로가 존재한다", () => { "json-document-composer-react", "json-document-file-intake", "json-document-database", + "json-document-annotation", "json-document-calendar", "json-document-editing", "json-document-react", diff --git a/scripts/external-kit-plan.test.mjs b/scripts/external-kit-plan.test.mjs index 6fff03e04..46c81ba97 100644 --- a/scripts/external-kit-plan.test.mjs +++ b/scripts/external-kit-plan.test.mjs @@ -12,6 +12,7 @@ test("첫 kit package 변경은 외부 소비자 검증을 선택한다", () => "json-document-react", "json-document-zod", "json-document-database", + "json-document-annotation", "json-document-calendar", ]) { assert.equal(createPlan([`packages/${directory}/src/index.ts`]).externalKit, true, directory); diff --git a/scripts/release-package.mjs b/scripts/release-package.mjs index 701055425..661e2b805 100644 --- a/scripts/release-package.mjs +++ b/scripts/release-package.mjs @@ -23,6 +23,7 @@ export const releases = [ release("json-document-tanstack-table", "packages/json-document-tanstack-table/package.json", "@interactive-os/json-document-tanstack-table"), release("json-document-zod", "packages/json-document-zod/package.json", "@interactive-os/json-document-zod"), release("json-document-database", "packages/json-document-database/package.json", "@interactive-os/json-document-database"), + release("json-document-annotation", "packages/json-document-annotation/package.json", "@interactive-os/json-document-annotation"), release("json-document-calendar", "packages/json-document-calendar/package.json", "@interactive-os/json-document-calendar"), release("json-document-contenteditable-collaboration", "packages/contenteditable-collaboration/package.json", "@interactive-os/json-document-contenteditable-collaboration"), release("json-document-collaboration", "packages/json-document-collaboration/package.json", "@interactive-os/json-document-collaboration"), diff --git a/scripts/release-package.test.mjs b/scripts/release-package.test.mjs index 61c4c3631..6331ba6c8 100644 --- a/scripts/release-package.test.mjs +++ b/scripts/release-package.test.mjs @@ -27,6 +27,12 @@ const databaseHand = [ "next", "packages/json-document-database/package.json", ]; +const annotationHand = [ + "json-document-annotation-v0.1.0-rc.0", + "@interactive-os/json-document-annotation", + "next", + "packages/json-document-annotation/package.json", +]; test("첫 npm kit의 stable과 RC release stream을 구분한다", () => { for (const [tag, workspace, distTag, packageFile] of firstKit) { @@ -48,6 +54,11 @@ test("Database Hand를 next release stream으로 해석한다", () => { assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile }); }); +test("Annotation Hand를 next release stream으로 해석한다", () => { + const [tag, workspace, distTag, packageFile] = annotationHand; + assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile }); +}); + test("지원하지 않는 package tag를 거부한다", () => { assert.throws(() => resolveRelease("json-document-rich-text-v0.1.0-rc.0"), /unsupported release tag/); }); diff --git a/scripts/verify-external-kit.mjs b/scripts/verify-external-kit.mjs index 454d3eb6f..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", @@ -27,6 +28,7 @@ const kitWorkspaces = [ "@interactive-os/json-document-react", "@interactive-os/json-document-zod", "@interactive-os/json-document-database", + "@interactive-os/json-document-annotation", "@interactive-os/json-document-calendar", ]; const fixtureSource = join(repositoryRoot, "fixtures", "external-kit"); diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index b69276c4b..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"), @@ -75,6 +81,10 @@ export function jsonDocumentSourceAliases(): SourceAlias[] { find: "@interactive-os/json-document-database", replacement: sourceFile("packages/json-document-database/src/index.ts"), }, + { + find: "@interactive-os/json-document-annotation", + replacement: sourceFile("packages/json-document-annotation/src/index.ts"), + }, { find: "@interactive-os/json-document-tanstack-table", replacement: sourceFile("packages/json-document-tanstack-table/src/index.ts"), diff --git a/site/package.json b/site/package.json index be1403c45..1bf74dd83 100644 --- a/site/package.json +++ b/site/package.json @@ -49,6 +49,9 @@ "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", "@ag-ui/encoder": "^0.0.59", @@ -57,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-canonical-module-closure.mjs b/site/scripts/check-canonical-module-closure.mjs index bbcea3857..c13811f16 100644 --- a/site/scripts/check-canonical-module-closure.mjs +++ b/site/scripts/check-canonical-module-closure.mjs @@ -10,6 +10,13 @@ const databasePropertyConsumers = [ "packages/json-document-database/src/database-hands.tsx", "packages/json-document-zod/src/database-document.ts", ]; +const annotationDemo = readSource("routes/annotation-demo/AnnotationDemoRoute.tsx"); +for (const symbol of ["AnnotationHand", "useAnnotationOutput"]) { + if (!hasNamedImport(annotationDemo, "@interactive-os/json-document-annotation", symbol)) throw new Error(`Annotation Demo must consume the canonical ${symbol}`); +} +for (const localResponsibility of ["createGestureSession", "projectWebClientPointToSVG", "function AnnotationShape", "function CommentComposer", "presentStructuredSnapshot", "JSON.stringify", "JSON.parse", "renderWebAnnotationRaster"]) { + if (annotationDemo.includes(localResponsibility)) throw new Error(`Annotation Demo owns displaced behavior: ${localResponsibility}`); +} const entries = [...registrySource.matchAll(/^\s*"\/[^"]+"[^\n]+"(routes\/[^"]+)"\),?$/gm)].map((match) => match[1]); const usages = [...sourceRegistry.matchAll(/packageName:\s*["']([^"']+)["'],\s*\n\s*symbol:\s*["']([^"']+)["'],\s*\n\s*sourcePath:\s*["']([^"']+)["']/g)].map((match) => ({ packageName: match[1], 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-interaction-handles.mjs b/site/scripts/check-interaction-handles.mjs index 035117eec..ddf54cf7e 100644 --- a/site/scripts/check-interaction-handles.mjs +++ b/site/scripts/check-interaction-handles.mjs @@ -7,9 +7,9 @@ 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("site/src/routes/annotation-demo/AnnotationDemoRoute.tsx"), + annotation: read("packages/json-document-annotation/src/annotation-hand.tsx"), }; requireText("owner", sources.owner, "createInteractionHandleSession"); 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(" PageRoute, } as any); -const PageDocsApiRoute = PageDocsApiRouteImport.update({ - id: "/docs/api", - path: "/docs/api", - getParentRoute: () => PageRoute, -} as any); const PageDocsClipboardRoute = PageDocsClipboardRouteImport.update({ id: "/docs/clipboard", path: "/docs/clipboard", @@ -791,164 +790,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: "/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({ @@ -1046,7 +1072,6 @@ export interface FileRoutesByFullPath { "/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/docs/adapters": typeof PageDocsAdaptersRoute; "/docs/animation": typeof PageDocsAnimationRoute; - "/docs/api": typeof PageDocsApiRouteWithChildren; "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/composer": typeof PageDocsComposerRoute; "/docs/concepts": typeof PageDocsConceptsRoute; @@ -1120,7 +1145,10 @@ export interface FileRoutesByFullPath { "/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/docs/api/ajv": typeof PageDocsApiAjvRoute; "/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; @@ -1131,6 +1159,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; @@ -1151,6 +1180,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; @@ -1203,7 +1233,6 @@ export interface FileRoutesByTo { "/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/docs/adapters": typeof PageDocsAdaptersRoute; "/docs/animation": typeof PageDocsAnimationRoute; - "/docs/api": typeof PageDocsApiRouteWithChildren; "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/composer": typeof PageDocsComposerRoute; "/docs/concepts": typeof PageDocsConceptsRoute; @@ -1277,7 +1306,10 @@ export interface FileRoutesByTo { "/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/docs/api/ajv": typeof PageDocsApiAjvRoute; "/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; @@ -1288,6 +1320,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; @@ -1308,6 +1341,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; @@ -1362,7 +1396,6 @@ 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/clipboard": typeof PageDocsClipboardRoute; "/_page/docs/composer": typeof PageDocsComposerRoute; "/_page/docs/concepts": typeof PageDocsConceptsRoute; @@ -1436,7 +1469,10 @@ export interface FileRoutesById { "/_page/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/_page/docs/api/ajv": typeof PageDocsApiAjvRoute; "/_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; @@ -1447,6 +1483,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; @@ -1467,6 +1504,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; @@ -1521,7 +1559,6 @@ export interface FileRouteTypes { | "/docs/adapter-virtual-selection" | "/docs/adapters" | "/docs/animation" - | "/docs/api" | "/docs/clipboard" | "/docs/composer" | "/docs/concepts" @@ -1595,7 +1632,10 @@ export interface FileRouteTypes { | "/docs/api/affordance" | "/docs/api/ajv" | "/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" @@ -1606,6 +1646,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" @@ -1626,6 +1667,7 @@ export interface FileRouteTypes { | "/docs/document-types/$candidate" | "/connectors/zod/" | "/docs/affordance/" + | "/docs/api/" | "/docs/collaboration/" | "/docs/document-types/" | "/docs/collaboration/text/lease" @@ -1678,7 +1720,6 @@ export interface FileRouteTypes { | "/docs/adapter-virtual-selection" | "/docs/adapters" | "/docs/animation" - | "/docs/api" | "/docs/clipboard" | "/docs/composer" | "/docs/concepts" @@ -1752,7 +1793,10 @@ export interface FileRouteTypes { | "/docs/api/affordance" | "/docs/api/ajv" | "/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" @@ -1763,6 +1807,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" @@ -1783,6 +1828,7 @@ export interface FileRouteTypes { | "/docs/document-types/$candidate" | "/connectors/zod" | "/docs/affordance" + | "/docs/api" | "/docs/collaboration" | "/docs/document-types" | "/docs/collaboration/text/lease" @@ -1836,7 +1882,6 @@ export interface FileRouteTypes { | "/_page/docs/adapter-virtual-selection" | "/_page/docs/adapters" | "/_page/docs/animation" - | "/_page/docs/api" | "/_page/docs/clipboard" | "/_page/docs/composer" | "/_page/docs/concepts" @@ -1910,7 +1955,10 @@ export interface FileRouteTypes { | "/_page/docs/api/affordance" | "/_page/docs/api/ajv" | "/_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" @@ -1921,6 +1969,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" @@ -1941,6 +1990,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" @@ -2318,13 +2368,6 @@ 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; - parentRoute: typeof PageRoute; - }; "/_page/docs/clipboard": { id: "/_page/docs/clipboard"; path: "/docs/clipboard"; @@ -2787,208 +2830,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: "/docs/api/annotation"; + fullPath: "/docs/api/annotation"; + preLoaderRoute: typeof PageDocsApiAnnotationRouteImport; + 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/"; @@ -3049,76 +3127,6 @@ declare module "@tanstack/react-router" { } } -interface PageDocsApiRouteChildren { - PageDocsApiA2uiRoute: typeof PageDocsApiA2uiRoute; - PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute; - PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute; - PageDocsApiAnimationReactRoute: typeof PageDocsApiAnimationReactRoute; - 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, - 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; @@ -3165,7 +3173,6 @@ interface PageRouteChildren { PageDocsAdapterVirtualSelectionRoute: typeof PageDocsAdapterVirtualSelectionRoute; PageDocsAdaptersRoute: typeof PageDocsAdaptersRoute; PageDocsAnimationRoute: typeof PageDocsAnimationRoute; - PageDocsApiRoute: typeof PageDocsApiRouteWithChildren; PageDocsClipboardRoute: typeof PageDocsClipboardRoute; PageDocsComposerRoute: typeof PageDocsComposerRoute; PageDocsConceptsRoute: typeof PageDocsConceptsRoute; @@ -3235,12 +3242,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; @@ -3293,7 +3334,6 @@ const PageRouteChildren: PageRouteChildren = { PageDocsAdapterVirtualSelectionRoute: PageDocsAdapterVirtualSelectionRoute, PageDocsAdaptersRoute: PageDocsAdaptersRoute, PageDocsAnimationRoute: PageDocsAnimationRoute, - PageDocsApiRoute: PageDocsApiRouteWithChildren, PageDocsClipboardRoute: PageDocsClipboardRoute, PageDocsComposerRoute: PageDocsComposerRoute, PageDocsConceptsRoute: PageDocsConceptsRoute, @@ -3363,12 +3403,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/_page/docs/api.tsx b/site/src/app/routes/_page/docs/api.tsx deleted file mode 100644 index efaf4142a..000000000 --- a/site/src/app/routes/_page/docs/api.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ApiReferenceRoute } from "../../../../routes/docs/DocsRoute"; - -export const Route = createFileRoute("/_page/docs/api")({ - component: ApiReferenceRoute, -}); diff --git a/site/src/app/routes/_page/docs/api/annotation.tsx b/site/src/app/routes/_page/docs/api/annotation.tsx new file mode 100644 index 000000000..eac482142 --- /dev/null +++ b/site/src/app/routes/_page/docs/api/annotation.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/annotation")({ + component: function AnnotationApiReferenceRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/api/calendar-document.tsx b/site/src/app/routes/_page/docs/api/calendar-document.tsx new file mode 100644 index 000000000..e698e1ecf --- /dev/null +++ b/site/src/app/routes/_page/docs/api/calendar-document.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/calendar-document")({ + component: function PackageApiReferenceRoute() { + return ; + }, +}); diff --git a/site/src/app/routes/_page/docs/api/canvas.tsx b/site/src/app/routes/_page/docs/api/canvas.tsx new file mode 100644 index 000000000..90850d753 --- /dev/null +++ b/site/src/app/routes/_page/docs/api/canvas.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/canvas")({ + component: function CanvasApiReferenceRoute() { return ; }, +}); diff --git a/site/src/app/routes/_page/docs/api/index.tsx b/site/src/app/routes/_page/docs/api/index.tsx new file mode 100644 index 000000000..95c6d7b34 --- /dev/null +++ b/site/src/app/routes/_page/docs/api/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ApiReferenceRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/")({ + component: ApiReferenceRoute, +}); diff --git a/site/src/app/routes/_page/docs/api/object-document.tsx b/site/src/app/routes/_page/docs/api/object-document.tsx new file mode 100644 index 000000000..da13697e2 --- /dev/null +++ b/site/src/app/routes/_page/docs/api/object-document.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DocsRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/object-document")({ + component: function ObjectDocumentApiReferenceRoute() { return ; }, +}); diff --git a/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx b/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx index d7f80a6da..4b45f7a3c 100644 --- a/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx +++ b/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx @@ -1,702 +1,61 @@ -import { - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, - type ChangeEvent, - type KeyboardEvent, - type PointerEvent, -} from "react"; +import { useState } from "react"; import { createJSONDocument } from "@interactive-os/json-document"; -import { - ANNOTATION_PROFILE_V1, - assertAnnotationDocument, - createAnnotationEditor, - type Annotation, - type AnnotationDocument, - type AnnotationPoint, - type AnnotationSource, -} from "@interactive-os/json-document-editing"; -import { createGestureSession, type InteractionHandleDescriptor, type InteractionHandleEvent } from "@interactive-os/json-document-affordance"; -import { - createWebPointerSession, - projectWebClientPointToSVG, - readWebRasterFile, - renderWebAnnotationRaster, - webSVGViewportFromElement, -} from "@interactive-os/json-document-web"; -import { - ArrowUpRight, - Download, - ImagePlus, - MessageSquare, - MousePointer2, - Pencil, - Redo2, - RotateCcw, - Save, - SendHorizontal, - ThumbsDown, - ThumbsUp, - Trash2, - Undo2, - ZoomIn, - ZoomOut, -} from "lucide-react"; +import { Command, ProductShell, Tabs } from "@interactive-os/json-document-ui-primitives-react"; +import { AnnotationHand, useAnnotationOutput, type AnnotationTool } from "@interactive-os/json-document-annotation"; +import { createAnnotationEditor } from "@interactive-os/json-document-editing"; import { DemoPage } from "../../shared/demo-workbench/DemoPage"; -import { Command, Field, Tabs, Toggle, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; import { PageHeader } from "../../shared/ui/primitives"; -import { ProductShell } from "@interactive-os/json-document-ui-primitives-react"; import { classes, ui } from "../../shared/ui/styles"; -import { CodeBlock } from "../../shared/ui/code-block"; -import { - initialAnnotationDocument, -} from "./annotation-state"; +import { initialAnnotationDocument } from "./annotation-state"; import { annotationDemoRecipe } from "./annotation-demo-styles"; -type Tool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; -type Output = "structured" | "image"; -type Gesture = - | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } - | { readonly type: "draw"; readonly points: ReadonlyArray } - | { readonly type: "move"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint } - | { readonly type: "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; +import { Save, RotateCcw } from "lucide-react"; +import { CodeBlock } from "../../shared/ui/code-block"; -const accent = "rgb(var(--color-border-accent))"; -const annotationDemoStyles = annotationDemoRecipe(); +const styles = annotationDemoRecipe(); export function AnnotationDemoRoute() { - const [documentSource] = useState(() => createJSONDocument(initialAnnotationDocument)); - const [editor] = useState(() => createAnnotationEditor(documentSource)); - useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision); - const [tool, setTool] = useState("comment"); - const [editingId, setEditingId] = useState(null); - const [previewId, setPreviewId] = useState(null); - const [, setGestureRevision] = useState(0); - const [gestureSession] = useState(() => createGestureSession({ - onBegin: () => setGestureRevision((revision) => revision + 1), - onPreview: () => setGestureRevision((revision) => revision + 1), - onCommit: () => setGestureRevision((revision) => revision + 1), - onCancel: () => setGestureRevision((revision) => revision + 1), - })); - const [pointerSession] = useState(() => createWebPointerSession<{ readonly active: true }>()); - const [savedState, setSavedState] = useState(null); - const [output, setOutput] = useState("structured"); - const [renderedImage, setRenderedImage] = useState(null); - const [zoom, setZoom] = useState(1); + const [document] = useState(() => createJSONDocument(initialAnnotationDocument)); + const [editor] = useState(() => createAnnotationEditor(document)); + const [tool, setTool] = useState("comment"); + const [output, setOutput] = useState<"structured" | "image">("structured"); const [announcement, setAnnouncement] = useState("클릭하거나 드래그해서 수정 코멘트를 남기세요."); - const svgRef = useRef(null); - const rasterUrlsRef = useRef(new Map([[ - initialAnnotationDocument.sources[0]!.id, - sitePath(initialAnnotationDocument.sources[0]!.src), - ]])); - const documentValue = editor.snapshot.value as AnnotationDocument; - const selectedId = editor.snapshot.selection.primaryId; - const selected = documentValue.annotations.find((annotation) => annotation.id === selectedId) ?? null; - const source = documentValue.sources[0]!; - const sourceUrl = rasterUrlsRef.current.get(source.id) ?? sourcePath(source.src); - const gesture = gestureSession.getActive(); - - useEffect(() => { - if (output !== "image") return; - let active = true; - setRenderedImage(null); - void renderWebAnnotationRaster({ document: documentValue, sourceId: source.id, sourceURL: sourceUrl, style: rasterStyle() }).then((result) => { - if (active) setRenderedImage(result.ok ? result.dataURL : null); - }); - return () => { active = false; }; - }, [output, documentValue, sourceUrl]); - - const structuredOutput = useMemo(() => presentStructuredSnapshot(documentValue, selectedId), [documentValue, selectedId]); - const structuredDownloadUrl = useMemo( - () => `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(structuredOutput, null, 2))}`, - [structuredOutput], - ); - - function setSelected(selectedId: string | null) { - editor.dispatch({ type: "selection.set", annotationId: selectedId, mode: "replace" }); - } - - function sendComment(annotation: Annotation, instruction: string) { - const nextInstruction = instruction.trim(); - if (annotation.body.instruction === nextInstruction) { - setTool("select"); - return; - } - editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: nextInstruction }); - setTool("select"); - setAnnouncement("수정 요청을 추가했습니다."); - } - - function submitComment(annotation: Annotation, instruction: string) { - sendComment(annotation, instruction); - setEditingId(null); - } - - function cancelComment(annotation: Annotation) { - if (annotation.body.instruction === "") { - editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); - setAnnouncement("작성 중인 요청을 취소했습니다."); - setEditingId(null); - return; - } - setSelected(null); - setEditingId(null); - } - - function chooseTool(nextTool: Tool) { - setTool(nextTool); - setEditingId(null); - if (selectedId !== null) setSelected(null); - } - - function deleteSelected() { - if (selectedId === null) return; - editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); - setEditingId(null); - setAnnouncement("선택한 annotation을 삭제했습니다."); - } - - function handleCanvasPointerDown(event: PointerEvent) { - if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; - const point = eventPoint(event); - if (point === null) return; - if (tool === "select") return setSelected(null); - pointerSession.begin(event.currentTarget, event.pointerId, { active: true }); - if (tool === "draw") { - gestureSession.begin({ type: "draw", points: [point] }); - return; - } - gestureSession.begin({ type: "create", tool, start: point, current: point }); - } - - function handleAnnotationInteraction( - interaction: InteractionHandleEvent, - event: PointerEvent, - annotation: Annotation, - type: "move" | "resize", - ) { - if (interaction.phase === "start") { - if (type === "move") { - setEditingId(null); - setPreviewId(null); - setSelected(annotation.id); - if (tool !== "select") return; - } - const start = eventPoint(event); - if (start !== null) gestureSession.begin({ type, id: annotation.id, start, current: start }); - return; - } - if (interaction.phase === "cancel") { - gestureSession.cancel("pointer-cancel"); - setAnnouncement("진행 중인 조작을 취소했습니다."); - return; - } - const active = gestureSession.getActive(); - if (active?.type !== type || active.id !== annotation.id) return; - const current = eventPoint(event); - if (current === null) return; - gestureSession.preview({ ...active, current }); - if (interaction.phase === "commit") commitActiveGesture(); - } - - function handlePointerMove(event: PointerEvent) { - if (gesture === null || pointerSession.getSnapshot()?.pointerId !== event.pointerId) return; - const point = eventPoint(event); - if (point === null) return; - if (gesture.type === "draw") { - const previous = gesture.points.at(-1); - if (previous !== undefined && distance(previous, point) >= 4) { - gestureSession.preview({ type: "draw", points: [...gesture.points, point] }); - } - return; - } - if (gesture.type === "create") { - gestureSession.preview({ ...gesture, current: point }); - return; - } - gestureSession.preview({ ...gesture, current: point }); - } - - function handlePointerUp(event: PointerEvent) { - if (pointerSession.commit(event.pointerId) === null) return; - commitActiveGesture(); - } - - function commitActiveGesture() { - const gesture = gestureSession.commit(); - if (gesture === null) return; - if (gesture.type === "draw") { - const annotation = createDrawAnnotation(source.id, gesture.points); - if (annotation !== null) { - editor.dispatch({ type: "annotation.create", annotation }); - setTool("select"); - setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); - setAnnouncement("자유선 코멘트를 만들었습니다."); - } - } else if (gesture.type === "create") { - const annotation = createAnnotation(source.id, gesture.tool, gesture.start, gesture.current); - if (annotation !== null) { - editor.dispatch({ type: "annotation.create", annotation }); - setTool("select"); - setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); - setAnnouncement(annotationAnnouncement(annotation)); - } - } else { - const dx = gesture.current.x - gesture.start.x; - const dy = gesture.current.y - gesture.start.y; - if (gesture.type === "move" && Math.hypot(dx, dy) < 4) { - const annotation = documentValue.annotations.find((item) => item.id === gesture.id); - if (annotation !== undefined && annotation.presentation.type !== "reaction") setEditingId(gesture.id); - return; - } - editor.dispatch(gesture.type === "move" - ? { type: "annotation.move", annotationId: gesture.id, dx, dy } - : { type: "annotation.resize", annotationId: gesture.id, handle: resizeHandle(documentValue, gesture.id), dx, dy }); - setAnnouncement(gesture.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); - } - } - - function cancelPointerGesture(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { - const cancelled = pointerSession.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel"); - if (cancelled === null) return; - gestureSession.cancel(reason); - setAnnouncement("진행 중인 조작을 취소했습니다."); - } - - function handleKeyDown(event: KeyboardEvent) { - const command = event.metaKey || event.ctrlKey; - if (command && event.key.toLowerCase() === "z") { - event.preventDefault(); - if (event.shiftKey) editor.redo(); - else editor.undo(); - return; - } - if (!command) { - const shortcutTool = toolFromShortcut(event.key); - if (shortcutTool !== null) { - event.preventDefault(); - chooseTool(shortcutTool); - return; - } - } - if (event.key === "Escape") { - event.preventDefault(); - const pointer = pointerSession.getSnapshot(); - if (pointer !== null) pointerSession.cancel(pointer.pointerId); - gestureSession.cancel("cancel"); - chooseTool("select"); - return; - } - if (event.key === "Delete" || event.key === "Backspace") { - event.preventDefault(); - deleteSelected(); - } - } - - function saveState() { - setSavedState(JSON.stringify(documentValue)); - setAnnouncement("Structured annotation state를 저장했습니다."); - } - - function restoreState() { - if (savedState === null) return; - const restored = JSON.parse(savedState) as AnnotationDocument; - assertAnnotationDocument(restored); - documentSource.commit([{ op: "replace", path: "", value: restored }]); - setSelected(null); - setAnnouncement("저장한 state에서 overlay를 복원했습니다."); - } - - async function replaceImage(event: ChangeEvent) { - const file = event.target.files?.[0]; - event.target.value = ""; - if (file === undefined) return; - try { - const raster = await readWebRasterFile(file); - if (!raster.ok) throw new Error(raster.reason ?? raster.code); - const nextSource: AnnotationSource = { id: `upload-${file.name}-${file.lastModified}`, src: file.name, width: raster.width, height: raster.height }; - const url = raster.dataURL; - rasterUrlsRef.current.set(nextSource.id, url); - documentSource.commit([{ op: "replace", path: "", value: { - profile: ANNOTATION_PROFILE_V1, - id: documentValue.id, - sources: [nextSource], - annotations: [], - } }]); - setZoom(1); - setAnnouncement(`${file.name} 이미지로 교체했습니다.`); - } catch { - setAnnouncement("지원하는 raster 이미지를 불러오지 못했습니다."); - } - } - - async function downloadImage() { - const result = await renderWebAnnotationRaster({ document: documentValue, sourceId: source.id, sourceURL: sourceUrl, style: rasterStyle() }); - if (!result.ok) { setAnnouncement("Annotation 이미지를 만들지 못했습니다."); return; } - const link = document.createElement("a"); - link.href = result.dataURL; - link.download = "annotation-request.png"; - link.click(); - setAnnouncement("Annotation이 적용된 이미지를 다운로드했습니다."); - } - - return ( - {announcement}

}> - 이미지 위에서 위치를 표시하고 수정 요청을 남겨 보세요. - - )}> - -
-
- cancelPointerGesture(event, "lost-capture")} - onPointerCancel={(event) => cancelPointerGesture(event, "pointer-cancel")} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - role="application" - tabIndex={0} - viewBox={`0 0 ${source.width} ${source.height}`} - > - - {documentValue.annotations.map((annotation, index) => ( - setPreviewId(visible ? annotation.id : null)} /> - ))} - {gesture?.type === "create" ? : null} - {gesture?.type === "draw" ? : null} - - {documentValue.annotations.map((annotation, index) => gesture === null && previewId === annotation.id && annotation.body.instruction.trim() !== "" && editingId !== annotation.id ? ( - - ) : null)} - {selected && editingId === selected.id ? ( - cancelComment(selected)} onSave={(instruction) => sendComment(selected, instruction)} onSubmit={(instruction) => submitComment(selected, instruction)} /> - ) : null} -
- -
-
-
- Annotation output -
- -
-
-
- ); -} - -function CommentComposer(props: { - readonly annotation: Annotation; - readonly index: number; - readonly source: AnnotationSource; - readonly onCancel: () => void; - readonly onSave: (instruction: string) => void; - readonly onSubmit: (instruction: string) => void; -}) { - const [draft, setDraft] = useState(props.annotation.body.instruction); - const inputRef = useRef(null); - useEffect(() => setDraft(props.annotation.body.instruction), [props.annotation.id, props.annotation.body.instruction]); - useEffect(() => { - const frame = requestAnimationFrame(() => { - const input = inputRef.current; - if (input === null) return; - input.focus(); - input.setSelectionRange(input.value.length, input.value.length); - }); - return () => cancelAnimationFrame(frame); - }, [props.annotation.id]); - const dock = composerDock(props.annotation, props.source); - return ( -
- { - if (draft.trim() !== "") props.onSave(draft); - }} - onValueChange={setDraft} - onKeyDown={(event) => { - if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { - event.preventDefault(); - if (draft.trim() !== "") props.onSubmit(draft); - } - if (event.key === "Escape") props.onCancel(); - }} - placeholder="수정 요청을 입력하세요…" - rows={1} - value={draft} - /> - props.onSubmit(draft)} - onMouseDown={(event) => event.preventDefault()} - > - -
- ); -} - -function ToolIcon(props: { readonly tool: Tool }) { - if (props.tool === "select") return