Browse Source
- useAnnotationEditor: 组件内矢量状态(reducer + 历史栈),关闭即丢弃 - MediaAnnotationOverlay: 就地 Konva overlay,选择/矩形/圆/箭头/自由/文字, Transformer 逐条选中/拖动/变换,exportComposite 压平图片+标注到 natural 分辨率 - MediaAnnotationToolbar: 浮动胶囊工具条(颜色/线宽/撤销重做/清空/保存) - AnnotationEditSession: 组装 + 键盘快捷键 + 保存流程 - createAnnotationNode: 上传合成图后建派生节点连线,不 regenerate(不进行组运行) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feature/handleReconstruction
5 changed files with 1062 additions and 1 deletions
@ -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> |
|||
); |
|||
}); |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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} |
|||
/> |
|||
</> |
|||
); |
|||
} |
|||
@ -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<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, |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue