|
|
|
@ -1,9 +1,10 @@ |
|
|
|
"use client"; |
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"; |
|
|
|
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 { useI18n } from "@/i18n"; |
|
|
|
import { |
|
|
|
@ -13,6 +14,7 @@ import { |
|
|
|
ArrowShape, |
|
|
|
FreehandShape, |
|
|
|
TextShape, |
|
|
|
NodeType, |
|
|
|
ToolType, |
|
|
|
} from "@/types"; |
|
|
|
import Konva from "konva"; |
|
|
|
@ -29,6 +31,40 @@ const COLORS = [ |
|
|
|
]; |
|
|
|
|
|
|
|
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 }; |
|
|
|
@ -62,6 +98,25 @@ export function AnnotationModal() { |
|
|
|
} = 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?.width) ?? |
|
|
|
readPositiveDimension(sourceNode?.style?.width) ?? |
|
|
|
readPositiveDimension(sourceNode?.measured?.width) ?? |
|
|
|
defaults.width, |
|
|
|
height: |
|
|
|
readPositiveDimension(sourceNode?.height) ?? |
|
|
|
readPositiveDimension(sourceNode?.style?.height) ?? |
|
|
|
readPositiveDimension(sourceNode?.measured?.height) ?? |
|
|
|
defaults.height, |
|
|
|
}; |
|
|
|
}, [nodes, sourceNodeId]); |
|
|
|
|
|
|
|
const stageRef = useRef<Konva.Stage>(null); |
|
|
|
const transformerRef = useRef<Konva.Transformer>(null); |
|
|
|
@ -95,16 +150,13 @@ export function AnnotationModal() { |
|
|
|
if (cancelled) return; |
|
|
|
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 }); |
|
|
|
const containerWidth = containerRef.current.clientWidth; |
|
|
|
const containerHeight = containerRef.current.clientHeight; |
|
|
|
setScale(1); |
|
|
|
setStageSize(nodeDisplaySize); |
|
|
|
setPosition({ |
|
|
|
x: (containerWidth - img.width * newScale) / 2 + 50, |
|
|
|
y: (containerHeight - img.height * newScale) / 2 + 50, |
|
|
|
x: (containerWidth - nodeDisplaySize.width) / 2, |
|
|
|
y: (containerHeight - nodeDisplaySize.height) / 2, |
|
|
|
}); |
|
|
|
} |
|
|
|
}; |
|
|
|
@ -123,7 +175,7 @@ export function AnnotationModal() { |
|
|
|
cancelled = true; |
|
|
|
revokeImageSource?.(); |
|
|
|
}; |
|
|
|
}, [sourceImage]); |
|
|
|
}, [sourceImage, nodeDisplaySize]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (transformerRef.current && stageRef.current) { |
|
|
|
@ -317,7 +369,11 @@ export function AnnotationModal() { |
|
|
|
const konvaImage = new Konva.Image({ image, width: image.width, height: image.height }); |
|
|
|
tempLayer.add(konvaImage); |
|
|
|
|
|
|
|
annotations.forEach((shape) => { |
|
|
|
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": { |
|
|
|
@ -342,7 +398,7 @@ export function AnnotationModal() { |
|
|
|
} |
|
|
|
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 }); |
|
|
|
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; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -353,7 +409,7 @@ export function AnnotationModal() { |
|
|
|
const dataUrl = tempStage.toDataURL({ pixelRatio: 1 }); |
|
|
|
tempStage.destroy(); |
|
|
|
return dataUrl; |
|
|
|
}, [image, annotations]); |
|
|
|
}, [image, annotations, stageSize]); |
|
|
|
|
|
|
|
const handleDone = useCallback(() => { |
|
|
|
if (!sourceNodeId) return; |
|
|
|
@ -390,6 +446,8 @@ export function AnnotationModal() { |
|
|
|
} |
|
|
|
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} |
|
|
|
@ -399,18 +457,19 @@ export function AnnotationModal() { |
|
|
|
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(); |
|
|
|
// 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, |
|
|
|
width: Math.max(20, textWidth * scaleX), |
|
|
|
height: Math.max(FIXED_TEXT_FONT_SIZE, textHeight * scaleY), |
|
|
|
}); |
|
|
|
}} |
|
|
|
onDblClick={() => { |
|
|
|
@ -442,6 +501,12 @@ export function AnnotationModal() { |
|
|
|
{ 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"> |
|
|
|
@ -575,9 +640,14 @@ export function AnnotationModal() { |
|
|
|
style={{ |
|
|
|
left: textInputPosition.x, |
|
|
|
top: textInputPosition.y, |
|
|
|
fontSize: `${toolOptions.fontSize * scale}px`, |
|
|
|
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: "100px", |
|
|
|
minWidth: `${DEFAULT_TEXT_BOX_WIDTH * scale}px`, |
|
|
|
padding: 0, |
|
|
|
boxSizing: "border-box", |
|
|
|
caretColor: "white", |
|
|
|
}} |
|
|
|
onKeyDown={(e) => { |
|
|
|
@ -592,8 +662,10 @@ export function AnnotationModal() { |
|
|
|
x: pendingTextPosition.x, |
|
|
|
y: pendingTextPosition.y, |
|
|
|
text: value, |
|
|
|
fontSize: toolOptions.fontSize, |
|
|
|
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, |
|
|
|
@ -639,8 +711,10 @@ export function AnnotationModal() { |
|
|
|
x: pendingTextPosition.x, |
|
|
|
y: pendingTextPosition.y, |
|
|
|
text: value, |
|
|
|
fontSize: toolOptions.fontSize, |
|
|
|
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, |
|
|
|
|