diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index d64ad93..bbd365f 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; -import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type } from "lucide-react"; +import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Eraser, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -218,6 +218,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [annotatorOpen, setAnnotatorOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [textEditPanelOpen, setTextEditPanelOpen] = useState(false); + const [notePanelOpen, setNotePanelOpen] = useState(false); const [textEditItems, setTextEditItems] = useState( () => normalizeTextEditItems(data.textEditItems), ); @@ -227,6 +228,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [deletedTextItems, setDeletedTextItems] = useState>([]); const [isEditingTitle, setIsEditingTitle] = useState(false); const [draftTitle, setDraftTitle] = useState(""); + const [draftNote, setDraftNote] = useState(""); // 文件输入的 ref const fileInputRef = useRef(null); @@ -271,6 +273,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { setTextEditPanelOpen(false); setIsEditingTitle(false); setPromptDialogOpen(false); + setNotePanelOpen(false); } }, [isSelected]); @@ -285,6 +288,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { setRegeneratePrompt(data.prompt || ""); } }, [data.prompt, promptDialogOpen]); + + useEffect(() => { + if (notePanelOpen) { + setDraftNote(data.note || ""); + } + }, [data.note, notePanelOpen]); // 🆕 处理图片点击标注 const handlePickerClick = useCallback(async (e: React.MouseEvent) => { @@ -359,11 +368,22 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { // 获取工具栏位置 - 在渲染时直接计算 const getToolbarPosition = () => { - if (containerRef.current) { + if (containerRef.current && typeof window !== "undefined") { const rect = containerRef.current.getBoundingClientRect(); + const toolbarHeight = 40; + const titleHeight = 28; + const gap = 10; + let top = rect.top - toolbarHeight - titleHeight - gap; + if (top < 12) { + top = rect.top + 8; + } + + const minLeft = 80; + const maxLeft = window.innerWidth - 80; + const left = Math.min(Math.max(rect.left + rect.width / 2, minLeft), maxLeft); return { - top: rect.top - 50, - left: rect.left + rect.width / 2, + top, + left, }; } return { top: 0, left: 0 }; @@ -388,6 +408,25 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { return { top, left }; }; + const getNotePanelPosition = () => { + if (!containerRef.current || typeof window === "undefined") { + return { top: 0, left: 0 }; + } + + const rect = containerRef.current.getBoundingClientRect(); + const panelWidth = 300; + const spacing = 12; + const maxTop = Math.max(12, window.innerHeight - 360); + + let left = rect.right + spacing; + if (left + panelWidth > window.innerWidth - 12) { + left = Math.max(12, rect.left - panelWidth - spacing); + } + + const top = Math.min(Math.max(12, rect.top + 8), maxTop); + return { top, left }; + }; + const displayTitle = getImageNodeTitle(data, node.id); const handleSaveTitle = useCallback(() => { @@ -403,6 +442,24 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { toast.success("图片名称已更新"); }, [data, displayTitle, draftTitle, node.id, updateNode]); + const handleOpenNotePanel = useCallback(() => { + setDraftNote(data.note || ""); + setNotePanelOpen(true); + }, [data.note]); + + const handleSaveNote = useCallback(() => { + const nextNote = draftNote.trim(); + updateNode(node.id, { + data: { + ...data, + note: nextNote || undefined, + }, + }); + setDraftNote(nextNote); + setNotePanelOpen(false); + toast.success(nextNote ? "备注已保存" : "备注已清除"); + }, [data, draftNote, node.id, updateNode]); + const detectTextItems = useCallback( async (forceRefresh: boolean = false) => { if (!data.src) { @@ -710,6 +767,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { 'scene-fusion': '详情替换', 'annotate': '图片标注', 'text-edit': '编辑文字', + 'note': '图片备注', 'prompt-regenerate': '提示词重绘', }; return names[action] || action; @@ -747,6 +805,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { await handleOpenTextEdit(); return; } + if (action === "note") { + handleOpenNotePanel(); + return; + } if (action === "prompt-regenerate") { setRegeneratePrompt(data.prompt || ""); setPromptDialogOpen(true); @@ -1330,6 +1392,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { 编辑文字 + handleAction('note')}> + + 图片备注 + { setDraftTitle(displayTitle); @@ -1362,6 +1428,16 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {

{data.prompt}

)} + + {data.note && !isSelected && ( +
+ + 备注 +
+ )} {/* 角度编辑对话框 */} @@ -1465,7 +1541,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { {/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */} - {isSelected && data.src && typeof document !== 'undefined' && createPortal( + {isSelected && data.src && !isEditingTitle && typeof document !== 'undefined' && createPortal(
编辑文字 + {/* 图片备注 */} + + + + + 图片备注 + + {/* 提示词重绘 */} @@ -1718,6 +1813,65 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { document.body )} + {/* 图片备注面板 */} + {isSelected && notePanelOpen && typeof document !== "undefined" && createPortal( +
e.stopPropagation()} + > +
+
+ + 图片备注 +
+ +
+ +
+