diff --git a/docs/api-reference/annotation.md b/docs/api-reference/annotation.md
new file mode 100644
index 00000000..c7e6c8a6
--- /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/editing.md b/docs/api-reference/editing.md
index 512f2cd1..9cd1f1c6 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
@@ -1521,6 +1543,11 @@ interface SheetSelection extends Record {
```ts
type SheetTopology = GridTopology;
```
+## `transformAnnotationSelector`
+
+```ts
+transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null
+```
## `TreeClipboard`
```ts
diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs
index 0e02331d..0ba9cdfe 100644
--- a/docs/api-reference/packages.mjs
+++ b/docs/api-reference/packages.mjs
@@ -13,6 +13,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/evaluate.mjs b/docs/evaluate.mjs
index 1bac21b2..14d3534a 100644
--- a/docs/evaluate.mjs
+++ b/docs/evaluate.mjs
@@ -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"),
diff --git a/docs/public/hands.md b/docs/public/hands.md
index c62d177a..3c1b40b3 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는
diff --git a/package-lock.json b/package-lock.json
index 0f02d817..43f43b8d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"packages/json-document-markdown-react",
"packages/json-document-zod",
"packages/json-document-database",
+ "packages/json-document-annotation",
"packages/json-document-calendar",
"packages/json-document-tanstack-table",
"packages/json-document-web",
@@ -1124,6 +1125,10 @@
"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
@@ -6504,6 +6509,37 @@
"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",
@@ -7022,6 +7058,7 @@
"@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-collaboration": "*",
"@interactive-os/json-document-composer": "*",
diff --git a/package.json b/package.json
index 81f3367a..1cab3947 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"packages/json-document-markdown-react",
"packages/json-document-zod",
"packages/json-document-database",
+ "packages/json-document-annotation",
"packages/json-document-calendar",
"packages/json-document-tanstack-table",
"packages/json-document-web",
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..2f593fd2
--- /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 00000000..7bba7fc7
--- /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 00000000..9759107a
--- /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
+
+
+ {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 00000000..c77a4e56
--- /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 00000000..0c9557b6
--- /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 00000000..b7b98d9d
--- /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 00000000..ec3598f6
--- /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
}>
- 이미지 위에서 위치를 표시하고 수정 요청을 남겨 보세요.
-
- )}>
-
-
-
-
- {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 ;
- if (props.tool === "comment") return ;
- if (props.tool === "draw") return ;
- if (props.tool === "like") return ;
- if (props.tool === "dislike") return ;
- return ;
-}
-
-function CommentPreview(props: { readonly annotation: Annotation; readonly index: number; readonly source: AnnotationSource }) {
- const dock = composerDock(props.annotation, props.source);
- return (
-
- {props.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 onPreviewChange: (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 = annotationBounds(annotation);
- const common = {
- fill: "none",
- stroke: accent,
- strokeWidth: props.selected ? 6 : 4,
- vectorEffect: "non-scaling-stroke" as const,
- };
- return (
- props.onPreviewChange(false)}
- onFocus={() => props.onPreviewChange(true)}
- onPointerEnter={() => props.onPreviewChange(true)}
- onPointerLeave={() => props.onPreviewChange(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 CommentNumberBadge(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 StrokeLine(props: {
- readonly points: ReadonlyArray;
- readonly selected?: boolean;
- readonly draft?: boolean;
-}) {
- const first = props.points[0];
- if (first === undefined) return null;
- const path = strokePathData(props.points);
- return (
-
- );
-}
-
-function strokePathData(points: ReadonlyArray): string {
- const first = points[0];
- if (first === undefined) return "";
- if (points.length === 2) {
- const last = points[1] ?? first;
- return `M ${first.x} ${first.y} L ${last.x} ${last.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.at(-1) ?? first;
- return [`M ${first.x} ${first.y}`, ...curves, `L ${last.x} ${last.y}`].join(" ");
-}
-
-function ArrowLine(props: { readonly from: AnnotationPoint; readonly to: AnnotationPoint; readonly selected: boolean }) {
- const angle = Math.atan2(props.to.y - props.from.y, props.to.x - props.from.x);
- const head = 34;
- const left = { x: props.to.x - head * Math.cos(angle - Math.PI / 6), y: props.to.y - head * Math.sin(angle - Math.PI / 6) };
- const right = { x: props.to.x - head * Math.cos(angle + Math.PI / 6), y: props.to.y - head * Math.sin(angle + Math.PI / 6) };
- return (
-
- );
-}
-
-function DraftShape({ gesture }: { readonly gesture: Extract }) {
- if (gesture.tool === "like" || gesture.tool === "dislike") return ;
- if (gesture.tool === "arrow") return ;
- if (distance(gesture.start, gesture.current) < 16) {
- return ;
- }
- const rectangle = rectangleFromPoints(gesture.start, gesture.current);
- return ;
-}
-
-function ReactionSticker(props: { readonly point: AnnotationPoint; readonly reaction: "like" | "dislike"; readonly selected: boolean; readonly draft?: boolean }) {
- const Icon = props.reaction === "like" ? ThumbsUp : ThumbsDown;
- return (
-
-
-
-
-
- );
-}
+ const source = initialAnnotationDocument.sources[0]!;
+ const state = useAnnotationOutput({ document, editor, sourceUrl: sitePath(source.src), rasterStyle: rasterStyle(), renderImage: output === "image" });
+ return {announcement}}>
+ 이미지 위에서 위치를 표시하고 수정 요청을 남겨 보세요.
+
+ }>
+
+ `annotation-${crypto.randomUUID()}`} onAnnouncement={setAnnouncement} rasterStyle={rasterStyle()} classNames={{
+ frame: styles.canvasFrame(), stage: styles.stage(), canvas: styles.canvas(), commentCard: styles.commentCard(),
+ commentInput: classes(ui.field.control, styles.commentInput()), commentPreview: styles.commentPreview(), sendButton: styles.sendButton(),
+ toolDock: styles.toolDock(), dockButton: styles.dockButton(), dockDivider: styles.dockDivider(),
+ }} />
+
+
+ Annotation output
+ { state.save(); setAnnouncement("Structured annotation state를 저장했습니다."); }}
+ onRestore={() => { if (state.restore()) setAnnouncement("저장한 state에서 overlay를 복원했습니다."); }} />
+
+ ;
+}
+
+function sitePath(path: string) { const base = import.meta.env.BASE_URL.replace(/\/$/, ""); return `${base}${path}` || "/"; }
+function rasterStyle() { const color = getComputedStyle(document.documentElement).getPropertyValue("--color-border-accent").trim(); const accent = ["rgb", "(", color, ")"].join(""); return { stroke: accent, fill: accent, lineWidth: 8, labelFont: "700 30px system-ui, sans-serif" }; }
function OutputPanel(props: {
readonly canRestore: boolean;
readonly onRestore: () => void;
readonly onSave: () => void;
- readonly output: Output;
- readonly setOutput: (output: Output) => void;
- readonly structured: unknown;
+ readonly output: "structured" | "image";
+ readonly setOutput: (output: "structured" | "image") => void;
+ readonly structured: string;
readonly structuredDownloadUrl: string;
readonly renderedImage: string | null;
+ readonly imageError: boolean;
}) {
return (
@@ -717,16 +76,16 @@ function OutputPanel(props: {
Download JSON
) : props.renderedImage === null ? (
- Rasterizing…
+ {props.imageError ? "이미지를 만들지 못했습니다." : "Rasterizing…"}
) : (

@@ -736,178 +95,3 @@ function OutputPanel(props: {
);
}
-
-function createAnnotation(
- sourceId: string,
- kind: Exclude
,
- start: AnnotationPoint,
- end: AnnotationPoint,
-): Annotation | null {
- const id = `annotation-${crypto.randomUUID()}`;
- if (kind === "like" || kind === "dislike") {
- return { id, target: { sourceId, selector: { type: "point", ...start } }, body: { instruction: "" }, presentation: { type: "reaction", reaction: kind } };
- }
- if (kind === "comment") {
- if (distance(start, end) < 16) return { id, target: { sourceId, selector: { type: "point", ...start } }, body: { instruction: "" }, presentation: { type: "marker" } };
- return { id, target: { sourceId, selector: { type: "rectangle", ...rectangleFromPoints(start, end) } }, body: { instruction: "" }, presentation: { type: "outline" } };
- }
- if (distance(start, end) < 8) return null;
- return { id, target: { sourceId, selector: { type: "arrow", from: start, to: end } }, body: { instruction: "" }, presentation: { type: "arrow" } };
-}
-
-function createDrawAnnotation(sourceId: string, points: ReadonlyArray): Annotation | null {
- if (points.length < 2 || pathLength(points) < 16) return null;
- return {
- id: `annotation-${crypto.randomUUID()}`,
- target: { sourceId, selector: { type: "path", points } },
- body: { instruction: "" },
- presentation: { type: "stroke" },
- };
-}
-
-function composerDock(annotation: Annotation, source: AnnotationSource) {
- const bounds = annotationBounds(annotation);
- const horizontal = bounds.x + bounds.width / 2 > source.width * 0.75 ? "left" : "right";
- const vertical = bounds.y < 48 ? "below" : bounds.y > source.height - 48 ? "above" : "center";
- return {
- horizontal,
- vertical,
- anchor: {
- type: "point" as const,
- x: horizontal === "left" ? bounds.x - 36 : bounds.x + 36,
- y: bounds.y,
- },
- };
-}
-
-function dockTransform(dock: ReturnType): string {
- const horizontal = dock.horizontal === "left" ? "-100%" : "0";
- const vertical = dock.vertical === "above" ? "-100%" : dock.vertical === "below" ? "0" : "-50%";
- return `translate(${horizontal}, ${vertical})`;
-}
-
-function annotationBounds(annotation: Annotation) {
- const selector = annotation.target.selector;
- if (selector.type === "arrow") return rectangleFromPoints(selector.from, selector.to);
- if (selector.type === "rectangle") return selector;
- if (selector.type === "path") {
- const xs = selector.points.map((point) => point.x); const ys = selector.points.map((point) => point.y);
- return { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
- }
- return { x: selector.x, y: selector.y, width: 0, height: 0 };
-}
-
-function eventPoint(event: PointerEvent): AnnotationPoint | null {
- const svg = event.currentTarget.ownerSVGElement ?? event.currentTarget as SVGSVGElement;
- const projected = projectWebClientPointToSVG(
- { x: event.clientX, y: event.clientY },
- webSVGViewportFromElement(svg),
- );
- return projected === null ? null : { x: projected.x, y: projected.y };
-}
-
-function rectangleFromPoints(start: AnnotationPoint, end: AnnotationPoint) {
- 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 distance(start: AnnotationPoint, end: AnnotationPoint): number {
- return Math.hypot(end.x - start.x, end.y - start.y);
-}
-
-function pathLength(points: ReadonlyArray): number {
- return points.slice(1).reduce((total, point, index) => total + distance(points[index] ?? point, point), 0);
-}
-
-function toolLabel(tool: Tool): string {
- return ({ select: "Select", comment: "Comment", draw: "Draw", arrow: "Arrow", like: "Like", dislike: "Dislike" })[tool];
-}
-
-function toolShortcut(tool: Tool): string {
- return ({ select: "V", comment: "C", draw: "D", arrow: "A", like: "L", dislike: "K" })[tool];
-}
-
-function toolFromShortcut(key: string): Tool | null {
- const normalized = key.toLowerCase();
- if (normalized === "v") return "select";
- if (normalized === "c") return "comment";
- if (normalized === "d") return "draw";
- if (normalized === "a") return "arrow";
- if (normalized === "l") return "like";
- if (normalized === "k") return "dislike";
- return null;
-}
-
-function annotationAnnouncement(annotation: Annotation): string {
- if (annotation.presentation.type === "reaction") return annotation.presentation.reaction === "like" ? "좋아요 스티커를 붙였습니다." : "싫어요 스티커를 붙였습니다.";
- if (annotation.presentation.type === "marker") return "위치 코멘트를 만들었습니다.";
- if (annotation.presentation.type === "outline") return "영역 코멘트를 만들었습니다.";
- if (annotation.presentation.type === "stroke") return "자유선 코멘트를 만들었습니다.";
- return "화살표 코멘트를 만들었습니다.";
-}
-
-function markLabel(annotation: Annotation): string {
- if (annotation.presentation.type === "reaction") return annotation.presentation.reaction === "like" ? "Like" : "Dislike";
- if (annotation.presentation.type === "marker") return "Point";
- if (annotation.presentation.type === "outline") return "Area";
- if (annotation.presentation.type === "stroke") return "Draw";
- return "Arrow";
-}
-
-function sitePath(path: string): string {
- const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
- return `${basePath}${path}` || "/";
-}
-
-function sourcePath(path: string): string {
- return path.startsWith("data:") ? path : sitePath(path);
-}
-
-function presentStructuredSnapshot(document: AnnotationDocument, selectedId: string | null) {
- return {
- ...document,
- selection: { kind: "annotation", ids: selectedId === null ? [] : [selectedId], primaryId: selectedId },
- };
-}
-
-function resizeHandle(document: AnnotationDocument, annotationId: string): "end" | "south-east" {
- return document.annotations.find((item) => item.id === annotationId)?.target.selector.type === "arrow" ? "end" : "south-east";
-}
-
-function projectGestureAnnotation(annotation: Annotation, gesture: Gesture | null): Annotation {
- if (gesture === null || (gesture.type !== "move" && gesture.type !== "resize") || gesture.id !== annotation.id) return annotation;
- const dx = gesture.current.x - gesture.start.x;
- const dy = gesture.current.y - gesture.start.y;
- const selector = annotation.target.selector;
- if (gesture.type === "move") {
- const point = (value: AnnotationPoint) => ({ x: value.x + dx, y: value.y + dy });
- const moved = selector.type === "point" || selector.type === "rectangle" ? { ...selector, ...point(selector) }
- : selector.type === "path" ? { ...selector, points: selector.points.map(point) }
- : { ...selector, from: point(selector.from), to: point(selector.to) };
- return { ...annotation, target: { ...annotation.target, selector: moved } };
- }
- if (selector.type === "rectangle") {
- return { ...annotation, target: { ...annotation.target, selector: { ...selector, width: Math.max(1, selector.width + dx), height: Math.max(1, selector.height + dy) } } };
- }
- if (selector.type === "path") {
- const bounds = annotationBounds(annotation);
- const width = Math.max(1, bounds.width); const height = Math.max(1, bounds.height);
- const scaleX = Math.max(1, width + dx) / width; const scaleY = Math.max(1, height + dy) / height;
- return { ...annotation, target: { ...annotation.target, selector: { ...selector, points: selector.points.map((point) => ({ x: bounds.x + (point.x - bounds.x) * scaleX, y: bounds.y + (point.y - bounds.y) * scaleY })) } } };
- }
- if (selector.type === "arrow") {
- const to = { x: selector.to.x + dx, y: selector.to.y + dy };
- return { ...annotation, target: { ...annotation.target, selector: { ...selector, to } } };
- }
- return annotation;
-}
-
-function rasterStyle() {
- const color = getComputedStyle(document.documentElement).getPropertyValue("--color-border-accent").trim();
- const accentColor = ["rgb", "(", color, ")"].join("");
- return { stroke: accentColor, fill: accentColor, lineWidth: 8, labelFont: "700 30px system-ui, sans-serif" };
-}
diff --git a/site/src/routes/annotation-demo/annotation-demo-styles.ts b/site/src/routes/annotation-demo/annotation-demo-styles.ts
index 17602423..43668d5d 100644
--- a/site/src/routes/annotation-demo/annotation-demo-styles.ts
+++ b/site/src/routes/annotation-demo/annotation-demo-styles.ts
@@ -6,25 +6,13 @@ export const annotationDemoRecipe = tv({
canvasFrame: "relative overflow-hidden rounded-surface bg-background-subtle",
stage: "relative",
canvas: "block h-auto w-full touch-none cursor-crosshair outline-none focus-visible:ring-2 focus-visible:ring-line-accent/25",
- draftComposer: "absolute bottom-6 left-1/2 z-10 flex min-h-14 w-[380px] max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-2 rounded-surface border border-line-subtle bg-background-canvas px-4 py-3 shadow-overlay",
- draftIdentity: "size-6 shrink-0 rounded-full bg-background-accent",
commentInput: "min-h-6 max-h-32 min-w-0 flex-1 resize-none overflow-y-auto [field-sizing:content] !border-0 !bg-transparent !p-0 text-sm leading-6 text-foreground-strong !outline-none !ring-0 placeholder:text-foreground-muted",
- composerAction: "grid size-7 shrink-0 place-items-center border-0 bg-transparent p-0 text-foreground-muted hover:text-foreground-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-line-accent/25",
- composerDivider: "h-6 w-px bg-line-subtle",
- submitAction: "grid size-7 shrink-0 place-items-center border-0 bg-transparent p-0 text-foreground-accent hover:text-foreground-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-line-accent/25 disabled:text-foreground-disabled",
- threadCard: "absolute z-10 grid w-[320px] translate-x-4 translate-y-4 gap-3 rounded-surface border border-line-subtle bg-background-canvas p-4 shadow-overlay",
- threadHeader: "flex items-center gap-2",
- threadBadge: "grid size-6 place-items-center rounded-control bg-background-accent text-xs font-semibold text-foreground-inverse",
- threadMenu: "ml-auto grid size-7 place-items-center border-0 bg-transparent p-0 text-foreground-muted hover:text-foreground-strong",
- threadBody: "m-0 text-sm leading-6 text-foreground-strong",
- threadRule: "h-px bg-line-subtle/70",
- replyInput: "rounded-control border border-line-subtle bg-background-canvas px-3 py-2 text-sm text-foreground-strong outline-none placeholder:text-foreground-muted focus:border-line-accent focus:ring-2 focus:ring-line-accent/20",
commentCard: "absolute z-10 flex w-[280px] items-center gap-1.5 rounded-surface border border-line-subtle bg-background-canvas px-2 py-1.5 shadow-overlay focus-within:border-line-accent",
commentPreview: "pointer-events-none absolute z-20 w-[280px] rounded-surface rounded-bl-none border border-line-subtle bg-background-canvas px-3 py-2.5 text-sm leading-5 text-foreground-strong shadow-overlay",
sendButton: "grid size-7 place-items-center rounded-full border-0 bg-background-accent p-0 text-foreground-inverse outline-none hover:bg-background-accent/90 focus-visible:ring-2 focus-visible:ring-line-accent/30 disabled:bg-background-subtle disabled:text-foreground-disabled",
toolDock: "absolute bottom-6 left-1/2 z-20 flex -translate-x-1/2 items-center gap-1 rounded-surface border border-line-subtle bg-background-canvas/95 p-1.5 shadow-overlay backdrop-blur",
dockButton: "grid size-9 place-items-center rounded-control border-0 bg-transparent text-foreground-muted outline-none hover:bg-background-subtle hover:text-foreground-strong aria-pressed:bg-background-accent aria-pressed:text-foreground-inverse focus-visible:ring-2 focus-visible:ring-line-accent/25 disabled:text-foreground-disabled",
- dockDivider: "mx-1 h-6 w-px bg-line-subtle",
structuredOutput: "m-0 max-h-64 overflow-auto whitespace-pre-wrap p-3 font-mono text-xs",
+ dockDivider: "mx-1 h-6 w-px bg-line-subtle",
},
});
diff --git a/site/src/routes/docs/DocsRoute.tsx b/site/src/routes/docs/DocsRoute.tsx
index fc5a99c7..3a7b119d 100644
--- a/site/src/routes/docs/DocsRoute.tsx
+++ b/site/src/routes/docs/DocsRoute.tsx
@@ -85,6 +85,7 @@ const docIllustrations: Record = {
animationApi: "patch",
markdownReactApi: "patch",
databaseApi: "database",
+ annotationApi: "cursor",
calendarApi: "database",
webApi: "terminal",
contenteditableApi: "cursor",
diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts
index 17a9b1a7..45bf72af 100644
--- a/site/src/routes/docs/doc-pages.ts
+++ b/site/src/routes/docs/doc-pages.ts
@@ -14,6 +14,7 @@ import uiPrimitivesApiMarkdown from "../../../../docs/api-reference/ui-primitive
import animationApiMarkdown from "../../../../docs/api-reference/animation-react.md?raw";
import markdownReactApiMarkdown from "../../../../docs/api-reference/markdown-react.md?raw";
import databaseApiMarkdown from "../../../../docs/api-reference/database.md?raw";
+import annotationApiMarkdown from "../../../../docs/api-reference/annotation.md?raw";
import calendarApiMarkdown from "../../../../docs/api-reference/calendar.md?raw";
import webApiMarkdown from "../../../../docs/api-reference/web.md?raw";
import contenteditableApiMarkdown from "../../../../docs/api-reference/contenteditable.md?raw";
@@ -188,6 +189,7 @@ export const docPages = {
animationApi: docPage("/docs/api/animation-react", animationApiMarkdown),
markdownReactApi: docPage("/docs/api/markdown-react", markdownReactApiMarkdown),
databaseApi: docPage("/docs/api/database", databaseApiMarkdown),
+ annotationApi: docPage("/docs/api/annotation", annotationApiMarkdown),
calendarApi: docPage("/docs/api/calendar", calendarApiMarkdown),
webApi: docPage("/docs/api/web", webApiMarkdown),
contenteditableApi: docPage("/docs/api/contenteditable", contenteditableApiMarkdown),
diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts
index 754b4cc0..9236bb23 100644
--- a/site/src/shared/demo-workbench/demo-sources.ts
+++ b/site/src/shared/demo-workbench/demo-sources.ts
@@ -68,6 +68,9 @@ import contentInteractionAffordanceSource from "../../../../packages/json-docume
import databaseEditingSource from "../../../../packages/json-document-editing/src/database.ts?raw";
import databasePropertyValueSource from "../../../../packages/json-document-editing/src/database-property-value.ts?raw";
import databaseHandSource from "../../../../packages/json-document-database/src/database-hand.tsx?raw";
+import annotationSelectionSource from "../../../../packages/json-document-editing/src/annotation-selection.ts?raw";
+import annotationOutputSource from "../../../../packages/json-document-annotation/src/annotation-output.ts?raw";
+import annotationHandSource from "../../../../packages/json-document-annotation/src/annotation-hand.tsx?raw";
import annotationEditingSource from "../../../../packages/json-document-editing/src/annotation.ts?raw";
import webSVGCoordinateSource from "../../../../packages/json-document-web/src/svg-coordinate.ts?raw";
import webRasterSource from "../../../../packages/json-document-web/src/raster-source.ts?raw";
@@ -105,6 +108,7 @@ import richTextReactSurfaceSource from "../../../../packages/json-document-rich-
import richTextRenderStoreSource from "../../../../packages/json-document-rich-text-react/src/render-store.ts?raw";
import uiFileSizeSource from "../../../../packages/json-document-file-intake/src/file-size.ts?raw";
import coreDocumentSource from "../../../../packages/json-document/src/application/document/create.ts?raw";
+import selectionKeySource from "../../../../packages/json-document-selection/src/key/index.ts?raw";
import selectionRangeSource from "../../../../packages/json-document-selection/src/range/index.ts?raw";
import selectionMaterializedRangeSource from "../../../../packages/json-document-selection/src/range/materialized.ts?raw";
import contentEditableReactSource from "../../../../packages/json-document-contenteditable/src/content-editable.tsx?raw";
@@ -144,6 +148,7 @@ const packageReferencePaths = new Map([
["packages/json-document-animation-react/", "/docs/api/animation-react"],
["packages/json-document-markdown-react/", "/docs/api/markdown-react"],
["packages/json-document-database/", "/docs/api/database"],
+ ["packages/json-document-annotation/", "/docs/api/annotation"],
["packages/json-document-web/", "/docs/api/web"],
["packages/json-document-contenteditable/", "/docs/api/contenteditable"],
["packages/json-document-rich-text/", "/docs/api/rich-text"],
@@ -246,6 +251,9 @@ const registeredUsageSources = new Map([
["packages/json-document-editing/src/database.ts", databaseEditingSource],
["packages/json-document-editing/src/database-property-value.ts", databasePropertyValueSource],
["packages/json-document-database/src/database-hand.tsx", databaseHandSource],
+ ["packages/json-document-annotation/src/annotation-hand.tsx", annotationHandSource],
+ ["packages/json-document-annotation/src/annotation-output.ts", annotationOutputSource],
+ ["packages/json-document-editing/src/annotation-selection.ts", annotationSelectionSource],
["packages/json-document-editing/src/annotation.ts", annotationEditingSource],
["packages/json-document-web/src/svg-coordinate.ts", webSVGCoordinateSource],
["packages/json-document-web/src/raster-source.ts", webRasterSource],
@@ -284,6 +292,7 @@ const registeredUsageSources = new Map([
["packages/json-document-file-intake/src/file-size.ts", uiFileSizeSource],
["packages/json-document/src/application/document/create.ts", coreDocumentSource],
["packages/json-document-selection/src/range/index.ts", selectionRangeSource],
+ ["packages/json-document-selection/src/key/index.ts", selectionKeySource],
["packages/json-document-selection/src/range/materialized.ts", selectionMaterializedRangeSource],
["packages/json-document-contenteditable/src/content-editable.tsx", contentEditableReactSource],
["packages/json-document-collaboration/src/create.ts", collaborationCreateSource],
@@ -942,6 +951,27 @@ const registeredPublicUsages = [
symbol: "createAnnotationEditor",
sourcePath: "packages/json-document-editing/src/annotation.ts",
},
+ // Public editor implementation spans its domain projection and the Key owner.
+ ...["packages/json-document-editing/src/annotation-selection.ts", "packages/json-document-selection/src/key/index.ts"].map((sourcePath) => ({
+ packageName: "@interactive-os/json-document-editing",
+ symbol: "createAnnotationEditor",
+ sourcePath,
+ })),
+ ...["transformAnnotationSelector", "annotationSelectorBounds", "annotationResizeHandle"].map((symbol) => ({
+ packageName: "@interactive-os/json-document-editing",
+ symbol,
+ sourcePath: "packages/json-document-editing/src/annotation.ts",
+ })),
+ {
+ packageName: "@interactive-os/json-document-annotation",
+ symbol: "AnnotationHand",
+ sourcePath: "packages/json-document-annotation/src/annotation-hand.tsx",
+ },
+ {
+ packageName: "@interactive-os/json-document-annotation",
+ symbol: "useAnnotationOutput",
+ sourcePath: "packages/json-document-annotation/src/annotation-output.ts",
+ },
{
packageName: "@interactive-os/json-document-react",
symbol: "editingItemProps",
diff --git a/site/tests/browser/annotation-demo.spec.ts b/site/tests/browser/annotation-demo.spec.ts
index 3cfa0dac..af8616f6 100644
--- a/site/tests/browser/annotation-demo.spec.ts
+++ b/site/tests/browser/annotation-demo.spec.ts
@@ -203,3 +203,56 @@ async function drawPath(page: Page, points: ReadonlyArray<{ x: number; y: number
for (const point of points.slice(1)) await page.mouse.move(point.x, point.y, { steps: 4 });
await page.mouse.up();
}
+
+test("output preserves exact document state across save, restore and raster preview", async ({ page }) => {
+ await page.goto("/demo/annotation");
+ const canvas = page.getByLabel("Raster annotation canvas");
+ await page.getByRole("button", { name: "Like", exact: true }).click();
+ await canvas.click({ position: { x: 240, y: 220 } });
+ await page.getByText("Annotation output", { exact: true }).click();
+ const saved = await structured(page);
+ expect(Object.keys(saved).sort()).toEqual(["annotations", "id", "profile", "sources"]);
+ await page.getByRole("button", { name: "Save state", exact: true }).click();
+ await page.getByRole("button", { name: "Delete annotation", exact: true }).click();
+ expect((await structured(page)).annotations).toHaveLength(0);
+ await page.getByRole("button", { name: "Restore state", exact: true }).click();
+ expect(await structured(page)).toEqual(saved);
+ await expect(page.locator('[data-annotation-id][data-selected="true"]')).toHaveCount(0);
+ await page.getByRole("tab", { name: "Image", exact: true }).click();
+ await expect(page.getByTestId("annotation-image-output")).toBeVisible();
+ await expect(page.getByRole("link", { name: "Download PNG", exact: true })).toHaveAttribute("href", /^data:image\/png/);
+});
+
+test("modified Delete is ignored while ordinary Delete and Undo share the editing contract", async ({ page }) => {
+ await page.goto("/demo/annotation");
+ const canvas = page.getByLabel("Raster annotation canvas");
+ await canvas.focus();
+ await canvas.press("l");
+ await canvas.click({ position: { x: 240, y: 220 } });
+ await canvas.focus();
+ await canvas.press("Alt+Backspace");
+ expect((await structured(page)).annotations).toHaveLength(1);
+ await canvas.press("Delete");
+ expect((await structured(page)).annotations).toHaveLength(0);
+ await canvas.press("ControlOrMeta+z");
+ expect((await structured(page)).annotations).toHaveLength(1);
+ await expect(page.locator('[data-annotation-id][data-selected="true"]')).toHaveCount(1);
+});
+
+
+test("source tabs connect Annotation Usage to each canonical responsibility", async ({ page }) => {
+ await page.goto("/demo/annotation");
+ const workbench = page.getByRole("region", { name: "Demo workbench" });
+ await workbench.getByRole("tab", { name: "AnnotationDemoRoute.tsx", exact: true }).click();
+ for (const [file, code, api] of [
+ ["annotation-hand.tsx", "useInteractionHandle", "annotation"],
+ ["annotation-output.ts", "export function useAnnotationOutput", "annotation"],
+ ["annotation.ts", "transformAnnotationSelector", "editing"],
+ ["annotation-selection.ts", "family.transition", "editing"],
+ ["index.ts", "createKeySelectionFamily", "selection"],
+ ]) {
+ await workbench.getByRole("tab", { name: file, exact: true }).click();
+ await expect(workbench.getByRole("tabpanel").locator("pre")).toContainText(code!);
+ await expect(workbench.getByRole("link", { name: "API Reference" })).toHaveAttribute("href", `/docs/api/${api}`);
+ }
+});
diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx
index 17d30ff5..166f5f71 100644
--- a/site/tests/unit/demo-workbench.test.tsx
+++ b/site/tests/unit/demo-workbench.test.tsx
@@ -54,6 +54,22 @@ describe("DemoWorkbench", () => {
});
describe("Demo definition and source discovery", () => {
+ test("Annotation Usage exposes the Hand, output, geometry, selection projection and Key owner", async () => {
+ const sources = await discoverDemoSources("routes/annotation-demo/AnnotationDemoRoute.tsx");
+ for (const path of [
+ "packages/json-document-annotation/src/annotation-hand.tsx",
+ "packages/json-document-annotation/src/annotation-output.ts",
+ "packages/json-document-editing/src/annotation.ts",
+ "packages/json-document-editing/src/annotation-selection.ts",
+ "packages/json-document-selection/src/key/index.ts",
+ ]) {
+ const file = sources.find((source) => source.path === path);
+ expect(file, path).toBeDefined();
+ expect(await file!.load()).not.toBe("");
+ expect(file!.referencePath).toMatch(/^\/docs\/api\//);
+ }
+ });
+
test("exposes the canonical editing-host predicate in clipboard Usage", async () => {
const sources = await discoverDemoSources("routes/adapters/clipboard/ClipboardAdapterDemoRoute.tsx");
const input = sources.find((file) => file.path === "packages/json-document-web/src/input.ts");
diff --git a/site/tsconfig.json b/site/tsconfig.json
index f68e9986..671c75e4 100644
--- a/site/tsconfig.json
+++ b/site/tsconfig.json
@@ -33,6 +33,7 @@
"@interactive-os/json-document-rich-text-react": ["../packages/json-document-rich-text-react/src/index.tsx"],
"@interactive-os/json-document-zod": ["../packages/json-document-zod/src/index.ts"],
"@interactive-os/json-document-database": ["../packages/json-document-database/src/index.ts"],
+ "@interactive-os/json-document-annotation": ["../packages/json-document-annotation/src/index.ts"],
"@interactive-os/json-document-collaboration": ["../packages/json-document-collaboration/src/index.ts"],
"@interactive-os/json-document-collaboration/text": ["../packages/json-document-collaboration/src/text-index.ts"],
"@interactive-os/json-document-collaboration/editing": ["../packages/json-document-collaboration/src/editing-index.ts"],
diff --git a/standards/repository-implementation-shape.md b/standards/repository-implementation-shape.md
index 3f192ad1..dc1cd306 100644
--- a/standards/repository-implementation-shape.md
+++ b/standards/repository-implementation-shape.md
@@ -204,7 +204,7 @@ foundation으로 유지한다.
## 현재 package 분류
-아래 표는 현재 29개 library package를 이 문서의 모형으로 빠짐없이 분류한다.
+아래 표는 현재 30개 library package를 이 문서의 모형으로 빠짐없이 분류한다.
`후속`은 이 RFC가 source를 이동하지 않고 별도 이슈가 책임짐을 뜻한다.
| Package path | 정본 모형 | 현재 판단 |
@@ -224,6 +224,7 @@ foundation으로 유지한다.
| `packages/json-document-markdown-react` | React projection family | 불완전한 스트리밍 Markdown의 복구 투영, GFM renderer, customization contract와 stylesheet를 유지 |
| `packages/json-document-zod` | Composite Connector | validator와 Database translation을 책임 file로 분리한 현재 모양 유지 |
| `packages/json-document-database` | Product-facing Hand | 기본 admin UI와 customization contract를 소유하고 headless domain package를 내부 구현으로 조합 |
+| `packages/json-document-annotation` | Product-facing Hand | Annotation 도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 소유 |
| `packages/json-document-calendar` | Product-facing Hand | Calendar editor 관찰, occurrence focus, naming, Web pointer interaction lifecycle을 정본 hook으로 유지 |
| `packages/json-document-tanstack-table` | Single-native Connector | 하나의 Table/Sheet binding으로 flat 유지 |
| `packages/json-document-web` | Adapter family | keyboard/clipboard/input/modifier 책임 file과 root facade 유지 |
diff --git a/tsconfig.build.json b/tsconfig.build.json
index c14e9481..e0c09979 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -15,6 +15,7 @@
{ "path": "./packages/json-document-markdown-react" },
{ "path": "./packages/json-document-zod" },
{ "path": "./packages/json-document-database" },
+ { "path": "./packages/json-document-annotation" },
{ "path": "./packages/json-document-calendar" },
{ "path": "./packages/json-document-tanstack-table" },
{ "path": "./packages/json-document-web" },