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/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/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index ef80fa85..ba6e6c5c 100644 --- a/docs/api-reference/packages.mjs +++ b/docs/api-reference/packages.mjs @@ -10,6 +10,7 @@ export const apiReferencePackages = [ ["affordance", "@interactive-os/json-document-affordance", "packages/json-document-affordance/src/index.ts", "Affordance", "입력 문법과 interaction session"], ["ui-primitives-react", "@interactive-os/json-document-ui-primitives-react", "packages/json-document-ui-primitives-react/src/index.ts", "UI Primitives", "표준 React UI primitive"], ["database", "@interactive-os/json-document-database", "packages/json-document-database/src/index.ts", "Hands", "Database Hand domain 계약"], + ["annotation", "@interactive-os/json-document-annotation", "packages/json-document-annotation/src/index.ts", "Hands", "Annotation Hand interaction과 SVG projection"], ["web", "@interactive-os/json-document-web", "packages/json-document-web/src/index.ts", "Adapter", "Web platform adapter"], ["contenteditable", "@interactive-os/json-document-contenteditable", "packages/json-document-contenteditable/src/index.ts", "Adapter", "contenteditable platform adapter"], ["rich-text", "@interactive-os/json-document-rich-text", "packages/json-document-rich-text/src/index.ts", "Editing", "Rich Text domain과 editing 계약"], diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 49e589c9..771f8226 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -114,6 +114,7 @@ const surfaces = { ajvReadme: read("packages/json-document-ajv/README.md"), zodReadme: read("packages/json-document-zod/README.md"), databaseReadme: read("packages/json-document-database/README.md"), + annotationReadme: read("packages/json-document-annotation/README.md"), tanstackTableReadme: read("packages/json-document-tanstack-table/README.md"), webReadme: read("packages/json-document-web/README.md"), contenteditableReadme: read("packages/json-document-contenteditable/README.md"), @@ -146,6 +147,7 @@ const activeCompanionPackages = new Set([ "@interactive-os/json-document-ui-primitives-react", "@interactive-os/json-document-zod", "@interactive-os/json-document-database", + "@interactive-os/json-document-annotation", "@interactive-os/json-document-tanstack-table", "@interactive-os/json-document-web", "@interactive-os/json-document-contenteditable", diff --git a/docs/public/hands.md b/docs/public/hands.md index f35ab286..f9895b63 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -14,14 +14,17 @@ editor.undo(); `AnnotationDocument`는 source와 selector geometry, presentation을 직렬화하고, selection과 undo/redo는 editor snapshot에 둡니다. Point, rectangle, path와 arrow selector는 geometry의 유일한 정본이며 presentation은 geometry를 반복하지 -않습니다. SVG 좌표 변환, pointer gesture, Canvas rasterization과 comment UI는 -Editing owner 밖에서 조합합니다. +않습니다. `@interactive-os/json-document-annotation`의 `AnnotationHand`가 +도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 +하나의 공개 surface로 제공합니다. ```ts -const gesture = createGestureSession(); -const point = projectWebClientPointToSVG(clientPoint, viewport); -const raster = await readWebRasterFile(file); -const output = await renderWebAnnotationRaster({ document, sourceId, sourceURL, style }); + crypto.randomUUID()} + rasterStyle={style} +/> ``` Gesture는 Affordance가 input-independent lifecycle로 소유하고 Pointer capture는 diff --git a/package-lock.json b/package-lock.json index 0c35f5a8..2398867b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "packages/json-document-ui-primitives-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", @@ -958,6 +959,10 @@ "resolved": "packages/json-document-ajv", "link": true }, + "node_modules/@interactive-os/json-document-annotation": { + "resolved": "packages/json-document-annotation", + "link": true + }, "node_modules/@interactive-os/json-document-collaboration": { "resolved": "packages/json-document-collaboration", "link": true @@ -5740,6 +5745,35 @@ "ajv": "^8.0.0" } }, + "packages/json-document-annotation": { + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-collaboration": { "name": "@interactive-os/json-document-collaboration", "version": "0.2.0-rc.1", @@ -6186,6 +6220,7 @@ "dependencies": { "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-ajv": "*", + "@interactive-os/json-document-annotation": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-composer": "*", "@interactive-os/json-document-composer-react": "*", diff --git a/package.json b/package.json index 3ae7ba76..a5df90f4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "packages/json-document-ui-primitives-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-tanstack-table", "packages/json-document-web", "packages/json-document-contenteditable", diff --git a/packages/json-document-annotation/LICENSE b/packages/json-document-annotation/LICENSE new file mode 100644 index 00000000..b66a4819 --- /dev/null +++ b/packages/json-document-annotation/LICENSE @@ -0,0 +1,3 @@ +MIT License + +Copyright (c) Interactive OS contributors diff --git a/packages/json-document-annotation/README.md b/packages/json-document-annotation/README.md new file mode 100644 index 00000000..3a3c80bf --- /dev/null +++ b/packages/json-document-annotation/README.md @@ -0,0 +1,21 @@ +# @interactive-os/json-document-annotation + +`AnnotationHand` is the canonical React interaction surface for raster +annotations. Editing owns the persistent document and selector transforms; +the Hand owns tools, gesture-to-Intent orchestration, SVG projection, +transient previews, resize handles, and comment UI. + +```tsx +import { AnnotationHand } from "@interactive-os/json-document-annotation"; + + crypto.randomUUID()} + rasterStyle={rasterStyle} +/> +``` + +The Host injects IDs, enabled tools, copy, class names, raster style, and the +concrete source URL. The serialized output remains an `AnnotationDocument`; +selection and history stay in the editor snapshot. diff --git a/packages/json-document-annotation/package.json b/packages/json-document-annotation/package.json new file mode 100644 index 00000000..6326a108 --- /dev/null +++ b/packages/json-document-annotation/package.json @@ -0,0 +1,10 @@ +{ + "name": "@interactive-os/json-document-annotation", "version": "0.1.0-rc.0", "description": "Official React Annotation Hand for json-document.", "type": "module", "license": "MIT", "sideEffects": false, + "main": "./dist/index.js", "types": "./dist/index.d.ts", "repository": { "type": "git", "url": "git+https://github.com/developer-1px/json-document.git", "directory": "packages/json-document-annotation" }, + "publishConfig": { "access": "public", "provenance": true, "tag": "next" }, "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "scripts": { "clean": "rm -rf dist", "build": "npm run clean && tsc -b tsconfig.json", "test": "vitest run --config vitest.config.ts", "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, + "dependencies": { "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", "lucide-react": "^1.33.0" }, + "peerDependencies": { "react": "^18.0.0 || ^19.0.0" }, + "devDependencies": { "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", "@interactive-os/json-document-ui-primitives-react": "*", "@interactive-os/json-document-web": "*", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "jsdom": "^29.1.1", "react": "^19.2.5", "react-dom": "^19.2.5", "typescript": "^5.0.0", "vitest": "^4.1.7" } +} diff --git a/packages/json-document-annotation/src/annotation-hand.tsx b/packages/json-document-annotation/src/annotation-hand.tsx new file mode 100644 index 00000000..fb5667b9 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-hand.tsx @@ -0,0 +1,207 @@ +import { useEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent } from "react"; +import { createGestureSession } from "@interactive-os/json-document-affordance"; +import { + annotationResizeHandle, + annotationSelectorBounds, + transformAnnotationSelector, + type Annotation, + type AnnotationDocument, + type AnnotationEditor, + type AnnotationPoint, + type AnnotationSource, +} from "@interactive-os/json-document-editing"; +import { createWebPointerSession, projectWebClientPointToSVG, renderWebAnnotationRaster, webSVGViewportFromElement, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; +import { IconButton, ToggleButton } from "@interactive-os/json-document-ui-primitives-react"; +import { ArrowUpRight, Download, MessageSquare, MousePointer2, Pencil, SendHorizontal, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; + +export type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +type Gesture = + | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } + | { readonly type: "draw"; readonly points: ReadonlyArray } + | { readonly type: "move" | "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; + +export const annotationTools = [ + { id: "select", label: "Select", shortcut: "V", icon: MousePointer2 }, + { id: "comment", label: "Comment", shortcut: "C", icon: MessageSquare }, + { id: "draw", label: "Draw", shortcut: "D", icon: Pencil }, + { id: "arrow", label: "Arrow", shortcut: "A", icon: ArrowUpRight }, + { id: "like", label: "Like", shortcut: "L", icon: ThumbsUp }, + { id: "dislike", label: "Dislike", shortcut: "K", icon: ThumbsDown }, +] as const; + +export interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} + +export interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} + +export interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} + +const defaultLabels = { + canvas: "Raster annotation canvas", tools: "Annotation tools", instruction: "Annotation instruction", + instructionPlaceholder: "수정 요청을 입력하세요…", sendComment: "Send comment", + deleteAnnotation: "Delete annotation", downloadImage: "Download annotated image", +}; +const accent = "rgb(var(--color-border-accent))"; + +export function AnnotationHand(props: AnnotationHandProps) { + useSyncExternalStore(props.editor.subscribe, () => props.editor.snapshot.revision, () => props.editor.snapshot.revision); + const labels = { ...defaultLabels, ...props.labels }; const classes = props.classNames ?? {}; + const enabled = props.enabledTools ?? annotationTools.map(({ id }) => id); + const [tool, setTool] = useState(enabled.includes("comment") ? "comment" : enabled[0] ?? "select"); + const [editingId, setEditingId] = useState(null); const [previewId, setPreviewId] = useState(null); + const [, redraw] = useState(0); + const [gestures] = useState(() => createGestureSession({ onBegin: rerender, onPreview: rerender, onCommit: rerender, onCancel: rerender })); + const [pointer] = useState(() => createWebPointerSession<{ readonly active: true }>()); + const svgRef = useRef(null); + const document = props.editor.snapshot.value as AnnotationDocument; const selectedId = props.editor.snapshot.selection.primaryId; + const selected = document.annotations.find(({ id }) => id === selectedId) ?? null; const source = document.sources[0]!; const gesture = gestures.getActive(); + function rerender() { redraw((value) => value + 1); } + function announce(message: string) { props.onAnnouncement?.(message); } + function select(id: string | null) { props.editor.dispatch({ type: "selection.set", annotationId: id, mode: "replace" }); } + function choose(next: AnnotationTool) { setTool(next); setEditingId(null); if (selectedId !== null) select(null); } + function remove() { if (selectedId === null) return; props.editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); setEditingId(null); announce("선택한 annotation을 삭제했습니다."); } + + function canvasDown(event: PointerEvent) { + if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; + const point = eventPoint(event); if (point === null) return; if (tool === "select") return select(null); + pointer.begin(event.currentTarget, event.pointerId, { active: true }); + gestures.begin(tool === "draw" ? { type: "draw", points: [point] } : { type: "create", tool, start: point, current: point }); + } + function annotationDown(event: PointerEvent, annotation: Annotation) { + event.stopPropagation(); setEditingId(null); setPreviewId(null); + if (tool !== "select") return select(annotation.id); + const svg = svgRef.current; const start = eventPoint(event); if (svg === null || start === null) return; + select(annotation.id); pointer.begin(svg, event.pointerId, { active: true }); gestures.begin({ type: "move", id: annotation.id, start, current: start }); + } + function resizeDown(event: PointerEvent, annotation: Annotation) { + event.stopPropagation(); const svg = svgRef.current; const start = eventPoint(event); if (svg === null || start === null) return; + pointer.begin(svg, event.pointerId, { active: true }); gestures.begin({ type: "resize", id: annotation.id, start, current: start }); + } + function pointerMove(event: PointerEvent) { + if (gesture === null || pointer.getSnapshot()?.pointerId !== event.pointerId) return; const point = eventPoint(event); if (point === null) return; + if (gesture.type === "draw") { const last = gesture.points[gesture.points.length - 1]; if (last && distance(last, point) >= 4) gestures.preview({ ...gesture, points: [...gesture.points, point] }); } + else gestures.preview({ ...gesture, current: point }); + } + function pointerUp(event: PointerEvent) { + if (pointer.commit(event.pointerId) === null) return; const committed = gestures.commit(); if (committed === null) return; + if (committed.type === "draw" || committed.type === "create") { + const annotation = committed.type === "draw" ? drawAnnotation(source.id, committed.points, props.createId) : createAnnotation(source.id, committed, props.createId); + if (annotation === null) return; props.editor.dispatch({ type: "annotation.create", annotation }); setTool("select"); + setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); announce(createdMessage(annotation)); return; + } + const dx = committed.current.x - committed.start.x; const dy = committed.current.y - committed.start.y; + if (committed.type === "move" && Math.hypot(dx, dy) < 4) { + const annotation = document.annotations.find(({ id }) => id === committed.id); if (annotation?.presentation.type !== "reaction") setEditingId(committed.id); return; + } + const annotation = document.annotations.find(({ id }) => id === committed.id); if (!annotation) return; + const handle = annotationResizeHandle(annotation.target.selector); + if (committed.type === "move") props.editor.dispatch({ type: "annotation.move", annotationId: committed.id, dx, dy }); + else if (handle !== null) props.editor.dispatch({ type: "annotation.resize", annotationId: committed.id, handle, dx, dy }); + announce(committed.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); + } + function cancel(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { + if (pointer.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel") === null) return; + gestures.cancel(reason); announce("진행 중인 조작을 취소했습니다."); + } + function keyDown(event: KeyboardEvent) { + const command = event.metaKey || event.ctrlKey; + if (command && event.key.toLowerCase() === "z") { event.preventDefault(); event.shiftKey ? props.editor.redo() : props.editor.undo(); return; } + const next = !command ? annotationTools.find(({ shortcut }) => shortcut.toLowerCase() === event.key.toLowerCase())?.id : undefined; + if (next && enabled.includes(next)) { event.preventDefault(); choose(next); return; } + if (event.key === "Escape") { event.preventDefault(); const active = pointer.getSnapshot(); if (active) pointer.cancel(active.pointerId); gestures.cancel("cancel"); choose("select"); } + if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); remove(); } + } + async function download() { + const result = await renderWebAnnotationRaster({ document, sourceId: source.id, sourceURL: props.sourceUrl, style: props.rasterStyle }); + if (!result.ok) return announce("Annotation 이미지를 만들지 못했습니다."); + const link = window.document.createElement("a"); link.href = result.dataURL; link.download = "annotation-request.png"; link.click(); announce("Annotation이 적용된 이미지를 다운로드했습니다."); + } + return
+
+ cancel(event, "lost-capture")} onPointerCancel={(event) => cancel(event, "pointer-cancel")} onPointerMove={pointerMove} onPointerUp={pointerUp} role="application" tabIndex={0} viewBox={`0 0 ${source.width} ${source.height}`}> + + {document.annotations.map((annotation, index) => setPreviewId(visible ? annotation.id : null)} onResize={resizeDown} />)} + {gesture?.type === "create" ? : null}{gesture?.type === "draw" ? : null} + + {document.annotations.map((annotation, index) => gesture === null && previewId === annotation.id && annotation.body.instruction.trim() && editingId !== annotation.id ? : null)} + {selected && editingId === selected.id ? cancelComment(selected)} onSave={(instruction) => saveComment(selected, instruction)} onSubmit={(instruction) => submitComment(selected, instruction)} /> : null} +
+ + {JSON.stringify(document)} +
; + + function saveComment(annotation: Annotation, instruction: string) { const value = instruction.trim(); if (annotation.body.instruction !== value) props.editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: value }); setTool("select"); announce("수정 요청을 추가했습니다."); } + function submitComment(annotation: Annotation, instruction: string) { saveComment(annotation, instruction); setEditingId(null); } + function cancelComment(annotation: Annotation) { if (!annotation.body.instruction) props.editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); else select(null); setEditingId(null); } +} + +function CommentComposer(props: { annotation: Annotation; index: number; source: AnnotationSource; classNames: AnnotationHandClassNames; labels: typeof defaultLabels; onCancel: () => void; onSave: (value: string) => void; onSubmit: (value: string) => void }) { + const [draft, setDraft] = useState(props.annotation.body.instruction); const input = useRef(null); const dock = composerDock(props.annotation, props.source); + useEffect(() => setDraft(props.annotation.body.instruction), [props.annotation.id, props.annotation.body.instruction]); + useEffect(() => { const frame = requestAnimationFrame(() => input.current?.focus()); return () => cancelAnimationFrame(frame); }, [props.annotation.id]); + return
+