feat(canvas): add visual annotation edit action

Add a VisualAnnotationPanel letting users mark regions on an image (brush,
circle, rectangle, cross) with per-mark edit instructions, composited into
an annotation guide image. The new "visual-annotation" node action sends
the unmarked source plus the annotated guide to the image model, editing
only numbered regions. Server validates the annotation image/instructions
and threads them through the generator task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 10:03:59 +08:00
parent 114198f1d9
commit bb8946b79b
17 changed files with 1271 additions and 46 deletions
@@ -57,6 +57,7 @@ export type { MultiAngleOptions } from "@/ui/components/MultiAnglePanel";
export type ImageToolbarItemId =
| "quickEdit"
| "visualAnnotation"
| "upscale"
| "removeBackground"
| "eraser"
@@ -154,6 +155,7 @@ type BrushPath = {
export const defaultToolbarVisibility: ImageToolbarVisibility = {
quickEdit: true,
visualAnnotation: true,
upscale: true,
removeBackground: true,
eraser: true,
@@ -170,6 +172,7 @@ export const defaultToolbarVisibility: ImageToolbarVisibility = {
};
const defaultToolbarOrder: ImageToolbarItemId[] = [
"visualAnnotation",
"upscale",
"removeBackground",
"eraser",
@@ -360,6 +363,7 @@ export function ImageNodeToolbar({
visibility,
onVisibilityChange,
onQuickEdit,
onVisualAnnotation,
onUpscale,
onRemoveBackground,
onEraser,
@@ -381,6 +385,7 @@ export function ImageNodeToolbar({
visibility: ImageToolbarVisibility;
onVisibilityChange: Dispatch<SetStateAction<ImageToolbarVisibility>>;
onQuickEdit: () => void;
onVisualAnnotation: () => void;
onUpscale: (scale: string) => void;
onRemoveBackground: () => void;
onEraser: () => void;
@@ -416,6 +421,7 @@ export function ImageNodeToolbar({
primary?: boolean;
}> = [
{ id: "quickEdit", label: t("quickEditTab"), icon: Wand2, onClick: onQuickEdit, primary: true },
{ id: "visualAnnotation", label: t("visualAnnotation"), icon: PenLine, onClick: onVisualAnnotation },
{ id: "removeBackground", label: t("removeBackground"), icon: Circle, onClick: onRemoveBackground },
{ id: "eraser", label: t("eraserTool"), icon: EraserIcon, onClick: onEraser },
{ id: "editElements", label: t("editElements"), icon: Layers, onClick: onEditElements },
@@ -0,0 +1,398 @@
.visual-annotation-overlay {
position: absolute;
z-index: 38;
overflow: hidden;
border: 1px solid rgba(47, 128, 255, 0.82);
background: rgba(47, 128, 255, 0.015);
cursor: crosshair;
touch-action: none;
}
.visual-annotation-overlay.is-brush {
cursor: cell;
}
.visual-annotation-overlay svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
pointer-events: none;
}
.visual-annotation-mark {
fill: none;
stroke: #2f80ff;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.96));
}
circle.visual-annotation-mark {
fill: #2f80ff;
stroke: #fff;
stroke-width: 1.5px;
}
.visual-annotation-mark.selected {
stroke: #1673f9;
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1));
}
.visual-annotation-mark.draft {
opacity: 0.72;
}
.visual-annotation-badge circle {
fill: #2f80ff;
stroke: #fff;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.visual-annotation-badge text {
fill: #fff;
font-family: inherit;
font-size: 11px;
font-weight: 760;
letter-spacing: 0;
user-select: none;
}
.visual-annotation-panel {
z-index: 42;
display: grid;
grid-template-rows: auto auto auto minmax(52px, 1fr) auto;
overflow: hidden;
border-color: rgba(222, 226, 232, 0.98);
border-radius: 12px;
background: rgba(255, 255, 255, 0.985);
box-shadow:
0 1px 2px rgba(17, 24, 39, 0.06),
0 18px 48px rgba(17, 24, 39, 0.17);
transform: none;
backdrop-filter: blur(16px);
}
.visual-annotation-header {
min-height: 46px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px 7px;
}
.visual-annotation-header strong {
min-width: 0;
flex: 1;
color: #20242b;
font-size: 14px;
font-weight: 720;
line-height: 20px;
letter-spacing: 0;
}
.visual-annotation-header-actions {
display: inline-flex;
align-items: center;
gap: 2px;
}
.visual-annotation-header-actions button {
width: 28px;
height: 28px;
display: inline-grid;
place-items: center;
border-radius: 8px;
color: #626975;
background: transparent;
}
.visual-annotation-header-actions button:hover:not(:disabled),
.visual-annotation-header-actions button:focus-visible {
color: #222831;
background: #f1f3f5;
}
.visual-annotation-header-actions button:disabled {
opacity: 0.36;
cursor: not-allowed;
}
.visual-annotation-tools {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
padding: 2px 12px 12px;
}
.visual-annotation-tools button {
height: 38px;
display: inline-grid;
place-items: center;
border: 1px solid transparent;
border-radius: 8px;
color: #707783;
background: #f7f8f9;
transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
.visual-annotation-tools button:hover:not(:disabled),
.visual-annotation-tools button:focus-visible {
color: #282e37;
background: #f0f2f4;
}
.visual-annotation-tools button.active {
border-color: rgba(47, 128, 255, 0.42);
color: #176fe9;
background: rgba(47, 128, 255, 0.09);
box-shadow: inset 0 0 0 1px rgba(47, 128, 255, 0.05);
}
.visual-annotation-tools button:disabled {
opacity: 0.44;
cursor: not-allowed;
}
.visual-annotation-list-heading {
min-height: 28px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 12px 6px;
color: #6d7480;
font-size: 12px;
font-weight: 650;
}
.visual-annotation-list-heading small {
min-width: 18px;
height: 18px;
display: inline-grid;
place-items: center;
border-radius: 9px;
color: #657080;
background: #eff1f4;
font-size: 10px;
font-weight: 700;
}
.visual-annotation-list {
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow-x: hidden;
overflow-y: auto;
padding: 0 12px 12px;
scrollbar-width: thin;
scrollbar-color: rgba(160, 166, 176, 0.56) transparent;
}
.visual-annotation-list::-webkit-scrollbar {
width: 7px;
}
.visual-annotation-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(160, 166, 176, 0.56);
}
.visual-annotation-empty {
min-height: 62px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #dde1e6;
border-radius: 8px;
color: #9aa1ac;
background: #fafafa;
font-size: 12px;
line-height: 18px;
}
.visual-annotation-item {
min-width: 0;
min-height: 42px;
display: grid;
grid-template-columns: 24px 16px minmax(0, 1fr) 28px;
align-items: center;
gap: 6px;
padding: 4px 5px 4px 7px;
border: 1px solid #e4e7eb;
border-radius: 8px;
background: #fff;
transition: border-color 0.14s ease, background 0.14s ease, box-shadow 0.14s ease;
}
.visual-annotation-item:hover,
.visual-annotation-item:focus-within {
border-color: #d7dce3;
background: #fbfbfc;
}
.visual-annotation-item.selected {
border-color: rgba(47, 128, 255, 0.58);
background: rgba(47, 128, 255, 0.055);
box-shadow: 0 0 0 1px rgba(47, 128, 255, 0.06);
}
.visual-annotation-item.needs-instruction:not(.selected) {
border-color: rgba(237, 170, 76, 0.5);
}
.visual-annotation-index {
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
border-radius: 7px;
color: #176fe9;
background: rgba(47, 128, 255, 0.1);
font-size: 11px;
font-weight: 760;
}
.visual-annotation-item-tool {
color: #77808d;
}
.visual-annotation-item input {
width: 100%;
min-width: 0;
height: 30px;
padding: 0;
border: 0;
outline: 0;
color: #2a3039;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 520;
letter-spacing: 0;
}
.visual-annotation-item input::placeholder {
color: #a0a6b0;
}
.visual-annotation-item > button {
width: 28px;
height: 28px;
display: inline-grid;
place-items: center;
border-radius: 7px;
color: #8a919c;
background: transparent;
}
.visual-annotation-item > button:hover:not(:disabled),
.visual-annotation-item > button:focus-visible {
color: #d14343;
background: rgba(209, 67, 67, 0.08);
}
.visual-annotation-footer {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 92px;
gap: 8px;
padding: 11px 12px 12px;
border-top: 1px solid #eceef1;
}
.visual-annotation-model,
.visual-annotation-generate {
min-width: 0;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 9px;
font-size: 12px;
font-weight: 650;
}
.visual-annotation-model {
justify-content: flex-start;
padding: 0 10px;
border: 1px solid #e1e4e8;
color: #303640;
background: #f8f8f8;
}
.visual-annotation-model > span {
min-width: 0;
flex: 1;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.visual-annotation-model:hover:not(:disabled),
.visual-annotation-model:focus-visible {
background: #f1f2f4;
}
.visual-annotation-generate {
color: #fff;
background: #22262d;
}
.visual-annotation-generate:hover:not(:disabled),
.visual-annotation-generate:focus-visible {
background: #0f1216;
}
.visual-annotation-model:disabled,
.visual-annotation-generate:disabled {
opacity: 0.44;
cursor: not-allowed;
}
.visual-annotation-model-menu {
width: 250px;
}
.visual-annotation-model-menu .ui-dropdown-menu-item {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto 16px;
gap: 8px;
}
.visual-annotation-model-menu .ui-dropdown-menu-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.visual-annotation-model-menu .ui-dropdown-menu-item small {
color: #8a919c;
font-size: 11px;
}
.visual-annotation-spin {
animation: visual-annotation-spin 0.8s linear infinite;
}
@keyframes visual-annotation-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 680px) {
.visual-annotation-panel {
grid-template-rows: auto auto auto minmax(52px, 150px) auto;
}
.visual-annotation-footer {
grid-template-columns: minmax(0, 1fr) 84px;
}
}
@@ -0,0 +1,554 @@
"use client";
import { Brush, Check, ChevronDown, Circle, Loader2, Sparkles, Square, Trash2, Undo2, X, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useI18n } from "@/i18n/i18n";
import type { CanvasComposerModel } from "@/ui/components/CanvasAgentComposer";
import type { NodeScreenBounds } from "@/ui/pages/CanvasWorkspace/canvas/types";
import "./index.css";
export type VisualAnnotationTool = "brush" | "circle" | "rectangle" | "cross";
export type VisualAnnotationPoint = {
x: number;
y: number;
};
export type VisualAnnotationMark = {
id: string;
index: number;
tool: VisualAnnotationTool;
points: VisualAnnotationPoint[];
instruction: string;
};
type DrawingState = {
pointerId: number;
mark: VisualAnnotationMark;
};
type ToolOption = {
id: VisualAnnotationTool;
label: string;
icon: LucideIcon;
};
const annotationColor = "#2F80FF";
export function VisualAnnotationPanel({
bounds,
stageWidth,
stageHeight,
marks,
models,
selectedModel,
disabled,
submitting,
onMarksChange,
onModelChange,
onCancel,
onApply
}: {
bounds: NodeScreenBounds;
stageWidth: number;
stageHeight: number;
marks: VisualAnnotationMark[];
models: CanvasComposerModel[];
selectedModel: CanvasComposerModel;
disabled: boolean;
submitting: boolean;
onMarksChange: (marks: VisualAnnotationMark[]) => void;
onModelChange: (modelId: string) => void;
onCancel: () => void;
onApply: () => void;
}) {
const { t } = useI18n();
const drawingRef = useRef<DrawingState | null>(null);
const [activeTool, setActiveTool] = useState<VisualAnnotationTool>("brush");
const [draftMark, setDraftMark] = useState<VisualAnnotationMark | null>(null);
const [selectedMarkId, setSelectedMarkId] = useState<string | null>(null);
const [modelOpen, setModelOpen] = useState(false);
const nextIndex = useMemo(() => marks.reduce((max, mark) => Math.max(max, mark.index), 0) + 1, [marks]);
const canApply = marks.length > 0 && marks.every((mark) => mark.instruction.trim());
const toolOptions: ToolOption[] = [
{ id: "brush", label: t("visualAnnotationBrush"), icon: Brush },
{ id: "circle", label: t("visualAnnotationCircle"), icon: Circle },
{ id: "rectangle", label: t("visualAnnotationRectangle"), icon: Square },
{ id: "cross", label: t("visualAnnotationCross"), icon: X }
];
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (drawingRef.current) {
drawingRef.current = null;
setDraftMark(null);
return;
}
onCancel();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onCancel]);
const beginMark = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (disabled) return;
const point = normalizedPointer(event);
const mark: VisualAnnotationMark = {
id: visualAnnotationId(),
index: nextIndex,
tool: activeTool,
points: activeTool === "brush" ? [point] : [point, point],
instruction: ""
};
drawingRef.current = { pointerId: event.pointerId, mark };
setDraftMark(mark);
setSelectedMarkId(mark.id);
event.currentTarget.setPointerCapture(event.pointerId);
};
const updateMark = (event: ReactPointerEvent<HTMLDivElement>) => {
const drawing = drawingRef.current;
if (!drawing || drawing.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const point = normalizedPointer(event);
const points = drawing.mark.tool === "brush" ? appendBrushPoint(drawing.mark.points, point, bounds) : [drawing.mark.points[0], point];
drawing.mark = { ...drawing.mark, points };
drawingRef.current = drawing;
setDraftMark(drawing.mark);
};
const finishMark = (event: ReactPointerEvent<HTMLDivElement>) => {
const drawing = drawingRef.current;
if (!drawing || drawing.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const mark = finalizeMark(drawing.mark, bounds);
drawingRef.current = null;
setDraftMark(null);
onMarksChange([...marks, mark]);
window.requestAnimationFrame(() => {
document.querySelector<HTMLInputElement>(`[data-visual-annotation-input="${mark.id}"]`)?.focus();
});
};
const cancelDraft = (event: ReactPointerEvent<HTMLDivElement>) => {
if (drawingRef.current?.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
drawingRef.current = null;
setDraftMark(null);
};
const updateInstruction = (id: string, instruction: string) => {
onMarksChange(marks.map((mark) => (mark.id === id ? { ...mark, instruction } : mark)));
};
const removeMark = (id: string) => {
const nextMarks = marks.filter((mark) => mark.id !== id);
onMarksChange(nextMarks);
setSelectedMarkId((current) => (current === id ? nextMarks.at(-1)?.id ?? null : current));
};
const undoLastMark = () => {
if (marks.length === 0 || disabled) return;
const nextMarks = marks.slice(0, -1);
onMarksChange(nextMarks);
setSelectedMarkId(nextMarks.at(-1)?.id ?? null);
};
return (
<>
<div
className={`visual-annotation-overlay is-${activeTool}`}
style={overlayStyle(bounds)}
data-testid="visual-annotation-overlay"
onPointerDown={beginMark}
onPointerMove={updateMark}
onPointerUp={finishMark}
onPointerCancel={cancelDraft}
>
<svg viewBox={`0 0 ${Math.max(bounds.width, 1)} ${Math.max(bounds.height, 1)}`} preserveAspectRatio="none" aria-hidden="true">
{marks.map((mark) => (
<VisualMark key={mark.id} mark={mark} bounds={bounds} selected={mark.id === selectedMarkId} />
))}
{draftMark && <VisualMark mark={draftMark} bounds={bounds} selected draft />}
</svg>
</div>
<section
className="image-action-panel visual-annotation-panel"
style={annotationPanelStyle(bounds, stageWidth, stageHeight)}
data-testid="visual-annotation-panel"
aria-label={t("visualAnnotation")}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<header className="visual-annotation-header">
<strong>{t("visualAnnotation")}</strong>
<div className="visual-annotation-header-actions">
<button type="button" aria-label={t("undo")} title={t("undo")} disabled={disabled || marks.length === 0} onClick={undoLastMark}>
<Undo2 size={16} />
</button>
<button type="button" aria-label={t("close")} title={t("close")} disabled={submitting} onClick={onCancel}>
<X size={17} />
</button>
</div>
</header>
<div className="visual-annotation-tools" role="toolbar" aria-label={t("visualAnnotationTools")}>
{toolOptions.map((tool) => {
const Icon = tool.icon;
const active = tool.id === activeTool;
return (
<button
key={tool.id}
type="button"
className={active ? "active" : ""}
aria-label={tool.label}
title={tool.label}
aria-pressed={active}
disabled={disabled}
onClick={() => setActiveTool(tool.id)}
>
<Icon size={19} />
</button>
);
})}
</div>
<div className="visual-annotation-list-heading">
<span>{t("visualAnnotationList")}</span>
{marks.length > 0 && <small>{marks.length}</small>}
</div>
<div className="visual-annotation-list">
{marks.length === 0 ? (
<div className="visual-annotation-empty">
<Brush size={18} />
<span>{t("visualAnnotationEmpty")}</span>
</div>
) : (
marks.map((mark) => {
const ToolIcon = toolOptions.find((tool) => tool.id === mark.tool)?.icon ?? Brush;
const selected = mark.id === selectedMarkId;
return (
<div key={mark.id} className={`visual-annotation-item ${selected ? "selected" : ""} ${mark.instruction.trim() ? "" : "needs-instruction"}`} onClick={() => setSelectedMarkId(mark.id)}>
<span className="visual-annotation-index">{mark.index}</span>
<ToolIcon className="visual-annotation-item-tool" size={14} />
<input
value={mark.instruction}
data-visual-annotation-input={mark.id}
aria-label={t("visualAnnotationInstructionLabel", { index: mark.index })}
placeholder={t("visualAnnotationInstructionPlaceholder")}
disabled={disabled}
onFocus={() => setSelectedMarkId(mark.id)}
onChange={(event) => updateInstruction(mark.id, event.target.value)}
/>
<button
type="button"
aria-label={t("visualAnnotationDelete", { index: mark.index })}
title={t("delete")}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
removeMark(mark.id);
}}
>
<Trash2 size={14} />
</button>
</div>
);
})
)}
</div>
<footer className="visual-annotation-footer">
<DropdownMenu open={modelOpen} onOpenChange={setModelOpen}>
<DropdownMenuTrigger asChild>
<button type="button" className="visual-annotation-model" aria-label={t("modelSelection")} disabled={disabled}>
<Sparkles size={16} />
<span>{selectedModel.label}</span>
<ChevronDown size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="visual-annotation-model-menu" align="start" side="top" sideOffset={10}>
{models.map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => {
onModelChange(model.id);
setModelOpen(false);
}}
>
<Sparkles size={15} />
<span>{model.label}</span>
<small>{model.credits}</small>
{model.id === selectedModel.id && <Check size={15} />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button type="button" className="visual-annotation-generate" disabled={disabled || !canApply} onClick={onApply}>
{submitting ? <Loader2 className="visual-annotation-spin" size={16} /> : <span>{t("generate")}</span>}
</button>
</footer>
</section>
</>
);
}
function VisualMark({ mark, bounds, selected, draft = false }: { mark: VisualAnnotationMark; bounds: NodeScreenBounds; selected: boolean; draft?: boolean }) {
const strokeWidth = annotationStrokeWidth(bounds);
const points = mark.points.map((point) => ({ x: point.x * bounds.width, y: point.y * bounds.height }));
const shapeClass = `visual-annotation-mark ${selected ? "selected" : ""} ${draft ? "draft" : ""}`;
const anchor = markAnchor(points, mark.tool);
const badgeAnchor = {
x: clamp(anchor.x, 13, Math.max(13, bounds.width - 13)),
y: clamp(anchor.y, 13, Math.max(13, bounds.height - 13))
};
let shape: ReactNode = null;
if (mark.tool === "brush") {
if (points.length === 1) {
shape = <circle className={shapeClass} cx={points[0].x} cy={points[0].y} r={strokeWidth / 2} />;
} else {
shape = <path className={shapeClass} d={pathFromPoints(points)} strokeWidth={strokeWidth} />;
}
} else {
const rect = pointRect(points);
if (mark.tool === "circle") {
shape = <ellipse className={shapeClass} cx={rect.x + rect.width / 2} cy={rect.y + rect.height / 2} rx={rect.width / 2} ry={rect.height / 2} strokeWidth={strokeWidth} />;
} else if (mark.tool === "rectangle") {
shape = <rect className={shapeClass} x={rect.x} y={rect.y} width={rect.width} height={rect.height} strokeWidth={strokeWidth} />;
} else {
shape = (
<g className={shapeClass} strokeWidth={strokeWidth}>
<line x1={rect.x} y1={rect.y} x2={rect.x + rect.width} y2={rect.y + rect.height} />
<line x1={rect.x + rect.width} y1={rect.y} x2={rect.x} y2={rect.y + rect.height} />
</g>
);
}
}
return (
<g>
{shape}
{!draft && (
<g className="visual-annotation-badge" transform={`translate(${badgeAnchor.x} ${badgeAnchor.y})`}>
<circle r="11" />
<text textAnchor="middle" dominantBaseline="central">
{mark.index}
</text>
</g>
)}
</g>
);
}
export function visualAnnotationPayload(marks: VisualAnnotationMark[]) {
return {
annotations: marks.map((mark) => ({
index: mark.index,
type: mark.tool,
instruction: mark.instruction.trim(),
points: mark.points.map((point) => ({ x: roundCoordinate(point.x), y: roundCoordinate(point.y) }))
}))
};
}
export async function renderVisualAnnotationGuide(source: Blob, marks: VisualAnnotationMark[]) {
const image = await loadBlobImage(source);
const width = Math.max(1, image.naturalWidth || image.width);
const height = Math.max(1, image.naturalHeight || image.height);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Failed to create annotation canvas");
context.drawImage(image, 0, 0, width, height);
const strokeWidth = Math.max(5, Math.min(28, Math.min(width, height) * 0.012));
marks.forEach((mark) => {
const points = mark.points.map((point) => ({ x: point.x * width, y: point.y * height }));
context.save();
context.strokeStyle = annotationColor;
context.fillStyle = annotationColor;
context.lineWidth = strokeWidth;
context.lineCap = "round";
context.lineJoin = "round";
context.shadowColor = "rgba(255, 255, 255, 0.86)";
context.shadowBlur = Math.max(2, strokeWidth * 0.18);
drawCanvasMark(context, mark.tool, points, strokeWidth);
context.restore();
drawCanvasBadge(context, mark.index, markAnchor(points, mark.tool), strokeWidth);
});
return canvasBlob(canvas, "image/png");
}
function drawCanvasMark(context: CanvasRenderingContext2D, tool: VisualAnnotationTool, points: VisualAnnotationPoint[], strokeWidth: number) {
if (tool === "brush") {
if (points.length === 1) {
context.beginPath();
context.arc(points[0].x, points[0].y, strokeWidth / 2, 0, Math.PI * 2);
context.fill();
return;
}
context.beginPath();
points.forEach((point, index) => (index === 0 ? context.moveTo(point.x, point.y) : context.lineTo(point.x, point.y)));
context.stroke();
return;
}
const rect = pointRect(points);
context.beginPath();
if (tool === "circle") {
context.ellipse(rect.x + rect.width / 2, rect.y + rect.height / 2, Math.max(rect.width / 2, 1), Math.max(rect.height / 2, 1), 0, 0, Math.PI * 2);
} else if (tool === "rectangle") {
context.rect(rect.x, rect.y, rect.width, rect.height);
} else {
context.moveTo(rect.x, rect.y);
context.lineTo(rect.x + rect.width, rect.y + rect.height);
context.moveTo(rect.x + rect.width, rect.y);
context.lineTo(rect.x, rect.y + rect.height);
}
context.stroke();
}
function drawCanvasBadge(context: CanvasRenderingContext2D, index: number, point: VisualAnnotationPoint, strokeWidth: number) {
const radius = Math.max(13, strokeWidth * 1.35);
const x = clamp(point.x, radius + 2, context.canvas.width - radius - 2);
const y = clamp(point.y, radius + 2, context.canvas.height - radius - 2);
context.save();
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fillStyle = annotationColor;
context.fill();
context.lineWidth = Math.max(2, strokeWidth * 0.22);
context.strokeStyle = "#FFFFFF";
context.stroke();
context.fillStyle = "#FFFFFF";
context.font = `700 ${Math.max(13, radius * 1.08)}px sans-serif`;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(String(index), x, y + 0.5);
context.restore();
}
function annotationPanelStyle(bounds: NodeScreenBounds, stageWidth: number, stageHeight: number): CSSProperties {
const margin = 12;
const gap = 14;
const panelWidth = Math.min(320, Math.max(260, stageWidth - margin * 2));
const panelHeight = Math.min(560, Math.max(340, stageHeight - margin * 2));
const right = bounds.left + bounds.width + gap;
const left = bounds.left - panelWidth - gap;
const fitsRight = right + panelWidth <= stageWidth - margin;
const fitsLeft = left >= margin;
const panelLeft = fitsRight ? right : fitsLeft ? left : clamp(bounds.left + bounds.width / 2 - panelWidth / 2, margin, Math.max(margin, stageWidth - panelWidth - margin));
const panelTop = clamp(bounds.top + bounds.height / 2 - panelHeight / 2, margin, Math.max(margin, stageHeight - panelHeight - margin));
return { left: panelLeft, top: panelTop, width: panelWidth, maxHeight: panelHeight };
}
function overlayStyle(bounds: NodeScreenBounds): CSSProperties {
return { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
}
function normalizedPointer(event: ReactPointerEvent<HTMLDivElement>): VisualAnnotationPoint {
const rect = event.currentTarget.getBoundingClientRect();
return {
x: clamp((event.clientX - rect.left) / Math.max(rect.width, 1), 0, 1),
y: clamp((event.clientY - rect.top) / Math.max(rect.height, 1), 0, 1)
};
}
function appendBrushPoint(points: VisualAnnotationPoint[], point: VisualAnnotationPoint, bounds: NodeScreenBounds) {
const previous = points.at(-1);
if (!previous) return [point];
const distance = Math.hypot((point.x - previous.x) * bounds.width, (point.y - previous.y) * bounds.height);
if (distance < 1.5) return points;
return [...points, point];
}
function finalizeMark(mark: VisualAnnotationMark, bounds: NodeScreenBounds): VisualAnnotationMark {
if (mark.tool === "brush") return mark;
const start = mark.points[0];
const end = mark.points[1] ?? start;
const minWidth = Math.min(0.16, 28 / Math.max(bounds.width, 1));
const minHeight = Math.min(0.16, 28 / Math.max(bounds.height, 1));
const width = Math.abs(end.x - start.x);
const height = Math.abs(end.y - start.y);
if (width >= minWidth || height >= minHeight) return mark;
return {
...mark,
points: [
{ x: clamp(start.x - minWidth / 2, 0, 1), y: clamp(start.y - minHeight / 2, 0, 1) },
{ x: clamp(start.x + minWidth / 2, 0, 1), y: clamp(start.y + minHeight / 2, 0, 1) }
]
};
}
function pointRect(points: VisualAnnotationPoint[]) {
const start = points[0] ?? { x: 0, y: 0 };
const end = points.at(-1) ?? start;
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 markAnchor(points: VisualAnnotationPoint[], tool: VisualAnnotationTool) {
if (points.length === 0) return { x: 0, y: 0 };
if (tool === "brush") return points.at(-1) ?? points[0];
const rect = pointRect(points);
return { x: rect.x, y: rect.y };
}
function pathFromPoints(points: VisualAnnotationPoint[]) {
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(" ");
}
function annotationStrokeWidth(bounds: NodeScreenBounds) {
return Math.max(4, Math.min(10, Math.min(bounds.width, bounds.height) * 0.018));
}
function visualAnnotationId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `visual-annotation-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function roundCoordinate(value: number) {
return Math.round(clamp(value, 0, 1) * 10000) / 10000;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
async function loadBlobImage(blob: Blob) {
const url = URL.createObjectURL(blob);
try {
const image = new Image();
image.decoding = "async";
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("Failed to load annotation source image"));
image.src = url;
});
return image;
} finally {
URL.revokeObjectURL(url);
}
}
function canvasBlob(canvas: HTMLCanvasElement, type: string) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Failed to encode annotation guide"))), type);
});
}