diff --git a/docs/api-reference/annotation.md b/docs/api-reference/annotation.md new file mode 100644 index 00000000..c7e6c8a6 --- /dev/null +++ b/docs/api-reference/annotation.md @@ -0,0 +1,99 @@ +# @interactive-os/json-document-annotation API + +**Owner:** Hands + +Annotation Hand interaction과 SVG projection의 public entrypoint입니다. 아래 항목은 package root에서 import할 수 있는 안정된 public API이며 internal 경로는 계약이 아닙니다. + +> 이 문서는 `packages/json-document-annotation/src/index.ts`에서 생성됩니다. API를 변경한 뒤 `npm run docs:api`를 실행하세요. + +## `AnnotationHand` + +```ts +AnnotationHand(props: AnnotationHandProps): import("/node_modules/@types/react/jsx-runtime").JSX.Element +``` +## `AnnotationHandClassNames` + +```ts +interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} +``` +## `AnnotationHandLabels` + +```ts +interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} +``` +## `AnnotationHandProps` + +```ts +interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} +``` +## `AnnotationOutput` + +```ts +interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} +``` +## `AnnotationOutputOptions` + +```ts +interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +``` +## `AnnotationTool` + +```ts +type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +``` +## `annotationTools` + +```ts +const annotationTools: readonly [{ readonly id: "select"; readonly label: "Select"; readonly shortcut: "V"; readonly icon: ForwardRefExoticComponent & RefAttributes>; }, ... 4 more ..., { ...; }] +``` +## `useAnnotationOutput` + +```ts +useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput +``` diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 512f2cd1..9cd1f1c6 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -26,6 +26,11 @@ interface Annotation extends Record { readonly id: string; re ```ts const ANNOTATION_PROFILE_V1: "urn:interactive-os:json-document:annotation:1" ``` +## `AnnotationBounds` + +```ts +interface AnnotationBounds extends AnnotationPoint { readonly width: number; readonly height: number } +``` ## `AnnotationDocument` ```ts @@ -62,6 +67,11 @@ type AnnotationPresentation = | { readonly type: "stroke" } | { readonly type: "arrow" }; ``` +## `annotationResizeHandle` + +```ts +annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null +``` ## `AnnotationSelection` ```ts @@ -76,6 +86,18 @@ type AnnotationSelector = | { readonly type: "path"; readonly points: ReadonlyArray } | { readonly type: "arrow"; readonly from: AnnotationPoint; readonly to: AnnotationPoint }; ``` +## `annotationSelectorBounds` + +```ts +annotationSelectorBounds(selector: AnnotationSelector): AnnotationBounds +``` +## `AnnotationSelectorTransform` + +```ts +type AnnotationSelectorTransform = + | { readonly type: "move"; readonly dx: number; readonly dy: number } + | { readonly type: "resize"; readonly handle: "end" | "south-east"; readonly dx: number; readonly dy: number }; +``` ## `AnnotationSource` ```ts @@ -1521,6 +1543,11 @@ interface SheetSelection extends Record { ```ts type SheetTopology = GridTopology; ``` +## `transformAnnotationSelector` + +```ts +transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null +``` ## `TreeClipboard` ```ts diff --git a/docs/api-reference/packages.mjs b/docs/api-reference/packages.mjs index 0e02331d..0ba9cdfe 100644 --- a/docs/api-reference/packages.mjs +++ b/docs/api-reference/packages.mjs @@ -13,6 +13,7 @@ export const apiReferencePackages = [ ["animation-react", "@interactive-os/json-document-animation-react", "packages/json-document-animation-react/src/index.ts", "UI Primitives", "생성 대기 시각 언어"], ["markdown-react", "@interactive-os/json-document-markdown-react", "packages/json-document-markdown-react/src/index.ts", "Artifact", "스트리밍 Markdown 투영과 렌더링"], ["database", "@interactive-os/json-document-database", "packages/json-document-database/src/index.ts", "Hands", "Database Hand domain 계약"], + ["annotation", "@interactive-os/json-document-annotation", "packages/json-document-annotation/src/index.ts", "Hands", "Annotation Hand interaction과 SVG projection"], ["calendar", "@interactive-os/json-document-calendar", "packages/json-document-calendar/src/index.ts", "Hands", "Calendar React lifecycle와 occurrence interaction 계약"], ["web", "@interactive-os/json-document-web", "packages/json-document-web/src/index.ts", "Adapter", "Web platform adapter"], ["contenteditable", "@interactive-os/json-document-contenteditable", "packages/json-document-contenteditable/src/index.ts", "Adapter", "contenteditable platform adapter"], diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 1bac21b2..14d3534a 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -123,6 +123,7 @@ const surfaces = { ajvReadme: read("packages/json-document-ajv/README.md"), zodReadme: read("packages/json-document-zod/README.md"), databaseReadme: read("packages/json-document-database/README.md"), + annotationReadme: read("packages/json-document-annotation/README.md"), tanstackTableReadme: read("packages/json-document-tanstack-table/README.md"), webReadme: read("packages/json-document-web/README.md"), contenteditableReadme: read("packages/json-document-contenteditable/README.md"), diff --git a/docs/public/hands.md b/docs/public/hands.md index c62d177a..3c1b40b3 100644 --- a/docs/public/hands.md +++ b/docs/public/hands.md @@ -14,14 +14,19 @@ editor.undo(); `AnnotationDocument`는 source와 selector geometry, presentation을 직렬화하고, selection과 undo/redo는 editor snapshot에 둡니다. Point, rectangle, path와 arrow selector는 geometry의 유일한 정본이며 presentation은 geometry를 반복하지 -않습니다. SVG 좌표 변환, pointer gesture, Canvas rasterization과 comment UI는 -Editing owner 밖에서 조합합니다. - -```ts -const gesture = createGestureSession(); -const point = projectWebClientPointToSVG(clientPoint, viewport); -const raster = await readWebRasterFile(file); -const output = await renderWebAnnotationRaster({ document, sourceId, sourceURL, style }); +않습니다. `@interactive-os/json-document-annotation`의 `AnnotationHand`가 +도구, gesture-to-Intent, SVG projection, transient preview와 comment UI를 +하나의 공개 surface로 제공합니다. + +```tsx + crypto.randomUUID()} + rasterStyle={style} +/> ``` Gesture는 Affordance가 input-independent lifecycle로 소유하고 Pointer capture는 diff --git a/package-lock.json b/package-lock.json index 0f02d817..43f43b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", "packages/json-document-tanstack-table", "packages/json-document-web", @@ -1124,6 +1125,10 @@ "resolved": "packages/json-document-animation-react", "link": true }, + "node_modules/@interactive-os/json-document-annotation": { + "resolved": "packages/json-document-annotation", + "link": true + }, "node_modules/@interactive-os/json-document-calendar": { "resolved": "packages/json-document-calendar", "link": true @@ -6504,6 +6509,37 @@ "react": "^18.0.0 || ^19.0.0" } }, + "packages/json-document-annotation": { + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "license": "MIT", + "dependencies": { + "@interactive-os/json-document": ">=3.0.0-rc.0 <4", + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "packages/json-document-calendar": { "name": "@interactive-os/json-document-calendar", "version": "0.1.0-rc.0", @@ -7022,6 +7058,7 @@ "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-ajv": "*", "@interactive-os/json-document-animation-react": "*", + "@interactive-os/json-document-annotation": "*", "@interactive-os/json-document-calendar": "*", "@interactive-os/json-document-collaboration": "*", "@interactive-os/json-document-composer": "*", diff --git a/package.json b/package.json index 81f3367a..1cab3947 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "packages/json-document-markdown-react", "packages/json-document-zod", "packages/json-document-database", + "packages/json-document-annotation", "packages/json-document-calendar", "packages/json-document-tanstack-table", "packages/json-document-web", diff --git a/packages/json-document-annotation/LICENSE b/packages/json-document-annotation/LICENSE new file mode 100644 index 00000000..b66a4819 --- /dev/null +++ b/packages/json-document-annotation/LICENSE @@ -0,0 +1,3 @@ +MIT License + +Copyright (c) Interactive OS contributors diff --git a/packages/json-document-annotation/README.md b/packages/json-document-annotation/README.md new file mode 100644 index 00000000..2f593fd2 --- /dev/null +++ b/packages/json-document-annotation/README.md @@ -0,0 +1,41 @@ +# @interactive-os/json-document-annotation + +`AnnotationHand` is the canonical React interaction surface for raster +annotations. Editing owns the persistent document and selector transforms; +the Hand owns tools, gesture-to-Intent orchestration, SVG projection, +transient previews, resize handles, and comment UI. + +```tsx +import { AnnotationHand } from "@interactive-os/json-document-annotation"; + + crypto.randomUUID()} + rasterStyle={rasterStyle} +/> +``` + +The Host owns the active `tool` and injects `onToolChange`, IDs, enabled tools, +copy, class names, `reactionShadow`, raster style, and the +concrete source URL. The serialized output remains an `AnnotationDocument`; +selection and history stay in the editor snapshot. + + +`useAnnotationOutput({ document, editor, sourceUrl, rasterStyle, renderImage })` +provides `structured`, `structuredDownloadUrl`, `renderedImage`, `imageError`, +`canRestore`, `save()` and `restore()`. Pass the same Core `document` instance +used to create `editor`. `save()` retains an immutable document snapshot; +`restore()` uses a Core commit and clears selection, returning whether it +succeeded. This is external document replacement, so the editor's external +history policy applies. A saved snapshot cannot be restored into a different +Core document instance. Image rendering is lazy and ignores stale completions. +The Host composes its own output tabs, copyable code display and download links. + +The Hand uses Key Selection through Editing, `useInteractionHandle` for move +and resize, Web pointer capture for creation, and the Web keyboard resolver for +Undo/Redo/Delete. Tool shortcuts are plain V/C/D/A/L/K; modified shortcuts and +IME composition do not choose a tool. Preview and commit both consume Editing's +`transformAnnotationSelector`. diff --git a/packages/json-document-annotation/package.json b/packages/json-document-annotation/package.json new file mode 100644 index 00000000..7bba7fc7 --- /dev/null +++ b/packages/json-document-annotation/package.json @@ -0,0 +1,66 @@ +{ + "name": "@interactive-os/json-document-annotation", + "version": "0.1.0-rc.0", + "description": "Official React Annotation Hand for json-document.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/developer-1px/json-document.git", + "directory": "packages/json-document-annotation" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "tag": "next" + }, + "files": [ + "dist", + "!dist/.tsbuildinfo", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -b tsconfig.json", + "test": "vitest run --config vitest.config.ts", + "pretypecheck": "node ../../scripts/workspace-tasks.mjs build-dependencies", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "verify": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { + "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-ui-primitives-react": ">=0.1.0-rc.0 <1", + "@interactive-os/json-document-web": ">=0.1.0-rc.0 <1", + "lucide-react": "^1.33.0", + "@interactive-os/json-document": ">=3.0.0-rc.0 <4" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@interactive-os/json-document-affordance": "*", + "@interactive-os/json-document-editing": "*", + "@interactive-os/json-document-ui-primitives-react": "*", + "@interactive-os/json-document-web": "*", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "@interactive-os/json-document": "*" + } +} diff --git a/packages/json-document-annotation/src/annotation-hand.tsx b/packages/json-document-annotation/src/annotation-hand.tsx new file mode 100644 index 00000000..9759107a --- /dev/null +++ b/packages/json-document-annotation/src/annotation-hand.tsx @@ -0,0 +1,326 @@ +import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; +import { createGestureSession, type InteractionHandleEvent, type InteractionHandleDescriptor } from "@interactive-os/json-document-affordance"; +import { + annotationResizeHandle, + annotationSelectorBounds, + transformAnnotationSelector, + type Annotation, + type AnnotationDocument, + type AnnotationEditor, + type AnnotationPoint, + type AnnotationSource, +} from "@interactive-os/json-document-editing"; +import { createWebKeyboardAdapter, createWebPointerSession, projectWebClientPointToSVG, renderWebAnnotationRaster, webSVGViewportFromElement, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; +import { Command, Field, Toggle, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; +import { ArrowUpRight, Download, MessageSquare, MousePointer2, Pencil, SendHorizontal, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; + +export type AnnotationTool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; +type Gesture = + | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } + | { readonly type: "draw"; readonly points: ReadonlyArray } + | { readonly type: "move" | "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; + +export const annotationTools = [ + { id: "select", label: "Select", shortcut: "V", icon: MousePointer2 }, + { id: "comment", label: "Comment", shortcut: "C", icon: MessageSquare }, + { id: "draw", label: "Draw", shortcut: "D", icon: Pencil }, + { id: "arrow", label: "Arrow", shortcut: "A", icon: ArrowUpRight }, + { id: "like", label: "Like", shortcut: "L", icon: ThumbsUp }, + { id: "dislike", label: "Dislike", shortcut: "K", icon: ThumbsDown }, +] as const; + +export interface AnnotationHandLabels { + readonly canvas?: string; + readonly tools?: string; + readonly instruction?: string; + readonly instructionPlaceholder?: string; + readonly sendComment?: string; + readonly deleteAnnotation?: string; + readonly downloadImage?: string; +} + +export interface AnnotationHandClassNames { + readonly frame?: string; + readonly stage?: string; + readonly canvas?: string; + readonly commentCard?: string; + readonly commentInput?: string; + readonly commentPreview?: string; + readonly sendButton?: string; + readonly toolDock?: string; + readonly dockButton?: string; + readonly dockDivider?: string; +} + +export interface AnnotationHandProps { + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly tool: AnnotationTool; + readonly onToolChange: (tool: AnnotationTool) => void; + readonly reactionShadow?: string; + readonly createId: () => string; + readonly classNames?: AnnotationHandClassNames; + readonly enabledTools?: ReadonlyArray; + readonly labels?: AnnotationHandLabels; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly onAnnouncement?: (message: string) => void; +} + +const defaultLabels = { + canvas: "Raster annotation canvas", tools: "Annotation tools", instruction: "Annotation instruction", + instructionPlaceholder: "수정 요청을 입력하세요…", sendComment: "Send comment", + deleteAnnotation: "Delete annotation", downloadImage: "Download annotated image", +}; +const accent = "var(--annotation-accent)"; +const keyboard = createWebKeyboardAdapter(); +const toolKeyboard = createWebKeyboardAdapter({ defaults: false, keymap: Object.fromEntries(annotationTools.map(({ id, shortcut }) => [shortcut.toLowerCase(), id])) }); + +export function AnnotationHand(props: AnnotationHandProps) { + useSyncExternalStore(props.editor.subscribe, () => props.editor.snapshot.revision, () => props.editor.snapshot.revision); + const labels = { ...defaultLabels, ...props.labels }; const classes = props.classNames ?? {}; + const enabled = props.enabledTools ?? annotationTools.map(({ id }) => id); + const { tool, onToolChange: setTool } = props; + const [editingId, setEditingId] = useState(null); const [previewId, setPreviewId] = useState(null); + const [, redraw] = useState(0); + const [gestures] = useState(() => createGestureSession({ onBegin: rerender, onPreview: rerender, onCommit: rerender, onCancel: rerender })); + const [pointer] = useState(() => createWebPointerSession<{ readonly active: true }>()); + const document = props.editor.snapshot.value as AnnotationDocument; const selectedId = props.editor.snapshot.selection.primaryId; + const selected = document.annotations.find(({ id }) => id === selectedId) ?? null; const source = document.sources[0]!; const gesture = gestures.getActive(); + function rerender() { redraw((value) => value + 1); } + function announce(message: string) { props.onAnnouncement?.(message); } + function select(id: string | null) { props.editor.dispatch({ type: "selection.set", annotationId: id, mode: "replace" }); } + function choose(next: AnnotationTool) { setTool(next); setEditingId(null); if (selectedId !== null) select(null); } + function remove() { if (selectedId === null) return; props.editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); setEditingId(null); announce("선택한 annotation을 삭제했습니다."); } + + function canvasDown(event: PointerEvent) { + if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; + const point = eventPoint(event); if (point === null) return; if (tool === "select") return select(null); + pointer.begin(event.currentTarget, event.pointerId, { active: true }); + gestures.begin(tool === "draw" ? { type: "draw", points: [point] } : { type: "create", tool, start: point, current: point }); + } + function handleInteraction(interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") { + if (interaction.phase === "start") { + if (type === "move") { setEditingId(null); setPreviewId(null); select(annotation.id); if (tool !== "select") return; } + const start = eventPoint(event); + if (start !== null) gestures.begin({ type, id: annotation.id, start, current: start }); + return; + } + if (interaction.phase === "cancel") { gestures.cancel("pointer-cancel"); announce("진행 중인 조작을 취소했습니다."); return; } + const active = gestures.getActive(); + if (active?.type !== type || active.id !== annotation.id) return; + const current = eventPoint(event); if (current === null) return; + gestures.preview({ ...active, current }); + if (interaction.phase === "commit") commitActiveGesture(); + } + function pointerMove(event: PointerEvent) { + const gesture = gestures.getActive(); + if (gesture === null || pointer.getSnapshot()?.pointerId !== event.pointerId) return; const point = eventPoint(event); if (point === null) return; + if (gesture.type === "draw") { const last = gesture.points[gesture.points.length - 1]; if (last && distance(last, point) >= 4) gestures.preview({ ...gesture, points: [...gesture.points, point] }); } + else gestures.preview({ ...gesture, current: point }); + } + function pointerUp(event: PointerEvent) { + pointerMove(event); + if (pointer.commit(event.pointerId) !== null) commitActiveGesture(); + } + function commitActiveGesture() { + const committed = gestures.commit(); if (committed === null) return; + if (committed.type === "draw" || committed.type === "create") { + const annotation = committed.type === "draw" ? drawAnnotation(source.id, committed.points, props.createId) : createAnnotation(source.id, committed, props.createId); + if (annotation === null || !props.editor.dispatch({ type: "annotation.create", annotation }).ok) return; setTool("select"); + setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); announce(createdMessage(annotation)); return; + } + const dx = committed.current.x - committed.start.x; const dy = committed.current.y - committed.start.y; + if (committed.type === "move" && Math.hypot(dx, dy) < 4) { + const annotation = document.annotations.find(({ id }) => id === committed.id); if (annotation?.presentation.type !== "reaction") setEditingId(committed.id); return; + } + const annotation = document.annotations.find(({ id }) => id === committed.id); if (!annotation) return; + const handle = annotationResizeHandle(annotation.target.selector); + const result = committed.type === "move" ? props.editor.dispatch({ type: "annotation.move", annotationId: committed.id, dx, dy }) + : handle === null ? null : props.editor.dispatch({ type: "annotation.resize", annotationId: committed.id, handle, dx, dy }); + if (!result?.ok) return; + announce(committed.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); + } + function cancel(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { + if (pointer.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel") === null) return; + gestures.cancel(reason); announce("진행 중인 조작을 취소했습니다."); + } + function keyDown(event: KeyboardEvent) { + if (event.nativeEvent.isComposing) return; + const command = keyboard.resolve(event); + if (command?.type === "undo" || command?.type === "redo") { event.preventDefault(); props.editor[command.type](); return; } + if (command?.type === "delete") { event.preventDefault(); remove(); return; } + const next = toolKeyboard.resolve(event); + if (next && enabled.includes(next)) { event.preventDefault(); choose(next); return; } + if (event.key === "Escape") { event.preventDefault(); const active = pointer.getSnapshot(); if (active) pointer.cancel(active.pointerId); gestures.cancel("cancel"); choose("select"); } + } + async function download() { + const result = await renderWebAnnotationRaster({ document, sourceId: source.id, sourceURL: props.sourceUrl, style: props.rasterStyle }); + if (!result.ok) return announce("Annotation 이미지를 만들지 못했습니다."); + const link = window.document.createElement("a"); link.href = result.dataURL; link.download = "annotation-request.png"; link.click(); announce("Annotation이 적용된 이미지를 다운로드했습니다."); + } + return
+
+ cancel(event, "lost-capture")} onPointerCancel={(event) => cancel(event, "pointer-cancel")} onPointerMove={pointerMove} onPointerUp={pointerUp} role="application" tabIndex={0} viewBox={`0 0 ${source.width} ${source.height}`}> + + {document.annotations.map((annotation, index) => setPreviewId(visible ? annotation.id : null)} />)} + {gesture?.type === "create" ? : null}{gesture?.type === "draw" ? : null} + + {document.annotations.map((annotation, index) => gesture === null && previewId === annotation.id && annotation.body.instruction.trim() && editingId !== annotation.id ? : null)} + {selected && editingId === selected.id ? cancelComment(selected)} onSave={(instruction) => saveComment(selected, instruction)} onSubmit={(instruction) => submitComment(selected, instruction)} /> : null} +
+ +
; + + function saveComment(annotation: Annotation, instruction: string) { const value = instruction.trim(); if (annotation.body.instruction !== value) props.editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: value }); setTool("select"); announce("수정 요청을 추가했습니다."); } + function submitComment(annotation: Annotation, instruction: string) { saveComment(annotation, instruction); setEditingId(null); } + function cancelComment(annotation: Annotation) { if (!annotation.body.instruction) props.editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); else select(null); setEditingId(null); } +} + +function CommentComposer(props: { annotation: Annotation; index: number; source: AnnotationSource; classNames: AnnotationHandClassNames; labels: typeof defaultLabels; onCancel: () => void; onSave: (value: string) => void; onSubmit: (value: string) => void }) { + const [draft, setDraft] = useState(props.annotation.body.instruction); const input = useRef(null); const dock = composerDock(props.annotation, props.source); + useEffect(() => setDraft(props.annotation.body.instruction), [props.annotation.id, props.annotation.body.instruction]); + useEffect(() => { const frame = requestAnimationFrame(() => input.current?.focus()); return () => cancelAnimationFrame(frame); }, [props.annotation.id]); + return
+ { if (draft.trim()) props.onSave(draft); }} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); if (draft.trim()) props.onSubmit(draft); } else if (event.key === "Escape") props.onCancel(); }} /> + props.onSubmit(draft)} onMouseDown={(event) => event.preventDefault()}> +
; +} +function CommentPreview({ annotation, index, source, className }: { annotation: Annotation; index: number; source: AnnotationSource; className?: string | undefined }) { const dock = composerDock(annotation, source); return
{annotation.body.instruction}
; } +function AnnotationShape(props: { + readonly annotation: Annotation; + readonly index: number; + readonly selected: boolean; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent, annotation: Annotation, type: "move" | "resize") => void; + readonly onPreview: (visible: boolean) => void; +}) { + const { annotation } = props; + const drag = useInteractionHandle({ + descriptor: { kind: "drag", cursor: { idle: "move", active: "grabbing" } }, + onHandle: (interaction, event) => props.onHandle(interaction, event, annotation, "move"), + }); + const selector = annotation.target.selector; + const bounds = annotationSelectorBounds(annotation.target.selector); + const common = { + fill: "none", + stroke: accent, + strokeWidth: props.selected ? 6 : 4, + vectorEffect: "non-scaling-stroke" as const, + }; + return ( + props.onPreview(false)} + onFocus={() => props.onPreview(true)} + onPointerEnter={() => props.onPreview(true)} + onPointerLeave={() => props.onPreview(false)} + {...drag.handleProps} + role="button" + tabIndex={0} + style={{ cursor: drag.cursor }} + > + {annotation.presentation.type === "marker" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "reaction" && selector.type === "point" ? ( + + ) : null} + {annotation.presentation.type === "outline" && selector.type === "rectangle" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type === "stroke" && selector.type === "path" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} /> + ) : null} + + ) : null} + {annotation.presentation.type === "arrow" && selector.type === "arrow" ? ( + <> + + {props.selected ? ( + props.onHandle(interaction, event, annotation, "resize")} + /> + ) : null} + + ) : null} + {annotation.presentation.type !== "marker" && annotation.presentation.type !== "reaction" ? ( + + ) : null} + + ); +} + +function AnnotationPointHandle(props: { + readonly "aria-label": string; + readonly cx: number; + readonly cy: number; + readonly descriptor: InteractionHandleDescriptor; + readonly onHandle: (interaction: InteractionHandleEvent, event: PointerEvent) => void; +}) { + const binding = useInteractionHandle({ descriptor: props.descriptor, onHandle: props.onHandle }); + return ; +} + +function Badge(props: { readonly index: number; readonly point: AnnotationPoint; readonly selected: boolean }) { + return ( + + + {props.index} + + ); +} + +function commentBubblePath(point: AnnotationPoint): string { + const { x, y } = point; + return `M ${x} ${y - 24} C ${x + 13.25} ${y - 24} ${x + 24} ${y - 13.25} ${x + 24} ${y} C ${x + 24} ${y + 13.25} ${x + 13.25} ${y + 24} ${x} ${y + 24} L ${x - 24} ${y + 24} L ${x - 24} ${y} C ${x - 24} ${y - 13.25} ${x - 13.25} ${y - 24} ${x} ${y - 24} Z`; +} + + +function Stroke({ points, selected, draft }: { points: ReadonlyArray; selected?: boolean; draft?: boolean }) { return ; } +function Arrow({ from, to, selected }: { from: AnnotationPoint; to: AnnotationPoint; selected: boolean }) { const a = Math.atan2(to.y - from.y, to.x - from.x); const point = (delta: number) => ({ x: to.x - 34 * Math.cos(a + delta), y: to.y - 34 * Math.sin(a + delta) }); const l = point(-Math.PI / 6), r = point(Math.PI / 6); return ; } +function Reaction(props: { readonly point: AnnotationPoint; readonly reaction: "like" | "dislike"; readonly selected: boolean; readonly draft?: boolean }) { + const Icon = props.reaction === "like" ? ThumbsUp : ThumbsDown; + return ( + + + + + + ); +} +function DraftShape({ gesture }: { gesture: Extract }) { if (gesture.tool === "like" || gesture.tool === "dislike") return ; if (gesture.tool === "arrow") return ; if (distance(gesture.start, gesture.current) < 16) return ; return ; } +function project(annotation: Annotation, gesture: Gesture | null): Annotation { if (!gesture || (gesture.type !== "move" && gesture.type !== "resize") || gesture.id !== annotation.id) return annotation; const selector = transformAnnotationSelector(annotation.target.selector, gesture.type === "move" ? { type: "move", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y } : { type: "resize", handle: annotationResizeHandle(annotation.target.selector) ?? "south-east", dx: gesture.current.x - gesture.start.x, dy: gesture.current.y - gesture.start.y }); return selector ? { ...annotation, target: { ...annotation.target, selector } } : annotation; } +function createAnnotation(sourceId: string, gesture: Extract, id: () => string): Annotation | null { const { tool, start, current } = gesture; if (tool === "like" || tool === "dislike") return { id: id(), target: { sourceId, selector: { type: "point", ...start } }, body: { instruction: "" }, presentation: { type: "reaction", reaction: tool } }; if (tool === "comment") return { id: id(), target: { sourceId, selector: distance(start, current) < 16 ? { type: "point", ...start } : { type: "rectangle", ...rectangle(start, current) } }, body: { instruction: "" }, presentation: { type: distance(start, current) < 16 ? "marker" : "outline" } }; return distance(start, current) < 8 ? null : { id: id(), target: { sourceId, selector: { type: "arrow", from: start, to: current } }, body: { instruction: "" }, presentation: { type: "arrow" } }; } +function drawAnnotation(sourceId: string, points: ReadonlyArray, id: () => string): Annotation | null { return points.length < 2 || pathLength(points) < 16 ? null : { id: id(), target: { sourceId, selector: { type: "path", points } }, body: { instruction: "" }, presentation: { type: "stroke" } }; } +function eventPoint(event: PointerEvent): AnnotationPoint | null { const svg = event.currentTarget.ownerSVGElement ?? event.currentTarget as SVGSVGElement; const point = projectWebClientPointToSVG({ x: event.clientX, y: event.clientY }, webSVGViewportFromElement(svg)); return point && { x: point.x, y: point.y }; } +function rectangle(a: AnnotationPoint, b: AnnotationPoint) { return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(b.x - a.x), height: Math.abs(b.y - a.y) }; } +function distance(a: AnnotationPoint, b: AnnotationPoint) { return Math.hypot(b.x - a.x, b.y - a.y); } +function pathLength(points: ReadonlyArray) { return points.slice(1).reduce((total, point, index) => total + distance(points[index] ?? point, point), 0); } +function pathData(points: ReadonlyArray) { const first = points[0]; if (!first) return ""; if (points.length === 2) return `M ${first.x} ${first.y} L ${points[1]!.x} ${points[1]!.y}`; const curves = points.slice(1, -1).map((point, index) => { const next = points[index + 2] ?? point; return `Q ${point.x} ${point.y} ${(point.x + next.x) / 2} ${(point.y + next.y) / 2}`; }); const last = points[points.length - 1] ?? first; return [`M ${first.x} ${first.y}`, ...curves, `L ${last.x} ${last.y}`].join(" "); } +function composerDock(annotation: Annotation, source: AnnotationSource) { const bounds = annotationSelectorBounds(annotation.target.selector); return { horizontal: bounds.x + bounds.width / 2 > source.width * .75 ? "left" : "right", vertical: bounds.y < 48 ? "below" : bounds.y > source.height - 48 ? "above" : "center", bounds }; } +function dockStyle(dock: ReturnType, source: AnnotationSource) { const left = dock.horizontal === "left" ? dock.bounds.x - 36 : dock.bounds.x + 36; const x = dock.horizontal === "left" ? "-100%" : "0"; const y = dock.vertical === "above" ? "-100%" : dock.vertical === "below" ? "0" : "-50%"; return { left: `${left / source.width * 100}%`, top: `${dock.bounds.y / source.height * 100}%`, transform: `translate(${x}, ${y})` }; } +function createdMessage(annotation: Annotation) { if (annotation.presentation.type === "reaction") return annotation.presentation.reaction === "like" ? "좋아요 스티커를 붙였습니다." : "싫어요 스티커를 붙였습니다."; return annotation.presentation.type === "marker" ? "위치 코멘트를 만들었습니다." : annotation.presentation.type === "outline" ? "영역 코멘트를 만들었습니다." : annotation.presentation.type === "stroke" ? "자유선 코멘트를 만들었습니다." : "화살표 코멘트를 만들었습니다."; } diff --git a/packages/json-document-annotation/src/annotation-output.ts b/packages/json-document-annotation/src/annotation-output.ts new file mode 100644 index 00000000..c77a4e56 --- /dev/null +++ b/packages/json-document-annotation/src/annotation-output.ts @@ -0,0 +1,58 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { JSONDocument } from "@interactive-os/json-document"; +import type { AnnotationDocument, AnnotationEditor } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterStyle } from "@interactive-os/json-document-web"; + +export interface AnnotationOutputOptions { + /** The same document instance passed to createAnnotationEditor. */ + readonly document: JSONDocument; + readonly editor: AnnotationEditor; + readonly sourceUrl: string; + readonly rasterStyle: WebAnnotationRasterStyle; + readonly renderImage: boolean; +} +export interface AnnotationOutput { + readonly structured: string; + readonly structuredDownloadUrl: string; + readonly renderedImage: string | null; + readonly imageError: boolean; + readonly canRestore: boolean; + save(): void; + restore(): boolean; +} + +/** Output lifecycle; the Host owns tabs, copy, links, and panel layout. */ +export function useAnnotationOutput(options: AnnotationOutputOptions): AnnotationOutput { + const { document, editor, sourceUrl, rasterStyle, renderImage } = options; + useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision); + const value = editor.snapshot.value as AnnotationDocument; + const [saved, setSaved] = useState<{ owner: JSONDocument; value: AnnotationDocument } | null>(null); + const [image, setImage] = useState<{ value: AnnotationDocument; sourceUrl: string; style: WebAnnotationRasterStyle; dataURL: string | null } | null>(null); + const { stroke, fill, lineWidth, labelFont } = rasterStyle; + useEffect(() => { + if (!renderImage) return; + let current = true; + const style = { stroke, fill, lineWidth, labelFont }; + void renderWebAnnotationRaster({ document: value, sourceId: value.sources[0]!.id, sourceURL: sourceUrl, style }) + .then((result) => { if (current) setImage({ value, sourceUrl, style, dataURL: result.ok ? result.dataURL : null }); }) + .catch(() => { if (current) setImage({ value, sourceUrl, style, dataURL: null }); }); + return () => { current = false; }; + }, [value, sourceUrl, stroke, fill, lineWidth, labelFont, renderImage]); + const currentImage = image?.value === value && image.sourceUrl === sourceUrl && image.style.stroke === stroke && image.style.fill === fill && image.style.lineWidth === lineWidth && image.style.labelFont === labelFont ? image : null; + const structured = JSON.stringify(value, null, 2); + return { + structured, + structuredDownloadUrl: `data:application/json;charset=utf-8,${encodeURIComponent(structured)}`, + renderedImage: currentImage?.dataURL ?? null, + imageError: currentImage !== null && currentImage.dataURL === null, + canRestore: saved?.owner === document, + save() { setSaved({ owner: document, value }); }, + restore() { + if (saved?.owner !== document) return false; + const result = document.commit([{ op: "replace", path: "", value: saved.value }]); + if (!result.ok) return false; + editor.dispatch({ type: "selection.set", annotationId: null, mode: "replace" }); + return true; + }, + }; +} diff --git a/packages/json-document-annotation/src/index.ts b/packages/json-document-annotation/src/index.ts new file mode 100644 index 00000000..0c9557b6 --- /dev/null +++ b/packages/json-document-annotation/src/index.ts @@ -0,0 +1,4 @@ +export { AnnotationHand, annotationTools } from "./annotation-hand.js"; +export type { AnnotationHandClassNames, AnnotationHandLabels, AnnotationHandProps, AnnotationTool } from "./annotation-hand.js"; +export { useAnnotationOutput } from "./annotation-output.js"; +export type { AnnotationOutput, AnnotationOutputOptions } from "./annotation-output.js"; diff --git a/packages/json-document-annotation/tests/annotation-hand.test.tsx b/packages/json-document-annotation/tests/annotation-hand.test.tsx new file mode 100644 index 00000000..b7b98d9d --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-hand.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { AnnotationHand, annotationTools } from "../src/index.js"; + +const document: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; + +afterEach(cleanup); + +describe("AnnotationHand", () => { + test("publishes one descriptor for every default tool", () => { + expect(annotationTools.map(({ id, shortcut }) => [id, shortcut])).toEqual([["select", "V"], ["comment", "C"], ["draw", "D"], ["arrow", "A"], ["like", "L"], ["dislike", "K"]]); + }); + + test("renders the canonical canvas and configurable tool set", () => { + render( "next"} tool="comment" onToolChange={() => {}} rasterStyle={rasterStyle} enabledTools={["select", "comment"]} />); + expect(screen.getByRole("application", { name: "Raster annotation canvas" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Select" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Draw" })).toBeNull(); + + }); +}); + + +test("the Host controls tools; modified keys and IME do not invoke ordinary commands", () => { + const editor = createAnnotationEditor(document); + const onToolChange = vi.fn(); + const undo = vi.spyOn(editor, "undo"), redo = vi.spyOn(editor, "redo"), dispatch = vi.spyOn(editor, "dispatch"); + const props = { editor, sourceUrl: "/image.png", createId: () => "next", rasterStyle, onToolChange }; + const view = render(); + const canvas = screen.getByRole("application"); + fireEvent.click(screen.getByRole("button", { name: "Draw" })); + expect(onToolChange).toHaveBeenLastCalledWith("draw"); + expect(canvas.getAttribute("data-tool")).toBe("comment"); + view.rerender(); + expect(canvas.getAttribute("data-tool")).toBe("draw"); + onToolChange.mockClear(); dispatch.mockClear(); + for (const modifiers of [{ altKey: true }, { ctrlKey: true }, { shiftKey: true }, { isComposing: true }]) fireEvent.keyDown(canvas, { key: "c", ...modifiers }); + expect(onToolChange).not.toHaveBeenCalled(); + fireEvent.keyDown(canvas, { key: "c" }); + expect(onToolChange).toHaveBeenLastCalledWith("comment"); + fireEvent.keyDown(canvas, { key: "z", metaKey: true }); + fireEvent.keyDown(canvas, { key: "Z", ctrlKey: true, shiftKey: true }); + expect(undo).toHaveBeenCalledTimes(1); expect(redo).toHaveBeenCalledTimes(1); + fireEvent.keyDown(canvas, { key: "z", ctrlKey: true, altKey: true }); + fireEvent.keyDown(canvas, { key: "z", metaKey: true, isComposing: true }); + expect(undo).toHaveBeenCalledTimes(1); +}); diff --git a/packages/json-document-annotation/tests/annotation-output.test.tsx b/packages/json-document-annotation/tests/annotation-output.test.tsx new file mode 100644 index 00000000..ec3598f6 --- /dev/null +++ b/packages/json-document-annotation/tests/annotation-output.test.tsx @@ -0,0 +1,62 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { createJSONDocument } from "@interactive-os/json-document"; +import { ANNOTATION_PROFILE_V1, createAnnotationEditor, type AnnotationDocument } from "@interactive-os/json-document-editing"; +import { renderWebAnnotationRaster, type WebAnnotationRasterResult } from "@interactive-os/json-document-web"; +import { useAnnotationOutput } from "../src/index.js"; + +vi.mock("@interactive-os/json-document-web", async (load) => ({ ...await load(), renderWebAnnotationRaster: vi.fn() })); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); +const initial: AnnotationDocument = { profile: ANNOTATION_PROFILE_V1, id: "test", sources: [{ id: "image", src: "/image.png", width: 100, height: 80 }], annotations: [] }; +const rasterStyle = { stroke: "red", fill: "red", lineWidth: 2, labelFont: "12px sans-serif" }; +function setup() { + const document = createJSONDocument(initial), editor = createAnnotationEditor(document); + return { document, editor, sourceUrl: "/image.png", rasterStyle, renderImage: false }; +} + +test("serializes only the document and restores a saved snapshot with cleared selection", () => { + const options = setup(); + const { result } = renderHook(() => useAnnotationOutput(options)); + expect(result.current.restore()).toBe(false); + act(() => result.current.save()); + act(() => { options.editor.dispatch({ type: "annotation.create", annotation: { id: "note", body: { instruction: "Inspect" }, presentation: { type: "marker" }, target: { sourceId: "image", selector: { type: "point", x: 10, y: 20 } } } }); }); + expect(options.editor.snapshot.selection.primaryId).toBe("note"); + expect(JSON.parse(result.current.structured)).toEqual(options.document.value); + expect(JSON.parse(decodeURIComponent(result.current.structuredDownloadUrl.split(",")[1]!))).toEqual(options.document.value); + expect(result.current.structured).not.toContain("primaryId"); + act(() => { expect(result.current.restore()).toBe(true); }); + expect(options.document.value).toEqual(initial); + expect(options.editor.snapshot.selection.ids).toEqual([]); +}); + +test("does not restore a snapshot from a replaced document owner", () => { + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: setup() }); + act(() => result.current.save()); + rerender(setup()); + expect(result.current.canRestore).toBe(false); + expect(result.current.restore()).toBe(false); +}); + +test("renders lazily, ignores stale raster completion, and exposes the current failure", async () => { + const pending: Array<(result: WebAnnotationRasterResult) => void> = []; + vi.mocked(renderWebAnnotationRaster).mockImplementation(() => new Promise((resolve) => pending.push(resolve))); + const options = setup(); + const { result, rerender } = renderHook(useAnnotationOutput, { initialProps: options }); + expect(pending).toHaveLength(0); + rerender({ ...options, renderImage: true }); + expect(pending).toHaveLength(1); + rerender({ ...options, renderImage: true, sourceUrl: "/new.png" }); + expect(pending).toHaveLength(2); + await act(async () => pending[0]!({ ok: true, dataURL: "data:old" })); + expect(result.current.renderedImage).toBeNull(); + await act(async () => pending[1]!({ ok: false, code: "raster.decode-failed" })); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); + + +test("reports an unexpected raster rejection without an unhandled promise", async () => { + vi.mocked(renderWebAnnotationRaster).mockRejectedValue(new Error("canvas unavailable")); + const options = { ...setup(), renderImage: true }; + const { result } = renderHook(() => useAnnotationOutput(options)); + await waitFor(() => expect(result.current.imageError).toBe(true)); +}); diff --git a/packages/json-document-annotation/tsconfig.json b/packages/json-document-annotation/tsconfig.json new file mode 100644 index 00000000..a90816ee --- /dev/null +++ b/packages/json-document-annotation/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig/library-react.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "references": [ + { + "path": "../json-document" + }, + { + "path": "../json-document-affordance" + }, + { + "path": "../json-document-editing" + }, + { + "path": "../json-document-ui-primitives-react" + }, + { + "path": "../json-document-web" + } + ], + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ] +} diff --git a/packages/json-document-annotation/tsconfig.test.json b/packages/json-document-annotation/tsconfig.test.json new file mode 100644 index 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/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index 7d368264..f8655014 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -244,3 +244,11 @@ preserves document values and existing Undo/Redo records; Undo after an edit restores the recorded offset range. These contracts are exercised by [Document editor tests](tests/document-editor.test.ts) and the existing [Document Usage](https://developer-1px.github.io/json-document/demo). + + +Annotation selection preserves its public `{ kind: "annotation", ids, primaryId }` +shape and the order in which IDs were selected. Membership and primary fallback +are owned by Key Selection; document reconciliation retains surviving IDs in +that order. Undo/Redo restores both the document and the associated selection. +`transformAnnotationSelector`, `annotationSelectorBounds`, and +`annotationResizeHandle` are the canonical geometry APIs for preview and commit. diff --git a/packages/json-document-editing/src/annotation-selection.ts b/packages/json-document-editing/src/annotation-selection.ts new file mode 100644 index 00000000..97f7ec86 --- /dev/null +++ b/packages/json-document-editing/src/annotation-selection.ts @@ -0,0 +1,30 @@ +import { createKeySelectionFamily, type KeySelection, type KeySelectionContext } from "@interactive-os/json-document-selection"; +import type { AnnotationDocument, AnnotationIntent, AnnotationSelection } from "./annotation.js"; + +const family = createKeySelectionFamily(); + +// Annotation exposes insertion order. The domain supplies that traversal order; +// the Key family alone owns membership, primary fallback, and reconciliation. +function context(selection: AnnotationSelection, document: AnnotationDocument): KeySelectionContext { + const rank = new Map(selection.ids.map((id, index) => [id, index])); + return { + keys: document.annotations.map(({ id }) => id).sort((a, b) => (rank.get(a) ?? rank.size) - (rank.get(b) ?? rank.size)), + universe: document.id, + universeMismatch: "clear", + }; +} +function toKey(selection: AnnotationSelection): KeySelection { + return { kind: "explicit", keys: selection.ids, primaryKey: selection.primaryId }; +} +function fromKey(selection: KeySelection, domain: KeySelectionContext): AnnotationSelection { + return { kind: "annotation", ids: family.targets(selection, domain), primaryId: selection.primaryKey }; +} +export function reconcileAnnotationSelection(selection: AnnotationSelection, document: AnnotationDocument): AnnotationSelection { + const domain = context(selection, document); + return fromKey(family.reconcile(toKey(selection), domain).state, domain); +} +export function transitionAnnotationSelection(selection: AnnotationSelection, intent: Extract, document: AnnotationDocument): AnnotationSelection { + const domain = context(selection, document); + const command = intent.annotationId === null ? { type: "clear" as const } : { type: intent.mode, keys: [intent.annotationId], primaryKey: intent.annotationId }; + return fromKey(family.transition(toKey(selection), command, domain).state, domain); +} diff --git a/packages/json-document-editing/src/annotation.ts b/packages/json-document-editing/src/annotation.ts index 212ab8de..fdbf660d 100644 --- a/packages/json-document-editing/src/annotation.ts +++ b/packages/json-document-editing/src/annotation.ts @@ -3,6 +3,7 @@ import { resolveDocumentSource, type EditingDocumentSource } from "./document-so import type { EditingHistoryOptions } from "./history.js"; import { createEditingSession, type EditingResult, type EditingSnapshot } from "./session.js"; import { assertAnnotation, assertAnnotationDocument } from "./annotation-validation.js"; +import { reconcileAnnotationSelection, transitionAnnotationSelection } from "./annotation-selection.js"; export const ANNOTATION_PROFILE_V1 = "urn:interactive-os:json-document:annotation:1" as const; export interface AnnotationPoint extends Record { readonly x: number; readonly y: number } @@ -38,9 +39,7 @@ export function createAnnotationEditor(source: EditingDocumentSource annotation.id)); - const ids = selection.ids.filter((id) => available.has(id)); - return selectionFor(ids, selection.primaryId !== null && available.has(selection.primaryId) ? selection.primaryId : ids.at(-1) ?? null); + return reconcileAnnotationSelection(selection, value as AnnotationDocument); }, }); const value = () => session.snapshot.value as AnnotationDocument; @@ -48,11 +47,7 @@ export function createAnnotationEditor(source: EditingDocumentSource item.id === intent.annotationId)) return failure("annotation.not-found"); - const current = session.snapshot.selection.ids; - const ids = intent.mode === "toggle" && intent.annotationId !== null - ? current.includes(intent.annotationId) ? current.filter((id) => id !== intent.annotationId) : [...current, intent.annotationId] - : intent.annotationId === null ? [] : [intent.annotationId]; - return success(session.select(selectionFor(ids, ids.includes(intent.annotationId ?? "") ? intent.annotationId : ids.at(-1) ?? null))); + return success(session.select(transitionAnnotationSelection(session.snapshot.selection, intent, value()))); } if (intent.type === "annotation.create") { try { assertAnnotation(intent.annotation, new Set(value().sources.map((item) => item.id))); } catch (error) { return failure("annotation.invalid", message(error)); } @@ -70,7 +65,9 @@ export function createAnnotationEditor(source: EditingDocumentSource session.undo(), redo: () => session.redo(), subscribe: (listener) => session.subscribe(listener) }; } -function move(selector: AnnotationSelector, dx: number, dy: number): AnnotationSelector { +export type AnnotationSelectorTransform = + | { readonly type: "move"; readonly dx: number; readonly dy: number } + | { readonly type: "resize"; readonly handle: "end" | "south-east"; readonly dx: number; readonly dy: number }; + +export interface AnnotationBounds extends AnnotationPoint { readonly width: number; readonly height: number } + +export function transformAnnotationSelector(selector: AnnotationSelector, transform: AnnotationSelectorTransform): AnnotationSelector | null { + if (transform.type === "resize") return resize(selector, transform.handle, transform.dx, transform.dy); + const { dx, dy } = transform; const point = (p: AnnotationPoint) => ({ x: p.x + dx, y: p.y + dy }); if (selector.type === "point" || selector.type === "rectangle") return { ...selector, ...point(selector) }; if (selector.type === "path") return { ...selector, points: selector.points.map(point) }; return { ...selector, from: point(selector.from), to: point(selector.to) }; } + +export function annotationSelectorBounds(selector: AnnotationSelector): AnnotationBounds { + if (selector.type === "arrow") return rectangleFromPoints(selector.from, selector.to); + if (selector.type === "rectangle") return { x: selector.x, y: selector.y, width: selector.width, height: selector.height }; + if (selector.type === "path") { + const xs = selector.points.map(({ x }) => x); const ys = selector.points.map(({ y }) => y); + const x = Math.min(...xs); const y = Math.min(...ys); + return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }; + } + return { x: selector.x, y: selector.y, width: 0, height: 0 }; +} + +export function annotationResizeHandle(selector: AnnotationSelector): "end" | "south-east" | null { + if (selector.type === "arrow") return "end"; + if (selector.type === "rectangle" || selector.type === "path") return "south-east"; + return null; +} + function resize(selector: AnnotationSelector, handle: "end" | "south-east", dx: number, dy: number): AnnotationSelector | null { if (handle === "south-east" && selector.type === "rectangle") return { ...selector, width: Math.max(1, selector.width + dx), height: Math.max(1, selector.height + dy) }; if (handle === "south-east" && selector.type === "path") { - const xs = selector.points.map((point) => point.x); const ys = selector.points.map((point) => point.y); - const x = Math.min(...xs); const y = Math.min(...ys); const width = Math.max(1, Math.max(...xs) - x); const height = Math.max(1, Math.max(...ys) - y); + const bounds = annotationSelectorBounds(selector); + const { x, y } = bounds; + const width = Math.max(1, bounds.width), height = Math.max(1, bounds.height); const scaleX = Math.max(1, width + dx) / width; const scaleY = Math.max(1, height + dy) / height; return { ...selector, points: selector.points.map((point) => ({ x: x + (point.x - x) * scaleX, y: y + (point.y - y) * scaleY })) }; } if (handle === "end" && selector.type === "arrow") { const to = { x: selector.to.x + dx, y: selector.to.y + dy }; return to.x === selector.from.x && to.y === selector.from.y ? null : { ...selector, to }; } return null; } +function rectangleFromPoints(start: AnnotationPoint, end: AnnotationPoint): AnnotationBounds { return { x: Math.min(start.x, end.x), y: Math.min(start.y, end.y), width: Math.abs(end.x - start.x), height: Math.abs(end.y - start.y) }; } function selectionFor(ids: ReadonlyArray, primaryId: string | null = ids.at(-1) ?? null): AnnotationSelection { return { kind: "annotation", ids, primaryId }; } function success(snapshot: EditingSnapshot): EditingResult { return { ok: true, snapshot }; } function failure(code: string, reason?: string): EditingResult { return { ok: false, code, ...(reason === undefined ? {} : { reason }) }; } diff --git a/packages/json-document-editing/src/index.ts b/packages/json-document-editing/src/index.ts index 470f7fcd..96439167 100644 --- a/packages/json-document-editing/src/index.ts +++ b/packages/json-document-editing/src/index.ts @@ -69,10 +69,11 @@ export { isCalendarAllDay, parseCalendarView, } from "./calendar-validation.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, @@ -80,6 +81,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..2fd77a2a 100644 --- a/packages/json-document-editing/tests/annotation-editor.test.ts +++ b/packages/json-document-editing/tests/annotation-editor.test.ts @@ -1,8 +1,12 @@ +import { createJSONDocument } from "@interactive-os/json-document"; import { describe, expect, test } from "vitest"; import { ANNOTATION_PROFILE_V1, + annotationResizeHandle, + annotationSelectorBounds, assertAnnotationDocument, createAnnotationEditor, + transformAnnotationSelector, type Annotation, type AnnotationDocument, } from "../src/index.js"; @@ -77,4 +81,60 @@ describe("Annotation editor", () => { expect(editor.dispatch({ type: "annotation.resize", annotationId: "path", handle: "south-east", dx: 2, dy: 3 }).ok).toBe(true); expect(editor.dispatch({ type: "annotation.resize", annotationId: "arrow", handle: "end", dx: 5, dy: -2 }).ok).toBe(true); }); + + test("projects preview geometry through the same selector contract used by commits", () => { + const transform = { type: "resize", handle: "south-east", dx: 10, dy: -20 } as const; + const projected = transformAnnotationSelector(rectangle.target.selector, transform); + const editor = createAnnotationEditor(document([rectangle])); + editor.dispatch({ type: "annotation.resize", annotationId: rectangle.id, handle: transform.handle, dx: transform.dx, dy: transform.dy }); + expect((editor.snapshot.value as AnnotationDocument).annotations[0]!.target.selector).toEqual(projected); + expect(annotationSelectorBounds(rectangle.target.selector)).toEqual({ x: 20, y: 30, width: 40, height: 50 }); + expect(annotationResizeHandle(rectangle.target.selector)).toBe("south-east"); + expect(annotationResizeHandle(point.target.selector)).toBeNull(); + }); +}); + + +describe("Annotation Key selection compatibility", () => { + test("keeps insertion order and primary across toggles, document edits, undo and redo", () => { + const editor = createAnnotationEditor(document([point, rectangle, arrow])); + editor.dispatch({ type: "selection.set", annotationId: "arrow", mode: "replace" }); + editor.dispatch({ type: "selection.set", annotationId: "point", mode: "toggle" }); + editor.dispatch({ type: "selection.set", annotationId: "rect", mode: "toggle" }); + const selected = { kind: "annotation", ids: ["arrow", "point", "rect"], primaryId: "rect" }; + expect(editor.snapshot.selection).toEqual(selected); + expect(editor.snapshot.canUndo).toBe(false); + editor.dispatch({ type: "annotation.move", annotationId: "rect", dx: 5, dy: 8 }); + expect(editor.snapshot.selection.ids).toEqual(["rect"]); + editor.undo(); + expect(editor.snapshot.selection).toEqual(selected); + editor.redo(); + expect(editor.snapshot.selection.ids).toEqual(["rect"]); + editor.undo(); + editor.dispatch({ type: "selection.set", annotationId: "rect", mode: "toggle" }); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: ["arrow", "point"], primaryId: "point" }); + const before = editor.snapshot; + expect(editor.dispatch({ type: "selection.set", annotationId: "missing", mode: "toggle" }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + editor.dispatch({ type: "selection.set", annotationId: null, mode: "toggle" }); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: [], primaryId: null }); + }); + + test("reconciles external removal without reordering surviving selections", () => { + const core = createJSONDocument(document([point, rectangle, arrow])); + const editor = createAnnotationEditor(core); + for (const annotationId of ["arrow", "rect", "point"]) editor.dispatch({ type: "selection.set", annotationId, mode: "toggle" }); + core.commit([{ op: "replace", path: "/annotations", value: [rectangle, arrow, point] }]); + expect(editor.snapshot.selection.ids).toEqual(["arrow", "rect", "point"]); + core.commit([{ op: "remove", path: "/annotations/2" }]); + expect(editor.snapshot.selection).toEqual({ kind: "annotation", ids: ["arrow", "rect"], primaryId: "rect" }); + }); + + test("rejects degenerate arrow preview and commit without changing history or selection", () => { + const editor = createAnnotationEditor(document([arrow])); + const before = editor.snapshot; + expect(transformAnnotationSelector(arrow.target.selector, { type: "resize", handle: "end", dx: -10, dy: -10 })).toBeNull(); + expect(editor.dispatch({ type: "annotation.resize", annotationId: "arrow", handle: "end", dx: -10, dy: -10 }).ok).toBe(false); + expect(editor.snapshot).toEqual(before); + }); }); diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs index 90ebc9c7..b2846d1a 100644 --- a/scripts/ci-plan.mjs +++ b/scripts/ci-plan.mjs @@ -31,6 +31,7 @@ const packageBrowserSpecs = new Map([ ["@interactive-os/json-document-ajv", ["site/tests/browser/connectors/ajv.spec.ts"]], ["@interactive-os/json-document-contenteditable", ["site/tests/browser/adapters/contenteditable.spec.ts"]], ["@interactive-os/json-document-database", ["site/tests/browser/database-demo.spec.ts"]], + ["@interactive-os/json-document-annotation", ["site/tests/browser/annotation-demo.spec.ts", "site/tests/browser/copy-selection-closure.spec.ts"]], ["@interactive-os/json-document-calendar", [ "site/tests/browser/calendar-app.spec.ts", "site/tests/browser/calendar-launch.spec.ts", @@ -73,6 +74,7 @@ const routeBrowserSpecs = new Map([ ["connectors/tanstack-table", ["site/tests/browser/connectors/tanstack-table.spec.ts"]], ["connectors/zod", ["site/tests/browser/connectors/zod.spec.ts"]], ["database-demo", ["site/tests/browser/database-demo.spec.ts"]], + ["annotation-demo", ["site/tests/browser/annotation-demo.spec.ts", "site/tests/browser/copy-selection-closure.spec.ts"]], ["document-demo", ["site/tests/browser/document-demo.spec.ts"]], ["editing-demos", ["site/tests/browser/editing-demos.spec.ts"]], ["rich-text-demo", ["site/tests/browser/rich-text-demo.spec.ts"]], @@ -88,6 +90,7 @@ const firstKitWorkspaces = new Set([ "@interactive-os/json-document-react", "@interactive-os/json-document-zod", "@interactive-os/json-document-database", + "@interactive-os/json-document-annotation", "@interactive-os/json-document-calendar", "@interactive-os/json-document-file-intake", "@interactive-os/json-document-rich-text-suggestion", diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 719f2ea1..5efb0714 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -40,7 +40,7 @@ test("기반 패키지 변경은 모든 역방향 소비자를 선택한다", () const plan = createPlan(["packages/json-document/src/index.ts"]); assert.equal(plan.full, false); - assert.equal(plan.packageWorkspaces.length, 26); + assert.equal(plan.packageWorkspaces.length, 27); assert.equal(plan.standards, true); assert.equal(plan.externalKit, true); assert.deepEqual(plan.browserSpecs, ["site/tests/browser"]); @@ -109,6 +109,7 @@ test("선택기가 반환하는 모든 browser 경로가 존재한다", () => { "json-document-composer-react", "json-document-file-intake", "json-document-database", + "json-document-annotation", "json-document-calendar", "json-document-editing", "json-document-react", diff --git a/scripts/external-kit-plan.test.mjs b/scripts/external-kit-plan.test.mjs index 6fff03e0..46c81ba9 100644 --- a/scripts/external-kit-plan.test.mjs +++ b/scripts/external-kit-plan.test.mjs @@ -12,6 +12,7 @@ test("첫 kit package 변경은 외부 소비자 검증을 선택한다", () => "json-document-react", "json-document-zod", "json-document-database", + "json-document-annotation", "json-document-calendar", ]) { assert.equal(createPlan([`packages/${directory}/src/index.ts`]).externalKit, true, directory); diff --git a/scripts/release-package.mjs b/scripts/release-package.mjs index 70105542..661e2b80 100644 --- a/scripts/release-package.mjs +++ b/scripts/release-package.mjs @@ -23,6 +23,7 @@ export const releases = [ release("json-document-tanstack-table", "packages/json-document-tanstack-table/package.json", "@interactive-os/json-document-tanstack-table"), release("json-document-zod", "packages/json-document-zod/package.json", "@interactive-os/json-document-zod"), release("json-document-database", "packages/json-document-database/package.json", "@interactive-os/json-document-database"), + release("json-document-annotation", "packages/json-document-annotation/package.json", "@interactive-os/json-document-annotation"), release("json-document-calendar", "packages/json-document-calendar/package.json", "@interactive-os/json-document-calendar"), release("json-document-contenteditable-collaboration", "packages/contenteditable-collaboration/package.json", "@interactive-os/json-document-contenteditable-collaboration"), release("json-document-collaboration", "packages/json-document-collaboration/package.json", "@interactive-os/json-document-collaboration"), diff --git a/scripts/release-package.test.mjs b/scripts/release-package.test.mjs index 61c4c363..6331ba6c 100644 --- a/scripts/release-package.test.mjs +++ b/scripts/release-package.test.mjs @@ -27,6 +27,12 @@ const databaseHand = [ "next", "packages/json-document-database/package.json", ]; +const annotationHand = [ + "json-document-annotation-v0.1.0-rc.0", + "@interactive-os/json-document-annotation", + "next", + "packages/json-document-annotation/package.json", +]; test("첫 npm kit의 stable과 RC release stream을 구분한다", () => { for (const [tag, workspace, distTag, packageFile] of firstKit) { @@ -48,6 +54,11 @@ test("Database Hand를 next release stream으로 해석한다", () => { assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile }); }); +test("Annotation Hand를 next release stream으로 해석한다", () => { + const [tag, workspace, distTag, packageFile] = annotationHand; + assert.deepEqual(resolveRelease(tag), { workspace, distTag, version: "0.1.0-rc.0", packageFile }); +}); + test("지원하지 않는 package tag를 거부한다", () => { assert.throws(() => resolveRelease("json-document-rich-text-v0.1.0-rc.0"), /unsupported release tag/); }); diff --git a/scripts/verify-external-kit.mjs b/scripts/verify-external-kit.mjs index 454d3eb6..35f54e27 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", "@interactive-os/json-document-calendar", ]; const fixtureSource = join(repositoryRoot, "fixtures", "external-kit"); diff --git a/site/config/json-document-source-aliases.ts b/site/config/json-document-source-aliases.ts index b69276c4..12c5aa01 100644 --- a/site/config/json-document-source-aliases.ts +++ b/site/config/json-document-source-aliases.ts @@ -75,6 +75,10 @@ export function jsonDocumentSourceAliases(): SourceAlias[] { find: "@interactive-os/json-document-database", replacement: sourceFile("packages/json-document-database/src/index.ts"), }, + { + find: "@interactive-os/json-document-annotation", + replacement: sourceFile("packages/json-document-annotation/src/index.ts"), + }, { find: "@interactive-os/json-document-tanstack-table", replacement: sourceFile("packages/json-document-tanstack-table/src/index.ts"), diff --git a/site/package.json b/site/package.json index be1403c4..88ec1dd0 100644 --- a/site/package.json +++ b/site/package.json @@ -49,6 +49,7 @@ "typecheck": "npm run check:ui && npm run check:icons && npm run check:primitives && npm run check:ui-roles && npm run check:choice-id && npm run check:canonical-modules && npm run check:interaction-handles && npm run check:contextual-affordance && npm run check:content-interaction && npm run check:product-shell-toolbar && npm run check:calendar-temporal && npm run check:calendar-keyboard && npm run check:calendar-rename && npm run check:calendar-viewport && npm run check:calendar-pointer-coordinate && npm run check:calendar-selection-drag && npm run check:anchored-floating-position && npm run check:calendar-year-months && npm run check:calendar-month-weeks && npm run check:calendar-period-label && npm run check:calendar-visible-date-shift && npm run check:calendar-time-label && npm run check:calendar-event-label && npm run check:calendar-interval-last-date && npm run check:calendar-date-part && npm run check:calendar-period-cells && npm run check:calendar-allday-span && npm run check:calendar-view-parser && npm run check:calendar-instant-format && npm run check:calendar-cell-interval && npm run check:calendar-date-grid && npm run check:calendar-month-grid && npm run check:calendar-time-grid && npm run check:calendar-event-inspector && npm run check:calendar-recurrence-transition && npm run check:calendar-document-calendars && npm run check:tokens && tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@interactive-os/json-document-annotation": "*", "@a2ui/web_core": "^0.10.6", "@ag-ui/core": "^0.0.59", "@ag-ui/encoder": "^0.0.59", diff --git a/site/scripts/check-canonical-module-closure.mjs b/site/scripts/check-canonical-module-closure.mjs index bbcea385..c13811f1 100644 --- a/site/scripts/check-canonical-module-closure.mjs +++ b/site/scripts/check-canonical-module-closure.mjs @@ -10,6 +10,13 @@ const databasePropertyConsumers = [ "packages/json-document-database/src/database-hands.tsx", "packages/json-document-zod/src/database-document.ts", ]; +const annotationDemo = readSource("routes/annotation-demo/AnnotationDemoRoute.tsx"); +for (const symbol of ["AnnotationHand", "useAnnotationOutput"]) { + if (!hasNamedImport(annotationDemo, "@interactive-os/json-document-annotation", symbol)) throw new Error(`Annotation Demo must consume the canonical ${symbol}`); +} +for (const localResponsibility of ["createGestureSession", "projectWebClientPointToSVG", "function AnnotationShape", "function CommentComposer", "presentStructuredSnapshot", "JSON.stringify", "JSON.parse", "renderWebAnnotationRaster"]) { + if (annotationDemo.includes(localResponsibility)) throw new Error(`Annotation Demo owns displaced behavior: ${localResponsibility}`); +} const entries = [...registrySource.matchAll(/^\s*"\/[^"]+"[^\n]+"(routes\/[^"]+)"\),?$/gm)].map((match) => match[1]); const usages = [...sourceRegistry.matchAll(/packageName:\s*["']([^"']+)["'],\s*\n\s*symbol:\s*["']([^"']+)["'],\s*\n\s*sourcePath:\s*["']([^"']+)["']/g)].map((match) => ({ packageName: match[1], diff --git a/site/scripts/check-interaction-handles.mjs b/site/scripts/check-interaction-handles.mjs index 035117ee..0f1a7cc8 100644 --- a/site/scripts/check-interaction-handles.mjs +++ b/site/scripts/check-interaction-handles.mjs @@ -9,7 +9,7 @@ const sources = { calendar: read("packages/json-document-calendar/src/use-calendar-pointer-interactions.ts"), canvas: read("site/src/routes/canvas-demo/CanvasDemoRoute.tsx"), database: read("packages/json-document-database/src/database-hand.tsx"), - annotation: read("site/src/routes/annotation-demo/AnnotationDemoRoute.tsx"), + annotation: read("packages/json-document-annotation/src/annotation-hand.tsx"), }; requireText("owner", sources.owner, "createInteractionHandleSession"); diff --git a/site/site-routes.json b/site/site-routes.json index c62b1a04..6fa7596e 100644 --- a/site/site-routes.json +++ b/site/site-routes.json @@ -404,6 +404,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/calendar", "label": "API · Calendar", diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index c6c571d7..4251679d 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -132,6 +132,7 @@ import { Route as PageDocsApiA2uiRouteImport } from "./routes/_page/docs/api/a2u import { Route as PageDocsApiAffordanceRouteImport } from "./routes/_page/docs/api/affordance"; import { Route as PageDocsApiAjvRouteImport } from "./routes/_page/docs/api/ajv"; import { Route as PageDocsApiAnimationReactRouteImport } from "./routes/_page/docs/api/animation-react"; +import { Route as PageDocsApiAnnotationRouteImport } from "./routes/_page/docs/api/annotation"; import { Route as PageDocsApiCalendarRouteImport } from "./routes/_page/docs/api/calendar"; import { Route as PageDocsApiCollaborationRouteImport } from "./routes/_page/docs/api/collaboration"; import { Route as PageDocsApiComposerRouteImport } from "./routes/_page/docs/api/composer"; @@ -812,6 +813,11 @@ const PageDocsApiAnimationReactRoute = path: "/animation-react", getParentRoute: () => PageDocsApiRoute, } as any); +const PageDocsApiAnnotationRoute = PageDocsApiAnnotationRouteImport.update({ + id: "/annotation", + path: "/annotation", + getParentRoute: () => PageDocsApiRoute, +} as any); const PageDocsApiCalendarRoute = PageDocsApiCalendarRouteImport.update({ id: "/calendar", path: "/calendar", @@ -1120,6 +1126,7 @@ export interface FileRoutesByFullPath { "/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/docs/api/ajv": typeof PageDocsApiAjvRoute; "/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; + "/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/docs/api/calendar": typeof PageDocsApiCalendarRoute; "/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/docs/api/composer": typeof PageDocsApiComposerRoute; @@ -1277,6 +1284,7 @@ export interface FileRoutesByTo { "/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/docs/api/ajv": typeof PageDocsApiAjvRoute; "/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; + "/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/docs/api/calendar": typeof PageDocsApiCalendarRoute; "/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/docs/api/composer": typeof PageDocsApiComposerRoute; @@ -1436,6 +1444,7 @@ export interface FileRoutesById { "/_page/docs/api/affordance": typeof PageDocsApiAffordanceRoute; "/_page/docs/api/ajv": typeof PageDocsApiAjvRoute; "/_page/docs/api/animation-react": typeof PageDocsApiAnimationReactRoute; + "/_page/docs/api/annotation": typeof PageDocsApiAnnotationRoute; "/_page/docs/api/calendar": typeof PageDocsApiCalendarRoute; "/_page/docs/api/collaboration": typeof PageDocsApiCollaborationRoute; "/_page/docs/api/composer": typeof PageDocsApiComposerRoute; @@ -1595,6 +1604,7 @@ export interface FileRouteTypes { | "/docs/api/affordance" | "/docs/api/ajv" | "/docs/api/animation-react" + | "/docs/api/annotation" | "/docs/api/calendar" | "/docs/api/collaboration" | "/docs/api/composer" @@ -1752,6 +1762,7 @@ export interface FileRouteTypes { | "/docs/api/affordance" | "/docs/api/ajv" | "/docs/api/animation-react" + | "/docs/api/annotation" | "/docs/api/calendar" | "/docs/api/collaboration" | "/docs/api/composer" @@ -1910,6 +1921,7 @@ export interface FileRouteTypes { | "/_page/docs/api/affordance" | "/_page/docs/api/ajv" | "/_page/docs/api/animation-react" + | "/_page/docs/api/annotation" | "/_page/docs/api/calendar" | "/_page/docs/api/collaboration" | "/_page/docs/api/composer" @@ -2815,6 +2827,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsApiAnimationReactRouteImport; 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/calendar": { id: "/_page/docs/api/calendar"; path: "/calendar"; @@ -3054,6 +3073,7 @@ interface PageDocsApiRouteChildren { PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute; PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute; PageDocsApiAnimationReactRoute: typeof PageDocsApiAnimationReactRoute; + PageDocsApiAnnotationRoute: typeof PageDocsApiAnnotationRoute; PageDocsApiCalendarRoute: typeof PageDocsApiCalendarRoute; PageDocsApiCollaborationRoute: typeof PageDocsApiCollaborationRoute; PageDocsApiComposerRoute: typeof PageDocsApiComposerRoute; @@ -3086,6 +3106,7 @@ const PageDocsApiRouteChildren: PageDocsApiRouteChildren = { PageDocsApiAffordanceRoute: PageDocsApiAffordanceRoute, PageDocsApiAjvRoute: PageDocsApiAjvRoute, PageDocsApiAnimationReactRoute: PageDocsApiAnimationReactRoute, + PageDocsApiAnnotationRoute: PageDocsApiAnnotationRoute, PageDocsApiCalendarRoute: PageDocsApiCalendarRoute, PageDocsApiCollaborationRoute: PageDocsApiCollaborationRoute, PageDocsApiComposerRoute: PageDocsApiComposerRoute, 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 d7f80a6d..4b45f7a3 100644 --- a/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx +++ b/site/src/routes/annotation-demo/AnnotationDemoRoute.tsx @@ -1,702 +1,61 @@ -import { - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, - type ChangeEvent, - type KeyboardEvent, - type PointerEvent, -} from "react"; +import { useState } from "react"; import { createJSONDocument } from "@interactive-os/json-document"; -import { - ANNOTATION_PROFILE_V1, - assertAnnotationDocument, - createAnnotationEditor, - type Annotation, - type AnnotationDocument, - type AnnotationPoint, - type AnnotationSource, -} from "@interactive-os/json-document-editing"; -import { createGestureSession, type InteractionHandleDescriptor, type InteractionHandleEvent } from "@interactive-os/json-document-affordance"; -import { - createWebPointerSession, - projectWebClientPointToSVG, - readWebRasterFile, - renderWebAnnotationRaster, - webSVGViewportFromElement, -} from "@interactive-os/json-document-web"; -import { - ArrowUpRight, - Download, - ImagePlus, - MessageSquare, - MousePointer2, - Pencil, - Redo2, - RotateCcw, - Save, - SendHorizontal, - ThumbsDown, - ThumbsUp, - Trash2, - Undo2, - ZoomIn, - ZoomOut, -} from "lucide-react"; +import { Command, ProductShell, Tabs } from "@interactive-os/json-document-ui-primitives-react"; +import { AnnotationHand, useAnnotationOutput, type AnnotationTool } from "@interactive-os/json-document-annotation"; +import { createAnnotationEditor } from "@interactive-os/json-document-editing"; import { DemoPage } from "../../shared/demo-workbench/DemoPage"; -import { Command, Field, Tabs, Toggle, useInteractionHandle } from "@interactive-os/json-document-ui-primitives-react"; import { PageHeader } from "../../shared/ui/primitives"; -import { ProductShell } from "@interactive-os/json-document-ui-primitives-react"; import { classes, ui } from "../../shared/ui/styles"; -import { CodeBlock } from "../../shared/ui/code-block"; -import { - initialAnnotationDocument, -} from "./annotation-state"; +import { initialAnnotationDocument } from "./annotation-state"; import { annotationDemoRecipe } from "./annotation-demo-styles"; -type Tool = "select" | "comment" | "draw" | "arrow" | "like" | "dislike"; -type Output = "structured" | "image"; -type Gesture = - | { readonly type: "create"; readonly tool: Exclude; readonly start: AnnotationPoint; readonly current: AnnotationPoint } - | { readonly type: "draw"; readonly points: ReadonlyArray } - | { readonly type: "move"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint } - | { readonly type: "resize"; readonly id: string; readonly start: AnnotationPoint; readonly current: AnnotationPoint }; +import { Save, RotateCcw } from "lucide-react"; +import { CodeBlock } from "../../shared/ui/code-block"; -const accent = "rgb(var(--color-border-accent))"; -const annotationDemoStyles = annotationDemoRecipe(); +const styles = annotationDemoRecipe(); export function AnnotationDemoRoute() { - const [documentSource] = useState(() => createJSONDocument(initialAnnotationDocument)); - const [editor] = useState(() => createAnnotationEditor(documentSource)); - useSyncExternalStore(editor.subscribe, () => editor.snapshot.revision, () => editor.snapshot.revision); - const [tool, setTool] = useState("comment"); - const [editingId, setEditingId] = useState(null); - const [previewId, setPreviewId] = useState(null); - const [, setGestureRevision] = useState(0); - const [gestureSession] = useState(() => createGestureSession({ - onBegin: () => setGestureRevision((revision) => revision + 1), - onPreview: () => setGestureRevision((revision) => revision + 1), - onCommit: () => setGestureRevision((revision) => revision + 1), - onCancel: () => setGestureRevision((revision) => revision + 1), - })); - const [pointerSession] = useState(() => createWebPointerSession<{ readonly active: true }>()); - const [savedState, setSavedState] = useState(null); - const [output, setOutput] = useState("structured"); - const [renderedImage, setRenderedImage] = useState(null); - const [zoom, setZoom] = useState(1); + const [document] = useState(() => createJSONDocument(initialAnnotationDocument)); + const [editor] = useState(() => createAnnotationEditor(document)); + const [tool, setTool] = useState("comment"); + const [output, setOutput] = useState<"structured" | "image">("structured"); const [announcement, setAnnouncement] = useState("클릭하거나 드래그해서 수정 코멘트를 남기세요."); - const svgRef = useRef(null); - const rasterUrlsRef = useRef(new Map([[ - initialAnnotationDocument.sources[0]!.id, - sitePath(initialAnnotationDocument.sources[0]!.src), - ]])); - const documentValue = editor.snapshot.value as AnnotationDocument; - const selectedId = editor.snapshot.selection.primaryId; - const selected = documentValue.annotations.find((annotation) => annotation.id === selectedId) ?? null; - const source = documentValue.sources[0]!; - const sourceUrl = rasterUrlsRef.current.get(source.id) ?? sourcePath(source.src); - const gesture = gestureSession.getActive(); - - useEffect(() => { - if (output !== "image") return; - let active = true; - setRenderedImage(null); - void renderWebAnnotationRaster({ document: documentValue, sourceId: source.id, sourceURL: sourceUrl, style: rasterStyle() }).then((result) => { - if (active) setRenderedImage(result.ok ? result.dataURL : null); - }); - return () => { active = false; }; - }, [output, documentValue, sourceUrl]); - - const structuredOutput = useMemo(() => presentStructuredSnapshot(documentValue, selectedId), [documentValue, selectedId]); - const structuredDownloadUrl = useMemo( - () => `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(structuredOutput, null, 2))}`, - [structuredOutput], - ); - - function setSelected(selectedId: string | null) { - editor.dispatch({ type: "selection.set", annotationId: selectedId, mode: "replace" }); - } - - function sendComment(annotation: Annotation, instruction: string) { - const nextInstruction = instruction.trim(); - if (annotation.body.instruction === nextInstruction) { - setTool("select"); - return; - } - editor.dispatch({ type: "annotation.body.set", annotationId: annotation.id, instruction: nextInstruction }); - setTool("select"); - setAnnouncement("수정 요청을 추가했습니다."); - } - - function submitComment(annotation: Annotation, instruction: string) { - sendComment(annotation, instruction); - setEditingId(null); - } - - function cancelComment(annotation: Annotation) { - if (annotation.body.instruction === "") { - editor.dispatch({ type: "annotation.delete", annotationId: annotation.id }); - setAnnouncement("작성 중인 요청을 취소했습니다."); - setEditingId(null); - return; - } - setSelected(null); - setEditingId(null); - } - - function chooseTool(nextTool: Tool) { - setTool(nextTool); - setEditingId(null); - if (selectedId !== null) setSelected(null); - } - - function deleteSelected() { - if (selectedId === null) return; - editor.dispatch({ type: "annotation.delete", annotationId: selectedId }); - setEditingId(null); - setAnnouncement("선택한 annotation을 삭제했습니다."); - } - - function handleCanvasPointerDown(event: PointerEvent) { - if (event.target !== event.currentTarget && (event.target as Element).closest("[data-annotation-id]")) return; - const point = eventPoint(event); - if (point === null) return; - if (tool === "select") return setSelected(null); - pointerSession.begin(event.currentTarget, event.pointerId, { active: true }); - if (tool === "draw") { - gestureSession.begin({ type: "draw", points: [point] }); - return; - } - gestureSession.begin({ type: "create", tool, start: point, current: point }); - } - - function handleAnnotationInteraction( - interaction: InteractionHandleEvent, - event: PointerEvent, - annotation: Annotation, - type: "move" | "resize", - ) { - if (interaction.phase === "start") { - if (type === "move") { - setEditingId(null); - setPreviewId(null); - setSelected(annotation.id); - if (tool !== "select") return; - } - const start = eventPoint(event); - if (start !== null) gestureSession.begin({ type, id: annotation.id, start, current: start }); - return; - } - if (interaction.phase === "cancel") { - gestureSession.cancel("pointer-cancel"); - setAnnouncement("진행 중인 조작을 취소했습니다."); - return; - } - const active = gestureSession.getActive(); - if (active?.type !== type || active.id !== annotation.id) return; - const current = eventPoint(event); - if (current === null) return; - gestureSession.preview({ ...active, current }); - if (interaction.phase === "commit") commitActiveGesture(); - } - - function handlePointerMove(event: PointerEvent) { - if (gesture === null || pointerSession.getSnapshot()?.pointerId !== event.pointerId) return; - const point = eventPoint(event); - if (point === null) return; - if (gesture.type === "draw") { - const previous = gesture.points.at(-1); - if (previous !== undefined && distance(previous, point) >= 4) { - gestureSession.preview({ type: "draw", points: [...gesture.points, point] }); - } - return; - } - if (gesture.type === "create") { - gestureSession.preview({ ...gesture, current: point }); - return; - } - gestureSession.preview({ ...gesture, current: point }); - } - - function handlePointerUp(event: PointerEvent) { - if (pointerSession.commit(event.pointerId) === null) return; - commitActiveGesture(); - } - - function commitActiveGesture() { - const gesture = gestureSession.commit(); - if (gesture === null) return; - if (gesture.type === "draw") { - const annotation = createDrawAnnotation(source.id, gesture.points); - if (annotation !== null) { - editor.dispatch({ type: "annotation.create", annotation }); - setTool("select"); - setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); - setAnnouncement("자유선 코멘트를 만들었습니다."); - } - } else if (gesture.type === "create") { - const annotation = createAnnotation(source.id, gesture.tool, gesture.start, gesture.current); - if (annotation !== null) { - editor.dispatch({ type: "annotation.create", annotation }); - setTool("select"); - setEditingId(annotation.presentation.type === "reaction" ? null : annotation.id); - setAnnouncement(annotationAnnouncement(annotation)); - } - } else { - const dx = gesture.current.x - gesture.start.x; - const dy = gesture.current.y - gesture.start.y; - if (gesture.type === "move" && Math.hypot(dx, dy) < 4) { - const annotation = documentValue.annotations.find((item) => item.id === gesture.id); - if (annotation !== undefined && annotation.presentation.type !== "reaction") setEditingId(gesture.id); - return; - } - editor.dispatch(gesture.type === "move" - ? { type: "annotation.move", annotationId: gesture.id, dx, dy } - : { type: "annotation.resize", annotationId: gesture.id, handle: resizeHandle(documentValue, gesture.id), dx, dy }); - setAnnouncement(gesture.type === "move" ? "Annotation을 이동했습니다." : "Target을 resize했습니다."); - } - } - - function cancelPointerGesture(event: PointerEvent, reason: "pointer-cancel" | "lost-capture") { - const cancelled = pointerSession.cancel(event.pointerId, reason === "lost-capture" ? "lost-capture" : "cancel"); - if (cancelled === null) return; - gestureSession.cancel(reason); - setAnnouncement("진행 중인 조작을 취소했습니다."); - } - - function handleKeyDown(event: KeyboardEvent) { - const command = event.metaKey || event.ctrlKey; - if (command && event.key.toLowerCase() === "z") { - event.preventDefault(); - if (event.shiftKey) editor.redo(); - else editor.undo(); - return; - } - if (!command) { - const shortcutTool = toolFromShortcut(event.key); - if (shortcutTool !== null) { - event.preventDefault(); - chooseTool(shortcutTool); - return; - } - } - if (event.key === "Escape") { - event.preventDefault(); - const pointer = pointerSession.getSnapshot(); - if (pointer !== null) pointerSession.cancel(pointer.pointerId); - gestureSession.cancel("cancel"); - chooseTool("select"); - return; - } - if (event.key === "Delete" || event.key === "Backspace") { - event.preventDefault(); - deleteSelected(); - } - } - - function saveState() { - setSavedState(JSON.stringify(documentValue)); - setAnnouncement("Structured annotation state를 저장했습니다."); - } - - function restoreState() { - if (savedState === null) return; - const restored = JSON.parse(savedState) as AnnotationDocument; - assertAnnotationDocument(restored); - documentSource.commit([{ op: "replace", path: "", value: restored }]); - setSelected(null); - setAnnouncement("저장한 state에서 overlay를 복원했습니다."); - } - - async function replaceImage(event: ChangeEvent) { - const file = event.target.files?.[0]; - event.target.value = ""; - if (file === undefined) return; - try { - const raster = await readWebRasterFile(file); - if (!raster.ok) throw new Error(raster.reason ?? raster.code); - const nextSource: AnnotationSource = { id: `upload-${file.name}-${file.lastModified}`, src: file.name, width: raster.width, height: raster.height }; - const url = raster.dataURL; - rasterUrlsRef.current.set(nextSource.id, url); - documentSource.commit([{ op: "replace", path: "", value: { - profile: ANNOTATION_PROFILE_V1, - id: documentValue.id, - sources: [nextSource], - annotations: [], - } }]); - setZoom(1); - setAnnouncement(`${file.name} 이미지로 교체했습니다.`); - } catch { - setAnnouncement("지원하는 raster 이미지를 불러오지 못했습니다."); - } - } - - async function downloadImage() { - const result = await renderWebAnnotationRaster({ document: documentValue, sourceId: source.id, sourceURL: sourceUrl, style: rasterStyle() }); - if (!result.ok) { setAnnouncement("Annotation 이미지를 만들지 못했습니다."); return; } - const link = document.createElement("a"); - link.href = result.dataURL; - link.download = "annotation-request.png"; - link.click(); - setAnnouncement("Annotation이 적용된 이미지를 다운로드했습니다."); - } - - return ( - {announcement}

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