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.
760 lines
29 KiB
760 lines
29 KiB
"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<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(() => {
|
|
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<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((): Promise<Blob> => {
|
|
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<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;
|
|
const textWidth = text.width ?? DEFAULT_TEXT_BOX_WIDTH;
|
|
const textHeight = text.height ?? DEFAULT_TEXT_BOX_HEIGHT;
|
|
return (
|
|
<Text
|
|
key={shape.id}
|
|
{...commonProps}
|
|
x={text.x}
|
|
y={text.y}
|
|
text={text.text || " "}
|
|
fontSize={text.fontSize}
|
|
fill={text.fill}
|
|
width={textWidth}
|
|
height={textHeight}
|
|
onTransformEnd={(e) => {
|
|
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 (
|
|
<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">{t("annotation.undo")}</button>
|
|
<button onClick={redo} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-white">{t("annotation.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">{t("annotation.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">
|
|
{t("annotation.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">
|
|
{t("annotation.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">{t("annotation.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">{t("annotation.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"
|
|
}`}
|
|
>
|
|
{t("annotation.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,
|
|
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);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|