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
+
+
+ {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 ;
+}
+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({ annotation, index, selected, onDown, onPreview, onResize }: { annotation: Annotation; index: number; selected: boolean; onDown: (event: PointerEvent, value: Annotation) => void; onPreview: (visible: boolean) => void; onResize: (event: PointerEvent, value: Annotation) => void }) {
+ const selector = annotation.target.selector; const bounds = annotationSelectorBounds(selector); const common = { fill: "none", stroke: accent, strokeWidth: selected ? 6 : 4, vectorEffect: "non-scaling-stroke" as const };
+ return onPreview(false)} onFocus={() => onPreview(true)} onPointerEnter={() => onPreview(true)} onPointerLeave={() => onPreview(false)} onPointerDown={(event) => onDown(event, annotation)} role="button" tabIndex={0} style={{ cursor: "move" }}>
+ {annotation.presentation.type === "marker" && selector.type === "point" ? : null}
+ {annotation.presentation.type === "reaction" && selector.type === "point" ? : null}
+ {annotation.presentation.type === "outline" && selector.type === "rectangle" ? <>{selected ? onResize(event, annotation)} /> : null}> : null}
+ {annotation.presentation.type === "stroke" && selector.type === "path" ? <>{selected ? onResize(event, annotation)} /> : null}> : null}
+ {annotation.presentation.type === "arrow" && selector.type === "arrow" ? <>{selected ? onResize(event, annotation)} /> : null}> : null}
+ {annotation.presentation.type !== "marker" && annotation.presentation.type !== "reaction" ? : null}
+ ;
+}
+function Badge({ index, point, selected }: { index: number; point: AnnotationPoint; selected: boolean }) { return {index}; }
+function Handle({ label, point, onDown }: { label: string; point: AnnotationPoint; onDown: (event: PointerEvent) => void }) { return ; }
+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({ point, reaction, selected, draft }: { point: AnnotationPoint; reaction: "like" | "dislike"; selected: boolean; draft?: boolean }) { const Icon = 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/index.ts b/packages/json-document-annotation/src/index.ts
new file mode 100644
index 00000000..040d3de9
--- /dev/null
+++ b/packages/json-document-annotation/src/index.ts
@@ -0,0 +1,2 @@
+export { AnnotationHand, annotationTools } from "./annotation-hand.js";
+export type { AnnotationHandClassNames, AnnotationHandLabels, AnnotationHandProps, AnnotationTool } from "./annotation-hand.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..77067834
--- /dev/null
+++ b/packages/json-document-annotation/tests/annotation-hand.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, test } 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" };
+
+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"} 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();
+ expect(JSON.parse(screen.getByTestId("annotation-structured-output").textContent ?? "null")).toEqual(document);
+ });
+});
diff --git a/packages/json-document-annotation/tsconfig.json b/packages/json-document-annotation/tsconfig.json
new file mode 100644
index 00000000..82edb349
--- /dev/null
+++ b/packages/json-document-annotation/tsconfig.json
@@ -0,0 +1 @@
+{"extends":"../../tsconfig/library-react.json","compilerOptions":{"rootDir":"src","outDir":"dist","tsBuildInfoFile":"dist/.tsbuildinfo"},"references":[{"path":"../json-document-affordance"},{"path":"../json-document-editing"},{"path":"../json-document-ui-primitives-react"},{"path":"../json-document-web"}],"include":["src/**/*.ts","src/**/*.tsx"]}
diff --git a/packages/json-document-annotation/tsconfig.test.json b/packages/json-document-annotation/tsconfig.test.json
new file mode 100644
index 00000000..6a4921de
--- /dev/null
+++ b/packages/json-document-annotation/tsconfig.test.json
@@ -0,0 +1 @@
+{"extends":"./tsconfig.json","compilerOptions":{"composite":false,"noEmit":true,"rootDir":".","tsBuildInfoFile":null},"include":["src/**/*.ts","src/**/*.tsx","tests/**/*.ts","tests/**/*.tsx"]}
diff --git a/packages/json-document-annotation/vitest.config.ts b/packages/json-document-annotation/vitest.config.ts
new file mode 100644
index 00000000..04e0964c
--- /dev/null
+++ b/packages/json-document-annotation/vitest.config.ts
@@ -0,0 +1,2 @@
+import { defineConfig } from "vitest/config";
+export default defineConfig({ test: { environment: "jsdom" } });
diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs
index bdd1f60e..4691025a 100644
--- a/scripts/ci-plan.mjs
+++ b/scripts/ci-plan.mjs
@@ -30,6 +30,7 @@ const packageBrowserSpecs = new Map([
["@interactive-os/json-document-ajv", ["site/tests/browser/connectors/ajv.spec.ts"]],
["@interactive-os/json-document-contenteditable", ["site/tests/browser/adapters/contenteditable.spec.ts"]],
["@interactive-os/json-document-database", ["site/tests/browser/database-demo.spec.ts"]],
+ ["@interactive-os/json-document-annotation", ["site/tests/browser/annotation-demo.spec.ts"]],
["@interactive-os/json-document-editing", [
"site/tests/browser/editing-demos.spec.ts",
"site/tests/browser/editor-slice-demos.spec.ts",
@@ -65,6 +66,7 @@ const routeBrowserSpecs = new Map([
["connectors/tanstack-table", ["site/tests/browser/connectors/tanstack-table.spec.ts"]],
["connectors/zod", ["site/tests/browser/connectors/zod.spec.ts"]],
["database-demo", ["site/tests/browser/database-demo.spec.ts"]],
+ ["annotation-demo", ["site/tests/browser/annotation-demo.spec.ts"]],
["document-demo", ["site/tests/browser/document-demo.spec.ts"]],
["editing-demos", ["site/tests/browser/editing-demos.spec.ts"]],
["rich-text-demo", ["site/tests/browser/rich-text-demo.spec.ts"]],
@@ -80,6 +82,7 @@ const firstKitWorkspaces = new Set([
"@interactive-os/json-document-react",
"@interactive-os/json-document-zod",
"@interactive-os/json-document-database",
+ "@interactive-os/json-document-annotation",
"@interactive-os/json-document-file-intake",
"@interactive-os/json-document-rich-text-suggestion",
"@interactive-os/json-document-rich-text-suggestion-react",
diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs
index aad16947..f0d65c06 100644
--- a/scripts/ci-plan.test.mjs
+++ b/scripts/ci-plan.test.mjs
@@ -39,7 +39,7 @@ test("기반 패키지 변경은 모든 역방향 소비자를 선택한다", ()
const plan = createPlan(["packages/json-document/src/index.ts"]);
assert.equal(plan.full, false);
- assert.equal(plan.packageWorkspaces.length, 24);
+ assert.equal(plan.packageWorkspaces.length, 25);
assert.equal(plan.standards, true);
assert.equal(plan.externalKit, true);
assert.deepEqual(plan.browserSpecs, ["site/tests/browser"]);
@@ -49,7 +49,7 @@ test("lockfile과 workflow 및 미분류 변경은 전체 검사로 승격한다
for (const path of ["package-lock.json", ".github/workflows/pages.yml", "unknown.bin"]) {
const plan = createPlan([path]);
assert.equal(plan.full, true, path);
- assert.equal(plan.packageWorkspaces.length, 25, path);
+ assert.equal(plan.packageWorkspaces.length, 26, path);
assert.deepEqual(plan.browserSpecs, ["site/tests/browser"], path);
}
});
@@ -90,7 +90,7 @@ test("main 계획은 현재 전체 품질 검사를 요구한다", () => {
assert.equal(plan.site, true);
assert.equal(plan.standards, true);
assert.equal(plan.externalKit, true);
- assert.equal(plan.packageWorkspaces.length, 25);
+ assert.equal(plan.packageWorkspaces.length, 26);
assert.deepEqual(plan.browserSpecs, ["site/tests/browser"]);
});
@@ -107,6 +107,7 @@ test("선택기가 반환하는 모든 browser 경로가 존재한다", () => {
"json-document-composer-react",
"json-document-file-intake",
"json-document-database",
+ "json-document-annotation",
"json-document-editing",
"json-document-react",
"json-document-react-hook-form",
diff --git a/scripts/external-kit-plan.test.mjs b/scripts/external-kit-plan.test.mjs
index c1db7b38..4892c96a 100644
--- a/scripts/external-kit-plan.test.mjs
+++ b/scripts/external-kit-plan.test.mjs
@@ -12,6 +12,7 @@ test("첫 kit package 변경은 외부 소비자 검증을 선택한다", () =>
"json-document-react",
"json-document-zod",
"json-document-database",
+ "json-document-annotation",
]) {
assert.equal(createPlan([`packages/${directory}/src/index.ts`]).externalKit, true, directory);
}
diff --git a/scripts/release-package.mjs b/scripts/release-package.mjs
index 71d97883..5c22df7e 100644
--- a/scripts/release-package.mjs
+++ b/scripts/release-package.mjs
@@ -22,6 +22,7 @@ export const releases = [
release("json-document-tanstack-table", "packages/json-document-tanstack-table/package.json", "@interactive-os/json-document-tanstack-table"),
release("json-document-zod", "packages/json-document-zod/package.json", "@interactive-os/json-document-zod"),
release("json-document-database", "packages/json-document-database/package.json", "@interactive-os/json-document-database"),
+ release("json-document-annotation", "packages/json-document-annotation/package.json", "@interactive-os/json-document-annotation"),
release("json-document-contenteditable-collaboration", "packages/contenteditable-collaboration/package.json", "@interactive-os/json-document-contenteditable-collaboration"),
release("json-document-collaboration", "packages/json-document-collaboration/package.json", "@interactive-os/json-document-collaboration"),
release("json-document", "packages/json-document/package.json", "@interactive-os/json-document"),
diff --git a/scripts/release-package.test.mjs b/scripts/release-package.test.mjs
index fa9f9c19..8ff2fc0b 100644
--- a/scripts/release-package.test.mjs
+++ b/scripts/release-package.test.mjs
@@ -25,6 +25,12 @@ const databaseHand = [
"next",
"packages/json-document-database/package.json",
];
+const annotationHand = [
+ "json-document-annotation-v0.1.0-rc.0",
+ "@interactive-os/json-document-annotation",
+ "next",
+ "packages/json-document-annotation/package.json",
+];
test("첫 npm kit의 stable과 RC release stream을 구분한다", () => {
for (const [tag, workspace, distTag, packageFile] of firstKit) {
@@ -46,6 +52,11 @@ test("Database Hand를 next release stream으로 해석한다", () => {
assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile });
});
+test("Annotation Hand를 next release stream으로 해석한다", () => {
+ const [tag, workspace, distTag, packageFile] = annotationHand;
+ assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile });
+});
+
test("지원하지 않는 package tag를 거부한다", () => {
assert.throws(() => resolveRelease("json-document-rich-text-v0.1.0-rc.0"), /unsupported release tag/);
});
diff --git a/scripts/verify-external-kit.mjs b/scripts/verify-external-kit.mjs
index 1aeb9c48..80ed95fa 100644
--- a/scripts/verify-external-kit.mjs
+++ b/scripts/verify-external-kit.mjs
@@ -27,6 +27,7 @@ const kitWorkspaces = [
"@interactive-os/json-document-react",
"@interactive-os/json-document-zod",
"@interactive-os/json-document-database",
+ "@interactive-os/json-document-annotation",
];
const fixtureSource = join(repositoryRoot, "fixtures", "external-kit");
const temporaryRoot = await mkdtemp(join(tmpdir(), "json-document-external-kit-"));
diff --git a/site/package.json b/site/package.json
index 3d81cd32..5acb3ee6 100644
--- a/site/package.json
+++ b/site/package.json
@@ -15,6 +15,7 @@
"typecheck": "npm run check:ui && npm run check:icons && npm run check:primitives && npm run check:canonical-modules && npm run check:tokens && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
+ "@interactive-os/json-document-annotation": "*",
"@interactive-os/json-document-affordance": "*",
"@interactive-os/json-document-ui-primitives-react": "*",
"@interactive-os/json-document-ajv": "*",
diff --git a/site/scripts/check-canonical-module-closure.mjs b/site/scripts/check-canonical-module-closure.mjs
index a09a5bd2..2c86a936 100644
--- a/site/scripts/check-canonical-module-closure.mjs
+++ b/site/scripts/check-canonical-module-closure.mjs
@@ -10,6 +10,13 @@ const databasePropertyConsumers = [
"packages/json-document-database/src/database-hands.tsx",
"packages/json-document-zod/src/database-document.ts",
];
+const annotationDemo = readSource("routes/annotation-demo/AnnotationDemoRoute.tsx");
+if (!hasNamedImport(annotationDemo, "@interactive-os/json-document-annotation", "AnnotationHand")) {
+ throw new Error("Annotation Demo must consume the canonical AnnotationHand");
+}
+for (const localResponsibility of ["createGestureSession", "projectWebClientPointToSVG", "function AnnotationShape", "function CommentComposer", "presentStructuredSnapshot"]) {
+ if (annotationDemo.includes(localResponsibility)) throw new Error(`Annotation Demo owns displaced behavior: ${localResponsibility}`);
+}
const entries = [...registrySource.matchAll(/^\s*"\/[^"]+"[^\n]+"(routes\/[^"]+)"\),?$/gm)].map((match) => match[1]);
const usages = [...sourceRegistry.matchAll(/packageName:\s*["']([^"']+)["'],\s*\n\s*symbol:\s*["']([^"']+)["'],\s*\n\s*sourcePath:\s*["']([^"']+)["']/g)].map((match) => ({
packageName: match[1],
diff --git a/site/site-routes.json b/site/site-routes.json
index 3a286abb..566a58e4 100644
--- a/site/site-routes.json
+++ b/site/site-routes.json
@@ -224,6 +224,14 @@
"language": "ko",
"navigationGroup": "Hands"
},
+ {
+ "path": "/docs/api/annotation",
+ "label": "API · Annotation",
+ "title": "Annotation API - json-document",
+ "description": "@interactive-os/json-document-annotation의 전체 public API입니다.",
+ "language": "ko",
+ "navigationGroup": "Hands"
+ },
{
"path": "/docs/api/collaboration",
"label": "API · Collaboration",
diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts
index 89b7169f..9030b453 100644
--- a/site/src/app/routeTree.gen.ts
+++ b/site/src/app/routeTree.gen.ts
@@ -110,6 +110,7 @@ import { Route as PageDocsAffordanceTypeaheadRouteImport } from "./routes/_page/
import { Route as PageDocsAffordanceZoomRouteImport } from "./routes/_page/docs/affordance/zoom";
import { Route as PageDocsApiAffordanceRouteImport } from "./routes/_page/docs/api/affordance";
import { Route as PageDocsApiAjvRouteImport } from "./routes/_page/docs/api/ajv";
+import { Route as PageDocsApiAnnotationRouteImport } from "./routes/_page/docs/api/annotation";
import { Route as PageDocsApiCollaborationRouteImport } from "./routes/_page/docs/api/collaboration";
import { Route as PageDocsApiComposerRouteImport } from "./routes/_page/docs/api/composer";
import { Route as PageDocsApiComposerReactRouteImport } from "./routes/_page/docs/api/composer-react";
@@ -670,6 +671,11 @@ const PageDocsApiAjvRoute = PageDocsApiAjvRouteImport.update({
path: "/ajv",
getParentRoute: () => PageDocsApiRoute,
} as any);
+const PageDocsApiAnnotationRoute = PageDocsApiAnnotationRouteImport.update({
+ id: "/annotation",
+ path: "/annotation",
+ getParentRoute: () => PageDocsApiRoute,
+} as any);
const PageDocsApiCollaborationRoute =
PageDocsApiCollaborationRouteImport.update({
id: "/collaboration",
@@ -933,6 +939,7 @@ export interface FileRoutesByFullPath {
"/docs/affordance/zoom": typeof PageDocsAffordanceZoomRoute;
"/docs/api/affordance": typeof PageDocsApiAffordanceRoute;
"/docs/api/ajv": typeof PageDocsApiAjvRoute;
+ "/docs/api/annotation": typeof PageDocsApiAnnotationRoute;
"/docs/api/collaboration": typeof PageDocsApiCollaborationRoute;
"/docs/api/composer": typeof PageDocsApiComposerRoute;
"/docs/api/composer-react": typeof PageDocsApiComposerReactRoute;
@@ -1064,6 +1071,7 @@ export interface FileRoutesByTo {
"/docs/affordance/zoom": typeof PageDocsAffordanceZoomRoute;
"/docs/api/affordance": typeof PageDocsApiAffordanceRoute;
"/docs/api/ajv": typeof PageDocsApiAjvRoute;
+ "/docs/api/annotation": typeof PageDocsApiAnnotationRoute;
"/docs/api/collaboration": typeof PageDocsApiCollaborationRoute;
"/docs/api/composer": typeof PageDocsApiComposerRoute;
"/docs/api/composer-react": typeof PageDocsApiComposerReactRoute;
@@ -1197,6 +1205,7 @@ export interface FileRoutesById {
"/_page/docs/affordance/zoom": typeof PageDocsAffordanceZoomRoute;
"/_page/docs/api/affordance": typeof PageDocsApiAffordanceRoute;
"/_page/docs/api/ajv": typeof PageDocsApiAjvRoute;
+ "/_page/docs/api/annotation": typeof PageDocsApiAnnotationRoute;
"/_page/docs/api/collaboration": typeof PageDocsApiCollaborationRoute;
"/_page/docs/api/composer": typeof PageDocsApiComposerRoute;
"/_page/docs/api/composer-react": typeof PageDocsApiComposerReactRoute;
@@ -1330,6 +1339,7 @@ export interface FileRouteTypes {
| "/docs/affordance/zoom"
| "/docs/api/affordance"
| "/docs/api/ajv"
+ | "/docs/api/annotation"
| "/docs/api/collaboration"
| "/docs/api/composer"
| "/docs/api/composer-react"
@@ -1461,6 +1471,7 @@ export interface FileRouteTypes {
| "/docs/affordance/zoom"
| "/docs/api/affordance"
| "/docs/api/ajv"
+ | "/docs/api/annotation"
| "/docs/api/collaboration"
| "/docs/api/composer"
| "/docs/api/composer-react"
@@ -1593,6 +1604,7 @@ export interface FileRouteTypes {
| "/_page/docs/affordance/zoom"
| "/_page/docs/api/affordance"
| "/_page/docs/api/ajv"
+ | "/_page/docs/api/annotation"
| "/_page/docs/api/collaboration"
| "/_page/docs/api/composer"
| "/_page/docs/api/composer-react"
@@ -2340,6 +2352,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof PageDocsApiAjvRouteImport;
parentRoute: typeof PageDocsApiRoute;
};
+ "/_page/docs/api/annotation": {
+ id: "/_page/docs/api/annotation";
+ path: "/annotation";
+ fullPath: "/docs/api/annotation";
+ preLoaderRoute: typeof PageDocsApiAnnotationRouteImport;
+ parentRoute: typeof PageDocsApiRoute;
+ };
"/_page/docs/api/collaboration": {
id: "/_page/docs/api/collaboration";
path: "/collaboration";
@@ -2549,6 +2568,7 @@ declare module "@tanstack/react-router" {
interface PageDocsApiRouteChildren {
PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute;
PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute;
+ PageDocsApiAnnotationRoute: typeof PageDocsApiAnnotationRoute;
PageDocsApiCollaborationRoute: typeof PageDocsApiCollaborationRoute;
PageDocsApiComposerRoute: typeof PageDocsApiComposerRoute;
PageDocsApiComposerReactRoute: typeof PageDocsApiComposerReactRoute;
@@ -2577,6 +2597,7 @@ interface PageDocsApiRouteChildren {
const PageDocsApiRouteChildren: PageDocsApiRouteChildren = {
PageDocsApiAffordanceRoute: PageDocsApiAffordanceRoute,
PageDocsApiAjvRoute: PageDocsApiAjvRoute,
+ PageDocsApiAnnotationRoute: PageDocsApiAnnotationRoute,
PageDocsApiCollaborationRoute: PageDocsApiCollaborationRoute,
PageDocsApiComposerRoute: PageDocsApiComposerRoute,
PageDocsApiComposerReactRoute: PageDocsApiComposerReactRoute,
diff --git a/site/src/app/routes/_page/docs/api/annotation.tsx b/site/src/app/routes/_page/docs/api/annotation.tsx
new file mode 100644
index 00000000..eac48214
--- /dev/null
+++ b/site/src/app/routes/_page/docs/api/annotation.tsx
@@ -0,0 +1,8 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { DocsRoute } from "../../../../../routes/docs/DocsRoute";
+
+export const Route = createFileRoute("/_page/docs/api/annotation")({
+ component: function AnnotationApiReferenceRoute() {
+ return ;
+ },
+});
diff --git a/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx b/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx
index 3fbf0340..2f03ef65 100644
--- a/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx
+++ b/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx
@@ -1,876 +1,33 @@
-import {
- useEffect,
- useMemo,
- useRef,
- useState,
- useSyncExternalStore,
- type ChangeEvent,
- type KeyboardEvent,
- type PointerEvent,
-} from "react";
+import { useState } from "react";
import { createJSONDocument } from "@interactive-os/json-document";
-import {
- ANNOTATION_PROFILE_V1,
- assertAnnotationDocument,
- createAnnotationEditor,
- type Annotation,
- type AnnotationDocument,
- type AnnotationPoint,
- type AnnotationSource,
-} from "@interactive-os/json-document-editing";
-import { createGestureSession } from "@interactive-os/json-document-affordance";
-import {
- createWebPointerSession,
- projectWebClientPointToSVG,
- readWebRasterFile,
- renderWebAnnotationRaster,
- webSVGViewportFromElement,
-} from "@interactive-os/json-document-web";
-import {
- ArrowUpRight,
- Download,
- ImagePlus,
- MessageSquare,
- MousePointer2,
- Pencil,
- Redo2,
- RotateCcw,
- Save,
- SendHorizontal,
- ThumbsDown,
- ThumbsUp,
- Trash2,
- Undo2,
- ZoomIn,
- ZoomOut,
-} from "lucide-react";
+import { AnnotationHand } from "@interactive-os/json-document-annotation";
+import { createAnnotationEditor } from "@interactive-os/json-document-editing";
import { DemoPage } from "../../shared/demo-workbench/DemoPage";
-import { IconButton, Tabs, ToggleButton } from "@interactive-os/json-document-ui-primitives-react";
import { PageHeader, ProductApp } from "../../shared/ui/primitives";
import { classes, ui } from "../../shared/ui/styles";
-import {
- initialAnnotationDocument,
-} from "./annotation-state";
+import { initialAnnotationDocument } from "./annotation-state";
import { annotationDemoRecipe } from "./annotation-demo-styles";
-type Tool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike";
-type Output = "structured" | "image";
-type Gesture =
- | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint }
- | { readonly type: "draw"; readonly points: ReadonlyArray }
- | { readonly type: "move"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }
- | { readonly type: "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint };
-
-const accent = "rgb(var(--color-border-accent))";
-const annotationDemoStyles = annotationDemoRecipe();
+const styles = annotationDemoRecipe();
export function AnnotationDemoRoute() {
- const [documentSource] = useState(() => createJSONDocument(initialAnnotationDocument));
- const [editor] = useState(() => createAnnotationEditor(documentSource));
- useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision);
- const [tool, setTool] = useState("comment");
- const [editingId, setEditingId] = useState(null);
- const [previewId, setPreviewId] = useState(null);
- const [, setGestureRevision] = useState(0);
- const [gestureSession] = useState(() => createGestureSession({
- onBegin: () => setGestureRevision((revision) => revision + 1),
- onPreview: () => setGestureRevision((revision) => revision + 1),
- onCommit: () => setGestureRevision((revision) => revision + 1),
- onCancel: () => setGestureRevision((revision) => revision + 1),
- }));
- const [pointerSession] = useState(() => createWebPointerSession<{ readonly active: true }>());
- const [savedState, setSavedState] = useState(null);
- const [output, setOutput] = useState
}>
- 이미지 위에서 위치를 표시하고 수정 요청을 남겨 보세요.
-
- )}>
-
-
-
-
- {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}
-
-
-
-
-
-
- );
-}
-
-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 (
-
- );
-}
-
-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 onPointerDown: (event: PointerEvent, annotation: Annotation) => void;
- readonly onPreviewChange: (visible: boolean) => void;
- readonly onResizePointerDown: (event: PointerEvent, annotation: Annotation) => void;
-}) {
- const { annotation } = props;
- 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)}
- onPointerDown={(event) => props.onPointerDown(event, annotation)}
- role="button"
- tabIndex={0}
- style={{ cursor: "move" }}
- >
- {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.onResizePointerDown(event, annotation)}
- style={{ cursor: "nwse-resize" }}
- />
- ) : null}
- >
- ) : null}
- {annotation.presentation.type === "stroke" && selector.type === "path" ? (
- <>
-
- {props.selected ? (
- props.onResizePointerDown(event, annotation)} style={{ cursor: "nwse-resize" }} />
- ) : null}
- >
- ) : null}
- {annotation.presentation.type === "arrow" && selector.type === "arrow" ? (
- <>
-
- {props.selected ? (
- props.onResizePointerDown(event, annotation)}
- style={{ cursor: "crosshair" }}
- />
- ) : null}
- >
- ) : null}
- {annotation.presentation.type !== "marker" && annotation.presentation.type !== "reaction" ? (
-
- ) : null}
-
- );
-}
-
-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 (
-
-
-
-
-
- );
-}
-
-function OutputPanel(props: {
- readonly canRestore: boolean;
- readonly onRestore: () => void;
- readonly onSave: () => void;
- readonly output: Output;
- readonly setOutput: (output: Output) => void;
- readonly structured: unknown;
- readonly structuredDownloadUrl: string;
- readonly renderedImage: string | null;
-}) {
- return (
-
- `annotation-output-tab-${value}`}
- panelId={(value) => `annotation-output-panel-${value}`}
- />
- {props.output === "structured" ? (
-
-
-
- {JSON.stringify(props.structured, null, 2)}
-
-
- ) : props.renderedImage === null ? (
- Rasterizing…
- ) : (
-
- )}
-
- );
-}
-
-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" };
-}
+ const source = initialAnnotationDocument.sources[0]!;
+ 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(),
+ }} />
+
+ ;
+}
+
+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" }; }
diff --git a/site/src/routes/annotation-demo/annotation-demo-styles.ts b/site/src/routes/annotation-demo/annotation-demo-styles.ts
index d56b27b8..d9696f15 100644
--- a/site/src/routes/annotation-demo/annotation-demo-styles.ts
+++ b/site/src/routes/annotation-demo/annotation-demo-styles.ts
@@ -6,25 +6,12 @@ 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/35",
- 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",
},
});
diff --git a/site/src/routes/docs/DocsRoute.tsx b/site/src/routes/docs/DocsRoute.tsx
index c308a395..c3a5e6f0 100644
--- a/site/src/routes/docs/DocsRoute.tsx
+++ b/site/src/routes/docs/DocsRoute.tsx
@@ -76,6 +76,7 @@ const docIllustrations: Record = {
affordanceApi: "patch",
uiPrimitivesApi: "patch",
databaseApi: "database",
+ annotationApi: "cursor",
webApi: "terminal",
contenteditableApi: "cursor",
richTextApi: "terminal",
diff --git a/site/src/routes/docs/doc-pages.ts b/site/src/routes/docs/doc-pages.ts
index 65fa661a..c25f2552 100644
--- a/site/src/routes/docs/doc-pages.ts
+++ b/site/src/routes/docs/doc-pages.ts
@@ -10,6 +10,7 @@ import tanStackTableApiMarkdown from "../../../../docs/api-reference/tanstack-ta
import affordanceApiMarkdown from "../../../../docs/api-reference/affordance.md?raw";
import uiPrimitivesApiMarkdown from "../../../../docs/api-reference/ui-primitives-react.md?raw";
import databaseApiMarkdown from "../../../../docs/api-reference/database.md?raw";
+import annotationApiMarkdown from "../../../../docs/api-reference/annotation.md?raw";
import webApiMarkdown from "../../../../docs/api-reference/web.md?raw";
import contenteditableApiMarkdown from "../../../../docs/api-reference/contenteditable.md?raw";
import richTextApiMarkdown from "../../../../docs/api-reference/rich-text.md?raw";
@@ -163,6 +164,7 @@ export const docPages = {
affordanceApi: docPage("/docs/api/affordance", affordanceApiMarkdown),
uiPrimitivesApi: docPage("/docs/api/ui-primitives-react", uiPrimitivesApiMarkdown),
databaseApi: docPage("/docs/api/database", databaseApiMarkdown),
+ annotationApi: docPage("/docs/api/annotation", annotationApiMarkdown),
webApi: docPage("/docs/api/web", webApiMarkdown),
contenteditableApi: docPage("/docs/api/contenteditable", contenteditableApiMarkdown),
richTextApi: docPage("/docs/api/rich-text", richTextApiMarkdown),
diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts
index a6b1d3df..4b5e4fc5 100644
--- a/site/src/shared/demo-workbench/demo-sources.ts
+++ b/site/src/shared/demo-workbench/demo-sources.ts
@@ -22,6 +22,7 @@ import gestureSessionSource from "../../../../packages/json-document-affordance/
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 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";
@@ -80,6 +81,7 @@ const packageReferencePaths = new Map([
["packages/json-document-affordance/", "/docs/api/affordance"],
["packages/json-document-ui-primitives-react/", "/docs/api/ui-primitives-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"],
@@ -136,6 +138,7 @@ 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-editing/src/annotation.ts", annotationEditingSource],
["packages/json-document-web/src/svg-coordinate.ts", webSVGCoordinateSource],
["packages/json-document-web/src/raster-source.ts", webRasterSource],
@@ -381,6 +384,11 @@ const registeredPublicUsages = [
symbol: "createAnnotationEditor",
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-react",
symbol: "editingItemProps",
diff --git a/site/tests/unit/app-shell.test.tsx b/site/tests/unit/app-shell.test.tsx
index 6f3a6afc..eb58346f 100644
--- a/site/tests/unit/app-shell.test.tsx
+++ b/site/tests/unit/app-shell.test.tsx
@@ -77,6 +77,7 @@ describe("official site shell", () => {
"API · Composer",
"API · Composer React",
"API · Database",
+ "API · Annotation",
"Overview",
"Official Hands · TBD",
"Order",
diff --git a/standards/repository-implementation-shape.md b/standards/repository-implementation-shape.md
index 660febf4..4a7c8e32 100644
--- a/standards/repository-implementation-shape.md
+++ b/standards/repository-implementation-shape.md
@@ -204,7 +204,7 @@ foundation으로 유지한다.
## 현재 package 분류
-아래 표는 현재 23개 library package를 이 문서의 모형으로 빠짐없이 분류한다.
+아래 표는 현재 26개 library package를 이 문서의 모형으로 빠짐없이 분류한다.
`후속`은 이 RFC가 source를 이동하지 않고 별도 이슈가 책임짐을 뜻한다.
| Package path | 정본 모형 | 현재 판단 |
@@ -221,6 +221,7 @@ foundation으로 유지한다.
| `packages/json-document-ui-primitives-react` | React UI Primitive family | 수렴한 Hands의 minimalist surface와 framework lifecycle을 책임별 module로 유지 |
| `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-tanstack-table` | Single-native Connector | 하나의 Table/Sheet binding으로 flat 유지 |
| `packages/json-document-web` | Adapter family | keyboard/clipboard/input/modifier 책임 file과 root facade 유지 |
| `packages/json-document-contenteditable` | Composite Adapter | React entry, binding, DOM adapter 책임 분리 유지 |
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 1529cc58..3ffab490 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -12,6 +12,7 @@
{ "path": "./packages/json-document-ui-primitives-react" },
{ "path": "./packages/json-document-zod" },
{ "path": "./packages/json-document-database" },
+ { "path": "./packages/json-document-annotation" },
{ "path": "./packages/json-document-tanstack-table" },
{ "path": "./packages/json-document-web" },
{ "path": "./packages/json-document-contenteditable" },
From 4c89fbc7a8680d41df77e7f9d85cf866d0690dec 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:11:56 +0900
Subject: [PATCH 3/3] fix(site): resolve Annotation Hand from source
---
site/config/json-document-source-aliases.ts | 4 ++++
site/tsconfig.json | 1 +
2 files changed, 5 insertions(+)
diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts
index 38788e71..4563998f 100644
--- a/site/config/json-document-source-aliases.ts
+++ b/site/config/json-document-source-aliases.ts
@@ -51,6 +51,10 @@ export function jsonDocumentSourceAliases(): SourceAlias[] {
find: "@interactive-os/json-document-database",
replacement: sourceFile("packages/json-document-database/src/index.ts"),
},
+ {
+ find: "@interactive-os/json-document-annotation",
+ replacement: sourceFile("packages/json-document-annotation/src/index.ts"),
+ },
{
find: "@interactive-os/json-document-tanstack-table",
replacement: sourceFile("packages/json-document-tanstack-table/src/index.ts"),
diff --git a/site/tsconfig.json b/site/tsconfig.json
index 88c8a2c5..20f28378 100644
--- a/site/tsconfig.json
+++ b/site/tsconfig.json
@@ -29,6 +29,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-contenteditable-collaboration": ["../packages/contenteditable-collaboration/src/index.ts"]