From 4a706ed06aa4ff046e2e7ebbf6cbb89ab2e013ab Mon Sep 17 00:00:00 2001 From: Luckyu_js <11670186+luckyu-js@user.noreply.gitee.com> Date: Mon, 13 Jul 2026 19:08:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(annotate):=20=E7=94=BB=E5=B8=83=E7=BA=A7?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E5=B7=A5=E5=85=B7=EF=BC=88Konva=20=E7=9F=A2?= =?UTF-8?q?=E9=87=8F=E5=B0=B1=E5=9C=B0=E7=BC=96=E8=BE=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAnnotationEditor: 组件内矢量状态(reducer + 历史栈),关闭即丢弃 - MediaAnnotationOverlay: 就地 Konva overlay,选择/矩形/圆/箭头/自由/文字, Transformer 逐条选中/拖动/变换,exportComposite 压平图片+标注到 natural 分辨率 - MediaAnnotationToolbar: 浮动胶囊工具条(颜色/线宽/撤销重做/清空/保存) - AnnotationEditSession: 组装 + 键盘快捷键 + 保存流程 - createAnnotationNode: 上传合成图后建派生节点连线,不 regenerate(不进行组运行) Co-Authored-By: Claude Opus 4.8 --- .../media/MediaAnnotationOverlay.tsx | 528 ++++++++++++++++++ .../media/MediaAnnotationToolbar.tsx | 206 +++++++ .../image-edit/AnnotationEditSession.tsx | 142 +++++ .../media/image-edit/useAnnotationEditor.ts | 156 ++++++ .../image-edit/useImageDerivedNodeActions.ts | 31 +- 5 files changed, 1062 insertions(+), 1 deletion(-) create mode 100644 src/components/media/MediaAnnotationOverlay.tsx create mode 100644 src/components/media/MediaAnnotationToolbar.tsx create mode 100644 src/components/media/image-edit/AnnotationEditSession.tsx create mode 100644 src/components/media/image-edit/useAnnotationEditor.ts diff --git a/src/components/media/MediaAnnotationOverlay.tsx b/src/components/media/MediaAnnotationOverlay.tsx new file mode 100644 index 00000000..79cefc87 --- /dev/null +++ b/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; +} + +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; + imageSource: string; + naturalWidth?: number; + naturalHeight?: number; + annotations: AnnotationShape[]; + selectedShapeId: string | null; + currentTool: ToolType; + toolOptions: ToolOptions; + onAddShape: (shape: AnnotationShape) => void; + onUpdateShape: (id: string, updates: Partial) => 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(function MediaAnnotationOverlay({ + mediaElementRef, + imageSource, + naturalWidth, + naturalHeight, + annotations, + selectedShapeId, + currentTool, + toolOptions, + onAddShape, + onUpdateShape, + onSelectShape, + onFrameChange, +}, ref) { + const overlayRef = useRef(null); + const stageRef = useRef(null); + const transformerRef = useRef(null); + const textInputRef = useRef(null); + const drawingRef = useRef(false); + const drawStartRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const textInputCreatedAtRef = useRef(0); + + const [displayImage, setDisplayImage] = useState(null); + const [bounds, setBounds] = useState(null); + const [currentShape, setCurrentShape] = useState(null); + const [editingTextId, setEditingTextId] = useState(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) => { + 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 => { + 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((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); + } + 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) => onUpdateShape(shape.id, { x: event.target.x(), y: event.target.y() }), + }; + + switch (shape.type) { + case "rectangle": + return ; + case "circle": + return ; + case "arrow": + return ; + case "freehand": + return ; + case "text": { + const textWidth = shape.width ?? DEFAULT_TEXT_BOX_WIDTH; + const textHeight = shape.height ?? DEFAULT_TEXT_BOX_HEIGHT; + return ( + { + 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); + }} + 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 ( +
+ {bounds && ( +
event.stopPropagation()} + > + + + {displayImage && } + {annotations.map((shape) => renderShape(shape))} + {currentShape && renderShape(currentShape, true)} + + + +
+ )} + + {editingTextId && bounds && ( + { + 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); + }} + /> + )} +
+ ); +}); diff --git a/src/components/media/MediaAnnotationToolbar.tsx b/src/components/media/MediaAnnotationToolbar.tsx new file mode 100644 index 00000000..3e56e219 --- /dev/null +++ b/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) => 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: }, + { type: "rectangle", title: "矩形", icon: }, + { + type: "circle", + title: "圆形", + icon: , + }, + { + type: "arrow", + title: "箭头", + icon: ( + + ), + }, + { type: "freehand", title: "画笔", icon: }, + { type: "text", title: "文字", icon: }, +]; + +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 = ( +
+ {COLORS.map((color) => ( +
+ ); + + const sizePopover = ( +
+ {STROKE_WIDTHS.map((width) => ( + + ))} +
+ ); + + return ( +
+ + 标注 +
+ + {TOOLS.map((tool) => ( + + + + + + + +
+ + + +
+ ); +} diff --git a/src/components/media/image-edit/AnnotationEditSession.tsx b/src/components/media/image-edit/AnnotationEditSession.tsx new file mode 100644 index 00000000..f179e7d3 --- /dev/null +++ b/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(null); + const editor = useAnnotationEditor(); + const [frameRect, setFrameRect] = useState(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 ( + <> + + selectedShapeId && deleteAnnotation(selectedShapeId)} + onCancel={onClose} + onSave={handleSave} + /> + + ); +} diff --git a/src/components/media/image-edit/useAnnotationEditor.ts b/src/components/media/image-edit/useAnnotationEditor.ts new file mode 100644 index 00000000..99b0b6e2 --- /dev/null +++ b/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 更新函数里 + * 嵌套调用另一个 setState(StrictMode 双调用会导致历史重复入栈)。 + */ + +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 } + | { 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) => void; + deleteAnnotation: (id: string) => void; + clearAnnotations: () => void; + selectShape: (id: string | null) => void; + setCurrentTool: (tool: ToolType) => void; + setToolOptions: (options: Partial) => void; + undo: () => void; + redo: () => void; +} + +export function useAnnotationEditor(): AnnotationEditorState { + const [shapes, dispatch] = useReducer(shapesReducer, INITIAL_SHAPES_STATE); + const [currentTool, setCurrentToolState] = useState("freehand"); + const [toolOptions, setToolOptionsState] = useState(DEFAULT_TOOL_OPTIONS); + + const addAnnotation = useCallback((shape: AnnotationShape) => dispatch({ type: "add", shape }), []); + const updateAnnotation = useCallback( + (id: string, updates: Partial) => 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) => { + 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, + }; +} diff --git a/src/components/media/image-edit/useImageDerivedNodeActions.ts b/src/components/media/image-edit/useImageDerivedNodeActions.ts index ab019585..1d7b7649 100644 --- a/src/components/media/image-edit/useImageDerivedNodeActions.ts +++ b/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; } +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, }; }