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