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:
@@ -268,6 +268,7 @@ export type DeleteProjectsResponse = {
|
||||
export type NodeActionRequest = {
|
||||
action:
|
||||
| "quick-edit"
|
||||
| "visual-annotation"
|
||||
| "upscale"
|
||||
| "remove-background"
|
||||
| "eraser"
|
||||
@@ -285,6 +286,7 @@ export type NodeActionRequest = {
|
||||
imageSize?: string;
|
||||
mask?: string;
|
||||
selection?: string;
|
||||
annotationImage?: string;
|
||||
options?: Record<string, string>;
|
||||
};
|
||||
|
||||
|
||||
@@ -504,6 +504,18 @@
|
||||
"quickEdit": "Quick edit",
|
||||
"quickEditTab": "Quick edit Tab",
|
||||
"quickEditPlaceholder": "Describe the edit for this image",
|
||||
"visualAnnotation": "Annotated edit",
|
||||
"visualAnnotationTools": "Annotation tools",
|
||||
"visualAnnotationBrush": "Brush mark",
|
||||
"visualAnnotationCircle": "Circle mark",
|
||||
"visualAnnotationRectangle": "Rectangle mark",
|
||||
"visualAnnotationCross": "Cross mark",
|
||||
"visualAnnotationList": "Mark list",
|
||||
"visualAnnotationEmpty": "No marks yet",
|
||||
"visualAnnotationInstructionPlaceholder": "Tell AI how to change this area",
|
||||
"visualAnnotationInstructionLabel": "Edit instruction for mark {index}",
|
||||
"visualAnnotationDelete": "Delete mark {index}",
|
||||
"visualAnnotationPrompt": "Use the unmarked source image as the base and edit only the numbered marked regions. The marks, numbers, and annotated guide are location cues only and must not appear in the final image. Keep every unmarked area unchanged.\n{instructions}",
|
||||
"upscale": "Upscale",
|
||||
"upscalePrompt": "Upscale this image to {scale} level with the model. Preserve composition and subject while improving detail, edges, and texture clarity.",
|
||||
"removeBackground": "Remove background",
|
||||
@@ -619,6 +631,8 @@
|
||||
"cropPromptPlaceholder": "Describe the cropped composition",
|
||||
"apply": "Apply",
|
||||
"cancel": "Cancel",
|
||||
"undo": "Undo",
|
||||
"delete": "Delete",
|
||||
"clipboardCopied": "Copied to system clipboard",
|
||||
"clipboardCopyFailed": "Could not write to system clipboard",
|
||||
"contextCopy": "Copy",
|
||||
|
||||
@@ -504,6 +504,18 @@
|
||||
"quickEdit": "快捷编辑",
|
||||
"quickEditTab": "快捷编辑Tab",
|
||||
"quickEditPlaceholder": "输入这张图的局部或整体修改要求",
|
||||
"visualAnnotation": "标注改图",
|
||||
"visualAnnotationTools": "标注工具",
|
||||
"visualAnnotationBrush": "画笔标记",
|
||||
"visualAnnotationCircle": "圆形标记",
|
||||
"visualAnnotationRectangle": "矩形标记",
|
||||
"visualAnnotationCross": "叉号标记",
|
||||
"visualAnnotationList": "标记列表",
|
||||
"visualAnnotationEmpty": "暂无标记",
|
||||
"visualAnnotationInstructionPlaceholder": "告诉 AI 这里怎么改",
|
||||
"visualAnnotationInstructionLabel": "标记 {index} 的修改说明",
|
||||
"visualAnnotationDelete": "删除标记 {index}",
|
||||
"visualAnnotationPrompt": "请以未标注原图为底图,严格按编号标记修改对应区域;标记线、编号和标注引导图只用于定位,最终图中不得保留任何标记。未标注区域保持不变。\n{instructions}",
|
||||
"upscale": "放大",
|
||||
"upscalePrompt": "请将这张图通过模型放大到 {scale} 级别,保持构图和主体不变,提升细节、边缘和纹理清晰度。",
|
||||
"removeBackground": "去背景",
|
||||
@@ -619,6 +631,8 @@
|
||||
"cropPromptPlaceholder": "描述裁剪后的构图用途",
|
||||
"apply": "应用",
|
||||
"cancel": "取消",
|
||||
"undo": "撤销",
|
||||
"delete": "删除",
|
||||
"clipboardCopied": "已复制到系统剪贴板",
|
||||
"clipboardCopyFailed": "无法写入系统剪贴板",
|
||||
"contextCopy": "复制",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { AdjustPanel, CropOverlay, defaultToolbarVisibility, EraserOverlay, Expa
|
||||
import { ImageTextEditPanel, type EditableTextLayer } from "@/ui/components/ImageTextEditPanel";
|
||||
import { MultiSelectionOverlay, type MultiSelectionResizeHandle } from "@/ui/components/MultiSelectionOverlay";
|
||||
import { TextStyleToolbar, type TextStylePatch } from "@/ui/components/TextStyleToolbar";
|
||||
import { renderVisualAnnotationGuide, visualAnnotationPayload, VisualAnnotationPanel, type VisualAnnotationMark, type VisualAnnotationTool } from "@/ui/components/VisualAnnotationPanel";
|
||||
import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCatalog";
|
||||
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
|
||||
import { SharePopover } from "@/ui/components/SharePopover";
|
||||
@@ -115,7 +116,7 @@ type HomeImageModel = {
|
||||
credits: number;
|
||||
};
|
||||
|
||||
type ImageActionMode = "quick-edit" | "eraser" | "edit-text" | "multi-angle" | "move-object" | "mockup" | "adjust" | "expand" | "crop" | "flip-rotate" | null;
|
||||
type ImageActionMode = "quick-edit" | "visual-annotation" | "eraser" | "edit-text" | "multi-angle" | "move-object" | "mockup" | "adjust" | "expand" | "crop" | "flip-rotate" | null;
|
||||
const defaultCropSelection: NormalizedRect = { x: 0, y: 0, width: 1, height: 1 };
|
||||
const imageModelBlueprints = [
|
||||
{
|
||||
@@ -630,6 +631,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const [toolbarVisibility, setToolbarVisibility] = useState<ImageToolbarVisibility>(defaultToolbarVisibility);
|
||||
const [imageActionMode, setImageActionMode] = useState<ImageActionMode>(null);
|
||||
const [quickEditPrompt, setQuickEditPrompt] = useState("");
|
||||
const [visualAnnotationMarks, setVisualAnnotationMarks] = useState<VisualAnnotationMark[]>([]);
|
||||
const [visualAnnotationSubmitting, setVisualAnnotationSubmitting] = useState(false);
|
||||
const [eraserBrushSize, setEraserBrushSize] = useState(42);
|
||||
const [eraserBrushColor, setEraserBrushColor] = useState("#5E6CFF");
|
||||
const [eraserBrushOpacity, setEraserBrushOpacity] = useState(72);
|
||||
@@ -663,7 +666,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
zoom: 50,
|
||||
prompt: ""
|
||||
});
|
||||
const canvasActionWorkActive = canvasActionGenerating || textEditLoading || nodes.some(isCanvasActionWorkingNode);
|
||||
const canvasActionWorkActive = canvasActionGenerating || textEditLoading || visualAnnotationSubmitting || nodes.some(isCanvasActionWorkingNode);
|
||||
const agentTimelineActive = agentGenerating || Boolean(project && !projectHasCanvasActionGeneration(project) && nodes.some(isAgentVisibleGeneratingNode));
|
||||
const mockupPreparing = Boolean(mockupPendingTargetId);
|
||||
const anyGenerating = agentGenerating || imageGeneratorGenerating || canvasActionWorkActive || mockupPreparing;
|
||||
@@ -1147,6 +1150,8 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
useEffect(() => {
|
||||
setImageActionMode(null);
|
||||
setQuickEditPrompt("");
|
||||
setVisualAnnotationMarks([]);
|
||||
setVisualAnnotationSubmitting(false);
|
||||
setEraserMarks([]);
|
||||
setTextEditItems([]);
|
||||
setTextEditLoading(false);
|
||||
@@ -2841,6 +2846,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
applyProject(result.project);
|
||||
startProjectEvents(result.project.id, result.project.lastThreadId, "canvas-action");
|
||||
setImageActionMode(null);
|
||||
setVisualAnnotationMarks([]);
|
||||
setEraserMarks([]);
|
||||
setTextEditItems([]);
|
||||
setTextEditLoading(false);
|
||||
@@ -3086,6 +3092,31 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
void runImageNodeAction("quick-edit", { prompt: quickEditPrompt.trim() });
|
||||
};
|
||||
|
||||
const applyVisualAnnotation = async () => {
|
||||
const targetNode = selectedImageNode;
|
||||
if (!targetNode || visualAnnotationSubmitting || visualAnnotationMarks.length === 0 || visualAnnotationMarks.some((mark) => !mark.instruction.trim())) return;
|
||||
setVisualAnnotationSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const source = await imageNodeRasterBlob(targetNode, "image/png");
|
||||
const guide = await renderVisualAnnotationGuide(source, visualAnnotationMarks);
|
||||
const guideFile = new File([guide], `visual-annotation-${Date.now()}.png`, { type: "image/png" });
|
||||
const annotationImage = await designGateway.uploadImage(guideFile);
|
||||
const instructions = visualAnnotationMarks
|
||||
.map((mark) => `${mark.index}. ${visualAnnotationToolLabel(mark.tool, t)}: ${mark.instruction.trim()}`)
|
||||
.join("\n");
|
||||
await runImageNodeAction("visual-annotation", {
|
||||
prompt: t("visualAnnotationPrompt", { instructions }),
|
||||
annotationImage,
|
||||
selection: JSON.stringify(visualAnnotationPayload(visualAnnotationMarks))
|
||||
});
|
||||
} catch (err) {
|
||||
setError(toUserMessageText(err, "generateError"));
|
||||
} finally {
|
||||
setVisualAnnotationSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openQuickEditPanel = () => {
|
||||
if (!selectedImageNode) return;
|
||||
const stageWidth = canvasStageSize.width;
|
||||
@@ -3743,7 +3774,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
<VectorizedImageToolbar bounds={selectedNodeBounds} leftInset={floatingToolbarLeftInset} onDownload={() => exportImageNode(selectedImageNode, "svg")} />
|
||||
) : (
|
||||
<>
|
||||
{imageActionMode !== "quick-edit" && imageActionMode !== "eraser" && imageActionMode !== "edit-text" && imageActionMode !== "expand" && imageActionMode !== "crop" && imageActionMode !== "flip-rotate" && selectedImageNode.status !== "text-extracting" && mockupPendingTargetId !== selectedImageNode.id && (
|
||||
{imageActionMode !== "quick-edit" && imageActionMode !== "visual-annotation" && imageActionMode !== "eraser" && imageActionMode !== "edit-text" && imageActionMode !== "expand" && imageActionMode !== "crop" && imageActionMode !== "flip-rotate" && selectedImageNode.status !== "text-extracting" && mockupPendingTargetId !== selectedImageNode.id && (
|
||||
<ImageNodeToolbar
|
||||
bounds={selectedNodeBounds}
|
||||
leftInset={floatingToolbarLeftInset}
|
||||
@@ -3751,6 +3782,10 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
visibility={toolbarVisibility}
|
||||
onVisibilityChange={setToolbarVisibility}
|
||||
onQuickEdit={openQuickEditPanel}
|
||||
onVisualAnnotation={() => {
|
||||
setVisualAnnotationMarks([]);
|
||||
setImageActionMode("visual-annotation");
|
||||
}}
|
||||
onUpscale={(scale) =>
|
||||
void runImageNodeAction("upscale", {
|
||||
prompt: t("upscalePrompt", { scale }),
|
||||
@@ -3794,6 +3829,26 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
onSubmit={applyQuickEdit}
|
||||
/>
|
||||
)}
|
||||
{imageActionMode === "visual-annotation" && (
|
||||
<VisualAnnotationPanel
|
||||
bounds={selectedNodeBounds}
|
||||
stageWidth={canvasStageSize.width}
|
||||
stageHeight={canvasStageSize.height}
|
||||
marks={visualAnnotationMarks}
|
||||
models={imageModels}
|
||||
selectedModel={selectedCanvasModel}
|
||||
disabled={selectedImageNodeBusy || visualAnnotationSubmitting}
|
||||
submitting={visualAnnotationSubmitting}
|
||||
onMarksChange={setVisualAnnotationMarks}
|
||||
onModelChange={setSelectedCanvasModelId}
|
||||
onCancel={() => {
|
||||
if (visualAnnotationSubmitting) return;
|
||||
setImageActionMode(null);
|
||||
setVisualAnnotationMarks([]);
|
||||
}}
|
||||
onApply={() => void applyVisualAnnotation()}
|
||||
/>
|
||||
)}
|
||||
{imageActionMode === "eraser" && (
|
||||
<EraserOverlay
|
||||
bounds={selectedNodeBounds}
|
||||
@@ -5738,6 +5793,16 @@ function isImageActionBusy(node: CanvasNode) {
|
||||
return node.status === "generating" || node.status === "uploading" || node.status === "text-extracting" || node.status === "mockup-modeling";
|
||||
}
|
||||
|
||||
function visualAnnotationToolLabel(tool: VisualAnnotationTool, t: (key: string) => string) {
|
||||
const keys: Record<VisualAnnotationTool, string> = {
|
||||
brush: "visualAnnotationBrush",
|
||||
circle: "visualAnnotationCircle",
|
||||
rectangle: "visualAnnotationRectangle",
|
||||
cross: "visualAnnotationCross"
|
||||
};
|
||||
return t(keys[tool]);
|
||||
}
|
||||
|
||||
function isVectorizedImageNode(node: CanvasNode) {
|
||||
return node.type === "image" && node.layerRole === "vectorized-image";
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user