You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

628 lines
24 KiB

"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<Konva.Stage>(null);
const transformerRef = useRef<Konva.Transformer>(null);
const textInputRef = useRef<HTMLInputElement>(null);
const [image, setImage] = useState<HTMLImageElement | null>(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<AnnotationShape | null>(null);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [textInputPosition, setTextInputPosition] = useState<{ x: number; y: number } | null>(null);
const [pendingTextPosition, setPendingTextPosition] = useState<{ x: number; y: number } | null>(null);
const textInputCreatedAt = useRef<number>(0);
const containerRef = useRef<HTMLDivElement>(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<MouseEvent>) => {
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<WheelEvent>) => {
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<DragEvent>) => { updateAnnotation(shape.id, { x: e.target.x(), y: e.target.y() }); },
};
switch (shape.type) {
case "rectangle": {
const rect = shape as RectangleShape;
return <Rect key={shape.id} {...commonProps} x={rect.x} y={rect.y} width={rect.width} height={rect.height} stroke={rect.stroke} strokeWidth={rect.strokeWidth} fill={rect.fill || undefined} />;
}
case "circle": {
const circle = shape as CircleShape;
return <Ellipse key={shape.id} {...commonProps} x={circle.x} y={circle.y} radiusX={circle.radiusX} radiusY={circle.radiusY} stroke={circle.stroke} strokeWidth={circle.strokeWidth} fill={circle.fill || undefined} />;
}
case "arrow": {
const arrow = shape as ArrowShape;
return <Arrow key={shape.id} {...commonProps} x={arrow.x} y={arrow.y} points={arrow.points} stroke={arrow.stroke} strokeWidth={arrow.strokeWidth} fill={arrow.stroke} />;
}
case "freehand": {
const freehand = shape as FreehandShape;
return <Line key={shape.id} {...commonProps} x={freehand.x} y={freehand.y} points={freehand.points} stroke={freehand.stroke} strokeWidth={freehand.strokeWidth} lineCap="round" lineJoin="round" />;
}
case "text": {
const text = shape as TextShape;
return (
<Text
key={shape.id}
{...commonProps}
x={text.x}
y={text.y}
text={text.text || " "}
fontSize={text.fontSize}
fill={text.fill}
onTransformEnd={(e) => {
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 (
<div className="fixed inset-0 z-[100] bg-neutral-950 flex flex-col">
{/* Top Bar */}
<div className="h-14 bg-neutral-900 flex items-center justify-between px-4 border-b border-neutral-800">
<div className="flex items-center gap-1.5">
{tools.map((tool) => (
<button
key={tool.type}
onClick={() => setCurrentTool(tool.type)}
className={`px-3.5 py-1.5 text-xs font-medium rounded transition-colors ${
currentTool === tool.type
? "bg-white text-neutral-900"
: "text-neutral-400 hover:text-white"
}`}
>
{tool.label}
</button>
))}
<div className="w-px h-6 bg-neutral-700 mx-3" />
<button onClick={undo} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-white">Undo</button>
<button onClick={redo} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-white">Redo</button>
<div className="w-px h-6 bg-neutral-700 mx-3" />
<button onClick={clearAnnotations} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-red-400">Clear</button>
</div>
<div className="flex items-center gap-3">
<button onClick={closeModal} className="px-4 py-1.5 text-xs font-medium text-neutral-400 hover:text-white">
Cancel
</button>
<button onClick={handleDone} className="px-4 py-1.5 text-xs font-medium bg-white text-neutral-900 rounded hover:bg-neutral-200">
Done
</button>
</div>
</div>
{/* Canvas Container */}
<div ref={containerRef} className="flex-1 overflow-hidden bg-neutral-900">
<Stage
ref={stageRef}
width={containerRef.current?.clientWidth || 800}
height={containerRef.current?.clientHeight || 600}
scaleX={scale}
scaleY={scale}
x={position.x}
y={position.y}
draggable={currentTool === "select"}
onDragEnd={(e) => { if (e.target === stageRef.current) setPosition({ x: e.target.x(), y: e.target.y() }); }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
>
<Layer>
{image && <KonvaImage image={image} width={stageSize.width} height={stageSize.height} />}
{annotations.map((shape) => renderShape(shape))}
{currentShape && renderShape(currentShape, true)}
<Transformer ref={transformerRef} />
</Layer>
</Stage>
</div>
{/* Bottom Options Bar */}
<div className="h-14 bg-neutral-900 flex items-center justify-center gap-6 px-4 border-t border-neutral-800">
{/* Colors */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-neutral-500 uppercase tracking-wide mr-1">Color</span>
{COLORS.map((color) => (
<button
key={color}
onClick={() => setToolOptions({ strokeColor: color })}
className={`w-6 h-6 rounded-full transition-transform ${
toolOptions.strokeColor === color ? "ring-2 ring-white ring-offset-2 ring-offset-neutral-900 scale-110" : "hover:scale-105"
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="w-px h-6 bg-neutral-700" />
{/* Stroke Width */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-neutral-500 uppercase tracking-wide mr-1">Size</span>
{STROKE_WIDTHS.map((width) => (
<button
key={width}
onClick={() => setToolOptions({ strokeWidth: width })}
className={`w-8 h-8 rounded flex items-center justify-center transition-colors ${
toolOptions.strokeWidth === width ? "bg-neutral-700" : "hover:bg-neutral-800"
}`}
>
<div className="bg-white rounded-full" style={{ width: width * 1.5, height: width * 1.5 }} />
</button>
))}
</div>
<div className="w-px h-6 bg-neutral-700" />
{/* Fill Toggle */}
<button
onClick={() => setToolOptions({ fillColor: toolOptions.fillColor ? null : toolOptions.strokeColor })}
className={`px-3 py-1.5 text-[10px] uppercase tracking-wide rounded transition-colors ${
toolOptions.fillColor ? "bg-neutral-700 text-white" : "text-neutral-500 hover:text-white"
}`}
>
Fill
</button>
{/* Zoom */}
<div className="flex items-center gap-2 ml-auto">
<button onClick={() => setScale(Math.max(scale - 0.1, 0.1))} className="w-7 h-7 rounded text-neutral-400 hover:text-white text-sm">-</button>
<span className="text-[10px] text-neutral-400 w-10 text-center">{Math.round(scale * 100)}%</span>
<button onClick={() => setScale(Math.min(scale + 0.1, 5))} className="w-7 h-7 rounded text-neutral-400 hover:text-white text-sm">+</button>
</div>
</div>
{/* Inline Text Input */}
{editingTextId && textInputPosition && (
<input
ref={textInputRef}
type="text"
autoFocus
defaultValue={editingTextId === "new" ? "" : (annotations.find((a) => 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);
}}
/>
)}
</div>
);
}