From bbd8552f79f83114f9d9bb550daef9ac90202559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 27 Aug 2026 03:07:30 +0900 Subject: [PATCH 1/3] feat(editing): expose canonical annotation geometry --- docs/api-reference/editing.md | 27 +++++++++++++++ .../json-document-editing/src/annotation.ts | 33 +++++++++++++++++-- packages/json-document-editing/src/index.ts | 4 ++- .../tests/annotation-editor.test.ts | 14 ++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 7efcf225..dc7657d1 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -21,6 +21,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 @@ -57,6 +62,11 @@ type AnnotationPresentation = | { readonly type: "stroke" } | { readonly type: "arrow" }; ``` +## `annotationResizeHandle` + +```ts +annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null +``` ## `AnnotationSelection` ```ts @@ -71,6 +81,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 @@ -890,6 +912,11 @@ interface SheetSelection extends Record { ```ts type SheetTopology = GridTopology; ``` +## `transformAnnotationSelector` + +```ts +transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null +``` ## `TreeClipboard` ```ts diff --git a/packages/json-document-editing/src/annotation.ts b/packages/json-document-editing/src/annotation.ts index 8c8cc780..645faa3b 100644 --- a/packages/json-document-editing/src/annotation.ts +++ b/packages/json-document-editing/src/annotation.ts @@ -60,7 +60,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") { @@ -89,6 +117,7 @@ function resize(selector: AnnotationSelector, handle: "end" | "south-east", dx: 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/index.ts b/packages/json-document-editing/src/index.ts index 83347ad0..5d1ee469 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -20,10 +20,11 @@ export { createSheetEditor } from "./sheet.js"; export { createTreeEditor } from "./tree.js"; export { projectTreeVisibility, treeVisibilityNeighbor } from "./tree-visibility.js"; export { createKanbanEditor } from "./kanban.js"; -export { ANNOTATION_PROFILE_V1, createAnnotationEditor } from "./annotation.js"; +export { ANNOTATION_PROFILE_V1, annotationResizeHandle, annotationSelectorBounds, createAnnotationEditor, transformAnnotationSelector } from "./annotation.js"; export { assertAnnotationDocument } from "./annotation-validation.js"; export type { Annotation, + AnnotationBounds, AnnotationDocument, AnnotationEditor, AnnotationIntent, @@ -31,6 +32,7 @@ export type { AnnotationPresentation, AnnotationSelection, AnnotationSelector, + AnnotationSelectorTransform, AnnotationSource, } from "./annotation.js"; export type { diff --git a/packages/json-document-editing/tests/annotation-editor.test.ts b/packages/json-document-editing/tests/annotation-editor.test.ts index d20c7d70..e171524e 100644 --- a/packages/json-document-editing/tests/annotation-editor.test.ts +++ b/packages/json-document-editing/tests/annotation-editor.test.ts @@ -1,8 +1,11 @@ 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 +80,15 @@ 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(); + }); }); From db51776945df44381ba5893fc7a5cc98ec709381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Thu, 27 Aug 2026 03:07:30 +0900 Subject: [PATCH 2/3] feat(annotation): add canonical Annotation Hand --- docs/api-reference/annotation.md | 66 ++ docs/api-reference/packages.mjs | 1 + docs/evaluate.mjs | 2 + docs/public/hands.md | 15 +- package-lock.json | 35 + package.json | 1 + packages/json-document-annotation/LICENSE | 3 + packages/json-document-annotation/README.md | 21 + .../json-document-annotation/package.json | 10 + .../src/annotation-hand.tsx | 207 ++++ .../json-document-annotation/src/index.ts | 2 + .../tests/annotation-hand.test.tsx | 21 + .../json-document-annotation/tsconfig.json | 1 + .../tsconfig.test.json | 1 + .../json-document-annotation/vitest.config.ts | 2 + scripts/ci-plan.mjs | 3 + scripts/ci-plan.test.mjs | 7 +- scripts/external-kit-plan.test.mjs | 1 + scripts/release-package.mjs | 1 + scripts/release-package.test.mjs | 11 + scripts/verify-external-kit.mjs | 1 + site/package.json | 1 + .../check-canonical-module-closure.mjs | 7 + site/site-routes.json | 8 + site/src/app/routeTree.gen.ts | 21 + .../app/routes/_page/docs/api/annotation.tsx | 8 + .../annotation-demo/AnnotationDemoRoute.tsx | 891 +----------------- .../annotation-demo/annotation-demo-styles.ts | 13 - site/src/routes/docs/DocsRoute.tsx | 1 + site/src/routes/docs/doc-pages.ts | 2 + .../src/shared/demo-workbench/demo-sources.ts | 8 + site/tests/unit/app-shell.test.tsx | 1 + standards/repository-implementation-shape.md | 3 +- tsconfig.build.json | 1 + 34 files changed, 487 insertions(+), 890 deletions(-) create mode 100644 docs/api-reference/annotation.md create mode 100644 packages/json-document-annotation/LICENSE create mode 100644 packages/json-document-annotation/README.md create mode 100644 packages/json-document-annotation/package.json create mode 100644 packages/json-document-annotation/src/annotation-hand.tsx create mode 100644 packages/json-document-annotation/src/index.ts create mode 100644 packages/json-document-annotation/tests/annotation-hand.test.tsx create mode 100644 packages/json-document-annotation/tsconfig.json create mode 100644 packages/json-document-annotation/tsconfig.test.json create mode 100644 packages/json-document-annotation/vitest.config.ts create mode 100644 site/src/app/routes/_page/docs/api/annotation.tsx diff --git a/docs/api-reference/annotation.md b/docs/api-reference/annotation.md new file mode 100644 index 00000000..1227d3d6 --- /dev/null +++ b/docs/api-reference/annotation.md @@ -0,0 +1,66 @@ +# @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 createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} +``` +## `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 ..., { ...; }] +``` diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index ef80fa85..ba6e6c5c 100644 --- a/docs/api-reference/packages.mjs +++ b/docs/api-reference/packages.mjs @@ -10,6 +10,7 @@ export const apiReferencePackages = [ ["affordance", "@interactive-os/json-document-affordance", "packages/json-document-affordance/src/index.ts", "Affordance", "입력 문법과 interaction session"], ["ui-primitives-react", "@interactive-os/json-document-ui-primitives-react", "packages/json-document-ui-primitives-react/src/index.ts", "UI Primitives", "표준 React UI primitive"], ["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"], ["web", "@interactive-os/json-document-web", "packages/json-document-web/src/index.ts", "Adapter", "Web platform adapter"], ["contenteditable", "@interactive-os/json-document-contenteditable", "packages/json-document-contenteditable/src/index.ts", "Adapter", "contenteditable platform adapter"], ["rich-text", "@interactive-os/json-document-rich-text", "packages/json-document-rich-text/src/index.ts", "Editing", "Rich Text domain과 editing 계약"], diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 49e589c9..771f8226 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -114,6 +114,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"), @@ -146,6 +147,7 @@ const activeCompanionPackages = new Set([ "@interactive-os/json-document-ui-primitives-react", "@interactive-os/json-document-zod", "@interactive-os/json-document-database", + "@interactive-os/json-document-annotation", "@interactive-os/json-document-tanstack-table", "@interactive-os/json-document-web", "@interactive-os/json-document-contenteditable", diff --git a/docs/public/hands.md b/docs/public/hands.md index f35ab286..f9895b63 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -14,14 +14,17 @@ 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 밖에서 조합합니다. +않습니다. `@interactive-os/json-document-annotation`의 `AnnotationHand`가 +도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 +하나의 공개 surface로 제공합니다. ```ts -const gesture = createGestureSession(); -const point = projectWebClientPointToSVG(clientPoint, viewport); -const raster = await readWebRasterFile(file); -const output = await renderWebAnnotationRaster({ document, sourceId, sourceURL, style }); + crypto.randomUUID()} + rasterStyle={style} +/> ``` Gesture는 Affordance가 input-independent lifecycle로 소유하고 Pointer capture는 diff --git a/package-lock.json b/package-lock.json index 0c35f5a8..2398867b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "packages/json-document-ui-primitives-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", @@ -958,6 +959,10 @@ "resolved": "packages/json-document-ajv", "link": true }, + "node_modules/@interactive-os/json-document-annotation": { + "resolved": "packages/json-document-annotation", + "link": true + }, "node_modules/@interactive-os/json-document-collaboration": { "resolved": "packages/json-document-collaboration", "link": true @@ -5740,6 +5745,35 @@ "ajv": "^8.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-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-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-collaboration": { "name": "@interactive-os/json-document-collaboration", "version": "0.2.0-rc.1", @@ -6186,6 +6220,7 @@ "dependencies": { "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-ajv": "*", + "@interactive-os/json-document-annotation": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-composer": "*", "@interactive-os/json-document-composer-react": "*", diff --git a/package.json b/package.json index 3ae7ba76..a5df90f4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "packages/json-document-ui-primitives-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", diff --git a/packages/json-document-annotation/LICENSE b/packages/json-document-annotation/LICENSE new file mode 100644 index 00000000..b66a4819 --- /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 00000000..3a3c80bf --- /dev/null +++ b/packages/json-document-annotation/README.md @@ -0,0 +1,21 @@ +# @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 injects IDs, enabled tools, copy, class names, raster style, and the +concrete source URL. The serialized output remains an `AnnotationDocument`; +selection and history stay in the editor snapshot. diff --git a/packages/json-document-annotation/package.json b/packages/json-document-annotation/package.json new file mode 100644 index 00000000..6326a108 --- /dev/null +++ b/packages/json-document-annotation/package.json @@ -0,0 +1,10 @@ +{ + "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" }, + "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" } +} 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 00000000..fb5667b9 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-hand.tsx @@ -0,0 +1,207 @@ +import { useEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent } from "react"; +import { createGestureSession } 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 { createWebPointerSession, projectWebClientPointToSVG, renderWebAnnotationRaster, webSVGViewportFromElement, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; +import { IconButton, ToggleButton } 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 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 = "rgb(var(--color-border-accent))"; + +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, setTool] = useState(enabled.includes("comment") ? "comment" : enabled[0] ?? "select"); + 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 svgRef = useRef(null); + 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 annotationDown(event: PointerEvent, annotation: Annotation) { + event.stopPropagation(); setEditingId(null); setPreviewId(null); + if (tool !== "select") return select(annotation.id); + const svg = svgRef.current; const start = eventPoint(event); if (svg === null || start === null) return; + select(annotation.id); pointer.begin(svg, event.pointerId, { active: true }); gestures.begin({ type: "move", id: annotation.id, start, current: start }); + } + function resizeDown(event: PointerEvent, annotation: Annotation) { + event.stopPropagation(); const svg = svgRef.current; const start = eventPoint(event); if (svg === null || start === null) return; + pointer.begin(svg, event.pointerId, { active: true }); gestures.begin({ type: "resize", id: annotation.id, start, current: start }); + } + function pointerMove(event: PointerEvent) { + 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) { + if (pointer.commit(event.pointerId) === null) return; 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) return; props.editor.dispatch({ type: "annotation.create", annotation }); 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); + if (committed.type === "move") props.editor.dispatch({ type: "annotation.move", annotationId: committed.id, dx, dy }); + else if (handle !== null) props.editor.dispatch({ type: "annotation.resize", annotationId: committed.id, handle, dx, dy }); + 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) { + const command = event.metaKey || event.ctrlKey; + if (command && event.key.toLowerCase() === "z") { event.preventDefault(); event.shiftKey ? props.editor.redo() : props.editor.undo(); return; } + const next = !command ? annotationTools.find(({ shortcut }) => shortcut.toLowerCase() === event.key.toLowerCase())?.id : undefined; + 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"); } + if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); remove(); } + } + 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)} onResize={resizeDown} />)} + {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} +
+ + {JSON.stringify(document)} +
; + + 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
+