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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user