diff --git a/src/components/AnnotationModal.tsx b/src/components/AnnotationModal.tsx index d719d771..9ebf3932 100644 --- a/src/components/AnnotationModal.tsx +++ b/src/components/AnnotationModal.tsx @@ -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(null); const transformerRef = useRef(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 ( { 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 (
@@ -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, diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 13c60dd4..7b3cda69 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -1290,7 +1290,6 @@ export function GenerationComposer() { const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : []; const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages; - const primaryReference = referenceImages[0]; const previewReferenceImage = referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null; const canEditReferenceImages = @@ -2121,15 +2120,21 @@ export function GenerationComposer() {
) : (
- {primaryReference ? ( - + {referenceImages.length > 0 ? ( + referenceImages.map((referenceImage, imageIndex) => { + const referenceLabel = `图${imageIndex + 1}`; + return ( + + ); + }) ) : (