import { Button, Modal, Slider } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Brush, Eraser, Redo2, RotateCcw, Save, Undo2 } from "lucide-react"; import { imageToDataUrl } from "@/services/image-storage"; type Point = { x: number; y: number }; type Stroke = { color: string; size: number; erase: boolean; points: Point[] }; export type CanvasImageAnnotationPayload = { sourceDataUrl: string; annotatedDataUrl: string }; const colors = ["#ef4444", "#f59e0b", "#22c55e", "#14b8a6", "#3b82f6", "#a855f7", "#ffffff", "#111827"]; export function CanvasNodeAnnotationDialog({ image, open, onClose, onConfirm, editMode = false }: { image: { url: string; storageKey?: string }; open: boolean; onClose: () => void; onConfirm: (dataUrl: string | CanvasImageAnnotationPayload) => void; editMode?: boolean; }) { const canvasRef = useRef(null); const sourceImageRef = useRef(null); const drawingRef = useRef(null); const [source, setSource] = useState(""); const [size, setSize] = useState({ width: 0, height: 0 }); const [mode, setMode] = useState<"brush" | "erase">("brush"); const [color, setColor] = useState(colors[0]); const [brushSize, setBrushSize] = useState(18); const [strokes, setStrokes] = useState([]); const [redoStrokes, setRedoStrokes] = useState([]); useEffect(() => { if (!open) return; let cancelled = false; void imageToDataUrl({ url: image.url, storageKey: image.storageKey }).then((dataUrl) => { if (cancelled || !dataUrl) return; const element = new Image(); element.onload = () => { if (cancelled) return; sourceImageRef.current = element; setSource(dataUrl); setSize({ width: element.naturalWidth, height: element.naturalHeight }); setStrokes([]); setRedoStrokes([]); }; element.src = dataUrl; }); return () => { cancelled = true; }; }, [image.storageKey, image.url, open]); useEffect(() => redraw(canvasRef.current, strokes), [size, strokes]); const startDraw = (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); const stroke: Stroke = { color, size: brushSize, erase: mode === "erase", points: [canvasPoint(event.currentTarget, event.clientX, event.clientY)] }; drawingRef.current = stroke; drawStroke(canvasRef.current, stroke); }; const moveDraw = (event: ReactPointerEvent) => { const stroke = drawingRef.current; if (!stroke) return; event.preventDefault(); stroke.points.push(canvasPoint(event.currentTarget, event.clientX, event.clientY)); redraw(canvasRef.current, [...strokes, stroke]); }; const stopDraw = () => { const stroke = drawingRef.current; if (!stroke) return; drawingRef.current = null; setStrokes((current) => [...current, stroke]); setRedoStrokes([]); }; const undo = () => setStrokes((current) => { const last = current.at(-1); if (!last) return current; setRedoStrokes((redo) => [...redo, last]); return current.slice(0, -1); }); const redo = () => setRedoStrokes((current) => { const last = current.at(-1); if (!last) return current; setStrokes((items) => [...items, last]); return current.slice(0, -1); }); const save = () => { const sourceImage = sourceImageRef.current; const annotation = canvasRef.current; if (!sourceImage || !annotation || !strokes.length) return; const output = document.createElement("canvas"); output.width = size.width; output.height = size.height; const context = output.getContext("2d"); if (!context) return; context.drawImage(sourceImage, 0, 0, output.width, output.height); context.drawImage(annotation, 0, 0); const annotatedDataUrl = annotation.toDataURL("image/png"); onConfirm(editMode ? { sourceDataUrl: source, annotatedDataUrl } : output.toDataURL("image/png")); }; return (
{editMode ? "标注编辑" : "标注"} setMode("brush")}> setMode("erase")}>
{colors.map((item) =>
{ setStrokes([]); setRedoStrokes([]); }}>
{source && size.width ? (
待标注图片
) : 正在读取图片...}
); } function ToolButton({ title, active, disabled, children, onClick }: { title: string; active?: boolean; disabled?: boolean; children: ReactNode; onClick: () => void }) { return ; } function canvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number): Point { const rect = canvas.getBoundingClientRect(); return { x: ((clientX - rect.left) / Math.max(1, rect.width)) * canvas.width, y: ((clientY - rect.top) / Math.max(1, rect.height)) * canvas.height }; } function redraw(canvas: HTMLCanvasElement | null, strokes: Stroke[]) { const context = canvas?.getContext("2d"); if (!canvas || !context) return; context.clearRect(0, 0, canvas.width, canvas.height); strokes.forEach((stroke) => drawStroke(canvas, stroke)); } function drawStroke(canvas: HTMLCanvasElement | null, stroke: Stroke) { const context = canvas?.getContext("2d"); if (!context || !stroke.points.length) return; context.save(); context.globalCompositeOperation = stroke.erase ? "destination-out" : "source-over"; context.strokeStyle = stroke.color; context.fillStyle = stroke.color; context.lineWidth = stroke.size; context.lineCap = "round"; context.lineJoin = "round"; const first = stroke.points[0]; if (stroke.points.length === 1) { context.beginPath(); context.arc(first.x, first.y, stroke.size / 2, 0, Math.PI * 2); context.fill(); } else { context.beginPath(); context.moveTo(first.x, first.y); stroke.points.slice(1).forEach((point) => context.lineTo(point.x, point.y)); context.stroke(); } context.restore(); }