Browse Source

feat(annotate): 画布级标注工具(Konva 矢量就地编辑)

- useAnnotationEditor: 组件内矢量状态(reducer + 历史栈),关闭即丢弃
- MediaAnnotationOverlay: 就地 Konva overlay,选择/矩形/圆/箭头/自由/文字,
  Transformer 逐条选中/拖动/变换,exportComposite 压平图片+标注到 natural 分辨率
- MediaAnnotationToolbar: 浮动胶囊工具条(颜色/线宽/撤销重做/清空/保存)
- AnnotationEditSession: 组装 + 键盘快捷键 + 保存流程
- createAnnotationNode: 上传合成图后建派生节点连线,不 regenerate(不进行组运行)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feature/handleReconstruction
Luckyu_js 1 day ago
parent
commit
4a706ed06a
  1. 528
      src/components/media/MediaAnnotationOverlay.tsx
  2. 206
      src/components/media/MediaAnnotationToolbar.tsx
  3. 142
      src/components/media/image-edit/AnnotationEditSession.tsx
  4. 156
      src/components/media/image-edit/useAnnotationEditor.ts
  5. 31
      src/components/media/image-edit/useImageDerivedNodeActions.ts

528
src/components/media/MediaAnnotationOverlay.tsx

@ -0,0 +1,528 @@
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import type { RefObject } from "react";
import { Stage, Layer, Image as KonvaImage, Rect, Ellipse, Arrow, Line, Text, Transformer } from "react-konva";
import Konva from "konva";
import { loadCropImage } from "@/components/media/imageCropUtils";
import type {
AnnotationShape,
ArrowShape,
CircleShape,
FreehandShape,
RectangleShape,
TextShape,
ToolOptions,
ToolType,
} from "@/types";
export interface MediaAnnotationCompositePayload {
blob: Blob;
width: number;
height: number;
}
export interface MediaAnnotationOverlayHandle {
exportComposite: () => Promise<MediaAnnotationCompositePayload | null>;
}
export interface MediaAnnotationFrameRect {
left: number;
top: number;
width: number;
height: number;
}
interface DisplayRect {
left: number;
top: number;
width: number;
height: number;
}
interface MediaAnnotationOverlayProps {
mediaElementRef: RefObject<HTMLImageElement | null>;
imageSource: string;
naturalWidth?: number;
naturalHeight?: number;
annotations: AnnotationShape[];
selectedShapeId: string | null;
currentTool: ToolType;
toolOptions: ToolOptions;
onAddShape: (shape: AnnotationShape) => void;
onUpdateShape: (id: string, updates: Partial<AnnotationShape>) => void;
onSelectShape: (id: string | null) => void;
onFrameChange?: (rect: MediaAnnotationFrameRect | null) => void;
}
const DEFAULT_TEXT_BOX_WIDTH = 180;
const DEFAULT_TEXT_BOX_HEIGHT = 32;
const FIXED_TEXT_FONT_SIZE = 24;
function validDimensions(width: unknown, height: unknown): { width: number; height: number } | null {
return typeof width === "number" && typeof height === "number" && width > 0 && height > 0
? { width, height }
: null;
}
function readImageNaturalDimensions(image: HTMLImageElement | null): { width: number; height: number } | null {
if (!image) return null;
return validDimensions(image.naturalWidth, image.naturalHeight);
}
/** 把某个 shape 从显示空间按比例映射到导出(natural)空间。 */
function scaleShape(shape: AnnotationShape, scaleX: number, scaleY: number): AnnotationShape {
const base = {
...shape,
x: shape.x * scaleX,
y: shape.y * scaleY,
strokeWidth: shape.strokeWidth * Math.max(scaleX, scaleY),
};
switch (shape.type) {
case "rectangle":
return { ...base, width: shape.width * scaleX, height: shape.height * scaleY } as RectangleShape;
case "circle":
return { ...base, radiusX: shape.radiusX * scaleX, radiusY: shape.radiusY * scaleY } as CircleShape;
case "arrow":
return { ...base, points: shape.points.map((point, index) => point * (index % 2 === 0 ? scaleX : scaleY)) } as ArrowShape;
case "freehand":
return { ...base, points: shape.points.map((point, index) => point * (index % 2 === 0 ? scaleX : scaleY)) } as FreehandShape;
case "text":
return {
...base,
fontSize: shape.fontSize * Math.max(scaleX, scaleY),
width: (shape.width ?? DEFAULT_TEXT_BOX_WIDTH) * scaleX,
height: (shape.height ?? DEFAULT_TEXT_BOX_HEIGHT) * scaleY,
} as TextShape;
}
}
function buildKonvaShape(shape: AnnotationShape): Konva.Shape | null {
switch (shape.type) {
case "rectangle": {
const rect = shape as RectangleShape;
return new Konva.Rect({ x: rect.x, y: rect.y, width: rect.width, height: rect.height, stroke: rect.stroke, strokeWidth: rect.strokeWidth, fill: rect.fill || undefined, opacity: rect.opacity });
}
case "circle": {
const circle = shape as CircleShape;
return new Konva.Ellipse({ x: circle.x, y: circle.y, radiusX: circle.radiusX, radiusY: circle.radiusY, stroke: circle.stroke, strokeWidth: circle.strokeWidth, fill: circle.fill || undefined, opacity: circle.opacity });
}
case "arrow": {
const arrow = shape as ArrowShape;
return new Konva.Arrow({ x: arrow.x, y: arrow.y, points: arrow.points, stroke: arrow.stroke, strokeWidth: arrow.strokeWidth, fill: arrow.stroke, opacity: arrow.opacity });
}
case "freehand": {
const freehand = shape as FreehandShape;
return new Konva.Line({ x: freehand.x, y: freehand.y, points: freehand.points, stroke: freehand.stroke, strokeWidth: freehand.strokeWidth, opacity: freehand.opacity, lineCap: "round", lineJoin: "round" });
}
case "text": {
const text = shape as TextShape;
return new Konva.Text({ x: text.x, y: text.y, text: text.text, fontSize: text.fontSize, fill: text.fill, opacity: text.opacity, width: text.width, height: text.height });
}
default:
return null;
}
}
export const MediaAnnotationOverlay = forwardRef<MediaAnnotationOverlayHandle, MediaAnnotationOverlayProps>(function MediaAnnotationOverlay({
mediaElementRef,
imageSource,
naturalWidth,
naturalHeight,
annotations,
selectedShapeId,
currentTool,
toolOptions,
onAddShape,
onUpdateShape,
onSelectShape,
onFrameChange,
}, ref) {
const overlayRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage>(null);
const transformerRef = useRef<Konva.Transformer>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const drawingRef = useRef(false);
const drawStartRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const textInputCreatedAtRef = useRef(0);
const [displayImage, setDisplayImage] = useState<HTMLImageElement | null>(null);
const [bounds, setBounds] = useState<DisplayRect | null>(null);
const [currentShape, setCurrentShape] = useState<AnnotationShape | null>(null);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [pendingTextPosition, setPendingTextPosition] = useState<{ x: number; y: number } | null>(null);
const [naturalDimensions, setNaturalDimensions] = useState<{ width: number; height: number } | null>(() =>
validDimensions(naturalWidth, naturalHeight)
);
// 加载用于回显的图片(走代理避免跨域污染),既作为 Stage 底图也用于导出。
useEffect(() => {
let cancelled = false;
loadCropImage(imageSource)
.then((image) => {
if (cancelled) return;
setDisplayImage(image);
const dims = readImageNaturalDimensions(image);
if (dims) setNaturalDimensions(dims);
})
.catch(() => {
if (!cancelled) setDisplayImage(null);
});
return () => {
cancelled = true;
};
}, [imageSource]);
useEffect(() => {
const explicit = validDimensions(naturalWidth, naturalHeight);
if (explicit) setNaturalDimensions(explicit);
}, [naturalWidth, naturalHeight]);
const measureBounds = useCallback((): DisplayRect | null => {
const overlay = overlayRef.current;
if (!overlay || !naturalDimensions) return null;
const width = overlay.clientWidth;
const height = overlay.clientHeight;
if (width <= 0 || height <= 0) return null;
const scale = Math.min(width / naturalDimensions.width, height / naturalDimensions.height);
const displayWidth = naturalDimensions.width * scale;
const displayHeight = naturalDimensions.height * scale;
return {
left: (width - displayWidth) / 2,
top: (height - displayHeight) / 2,
width: displayWidth,
height: displayHeight,
};
}, [naturalDimensions]);
useEffect(() => {
const overlay = overlayRef.current;
if (!overlay) return;
const update = () => {
const next = measureBounds();
if (!next) return;
setBounds(next);
onFrameChange?.(next);
};
update();
const observer = new ResizeObserver(update);
observer.observe(overlay);
return () => observer.disconnect();
}, [measureBounds, onFrameChange]);
// 选中态变化时把 Transformer 挂到对应节点(仅 select 工具下)。
useEffect(() => {
const transformer = transformerRef.current;
const stage = stageRef.current;
if (!transformer || !stage) return;
if (selectedShapeId && currentTool === "select") {
const node = stage.findOne(`#${selectedShapeId}`);
transformer.nodes(node ? [node] : []);
} else {
transformer.nodes([]);
}
transformer.getLayer()?.batchDraw();
}, [selectedShapeId, currentTool, annotations]);
const getRelativePointerPosition = useCallback(() => {
const stage = stageRef.current;
if (!stage) return { x: 0, y: 0 };
const transform = stage.getAbsoluteTransform().copy().invert();
const pointer = stage.getPointerPosition();
if (!pointer) return { x: 0, y: 0 };
return transform.point(pointer);
}, []);
const handleMouseDown = useCallback((event: Konva.KonvaEventObject<MouseEvent>) => {
if (currentTool === "select") {
const clickedEmpty = event.target === event.target.getStage() || event.target.getClassName() === "Image";
if (clickedEmpty) onSelectShape(null);
return;
}
const pos = getRelativePointerPosition();
drawStartRef.current = pos;
const id = `shape-${Date.now()}`;
const baseShape = {
id,
x: pos.x,
y: pos.y,
stroke: toolOptions.strokeColor,
strokeWidth: toolOptions.strokeWidth,
opacity: toolOptions.opacity,
};
let newShape: AnnotationShape | null = null;
switch (currentTool) {
case "rectangle":
newShape = { ...baseShape, type: "rectangle", width: 0, height: 0, fill: toolOptions.fillColor } as RectangleShape;
break;
case "circle":
newShape = { ...baseShape, type: "circle", radiusX: 0, radiusY: 0, fill: toolOptions.fillColor } as CircleShape;
break;
case "arrow":
newShape = { ...baseShape, type: "arrow", points: [0, 0, 0, 0] } as ArrowShape;
break;
case "freehand":
newShape = { ...baseShape, type: "freehand", points: [0, 0] } as FreehandShape;
break;
case "text":
setPendingTextPosition({ x: pos.x, y: pos.y });
textInputCreatedAtRef.current = Date.now();
setEditingTextId("new");
setTimeout(() => textInputRef.current?.focus(), 0);
return;
}
if (newShape) {
drawingRef.current = true;
setCurrentShape(newShape);
}
}, [currentTool, toolOptions, getRelativePointerPosition, onSelectShape]);
const handleMouseMove = useCallback(() => {
if (!drawingRef.current || !currentShape) return;
const pos = getRelativePointerPosition();
const start = drawStartRef.current;
switch (currentShape.type) {
case "rectangle": {
const width = pos.x - start.x;
const height = pos.y - start.y;
setCurrentShape({ ...currentShape, x: width < 0 ? pos.x : start.x, y: height < 0 ? pos.y : start.y, width: Math.abs(width), height: Math.abs(height) } as RectangleShape);
break;
}
case "circle": {
const radiusX = Math.abs(pos.x - start.x) / 2;
const radiusY = Math.abs(pos.y - start.y) / 2;
setCurrentShape({ ...currentShape, x: (start.x + pos.x) / 2, y: (start.y + pos.y) / 2, radiusX, radiusY } as CircleShape);
break;
}
case "arrow":
setCurrentShape({ ...currentShape, points: [0, 0, pos.x - start.x, pos.y - start.y] } as ArrowShape);
break;
case "freehand": {
const freehand = currentShape as FreehandShape;
setCurrentShape({ ...freehand, points: [...freehand.points, pos.x - start.x, pos.y - start.y] } as FreehandShape);
break;
}
}
}, [currentShape, getRelativePointerPosition]);
const handleMouseUp = useCallback(() => {
if (!drawingRef.current || !currentShape) return;
drawingRef.current = false;
let shouldAdd = true;
if (currentShape.type === "rectangle") {
shouldAdd = currentShape.width > 5 && currentShape.height > 5;
} else if (currentShape.type === "circle") {
shouldAdd = currentShape.radiusX > 5 && currentShape.radiusY > 5;
} else if (currentShape.type === "arrow") {
const [, , dx, dy] = currentShape.points;
shouldAdd = Math.hypot(dx, dy) > 10;
}
if (shouldAdd) onAddShape(currentShape);
setCurrentShape(null);
}, [currentShape, onAddShape]);
const exportComposite = useCallback(async (): Promise<MediaAnnotationCompositePayload | null> => {
const image = displayImage;
if (!image || !bounds) return null;
const naturalW = image.naturalWidth || image.width;
const naturalH = image.naturalHeight || image.height;
if (naturalW <= 0 || naturalH <= 0) return null;
const tempStage = new Konva.Stage({ container: document.createElement("div"), width: naturalW, height: naturalH });
const tempLayer = new Konva.Layer();
tempStage.add(tempLayer);
tempLayer.add(new Konva.Image({ image, width: naturalW, height: naturalH }));
const scaleX = naturalW / bounds.width;
const scaleY = naturalH / bounds.height;
annotations.forEach((annotation) => {
const konvaShape = buildKonvaShape(scaleShape(annotation, scaleX, scaleY));
if (konvaShape) tempLayer.add(konvaShape);
});
tempLayer.draw();
try {
const blob = await new Promise<Blob>((resolve, reject) => {
tempStage.toBlob({
pixelRatio: 1,
callback: (result) => (result ? resolve(result) : reject(new Error("Annotation export failed"))),
});
});
return { blob, width: naturalW, height: naturalH };
} finally {
tempStage.destroy();
}
}, [displayImage, bounds, annotations]);
useImperativeHandle(ref, () => ({ exportComposite }), [exportComposite]);
const commitText = useCallback((rawValue: string) => {
const value = rawValue.trim();
if (editingTextId === "new") {
if (value && pendingTextPosition) {
onAddShape({
id: `shape-${Date.now()}`,
type: "text",
x: pendingTextPosition.x,
y: pendingTextPosition.y,
text: value,
fontSize: FIXED_TEXT_FONT_SIZE,
fill: toolOptions.strokeColor,
width: DEFAULT_TEXT_BOX_WIDTH,
height: DEFAULT_TEXT_BOX_HEIGHT,
stroke: toolOptions.strokeColor,
strokeWidth: toolOptions.strokeWidth,
opacity: toolOptions.opacity,
} as TextShape);
}
} else if (editingTextId) {
onUpdateShape(editingTextId, { text: value } as Partial<TextShape>);
}
setEditingTextId(null);
setPendingTextPosition(null);
}, [editingTextId, pendingTextPosition, toolOptions, onAddShape, onUpdateShape]);
const renderShape = (shape: AnnotationShape, isPreview = false) => {
const commonProps = {
id: shape.id,
opacity: shape.opacity,
onClick: () => { if (currentTool === "select") onSelectShape(shape.id); },
onTap: () => { if (currentTool === "select") onSelectShape(shape.id); },
draggable: currentTool === "select" && !isPreview,
onDragEnd: (event: Konva.KonvaEventObject<DragEvent>) => onUpdateShape(shape.id, { x: event.target.x(), y: event.target.y() }),
};
switch (shape.type) {
case "rectangle":
return <Rect key={shape.id} {...commonProps} x={shape.x} y={shape.y} width={shape.width} height={shape.height} stroke={shape.stroke} strokeWidth={shape.strokeWidth} fill={shape.fill || undefined} />;
case "circle":
return <Ellipse key={shape.id} {...commonProps} x={shape.x} y={shape.y} radiusX={shape.radiusX} radiusY={shape.radiusY} stroke={shape.stroke} strokeWidth={shape.strokeWidth} fill={shape.fill || undefined} />;
case "arrow":
return <Arrow key={shape.id} {...commonProps} x={shape.x} y={shape.y} points={shape.points} stroke={shape.stroke} strokeWidth={shape.strokeWidth} fill={shape.stroke} />;
case "freehand":
return <Line key={shape.id} {...commonProps} x={shape.x} y={shape.y} points={shape.points} stroke={shape.stroke} strokeWidth={shape.strokeWidth} lineCap="round" lineJoin="round" />;
case "text": {
const textWidth = shape.width ?? DEFAULT_TEXT_BOX_WIDTH;
const textHeight = shape.height ?? DEFAULT_TEXT_BOX_HEIGHT;
return (
<Text
key={shape.id}
{...commonProps}
x={shape.x}
y={shape.y}
text={shape.text || " "}
fontSize={shape.fontSize}
fill={shape.fill}
width={textWidth}
height={textHeight}
onTransformEnd={(event) => {
const node = event.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onUpdateShape(shape.id, {
x: node.x(),
y: node.y(),
width: Math.max(20, textWidth * scaleX),
height: Math.max(FIXED_TEXT_FONT_SIZE, textHeight * scaleY),
} as Partial<TextShape>);
}}
onDblClick={() => {
if (currentTool !== "select") return;
setPendingTextPosition({ x: shape.x, y: shape.y });
setEditingTextId(shape.id);
setTimeout(() => textInputRef.current?.focus(), 0);
}}
/>
);
}
}
};
const editingShape = editingTextId && editingTextId !== "new"
? annotations.find((shape): shape is TextShape => shape.id === editingTextId && shape.type === "text")
: null;
const textBoxLeft = (bounds?.left ?? 0) + (pendingTextPosition?.x ?? 0);
const textBoxTop = (bounds?.top ?? 0) + (pendingTextPosition?.y ?? 0);
return (
<div
ref={overlayRef}
className="nodrag nopan nowheel absolute inset-0 z-[10004] touch-none select-none overflow-visible"
data-media-annotation-overlay
>
{bounds && (
<div
className="absolute"
style={{ left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height }}
onPointerDownCapture={(event) => event.stopPropagation()}
>
<Stage
ref={stageRef}
width={bounds.width}
height={bounds.height}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: currentTool === "select" ? "default" : "crosshair" }}
>
<Layer>
{displayImage && <KonvaImage image={displayImage} width={bounds.width} height={bounds.height} listening={false} />}
{annotations.map((shape) => renderShape(shape))}
{currentShape && renderShape(currentShape, true)}
<Transformer ref={transformerRef} rotateEnabled ignoreStroke keepRatio={false} />
</Layer>
</Stage>
</div>
)}
{editingTextId && bounds && (
<input
ref={textInputRef}
type="text"
autoFocus
defaultValue={editingShape?.text ?? ""}
className="absolute z-[10006] border-none bg-transparent p-0 outline-none"
style={{
left: textBoxLeft,
top: textBoxTop,
width: DEFAULT_TEXT_BOX_WIDTH,
height: DEFAULT_TEXT_BOX_HEIGHT,
fontSize: editingShape?.fontSize ?? FIXED_TEXT_FONT_SIZE,
lineHeight: `${DEFAULT_TEXT_BOX_HEIGHT}px`,
color: editingShape?.fill ?? toolOptions.strokeColor,
caretColor: toolOptions.strokeColor,
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitText((event.target as HTMLInputElement).value);
} else if (event.key === "Escape") {
event.preventDefault();
setEditingTextId(null);
setPendingTextPosition(null);
}
}}
onBlur={(event) => {
// 忽略创建瞬间由同一次点击引发的失焦(200ms 内),否则输入框刚出现就被关掉。
if (Date.now() - textInputCreatedAtRef.current < 200) {
event.target.focus();
return;
}
commitText(event.target.value);
}}
/>
)}
</div>
);
});

206
src/components/media/MediaAnnotationToolbar.tsx

@ -0,0 +1,206 @@
"use client";
import type { CSSProperties } from "react";
import { Button, Popover, Tooltip } from "antd";
import {
BorderOutlined,
ClearOutlined,
CloseOutlined,
DeleteOutlined,
EditOutlined,
FontSizeOutlined,
RedoOutlined,
SelectOutlined,
UndoOutlined,
} from "@ant-design/icons";
import type { ToolOptions, ToolType } from "@/types";
interface MediaAnnotationToolbarProps {
frameRect?: { left: number; top: number; width: number; height: number } | null;
currentTool: ToolType;
toolOptions: ToolOptions;
canUndo: boolean;
canRedo: boolean;
hasSelection: boolean;
busy?: boolean;
onToolChange: (tool: ToolType) => void;
onOptionsChange: (options: Partial<ToolOptions>) => void;
onUndo: () => void;
onRedo: () => void;
onClear: () => void;
onDeleteSelected: () => void;
onCancel: () => void;
onSave: () => void;
}
const COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#000000", "#ffffff"];
const STROKE_WIDTHS = [2, 4, 8];
const TOOLS: { type: ToolType; title: string; icon: React.ReactNode }[] = [
{ type: "select", title: "选择", icon: <SelectOutlined /> },
{ type: "rectangle", title: "矩形", icon: <BorderOutlined /> },
{
type: "circle",
title: "圆形",
icon: <span className="inline-block h-3.5 w-3.5 rounded-full border-[1.6px] border-current" />,
},
{
type: "arrow",
title: "箭头",
icon: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 19 19 5m0 0h-8m8 0v8" />
</svg>
),
},
{ type: "freehand", title: "画笔", icon: <EditOutlined /> },
{ type: "text", title: "文字", icon: <FontSizeOutlined /> },
];
export function MediaAnnotationToolbar({
frameRect,
currentTool,
toolOptions,
canUndo,
canRedo,
hasSelection,
busy = false,
onToolChange,
onOptionsChange,
onUndo,
onRedo,
onClear,
onDeleteSelected,
onCancel,
onSave,
}: MediaAnnotationToolbarProps) {
if (!frameRect) return null;
const centerX = frameRect.left + frameRect.width / 2;
const topToolbarStyle = {
left: centerX,
top: frameRect.top,
transform: "translate(-50%, calc(-100% - 10px))",
maxWidth: `min(${Math.max(frameRect.width, 360)}px, calc(100vw - 32px))`,
} as CSSProperties;
const toolButtonClass = (active: boolean) =>
`!h-9 !w-9 !rounded-md ${active ? "!bg-[var(--surface-hover)] !text-[var(--text-primary)]" : "!text-[var(--text-secondary)]"}`;
const colorPopover = (
<div className="flex w-[168px] flex-wrap gap-2 p-1">
{COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={`颜色 ${color}`}
onClick={() => onOptionsChange({ strokeColor: color, fillColor: toolOptions.fillColor ? color : null })}
className={`h-6 w-6 rounded-full border border-[var(--border-subtle)] transition-transform ${
toolOptions.strokeColor === color ? "ring-2 ring-[var(--text-primary)] ring-offset-1 ring-offset-[var(--surface-2)]" : "hover:scale-110"
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
);
const sizePopover = (
<div className="flex items-center gap-2 p-1">
{STROKE_WIDTHS.map((width) => (
<button
key={width}
type="button"
aria-label={`线宽 ${width}`}
onClick={() => onOptionsChange({ strokeWidth: width })}
className={`flex h-8 w-8 items-center justify-center rounded-md transition-colors ${
toolOptions.strokeWidth === width ? "bg-[var(--surface-hover)]" : "hover:bg-[var(--surface-hover)]"
}`}
>
<span className="rounded-full bg-[var(--text-primary)]" style={{ width: width * 1.6, height: width * 1.6 }} />
</button>
))}
</div>
);
return (
<div
className="nodrag nopan nowheel absolute z-[10005] flex items-center gap-1.5 overflow-visible rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] px-3 py-2 text-sm text-[var(--text-primary)] shadow-[var(--shadow-panel)]"
style={topToolbarStyle}
>
<button
type="button"
aria-label="关闭标注工具"
disabled={busy}
onClick={onCancel}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-base text-[var(--text-secondary)] hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)] disabled:opacity-70"
>
<CloseOutlined />
</button>
<span className="shrink-0 text-sm font-medium text-[var(--text-primary)]"></span>
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
{TOOLS.map((tool) => (
<Tooltip key={tool.type} title={tool.title}>
<Button
type="text"
icon={tool.icon}
disabled={busy}
aria-label={tool.title}
onClick={() => onToolChange(tool.type)}
className={toolButtonClass(currentTool === tool.type)}
/>
</Tooltip>
))}
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
<Popover content={colorPopover} trigger="click" placement="bottom">
<button
type="button"
aria-label="颜色"
disabled={busy}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md hover:bg-[var(--surface-hover)] disabled:opacity-70"
>
<span className="h-5 w-5 rounded-full border border-[var(--border-subtle)]" style={{ backgroundColor: toolOptions.strokeColor }} />
</button>
</Popover>
<Popover content={sizePopover} trigger="click" placement="bottom">
<button
type="button"
aria-label="线宽"
disabled={busy}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md hover:bg-[var(--surface-hover)] disabled:opacity-70"
>
<span className="rounded-full bg-[var(--text-primary)]" style={{ width: toolOptions.strokeWidth * 1.6, height: toolOptions.strokeWidth * 1.6 }} />
</button>
</Popover>
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
<Tooltip title="删除选中">
<Button type="text" icon={<DeleteOutlined />} disabled={busy || !hasSelection} onClick={onDeleteSelected} className="!h-9 !w-9 !rounded-md" />
</Tooltip>
<Tooltip title="撤销">
<Button type="text" icon={<UndoOutlined />} disabled={busy || !canUndo} onClick={onUndo} className="!h-9 !w-9 !rounded-md" />
</Tooltip>
<Tooltip title="重做">
<Button type="text" icon={<RedoOutlined />} disabled={busy || !canRedo} onClick={onRedo} className="!h-9 !w-9 !rounded-md" />
</Tooltip>
<Tooltip title="清空">
<Button type="text" icon={<ClearOutlined />} disabled={busy} onClick={onClear} className="!h-9 !w-9 !rounded-md" />
</Tooltip>
<div className="mx-1 h-6 w-px shrink-0 bg-[var(--border-subtle)]" />
<button
type="button"
aria-label="保存标注"
disabled={busy}
onClick={onSave}
className="flex h-9 shrink-0 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--text-primary)] px-4 text-sm font-semibold text-[var(--text-inverse)] transition-colors hover:bg-[var(--text-secondary)] disabled:cursor-wait disabled:bg-[var(--surface-active)] disabled:text-[var(--text-disabled)]"
>
</button>
</div>
);
}

142
src/components/media/image-edit/AnnotationEditSession.tsx

@ -0,0 +1,142 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload";
import {
MediaAnnotationOverlay,
type MediaAnnotationFrameRect,
type MediaAnnotationOverlayHandle,
} from "@/components/media/MediaAnnotationOverlay";
import { MediaAnnotationToolbar } from "@/components/media/MediaAnnotationToolbar";
import { useAnnotationEditor } from "./useAnnotationEditor";
import { useImageDerivedNodeActions } from "./useImageDerivedNodeActions";
import type { ImageEditSessionCommonProps } from "./types";
function isEditableTarget(target: EventTarget | null): boolean {
const element = target as HTMLElement | null;
if (!element) return false;
const tag = element.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || element.isContentEditable;
}
export function AnnotationEditSession({
nodeId,
imageRef,
imageSource,
dimensions,
onClose,
}: ImageEditSessionCommonProps) {
const overlayRef = useRef<MediaAnnotationOverlayHandle>(null);
const editor = useAnnotationEditor();
const [frameRect, setFrameRect] = useState<MediaAnnotationFrameRect | null>(null);
const [busy, setBusy] = useState(false);
const { createAnnotationNode } = useImageDerivedNodeActions({ sourceNodeId: nodeId, imageSource });
const {
annotations,
selectedShapeId,
currentTool,
toolOptions,
canUndo,
canRedo,
addAnnotation,
updateAnnotation,
deleteAnnotation,
clearAnnotations,
selectShape,
setCurrentTool,
setToolOptions,
undo,
redo,
} = editor;
const handleSave = useCallback(() => {
if (busy) return;
setBusy(true);
void (async () => {
const composite = await overlayRef.current?.exportComposite();
if (!composite) {
setBusy(false);
return;
}
onClose();
const uploaded = await uploadBlobToGatewayMedia(
composite.blob,
`annotation-${Date.now()}.png`,
"image/png",
"image"
);
createAnnotationNode({
imageUrl: uploaded.url,
previewUrl: uploaded.previewUrl,
dimensions: { width: composite.width, height: composite.height },
});
})().catch((error) => {
setBusy(false);
alert(error instanceof Error ? error.message : "标注保存失败");
});
}, [busy, createAnnotationNode, onClose]);
// 键盘:删除选中 / 撤销重做 / Esc 关闭。文字输入聚焦时不拦截,交给输入框自身。
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (event.key === "Delete" || event.key === "Backspace") {
if (selectedShapeId) {
event.preventDefault();
deleteAnnotation(selectedShapeId);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) redo();
else undo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedShapeId, deleteAnnotation, onClose, undo, redo]);
return (
<>
<MediaAnnotationOverlay
ref={overlayRef}
mediaElementRef={imageRef}
imageSource={imageSource}
naturalWidth={dimensions?.width}
naturalHeight={dimensions?.height}
annotations={annotations}
selectedShapeId={selectedShapeId}
currentTool={currentTool}
toolOptions={toolOptions}
onAddShape={addAnnotation}
onUpdateShape={updateAnnotation}
onSelectShape={selectShape}
onFrameChange={setFrameRect}
/>
<MediaAnnotationToolbar
frameRect={frameRect}
currentTool={currentTool}
toolOptions={toolOptions}
canUndo={canUndo}
canRedo={canRedo}
hasSelection={Boolean(selectedShapeId)}
busy={busy}
onToolChange={setCurrentTool}
onOptionsChange={setToolOptions}
onUndo={undo}
onRedo={redo}
onClear={clearAnnotations}
onDeleteSelected={() => selectedShapeId && deleteAnnotation(selectedShapeId)}
onCancel={onClose}
onSave={handleSave}
/>
</>
);
}

156
src/components/media/image-edit/useAnnotationEditor.ts

@ -0,0 +1,156 @@
"use client";
import { useCallback, useReducer, useState } from "react";
import type { AnnotationShape, ToolOptions, ToolType } from "@/types";
/**
*
*
* `annotationStore` session hook
* 宿 session
* "保存"
* shape
*
* + + reducer setState
* setStateStrictMode
*/
const DEFAULT_TOOL_OPTIONS: ToolOptions = {
strokeColor: "#f97316", // orange-500
strokeWidth: 4,
fillColor: null,
fontSize: 24,
opacity: 1,
};
interface ShapesState {
annotations: AnnotationShape[];
selectedShapeId: string | null;
history: AnnotationShape[][];
historyIndex: number;
}
type ShapesAction =
| { type: "add"; shape: AnnotationShape }
| { type: "update"; id: string; updates: Partial<AnnotationShape> }
| { type: "delete"; id: string }
| { type: "clear" }
| { type: "select"; id: string | null }
| { type: "deselect" }
| { type: "undo" }
| { type: "redo" };
// 把新的形状列表推进历史栈:截断当前 redo 分支后追加。
function pushHistory(state: ShapesState, next: AnnotationShape[]): ShapesState {
const history = state.history.slice(0, state.historyIndex + 1);
history.push(next);
return { ...state, annotations: next, history, historyIndex: history.length - 1 };
}
function shapesReducer(state: ShapesState, action: ShapesAction): ShapesState {
switch (action.type) {
case "add":
return pushHistory(state, [...state.annotations, action.shape]);
// 拖动/变换的落定更新计入历史(一次手势只在结束时 dispatch 一次)。
case "update": {
const next = state.annotations.map((shape) =>
shape.id === action.id ? { ...shape, ...action.updates } as AnnotationShape : shape
);
return pushHistory(state, next);
}
case "delete": {
const next = state.annotations.filter((shape) => shape.id !== action.id);
const pushed = pushHistory(state, next);
return { ...pushed, selectedShapeId: state.selectedShapeId === action.id ? null : state.selectedShapeId };
}
case "clear":
return state.annotations.length === 0
? state
: { ...pushHistory(state, []), selectedShapeId: null };
case "select":
return { ...state, selectedShapeId: action.id };
case "deselect":
return state.selectedShapeId === null ? state : { ...state, selectedShapeId: null };
case "undo": {
if (state.historyIndex <= 0) return state;
const historyIndex = state.historyIndex - 1;
return { ...state, historyIndex, annotations: [...state.history[historyIndex]], selectedShapeId: null };
}
case "redo": {
if (state.historyIndex >= state.history.length - 1) return state;
const historyIndex = state.historyIndex + 1;
return { ...state, historyIndex, annotations: [...state.history[historyIndex]], selectedShapeId: null };
}
default:
return state;
}
}
const INITIAL_SHAPES_STATE: ShapesState = {
annotations: [],
selectedShapeId: null,
history: [[]],
historyIndex: 0,
};
export interface AnnotationEditorState {
annotations: AnnotationShape[];
selectedShapeId: string | null;
currentTool: ToolType;
toolOptions: ToolOptions;
canUndo: boolean;
canRedo: boolean;
addAnnotation: (shape: AnnotationShape) => void;
updateAnnotation: (id: string, updates: Partial<AnnotationShape>) => void;
deleteAnnotation: (id: string) => void;
clearAnnotations: () => void;
selectShape: (id: string | null) => void;
setCurrentTool: (tool: ToolType) => void;
setToolOptions: (options: Partial<ToolOptions>) => void;
undo: () => void;
redo: () => void;
}
export function useAnnotationEditor(): AnnotationEditorState {
const [shapes, dispatch] = useReducer(shapesReducer, INITIAL_SHAPES_STATE);
const [currentTool, setCurrentToolState] = useState<ToolType>("freehand");
const [toolOptions, setToolOptionsState] = useState<ToolOptions>(DEFAULT_TOOL_OPTIONS);
const addAnnotation = useCallback((shape: AnnotationShape) => dispatch({ type: "add", shape }), []);
const updateAnnotation = useCallback(
(id: string, updates: Partial<AnnotationShape>) => dispatch({ type: "update", id, updates }),
[]
);
const deleteAnnotation = useCallback((id: string) => dispatch({ type: "delete", id }), []);
const clearAnnotations = useCallback(() => dispatch({ type: "clear" }), []);
const selectShape = useCallback((id: string | null) => dispatch({ type: "select", id }), []);
const undo = useCallback(() => dispatch({ type: "undo" }), []);
const redo = useCallback(() => dispatch({ type: "redo" }), []);
const setCurrentTool = useCallback((tool: ToolType) => {
setCurrentToolState(tool);
dispatch({ type: "deselect" });
}, []);
const setToolOptions = useCallback((options: Partial<ToolOptions>) => {
setToolOptionsState((prev) => ({ ...prev, ...options }));
}, []);
return {
annotations: shapes.annotations,
selectedShapeId: shapes.selectedShapeId,
currentTool,
toolOptions,
canUndo: shapes.historyIndex > 0,
canRedo: shapes.historyIndex < shapes.history.length - 1,
addAnnotation,
updateAnnotation,
deleteAnnotation,
clearAnnotations,
selectShape,
setCurrentTool,
setToolOptions,
undo,
redo,
};
}

31
src/components/media/image-edit/useImageDerivedNodeActions.ts

@ -12,7 +12,7 @@ import { defaultNodeDimensions } from "@/constants/nodeDimensions";
import { useWorkflowStore } from "@/store/workflowStore";
import type { SmartImageNodeData } from "@/types";
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes";
import { createPendingDerivedImageNode, createDerivedImageNode } from "@/utils/derivedImageNodes";
import { createSplitGridImageNodes } from "@/utils/splitGridNodes";
import { readHighDefinitionSourceImageDimensions } from "@/utils/highDefinitionNodes";
import { deriveOutpaintingOutputDimensions, OUTPAINTING_DEFAULT_PROMPT } from "@/utils/outpaintingNodes";
@ -42,6 +42,12 @@ interface CreateSplitGridNodesOptions {
selectedCellKeys: Set<string>;
}
interface CreateAnnotationNodeOptions {
imageUrl: string;
previewUrl?: string;
dimensions: { width: number; height: number } | null;
}
function getNodeWidth(node: { style?: { width?: unknown } | undefined }) {
if (typeof node.style?.width === "number") return node.style.width;
return defaultNodeDimensions.imageInput.width;
@ -191,10 +197,33 @@ export function useImageDerivedNodeActions({
return createdNodeIds;
}, [addNode, getNodeById, onConnect, regenerateNode, selectSingleNode, sourceNodeId]);
// 标注:合成图已在 overlay 里压平并上传,这里直接建带图的派生节点、连边选中。
// 刻意不调用 regenerateNode —— 不触发任何生成/组运行。
const createAnnotationNode = useCallback(({ imageUrl, previewUrl, dimensions }: CreateAnnotationNodeOptions) => {
const placement = getSourcePlacement();
if (!placement) return null;
const size = dimensions ? resolveMediaNodeCreationSizeFromDimensions(dimensions) ?? undefined : undefined;
return createDerivedImageNode({
sourceNodeId,
position: placement.position,
image: imageUrl,
previewImage: previewUrl,
filename: `annotation-${Date.now()}.png`,
dimensions,
customTitle: "标注",
operation: "annotate",
size,
addNode,
onConnect,
selectSingleNode,
});
}, [addNode, getSourcePlacement, onConnect, selectSingleNode, sourceNodeId]);
return {
createCropNode,
createOutpaintingNode,
createInpaintingNode,
createSplitGridNodes,
createAnnotationNode,
};
}

Loading…
Cancel
Save