From 6cf05972275cc6d0f2be9584b383199db449eea5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 8 Feb 2026 11:52:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=BB=E5=B8=83=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=B9=B6=E6=94=AF=E6=8C=81=E5=9B=BE=E7=89=87=E9=87=8D?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(chat)/canvas/page.tsx | 48 +++- app/canvas/[id]/page.tsx | 21 +- components/canvas/CanvasChatPanel.tsx | 136 +++++------ components/canvas/CanvasWorkspace.tsx | 312 +++++++++++++++++++++----- components/canvas/nodes/ImageNode.tsx | 115 +++++++++- types/canvas/index.ts | 1 + 6 files changed, 493 insertions(+), 140 deletions(-) diff --git a/app/(chat)/canvas/page.tsx b/app/(chat)/canvas/page.tsx index ede75e562..894202067 100644 --- a/app/(chat)/canvas/page.tsx +++ b/app/(chat)/canvas/page.tsx @@ -1,24 +1,58 @@ "use client"; import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { Loader2 } from "lucide-react"; -// 自动重定向到新画布 +// 默认进入最近画布,避免误触直接新建 export default function CanvasRedirectPage() { const router = useRouter(); + const searchParams = useSearchParams(); useEffect(() => { - // 生成新的画布 ID 并跳转 - const newId = crypto.randomUUID(); - router.replace(`/canvas/${newId}`); - }, [router]); + const shouldCreateNew = searchParams.get("new") === "1"; + + const openCanvas = async () => { + if (shouldCreateNew) { + const newId = crypto.randomUUID(); + router.replace(`/canvas/${newId}`); + return; + } + + try { + const response = await fetch("/api/canvas", { cache: "no-store" }); + if (response.ok) { + const result = await response.json(); + const items = result?.data || []; + if (Array.isArray(items) && items.length > 0) { + const sorted = [...items].sort((a, b) => { + const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime(); + const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime(); + return bTime - aTime; + }); + const latest = sorted[0]; + if (latest?.id) { + router.replace(`/canvas/${latest.id}`); + return; + } + } + } + } catch (_error) { + // ignore and fallback + } + + const newId = crypto.randomUUID(); + router.replace(`/canvas/${newId}`); + }; + + void openCanvas(); + }, [router, searchParams]); return (
- 正在创建新画布... + 正在打开画布...
); diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 69e6f167d..a3a253311 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -157,7 +157,7 @@ export default function CanvasEditorPage({ params }: PageProps) { useCanvasStore.getState().setProject(id, '未命名画布'); // 保存新画布到数据库 try { - await canvasService.saveCanvasState(id, { nodes: [], connections: [] }); + await canvasService.saveCanvasState(id, { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select' }); } catch (error) { console.error('[Canvas] 创建新画布失败:', error); } @@ -272,6 +272,7 @@ export default function CanvasEditorPage({ params }: PageProps) { data: { src: '', status: 'pending', + title: '新图片', prompt: '', }, connections: [], @@ -337,6 +338,7 @@ export default function CanvasEditorPage({ params }: PageProps) { data: { src: '', status: 'generating', + title: prompt.slice(0, 24) || '生成中', prompt: prompt, }, connections: [], @@ -361,6 +363,7 @@ export default function CanvasEditorPage({ params }: PageProps) { data: { src: image.originalUrl || image.thumbnailUrl, status: 'completed', + title: `${prompt.slice(0, 20) || '生成图片'}-${index + 1}`, prompt: prompt, }, }); @@ -376,6 +379,7 @@ export default function CanvasEditorPage({ params }: PageProps) { data: { src: '', status: 'failed', + title: '生成失败', prompt: prompt, }, }); @@ -390,6 +394,7 @@ export default function CanvasEditorPage({ params }: PageProps) { data: { src: '', status: 'failed', + title: '生成失败', prompt: prompt, }, }); @@ -596,6 +601,20 @@ export default function CanvasEditorPage({ params }: PageProps) { 平移 (H) + + + + + 新建画布 + + {/* 快捷工具 - 仅在选中图片时显示 */} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index dea85e02d..d00d6e533 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -134,6 +134,12 @@ interface PickedObjectAttachment extends Attachment { }; } +interface CanvasImageAttachment { + url: string; + name: string; + contentType: string; +} + export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) { const router = useRouter(); const [input, setInput] = useState(""); @@ -697,77 +703,80 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF // 标记是否已经为当前画布设置过标题(避免重复调用) const hasSetCanvasTitle = useRef(false); - // 获取最近的图片 URL(支持多张图片) - const getRecentImageUrls = useCallback((): string[] => { - const urls: string[] = []; - const addedUrls = new Set(); // 用于去重 - - // 辅助函数:添加图片 URL 并去重 - const addUrl = (url: string) => { - if (url && !addedUrls.has(url)) { - addedUrls.add(url); - urls.push(url); - } + // 获取最近的图片附件(支持多张图片与名称同步) + const getRecentImageAttachments = useCallback((): CanvasImageAttachment[] => { + const result: CanvasImageAttachment[] = []; + const addedUrls = new Set(); + + const addAttachment = (item: CanvasImageAttachment) => { + if (!item.url || addedUrls.has(item.url)) return; + addedUrls.add(item.url); + result.push(item); }; - + + const getNodeName = (node: any, index: number) => { + return ( + node?.data?.title || + node?.data?.prompt || + `图片 ${index + 1}` + ); + }; + // 1. 优先使用画布中选中的图片(支持多选) if (selectedImageNodes.length > 0) { - selectedImageNodes.forEach(node => { + selectedImageNodes.forEach((node, index) => { const src = (node.data as any)?.src; - if (src) addUrl(src); + if (src) { + addAttachment({ + url: src, + name: getNodeName(node, index), + contentType: "image/jpeg", + }); + } }); - // 如果选中了图片,只返回选中的 - if (urls.length > 0) { - console.log('[CanvasChat] 使用选中的图片:', urls.length, '张'); - return urls; + if (result.length > 0) { + console.log("[CanvasChat] 使用选中的图片:", result.length, "张"); + return result; } } - - // 2. 其次从历史对话中查找最近一次消息的所有图片 - for (let i = messages.length - 1; i >= 0; i--) { + + // 2. 历史对话最近一轮图片 + for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i]; - const msgUrls: string[] = []; - - // 检查用户消息的附件 - if (msg.role === 'user' && (msg as any).attachments?.length > 0) { - for (const attachment of (msg as any).attachments) { - if (attachment.url && attachment.contentType?.startsWith('image/')) { - msgUrls.push(attachment.url); - } - } - } - - // 检查用户消息的 parts - if (msg.role === 'user' && (msg as any).parts) { + const msgAttachments: CanvasImageAttachment[] = []; + if (msg.role === "user" && (msg as any).parts) { for (const part of (msg as any).parts) { - if (part.type === 'file' && part.url && part.mediaType?.startsWith('image/')) { - msgUrls.push(part.url); + if (part.type === "file" && part.url && part.mediaType?.startsWith("image/")) { + msgAttachments.push({ + url: part.url, + name: part.name || "图片", + contentType: part.mediaType || "image/jpeg", + }); } } } - - // 如果这条消息有图片,返回该消息的所有图片 - if (msgUrls.length > 0) { - msgUrls.forEach(addUrl); - console.log('[CanvasChat] 使用历史对话图片:', urls.length, '张'); - return urls; + if (msgAttachments.length > 0) { + msgAttachments.forEach(addAttachment); + console.log("[CanvasChat] 使用历史对话图片:", result.length, "张"); + return result; } } - - // 3. 最后使用画布中最近添加的图片 - const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src); + + // 3. 画布中最近图片 + const imageNodes = nodes.filter((n) => n.type === "image" && (n.data as any)?.src); if (imageNodes.length > 0) { - // 取最后几张(最多5张) - const recentNodes = imageNodes.slice(-5); - recentNodes.forEach(node => { - const src = (node.data as any)?.src; - if (src) addUrl(src); + imageNodes.slice(-5).forEach((node, index) => { + addAttachment({ + url: (node.data as any).src, + name: getNodeName(node, index), + contentType: "image/jpeg", + }); }); - console.log('[CanvasChat] 使用画布图片:', urls.length, '张'); + console.log("[CanvasChat] 使用画布图片:", result.length, "张"); } - - return urls; - }, [selectedImageNodes, nodes, messages]); + + return result; + }, [messages, nodes, selectedImageNodes]); // 🆕 智能自动模式上下文(画布图片布局) const buildAutoContext = useCallback(() => { @@ -827,17 +836,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF // 🆕 如果没有附件,自动使用最近的图片(支持多张) let effectiveAttachments = [...attachments]; if (effectiveAttachments.length === 0 && input.trim()) { - const recentImageUrls = getRecentImageUrls(); - if (recentImageUrls.length > 0) { + const recentAttachments = getRecentImageAttachments(); + if (recentAttachments.length > 0) { // 自动添加所有最近的图片作为附件 - recentImageUrls.forEach((url, index) => { + recentAttachments.forEach((attachment) => { effectiveAttachments.push({ - url: url, - name: `图片 ${index + 1}`, - contentType: 'image/jpeg', + url: attachment.url, + name: attachment.name, + contentType: attachment.contentType, }); }); - console.log('[CanvasChat] 自动使用最近图片:', recentImageUrls.length, '张'); + console.log('[CanvasChat] 自动使用最近图片:', recentAttachments.length, '张'); } } @@ -914,7 +923,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF editableDiv.innerHTML = ''; console.log('[CanvasChat] 已清空输入框和选中状态'); } - }, [input, attachments, selectedModelId, sendMessage, getRecentImageUrls, clearSelection, buildAutoContext]); + }, [input, attachments, selectedModelId, sendMessage, getRecentImageAttachments, clearSelection, buildAutoContext]); // 处理文件选择 - 上传后立即添加到画布 const handleFileSelect = async (e: React.ChangeEvent) => { @@ -990,7 +999,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF data: { src: imageUrl, status: 'completed', - prompt: '用户上传', + title: file.name.replace(/\.[^.]+$/, ""), + prompt: file.name.replace(/\.[^.]+$/, ""), }, connections: [], }); diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index f92134b69..f6063a98f 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -40,8 +40,19 @@ export function CanvasWorkspace() { const containerRef = useRef(null); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false); + const [marqueeStart, setMarqueeStart] = useState(null); + const [marqueeEnd, setMarqueeEnd] = useState(null); + const [isFileDragActive, setIsFileDragActive] = useState(false); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const dragCounterRef = useRef(0); + const dragPointerRef = useRef({ x: 0, y: 0 }); + const groupDragRef = useRef<{ + nodeId: string; + startX: number; + startY: number; + originPositions: Record; + } | null>(null); // Store state const nodes = useCanvasStore(canvasSelectors.nodes); @@ -102,7 +113,7 @@ export function CanvasWorkspace() { window.removeEventListener("drop", handleWindowDrop); }; }, [isFileDrag]); - + // 性能优化:只渲染视口内的节点(虚拟化) const visibleNodes = useMemo(() => { // 如果节点数量少于 50,直接渲染所有节点 @@ -117,6 +128,7 @@ export function CanvasWorkspace() { // Store actions const setViewport = useCanvasStore((state) => state.setViewport); const selectNode = useCanvasStore((state) => state.selectNode); + const selectNodes = useCanvasStore((state) => state.selectNodes); const clearSelection = useCanvasStore((state) => state.clearSelection); const addNode = useCanvasStore((state) => state.addNode); const moveNode = useCanvasStore((state) => state.moveNode); @@ -126,12 +138,26 @@ export function CanvasWorkspace() { const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId); const centerTimeoutRef = useRef(null); + const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return { x: 0, y: 0 }; + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + }, []); + + const isPointOnNodeElement = useCallback((target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + return !!target.closest("[data-canvas-node-id]"); + }, []); + // 处理画布点击(取消选择) const handleCanvasClick = useCallback((e: React.MouseEvent) => { - if (e.target === e.currentTarget) { + if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) { clearSelection(); } - }, [clearSelection]); + }, [clearSelection, isMarqueeSelecting, isPointOnNodeElement]); // 处理画布平移开始 const handleMouseDown = useCallback((e: React.MouseEvent) => { @@ -139,23 +165,70 @@ export function CanvasWorkspace() { setIsPanning(true); setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y }); e.preventDefault(); + return; } - }, [tool, viewport]); + + if (tool === "select" && e.button === 0 && !isPointOnNodeElement(e.target)) { + const start = toCanvasPosition(e.clientX, e.clientY); + setIsMarqueeSelecting(true); + setMarqueeStart(start); + setMarqueeEnd(start); + } + }, [isPointOnNodeElement, toCanvasPosition, tool, viewport]); // 处理画布平移 const handleMouseMove = useCallback((e: React.MouseEvent) => { + dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); if (isPanning) { setViewport({ x: e.clientX - panStart.x, y: e.clientY - panStart.y, }); + return; } - }, [isPanning, panStart, setViewport]); + + if (isMarqueeSelecting) { + setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY)); + } + }, [isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]); // 处理画布平移结束 const handleMouseUp = useCallback(() => { setIsPanning(false); - }, []); + + if (isMarqueeSelecting && marqueeStart && marqueeEnd) { + const left = Math.min(marqueeStart.x, marqueeEnd.x); + const top = Math.min(marqueeStart.y, marqueeEnd.y); + const right = Math.max(marqueeStart.x, marqueeEnd.x); + const bottom = Math.max(marqueeStart.y, marqueeEnd.y); + + const minSize = 4; + if (right - left < minSize && bottom - top < minSize) { + clearSelection(); + } else { + const selectedIds = nodes + .filter((node) => { + const x = node.position.x * viewport.zoom + viewport.x; + const y = node.position.y * viewport.zoom + viewport.y; + const width = node.size.width * viewport.zoom; + const height = node.size.height * viewport.zoom; + const nodeRight = x + width; + const nodeBottom = y + height; + return !(nodeRight < left || x > right || nodeBottom < top || y > bottom); + }) + .map((node) => node.id); + if (selectedIds.length > 0) { + selectNodes(selectedIds); + } else { + clearSelection(); + } + } + } + + setIsMarqueeSelecting(false); + setMarqueeStart(null); + setMarqueeEnd(null); + }, [clearSelection, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]); // 新增图片后自动居中展示 const centerNodeInView = useCallback((nodeId: string) => { @@ -193,32 +266,30 @@ export function CanvasWorkspace() { // 处理滚轮缩放 const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); - - // Shift + 滚轮 = 平移 - if (e.shiftKey) { + + // mac 触控板优化: + // 1) 双指滚动默认平移画布 + // 2) pinch(通常会触发 ctrlKey) 或 Cmd/Ctrl + 滚轮时缩放 + const shouldZoom = e.ctrlKey || e.metaKey; + if (!shouldZoom) { setViewport({ - x: viewport.x - e.deltaY, - y: viewport.y - e.deltaX, + x: viewport.x - e.deltaX, + y: viewport.y - e.deltaY, }); return; } - - // 默认滚轮 = 缩放(以鼠标位置为中心) - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * delta)); - + + const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05; + const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * zoomFactor)); const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - const scaleChange = newZoom / viewport.zoom; - const newX = mouseX - (mouseX - viewport.x) * scaleChange; - const newY = mouseY - (mouseY - viewport.y) * scaleChange; - - setViewport({ x: newX, y: newY, zoom: newZoom }); - } - }, [viewport, setViewport]); + if (!rect) return; + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const scaleChange = newZoom / viewport.zoom; + const newX = mouseX - (mouseX - viewport.x) * scaleChange; + const newY = mouseY - (mouseY - viewport.y) * scaleChange; + setViewport({ x: newX, y: newY, zoom: newZoom }); + }, [setViewport, viewport.x, viewport.y, viewport.zoom]); const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number }> => { return new Promise((resolve) => { @@ -250,45 +321,24 @@ export function CanvasWorkspace() { }); }, []); - const handleDragEnter = useCallback((e: React.DragEvent) => { - if (!isFileDrag(e.dataTransfer)) return; - e.preventDefault(); - dragCounterRef.current += 1; - }, [isFileDrag]); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - if (!isFileDrag(e.dataTransfer)) return; - e.preventDefault(); - dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); - }, [isFileDrag]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - if (!isFileDrag(e.dataTransfer)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }, [isFileDrag]); - - const handleDrop = useCallback(async (e: React.DragEvent) => { - if (!isFileDrag(e.dataTransfer)) return; - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current = 0; - const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/")); + const processDroppedFiles = useCallback(async (files: File[], dropClientX: number, dropClientY: number) => { if (files.length === 0) return; const rect = containerRef.current?.getBoundingClientRect(); - const baseX = rect ? (e.clientX - rect.left - viewport.x) / viewport.zoom : 100; - const baseY = rect ? (e.clientY - rect.top - viewport.y) / viewport.zoom : 100; + const baseX = rect ? (dropClientX - rect.left - viewport.x) / viewport.zoom : 100; + const baseY = rect ? (dropClientY - rect.top - viewport.y) / viewport.zoom : 100; const spacing = 20; - for (let index = 0; index < files.length; index++) { + for (let index = 0; index < files.length; index += 1) { const file = files[index]; + if (!file.type.startsWith("image/")) continue; if (file.size > 50 * 1024 * 1024) { toast.error("图片大小不能超过 50MB"); continue; } const dimensions = await getImageDimensionsFromFile(file); + const cleanName = file.name.replace(/\.[^.]+$/, ""); const placeholderId = addNode({ type: "image", position: { x: baseX + index * spacing, y: baseY + index * spacing }, @@ -296,7 +346,8 @@ export function CanvasWorkspace() { data: { src: "", status: "generating", - prompt: file.name || "拖拽上传", + prompt: cleanName || "拖拽上传", + title: cleanName || "拖拽上传", }, connections: [], }); @@ -308,7 +359,8 @@ export function CanvasWorkspace() { data: { src: result.data.url, status: "completed", - prompt: file.name || "拖拽上传", + prompt: cleanName || "拖拽上传", + title: cleanName || "拖拽上传", }, }); toast.success("图片已添加到画布"); @@ -317,23 +369,85 @@ export function CanvasWorkspace() { data: { src: "", status: "failed", - prompt: file.name || "拖拽上传", + prompt: cleanName || "拖拽上传", + title: cleanName || "拖拽上传", }, }); toast.error(result.error || "图片上传失败"); } - } catch (error) { + } catch (_error) { updateNode(placeholderId, { data: { src: "", status: "failed", - prompt: file.name || "拖拽上传", + prompt: cleanName || "拖拽上传", + title: cleanName || "拖拽上传", }, }); toast.error("图片上传失败"); } } - }, [addNode, getImageDimensionsFromFile, isFileDrag, updateNode, viewport.x, viewport.y, viewport.zoom]); + }, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); + + React.useEffect(() => { + const handleWindowDrop = async (e: DragEvent) => { + if (!isFileDrag(e.dataTransfer)) return; + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const x = e.clientX || dragPointerRef.current.x + rect.left; + const y = e.clientY || dragPointerRef.current.y + rect.top; + const inContainer = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + if (!inContainer) return; + + const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/")); + if (files.length === 0) return; + e.preventDefault(); + dragCounterRef.current = 0; + setIsFileDragActive(false); + await processDroppedFiles(files, x, y); + }; + + window.addEventListener("drop", handleWindowDrop); + return () => window.removeEventListener("drop", handleWindowDrop); + }, [isFileDrag, processDroppedFiles]); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + dragCounterRef.current += 1; + setIsFileDragActive(true); + dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); + }, [isFileDrag, toCanvasPosition]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) { + setIsFileDragActive(false); + } + }, [isFileDrag]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY); + }, [isFileDrag, toCanvasPosition]); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = 0; + setIsFileDragActive(false); + + const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/")); + if (files.length > 0) { + await processDroppedFiles(files, e.clientX, e.clientY); + } + }, [isFileDrag, processDroppedFiles]); // 渲染节点 const renderNode = (node: CanvasNode) => { @@ -354,12 +468,64 @@ export function CanvasWorkspace() { onDragStart={() => { if (!isSelected) { selectNode(node.id); + groupDragRef.current = null; + return; + } + + if (selectedNodeIds.length > 1) { + const originPositions: Record = {}; + nodes.forEach((item) => { + if (selectedNodeIds.includes(item.id)) { + originPositions[item.id] = { ...item.position }; + } + }); + groupDragRef.current = { + nodeId: node.id, + startX: node.position.x * viewport.zoom + viewport.x, + startY: node.position.y * viewport.zoom + viewport.y, + originPositions, + }; + } else { + groupDragRef.current = null; } }} + onDrag={(e, d) => { + const group = groupDragRef.current; + if (!group || group.nodeId !== node.id || selectedNodeIds.length <= 1) return; + const deltaCanvasX = (d.x - group.startX) / viewport.zoom; + const deltaCanvasY = (d.y - group.startY) / viewport.zoom; + + selectedNodeIds.forEach((id) => { + const origin = group.originPositions[id]; + if (!origin) return; + moveNode(id, { + x: origin.x + deltaCanvasX, + y: origin.y + deltaCanvasY, + }); + }); + }} onDragStop={(e, d) => { + const group = groupDragRef.current; + if (group && group.nodeId === node.id && selectedNodeIds.length > 1) { + const deltaCanvasX = (d.x - group.startX) / viewport.zoom; + const deltaCanvasY = (d.y - group.startY) / viewport.zoom; + selectedNodeIds.forEach((id) => { + const origin = group.originPositions[id]; + if (!origin) return; + moveNode(id, { + x: origin.x + deltaCanvasX, + y: origin.y + deltaCanvasY, + }); + }); + groupDragRef.current = null; + pushHistory(); + return; + } + const newX = (d.x - viewport.x) / viewport.zoom; const newY = (d.y - viewport.y) / viewport.zoom; moveNode(node.id, { x: newX, y: newY }); + groupDragRef.current = null; pushHistory(); }} onResizeStop={(e, direction, ref, delta, position) => { @@ -376,9 +542,9 @@ export function CanvasWorkspace() { enableResizing={isSelected && tool === 'select'} disableDragging={tool !== 'select'} className={cn( - "absolute", - isSelected && "ring-2 ring-primary ring-offset-2" + "absolute" )} + data-canvas-node-id={node.id} style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部 onClick={(e: React.MouseEvent) => { e.stopPropagation(); @@ -424,6 +590,8 @@ export function CanvasWorkspace() { onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} > @@ -444,6 +612,26 @@ export function CanvasWorkspace() { }} /> + {isFileDragActive && ( +
+
+ 松开鼠标即可添加图片到画布 +
+
+ )} + + {isMarqueeSelecting && marqueeStart && marqueeEnd && ( +
+ )} + {/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
{visibleNodes.map(renderNode)} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 15ca08cf6..4c29dbda5 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -60,6 +60,28 @@ const normalizeTextEditItems = (items: ImageTextEditItem[] | undefined): ImageTe const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url); +const normalizeImageTitle = (raw?: string) => { + const title = (raw || "").trim(); + return title || ""; +}; + +const getImageNodeTitle = (data: ImageNodeData, nodeId: string) => { + return ( + normalizeImageTitle(data.title) || + normalizeImageTitle(data.prompt) || + `图片-${nodeId.slice(0, 6)}` + ); +}; + +const sanitizeFileName = (name: string) => { + const safe = name + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); + return safe || "image"; +}; + /** * 在图片上绘制标记点,返回带标记的图片 base64 */ @@ -168,6 +190,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const [isDetectingText, setIsDetectingText] = useState(false); const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); const [deletedTextItems, setDeletedTextItems] = useState>([]); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [draftTitle, setDraftTitle] = useState(""); // 文件输入的 ref const fileInputRef = useRef(null); @@ -210,8 +234,15 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { useEffect(() => { if (!isSelected) { setTextEditPanelOpen(false); + setIsEditingTitle(false); } }, [isSelected]); + + useEffect(() => { + if (!isEditingTitle) { + setDraftTitle(getImageNodeTitle(data, node.id)); + } + }, [data, isEditingTitle, node.id]); // 🆕 处理图片点击标注 const handlePickerClick = useCallback(async (e: React.MouseEvent) => { @@ -315,6 +346,21 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { return { top, left }; }; + const displayTitle = getImageNodeTitle(data, node.id); + + const handleSaveTitle = useCallback(() => { + const nextTitle = normalizeImageTitle(draftTitle) || displayTitle; + updateNode(node.id, { + data: { + ...data, + title: nextTitle, + }, + }); + setDraftTitle(nextTitle); + setIsEditingTitle(false); + toast.success("图片名称已更新"); + }, [data, displayTitle, draftTitle, node.id, updateNode]); + const detectTextItems = useCallback( async (forceRefresh: boolean = false) => { if (!data.src) { @@ -487,6 +533,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: "", status: "generating", prompt: "文字替换处理中...", + title: `${displayTitle}-文字替换`, originalSrc: data.src, }, connections: [node.id], @@ -507,6 +554,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: result.data.originalUrl, status: "completed", prompt: "文字替换结果", + title: `${displayTitle}-文字替换`, originalSrc: data.src, model: result.data.model, }, @@ -532,6 +580,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: "", status: "failed", prompt: "文字替换失败", + title: `${displayTitle}-文字替换失败`, originalSrc: data.src, }, }); @@ -573,6 +622,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { }); try { + const fileBaseName = file.name.replace(/\.[^.]+$/, ""); // 上传到存储桶 const result = await imageGenerationService.uploadImage(file); @@ -581,7 +631,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { data: { ...data, src: result.data.url, - status: 'completed' + status: 'completed', + title: normalizeImageTitle(data.title) || fileBaseName, }, }); toast.success('图片上传成功'); @@ -669,6 +720,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: '', // 暂时没有图片 status: 'generating', prompt: `${actionName}处理中...`, + title: `${displayTitle}-${actionName}`, originalSrc: data.src, }, connections: [node.id], @@ -704,6 +756,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: result.data.url, status: 'completed', prompt: `${actionName}结果`, + title: `${displayTitle}-${actionName}`, originalSrc: data.src, }, }); @@ -717,6 +770,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: '', status: 'failed', prompt: `${actionName}失败`, + title: `${displayTitle}-${actionName}-失败`, originalSrc: data.src, }, }); @@ -732,6 +786,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: '', status: 'failed', prompt: `${actionName}失败`, + title: `${displayTitle}-${actionName}-失败`, originalSrc: data.src, }, }); @@ -774,6 +829,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: resultUrl, status: 'completed', prompt: '角度编辑结果', + title: `${displayTitle}-角度编辑`, originalSrc: data.src, }, connections: [node.id], @@ -850,6 +906,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: '', status: 'generating', prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`, + title: `${displayTitle}-${resizeWidth}x${resizeHeight}`, originalSrc: data.src, }, connections: [node.id], @@ -864,6 +921,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: result.data.url, status: 'completed', prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`, + title: `${displayTitle}-${resizeWidth}x${resizeHeight}`, originalSrc: data.src, }, }); @@ -874,6 +932,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: '', status: 'failed', prompt: '尺寸调整失败', + title: `${displayTitle}-尺寸调整失败`, originalSrc: data.src, }, }); @@ -886,6 +945,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { src: '', status: 'failed', prompt: '尺寸调整失败', + title: `${displayTitle}-尺寸调整失败`, originalSrc: data.src, }, }); @@ -916,7 +976,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { // 从 URL 中提取文件扩展名,默认使用 png const urlParts = data.src.split('.'); const ext = urlParts[urlParts.length - 1]?.split('?')[0] || 'png'; - link.download = `image-${node.id}.${ext}`; + link.download = `${sanitizeFileName(displayTitle)}.${ext}`; // 触发下载 document.body.appendChild(link); @@ -941,9 +1001,39 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { {/* 选中时在图片上方显示标题和尺寸 */} {isSelected && data.src && (
-
+
- {data.prompt || '图片'} + {isEditingTitle ? ( + setDraftTitle(e.target.value)} + onBlur={handleSaveTitle} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveTitle(); + } else if (e.key === "Escape") { + e.preventDefault(); + setDraftTitle(displayTitle); + setIsEditingTitle(false); + } + }} + /> + ) : ( + { + e.stopPropagation(); + setDraftTitle(displayTitle); + setIsEditingTitle(true); + }} + > + {displayTitle} + + )}
{node.size.width} × {node.size.height}
@@ -953,7 +1043,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { ref={containerRef} className={cn( "relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors", - isSelected ? "border-primary" : "border-transparent" + isSelected + ? "border-transparent shadow-[0_0_0_1px_rgba(59,130,246,0.18),0_14px_28px_-18px_rgba(59,130,246,0.45)]" + : "border-transparent" )} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -963,7 +1055,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{data.prompt 编辑文字 + { + setDraftTitle(displayTitle); + setIsEditingTitle(true); + }} + > + + 重命名 + @@ -1214,7 +1315,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { onClick={() => { addPendingChatAttachment({ url: data.src!, - name: data.prompt || '画布图片', + name: displayTitle, contentType: 'image/jpeg', }); toast.success('已添加到对话框'); diff --git a/types/canvas/index.ts b/types/canvas/index.ts index 6e35d2571..8294132c4 100644 --- a/types/canvas/index.ts +++ b/types/canvas/index.ts @@ -41,6 +41,7 @@ export interface ImageTextEditItem { // 图片节点数据 export interface ImageNodeData { src: string; + title?: string; prompt?: string; model?: string; status: NodeStatus;