"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Stage, Layer, Image as KonvaImage, Rect, Ellipse, Arrow, Line, Text, Transformer } from "react-konva"; import { useAnnotationStore } from "@/store/annotationStore"; import { useWorkflowStore } from "@/store/workflowStore"; import { AnnotationShape, RectangleShape, CircleShape, ArrowShape, FreehandShape, TextShape, ToolType, } from "@/types"; import Konva from "konva"; const COLORS = [ "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#000000", "#ffffff", ]; const STROKE_WIDTHS = [2, 4, 8]; export function AnnotationModal() { const { isModalOpen, sourceNodeId, sourceImage, annotations, selectedShapeId, currentTool, toolOptions, closeModal, addAnnotation, updateAnnotation, deleteAnnotation, clearAnnotations, selectShape, setCurrentTool, setToolOptions, undo, redo, } = useAnnotationStore(); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const stageRef = useRef(null); const transformerRef = useRef(null); const textInputRef = useRef(null); const [image, setImage] = useState(null); const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); const [scale, setScale] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDrawing, setIsDrawing] = useState(false); const [drawStart, setDrawStart] = useState({ x: 0, y: 0 }); const [currentShape, setCurrentShape] = useState(null); const [editingTextId, setEditingTextId] = useState(null); const [textInputPosition, setTextInputPosition] = useState<{ x: number; y: number } | null>(null); const [pendingTextPosition, setPendingTextPosition] = useState<{ x: number; y: number } | null>(null); const textInputCreatedAt = useRef(0); const containerRef = useRef(null); useEffect(() => { if (sourceImage) { const img = new window.Image(); img.onload = () => { setImage(img); if (containerRef.current) { const containerWidth = containerRef.current.clientWidth - 100; const containerHeight = containerRef.current.clientHeight - 100; const scaleX = containerWidth / img.width; const scaleY = containerHeight / img.height; const newScale = Math.min(scaleX, scaleY, 1); setScale(newScale); setStageSize({ width: img.width, height: img.height }); setPosition({ x: (containerWidth - img.width * newScale) / 2 + 50, y: (containerHeight - img.height * newScale) / 2 + 50, }); } }; img.src = sourceImage; } }, [sourceImage]); useEffect(() => { if (transformerRef.current && stageRef.current) { const selectedNode = stageRef.current.findOne(`#${selectedShapeId}`); if (selectedNode && currentTool === "select") { transformerRef.current.nodes([selectedNode]); } else { transformerRef.current.nodes([]); } transformerRef.current.getLayer()?.batchDraw(); } }, [selectedShapeId, currentTool]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isModalOpen) return; if (e.key === "Delete" || e.key === "Backspace") { if (selectedShapeId && !editingTextId) { deleteAnnotation(selectedShapeId); } } if (e.key === "Escape") { if (editingTextId) { setEditingTextId(null); setTextInputPosition(null); setPendingTextPosition(null); } else { closeModal(); } } if (e.ctrlKey || e.metaKey) { if (e.key === "z") { e.preventDefault(); if (e.shiftKey) { redo(); } else { undo(); } } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isModalOpen, selectedShapeId, editingTextId, deleteAnnotation, closeModal, undo, redo]); const getRelativePointerPosition = useCallback(() => { const stage = stageRef.current; if (!stage) return { x: 0, y: 0 }; const transform = stage.getAbsoluteTransform().copy().invert(); const pos = stage.getPointerPosition(); if (!pos) return { x: 0, y: 0 }; return transform.point(pos); }, []); const handleMouseDown = useCallback( (e: Konva.KonvaEventObject) => { if (currentTool === "select") { const clickedOnEmpty = e.target === e.target.getStage() || e.target.getClassName() === "Image"; if (clickedOnEmpty) { selectShape(null); } return; } const pos = getRelativePointerPosition(); setIsDrawing(true); setDrawStart(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": { // Calculate screen position for the input const stage = stageRef.current; if (stage) { const container = stage.container(); const stageBox = container?.getBoundingClientRect(); if (stageBox) { const screenX = stageBox.left + pos.x * scale + position.x; const screenY = stageBox.top + pos.y * scale + position.y; setTextInputPosition({ x: screenX, y: screenY }); setPendingTextPosition({ x: pos.x, y: pos.y }); } } textInputCreatedAt.current = Date.now(); setEditingTextId("new"); setIsDrawing(false); setTimeout(() => textInputRef.current?.focus(), 0); return; } } if (newShape) setCurrentShape(newShape); }, [currentTool, toolOptions, getRelativePointerPosition, selectShape, addAnnotation, scale, position] ); const handleMouseMove = useCallback(() => { if (!isDrawing || !currentShape) return; const pos = getRelativePointerPosition(); switch (currentShape.type) { case "rectangle": { const width = pos.x - drawStart.x; const height = pos.y - drawStart.y; setCurrentShape({ ...currentShape, x: width < 0 ? pos.x : drawStart.x, y: height < 0 ? pos.y : drawStart.y, width: Math.abs(width), height: Math.abs(height) } as RectangleShape); break; } case "circle": { const radiusX = Math.abs(pos.x - drawStart.x) / 2; const radiusY = Math.abs(pos.y - drawStart.y) / 2; setCurrentShape({ ...currentShape, x: (drawStart.x + pos.x) / 2, y: (drawStart.y + pos.y) / 2, radiusX, radiusY } as CircleShape); break; } case "arrow": setCurrentShape({ ...currentShape, points: [0, 0, pos.x - drawStart.x, pos.y - drawStart.y] } as ArrowShape); break; case "freehand": { const freehand = currentShape as FreehandShape; setCurrentShape({ ...freehand, points: [...freehand.points, pos.x - drawStart.x, pos.y - drawStart.y] } as FreehandShape); break; } } }, [isDrawing, currentShape, drawStart, getRelativePointerPosition]); const handleMouseUp = useCallback(() => { if (!isDrawing || !currentShape) return; setIsDrawing(false); let shouldAdd = true; if (currentShape.type === "rectangle") { const rect = currentShape as RectangleShape; shouldAdd = rect.width > 5 && rect.height > 5; } else if (currentShape.type === "circle") { const circle = currentShape as CircleShape; shouldAdd = circle.radiusX > 5 && circle.radiusY > 5; } else if (currentShape.type === "arrow") { const arrow = currentShape as ArrowShape; const dx = arrow.points[2]; const dy = arrow.points[3]; shouldAdd = Math.sqrt(dx * dx + dy * dy) > 10; } if (shouldAdd) addAnnotation(currentShape); setCurrentShape(null); }, [isDrawing, currentShape, addAnnotation]); const handleWheel = useCallback((e: Konva.KonvaEventObject) => { e.evt.preventDefault(); const scaleBy = 1.1; const oldScale = scale; const newScale = e.evt.deltaY > 0 ? oldScale / scaleBy : oldScale * scaleBy; setScale(Math.min(Math.max(newScale, 0.1), 5)); }, [scale]); const flattenImage = useCallback((): string => { const stage = stageRef.current; if (!stage || !image) return ""; const tempStage = new Konva.Stage({ container: document.createElement("div"), width: image.width, height: image.height, }); const tempLayer = new Konva.Layer(); tempStage.add(tempLayer); const konvaImage = new Konva.Image({ image, width: image.width, height: image.height }); tempLayer.add(konvaImage); annotations.forEach((shape) => { let konvaShape: Konva.Shape | null = null; switch (shape.type) { case "rectangle": { const rect = shape as RectangleShape; konvaShape = 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 }); break; } case "circle": { const circle = shape as CircleShape; konvaShape = 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 }); break; } case "arrow": { const arrow = shape as ArrowShape; konvaShape = new Konva.Arrow({ x: arrow.x, y: arrow.y, points: arrow.points, stroke: arrow.stroke, strokeWidth: arrow.strokeWidth, fill: arrow.stroke, opacity: arrow.opacity }); break; } case "freehand": { const freehand = shape as FreehandShape; konvaShape = 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" }); break; } case "text": { const text = shape as TextShape; konvaShape = new Konva.Text({ x: text.x, y: text.y, text: text.text, fontSize: text.fontSize, fill: text.fill, opacity: text.opacity }); break; } } if (konvaShape) tempLayer.add(konvaShape); }); tempLayer.draw(); const dataUrl = tempStage.toDataURL({ pixelRatio: 1 }); tempStage.destroy(); return dataUrl; }, [image, annotations]); const handleDone = useCallback(() => { if (!sourceNodeId) return; const flattenedImage = flattenImage(); updateNodeData(sourceNodeId, { annotations, outputImage: flattenedImage }); closeModal(); }, [sourceNodeId, annotations, flattenImage, updateNodeData, closeModal]); const renderShape = (shape: AnnotationShape, isPreview = false) => { const commonProps = { id: shape.id, opacity: shape.opacity, onClick: () => { if (currentTool === "select") selectShape(shape.id); }, draggable: currentTool === "select" && !isPreview, onDragEnd: (e: Konva.KonvaEventObject) => { updateAnnotation(shape.id, { x: e.target.x(), y: e.target.y() }); }, }; switch (shape.type) { case "rectangle": { const rect = shape as RectangleShape; return ; } case "circle": { const circle = shape as CircleShape; return ; } case "arrow": { const arrow = shape as ArrowShape; return ; } case "freehand": { const freehand = shape as FreehandShape; return ; } case "text": { const text = shape as TextShape; return ( { const node = e.target; const scaleX = node.scaleX(); const scaleY = node.scaleY(); // Reset scale and apply it to fontSize instead node.scaleX(1); node.scaleY(1); const newFontSize = Math.round(text.fontSize * Math.max(scaleX, scaleY)); updateAnnotation(shape.id, { x: node.x(), y: node.y(), fontSize: newFontSize, }); }} onDblClick={() => { if (currentTool === "select") { const stage = stageRef.current; if (stage) { const stageBox = stage.container().getBoundingClientRect(); const screenX = stageBox.left + text.x * scale + position.x; const screenY = stageBox.top + text.y * scale + position.y; setTextInputPosition({ x: screenX, y: screenY }); } setEditingTextId(shape.id); setTimeout(() => textInputRef.current?.focus(), 0); } }} /> ); } } }; if (!isModalOpen) return null; const tools: { type: ToolType; label: string }[] = [ { type: "select", label: "Select" }, { type: "rectangle", label: "Rect" }, { type: "circle", label: "Circle" }, { type: "arrow", label: "Arrow" }, { type: "freehand", label: "Draw" }, { type: "text", label: "Text" }, ]; return (
{/* Top Bar */}
{tools.map((tool) => ( ))}
{/* Canvas Container */}
{ if (e.target === stageRef.current) setPosition({ x: e.target.x(), y: e.target.y() }); }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel} > {image && } {annotations.map((shape) => renderShape(shape))} {currentShape && renderShape(currentShape, true)}
{/* Bottom Options Bar */}
{/* Colors */}
Color {COLORS.map((color) => (
{/* Stroke Width */}
Size {STROKE_WIDTHS.map((width) => ( ))}
{/* Fill Toggle */} {/* Zoom */}
{Math.round(scale * 100)}%
{/* Inline Text Input */} {editingTextId && textInputPosition && ( a.id === editingTextId) as TextShape)?.text || ""} className="fixed z-[110] bg-transparent border-none outline-none" style={{ left: textInputPosition.x, top: textInputPosition.y, fontSize: `${toolOptions.fontSize * scale}px`, color: editingTextId === "new" ? toolOptions.strokeColor : ((annotations.find((a) => a.id === editingTextId) as TextShape)?.fill || toolOptions.strokeColor), minWidth: "100px", caretColor: "white", }} onKeyDown={(e) => { if (e.key === "Enter") { const value = (e.target as HTMLInputElement).value; if (value.trim()) { if (editingTextId === "new" && pendingTextPosition) { // Create new text annotation const newShape: TextShape = { id: `shape-${Date.now()}`, type: "text", x: pendingTextPosition.x, y: pendingTextPosition.y, text: value, fontSize: toolOptions.fontSize, fill: toolOptions.strokeColor, stroke: toolOptions.strokeColor, strokeWidth: toolOptions.strokeWidth, opacity: toolOptions.opacity, }; addAnnotation(newShape); } else { updateAnnotation(editingTextId, { text: value }); } } else if (editingTextId !== "new") { deleteAnnotation(editingTextId); } setEditingTextId(null); setTextInputPosition(null); setPendingTextPosition(null); } if (e.key === "Escape") { if (editingTextId !== "new") { const currentText = (annotations.find((a) => a.id === editingTextId) as TextShape)?.text; if (!currentText) { deleteAnnotation(editingTextId); } } setEditingTextId(null); setTextInputPosition(null); setPendingTextPosition(null); } }} onBlur={(e) => { // Ignore blur events that happen immediately after creation (within 200ms) // This prevents the click that created the input from also triggering blur if (Date.now() - textInputCreatedAt.current < 200) { e.target.focus(); return; } const value = e.target.value; if (value.trim()) { if (editingTextId === "new" && pendingTextPosition) { // Create new text annotation const newShape: TextShape = { id: `shape-${Date.now()}`, type: "text", x: pendingTextPosition.x, y: pendingTextPosition.y, text: value, fontSize: toolOptions.fontSize, fill: toolOptions.strokeColor, stroke: toolOptions.strokeColor, strokeWidth: toolOptions.strokeWidth, opacity: toolOptions.opacity, }; addAnnotation(newShape); } else { updateAnnotation(editingTextId, { text: value }); } } else if (editingTextId !== "new") { deleteAnnotation(editingTextId); } setEditingTextId(null); setTextInputPosition(null); setPendingTextPosition(null); }} /> )}
); }