"use client"; import { useCallback, useEffect, useMemo, 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 { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { isHttpMediaUrl } from "@/utils/mediaResolver"; import { uploadBlobToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { useI18n } from "@/i18n"; import { AnnotationShape, RectangleShape, CircleShape, ArrowShape, FreehandShape, TextShape, NodeType, ToolType, } from "@/types"; import Konva from "konva"; const COLORS = [ "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#000000", "#ffffff", ]; const STROKE_WIDTHS = [2, 4, 8]; const DEFAULT_TEXT_BOX_WIDTH = 180; const DEFAULT_TEXT_BOX_HEIGHT = 32; const FIXED_TEXT_FONT_SIZE = 24; function readPositiveDimension(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; } 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; } } async function getCanvasSafeImageSource(source: string): Promise<{ src: string; revoke?: () => void }> { if (!isHttpMediaUrl(source)) return { src: source }; const response = await fetch(`/api/proxy-media?url=${encodeURIComponent(source)}`); if (!response.ok) throw new Error(`Failed to load image through proxy: ${response.status}`); const objectUrl = URL.createObjectURL(await response.blob()); return { src: objectUrl, revoke: () => URL.revokeObjectURL(objectUrl) }; } export function AnnotationModal() { const { t } = useI18n(); 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 nodes = useWorkflowStore((state) => state.nodes ?? []); const nodeDisplaySize = useMemo(() => { const sourceNode = sourceNodeId ? nodes.find((node) => node.id === sourceNodeId) : null; const nodeType = sourceNode?.type as NodeType | undefined; const defaults = nodeType ? defaultNodeDimensions[nodeType] : { width: 800, height: 600 }; return { width: readPositiveDimension(sourceNode?.style?.width) ?? defaults.width, height: readPositiveDimension(sourceNode?.style?.height) ?? defaults.height, }; }, [nodes, sourceNodeId]); 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(() => { return () => { closeModal(); }; }, [closeModal]); useEffect(() => { let cancelled = false; let revokeImageSource: (() => void) | undefined; if (sourceImage) { getCanvasSafeImageSource(sourceImage).then(({ src, revoke }) => { if (cancelled) { revoke?.(); return; } revokeImageSource = revoke; const img = new window.Image(); img.onload = () => { if (cancelled) return; setImage(img); if (containerRef.current) { const containerWidth = containerRef.current.clientWidth; const containerHeight = containerRef.current.clientHeight; setScale(1); setStageSize(nodeDisplaySize); setPosition({ x: (containerWidth - nodeDisplaySize.width) / 2, y: (containerHeight - nodeDisplaySize.height) / 2, }); } }; img.onerror = () => { if (!cancelled) setImage(null); }; img.src = src; }).catch(() => { if (!cancelled) setImage(null); }); } else { setImage(null); } return () => { cancelled = true; revokeImageSource?.(); }; }, [sourceImage, nodeDisplaySize]); 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((): Promise => { const stage = stageRef.current; if (!stage || !image) return Promise.reject(new Error("No image to annotate")); 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); const scaleX = image.width / stageSize.width; const scaleY = image.height / stageSize.height; annotations.forEach((annotation) => { const shape = scaleShape(annotation, scaleX, scaleY); 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, width: text.width, height: text.height }); break; } } if (konvaShape) tempLayer.add(konvaShape); }); tempLayer.draw(); return new Promise((resolve, reject) => { tempStage.toBlob({ pixelRatio: 1, callback: (blob) => { tempStage.destroy(); if (blob) { resolve(blob); return; } reject(new Error("Annotation image export failed")); }, }); }); }, [image, annotations, stageSize]); const handleDone = useCallback(async () => { if (!sourceNodeId) return; try { const flattenedImage = await flattenImage(); const uploaded = await uploadBlobToGatewayMedia( flattenedImage, `annotation-${Date.now()}.png`, "image/png", "image" ); updateNodeData(sourceNodeId, { annotations, outputImage: uploaded.url, outputImageRef: undefined }); closeModal(); } catch (error) { alert(error instanceof Error ? error.message : "Annotation image upload failed"); } }, [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; const textWidth = text.width ?? DEFAULT_TEXT_BOX_WIDTH; const textHeight = text.height ?? DEFAULT_TEXT_BOX_HEIGHT; return ( { const node = e.target; const scaleX = node.scaleX(); const scaleY = node.scaleY(); node.scaleX(1); node.scaleY(1); updateAnnotation(shape.id, { x: node.x(), y: node.y(), width: Math.max(20, textWidth * scaleX), height: Math.max(FIXED_TEXT_FONT_SIZE, textHeight * scaleY), }); }} 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: t("annotation.tool.select") }, { type: "rectangle", label: t("annotation.tool.rectangle") }, { type: "circle", label: t("annotation.tool.circle") }, { type: "arrow", label: t("annotation.tool.arrow") }, { type: "freehand", label: t("annotation.tool.draw") }, { type: "text", label: t("annotation.tool.text") }, ]; const editingText = editingTextId && editingTextId !== "new" ? annotations.find((annotation): annotation is TextShape => annotation.id === editingTextId && annotation.type === "text") : null; const editingTextWidth = (editingText?.width ?? DEFAULT_TEXT_BOX_WIDTH) * scale; const editingTextHeight = (editingText?.height ?? DEFAULT_TEXT_BOX_HEIGHT) * scale; const editingFontSize = (editingText?.fontSize ?? FIXED_TEXT_FONT_SIZE) * scale; 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 */}
{t("annotation.color")} {COLORS.map((color) => (
{/* Stroke Width */}
{t("annotation.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, width: `${editingTextWidth}px`, height: `${editingTextHeight}px`, fontSize: `${editingFontSize}px`, lineHeight: `${editingTextHeight}px`, color: editingTextId === "new" ? toolOptions.strokeColor : ((annotations.find((a) => a.id === editingTextId) as TextShape)?.fill || toolOptions.strokeColor), minWidth: `${DEFAULT_TEXT_BOX_WIDTH * scale}px`, padding: 0, boxSizing: "border-box", 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: 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, }; 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: 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, }; addAnnotation(newShape); } else { updateAnnotation(editingTextId, { text: value }); } } else if (editingTextId !== "new") { deleteAnnotation(editingTextId); } setEditingTextId(null); setTextInputPosition(null); setPendingTextPosition(null); }} /> )}
); }