优化画布交互并支持图片重命名同步

This commit is contained in:
Your Name 2026-02-08 11:52:37 +08:00
parent 800017b78c
commit 6cf0597227
6 changed files with 493 additions and 140 deletions

View File

@ -1,24 +1,58 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
// 自动重定向到新画布 // 默认进入最近画布,避免误触直接新建
export default function CanvasRedirectPage() { export default function CanvasRedirectPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
// 生成新的画布 ID 并跳转 const shouldCreateNew = searchParams.get("new") === "1";
const newId = crypto.randomUUID();
router.replace(`/canvas/${newId}`); const openCanvas = async () => {
}, [router]); 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 ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">正在创建新画布...</span> <span className="text-sm text-muted-foreground">正在打开画布...</span>
</div> </div>
</div> </div>
); );

View File

@ -157,7 +157,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
useCanvasStore.getState().setProject(id, '未命名画布'); useCanvasStore.getState().setProject(id, '未命名画布');
// 保存新画布到数据库 // 保存新画布到数据库
try { 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) { } catch (error) {
console.error('[Canvas] 创建新画布失败:', error); console.error('[Canvas] 创建新画布失败:', error);
} }
@ -272,6 +272,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
data: { data: {
src: '', src: '',
status: 'pending', status: 'pending',
title: '新图片',
prompt: '', prompt: '',
}, },
connections: [], connections: [],
@ -337,6 +338,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
data: { data: {
src: '', src: '',
status: 'generating', status: 'generating',
title: prompt.slice(0, 24) || '生成中',
prompt: prompt, prompt: prompt,
}, },
connections: [], connections: [],
@ -361,6 +363,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
data: { data: {
src: image.originalUrl || image.thumbnailUrl, src: image.originalUrl || image.thumbnailUrl,
status: 'completed', status: 'completed',
title: `${prompt.slice(0, 20) || '生成图片'}-${index + 1}`,
prompt: prompt, prompt: prompt,
}, },
}); });
@ -376,6 +379,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
data: { data: {
src: '', src: '',
status: 'failed', status: 'failed',
title: '生成失败',
prompt: prompt, prompt: prompt,
}, },
}); });
@ -390,6 +394,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
data: { data: {
src: '', src: '',
status: 'failed', status: 'failed',
title: '生成失败',
prompt: prompt, prompt: prompt,
}, },
}); });
@ -596,6 +601,20 @@ export default function CanvasEditorPage({ params }: PageProps) {
<TooltipContent side="right">平移 (H)</TooltipContent> <TooltipContent side="right">平移 (H)</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/canvas?new=1')}
className="rounded-xl h-9 w-9"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">新建画布</TooltipContent>
</Tooltip>
<Separator orientation="horizontal" className="w-5 my-0.5" /> <Separator orientation="horizontal" className="w-5 my-0.5" />
{/* 快捷工具 - 仅在选中图片时显示 */} {/* 快捷工具 - 仅在选中图片时显示 */}

View File

@ -134,6 +134,12 @@ interface PickedObjectAttachment extends Attachment {
}; };
} }
interface CanvasImageAttachment {
url: string;
name: string;
contentType: string;
}
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) { export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) {
const router = useRouter(); const router = useRouter();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@ -697,77 +703,80 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 标记是否已经为当前画布设置过标题(避免重复调用) // 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false); const hasSetCanvasTitle = useRef(false);
// 获取最近的图片 URL(支持多张图片) // 获取最近的图片附件(支持多张图片与名称同步)
const getRecentImageUrls = useCallback((): string[] => { const getRecentImageAttachments = useCallback((): CanvasImageAttachment[] => {
const urls: string[] = []; const result: CanvasImageAttachment[] = [];
const addedUrls = new Set<string>(); // 用于去重 const addedUrls = new Set<string>();
// 辅助函数:添加图片 URL 并去重 const addAttachment = (item: CanvasImageAttachment) => {
const addUrl = (url: string) => { if (!item.url || addedUrls.has(item.url)) return;
if (url && !addedUrls.has(url)) { addedUrls.add(item.url);
addedUrls.add(url); result.push(item);
urls.push(url); };
}
const getNodeName = (node: any, index: number) => {
return (
node?.data?.title ||
node?.data?.prompt ||
`图片 ${index + 1}`
);
}; };
// 1. 优先使用画布中选中的图片(支持多选) // 1. 优先使用画布中选中的图片(支持多选)
if (selectedImageNodes.length > 0) { if (selectedImageNodes.length > 0) {
selectedImageNodes.forEach(node => { selectedImageNodes.forEach((node, index) => {
const src = (node.data as any)?.src; const src = (node.data as any)?.src;
if (src) addUrl(src); if (src) {
addAttachment({
url: src,
name: getNodeName(node, index),
contentType: "image/jpeg",
});
}
}); });
// 如果选中了图片,只返回选中的 if (result.length > 0) {
if (urls.length > 0) { console.log("[CanvasChat] 使用选中的图片:", result.length, "张");
console.log('[CanvasChat] 使用选中的图片:', urls.length, '张'); return result;
return urls;
} }
} }
// 2. 其次从历史对话中查找最近一次消息的所有图片 // 2. 历史对话最近一轮图片
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]; const msg = messages[i];
const msgUrls: string[] = []; const msgAttachments: CanvasImageAttachment[] = [];
if (msg.role === "user" && (msg as any).parts) {
// 检查用户消息的附件
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) {
for (const part of (msg as any).parts) { for (const part of (msg as any).parts) {
if (part.type === 'file' && part.url && part.mediaType?.startsWith('image/')) { if (part.type === "file" && part.url && part.mediaType?.startsWith("image/")) {
msgUrls.push(part.url); msgAttachments.push({
url: part.url,
name: part.name || "图片",
contentType: part.mediaType || "image/jpeg",
});
} }
} }
} }
if (msgAttachments.length > 0) {
// 如果这条消息有图片,返回该消息的所有图片 msgAttachments.forEach(addAttachment);
if (msgUrls.length > 0) { console.log("[CanvasChat] 使用历史对话图片:", result.length, "张");
msgUrls.forEach(addUrl); return result;
console.log('[CanvasChat] 使用历史对话图片:', urls.length, '张');
return urls;
} }
} }
// 3. 最后使用画布中最近添加的图片 // 3. 画布中最近图片
const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src); const imageNodes = nodes.filter((n) => n.type === "image" && (n.data as any)?.src);
if (imageNodes.length > 0) { if (imageNodes.length > 0) {
// 取最后几张(最多5张) imageNodes.slice(-5).forEach((node, index) => {
const recentNodes = imageNodes.slice(-5); addAttachment({
recentNodes.forEach(node => { url: (node.data as any).src,
const src = (node.data as any)?.src; name: getNodeName(node, index),
if (src) addUrl(src); contentType: "image/jpeg",
});
}); });
console.log('[CanvasChat] 使用画布图片:', urls.length, '张'); console.log("[CanvasChat] 使用画布图片:", result.length, "张");
} }
return urls; return result;
}, [selectedImageNodes, nodes, messages]); }, [messages, nodes, selectedImageNodes]);
// 🆕 智能自动模式上下文(画布图片布局) // 🆕 智能自动模式上下文(画布图片布局)
const buildAutoContext = useCallback(() => { const buildAutoContext = useCallback(() => {
@ -827,17 +836,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 🆕 如果没有附件,自动使用最近的图片(支持多张) // 🆕 如果没有附件,自动使用最近的图片(支持多张)
let effectiveAttachments = [...attachments]; let effectiveAttachments = [...attachments];
if (effectiveAttachments.length === 0 && input.trim()) { if (effectiveAttachments.length === 0 && input.trim()) {
const recentImageUrls = getRecentImageUrls(); const recentAttachments = getRecentImageAttachments();
if (recentImageUrls.length > 0) { if (recentAttachments.length > 0) {
// 自动添加所有最近的图片作为附件 // 自动添加所有最近的图片作为附件
recentImageUrls.forEach((url, index) => { recentAttachments.forEach((attachment) => {
effectiveAttachments.push({ effectiveAttachments.push({
url: url, url: attachment.url,
name: `图片 ${index + 1}`, name: attachment.name,
contentType: 'image/jpeg', 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 = ''; editableDiv.innerHTML = '';
console.log('[CanvasChat] 已清空输入框和选中状态'); console.log('[CanvasChat] 已清空输入框和选中状态');
} }
}, [input, attachments, selectedModelId, sendMessage, getRecentImageUrls, clearSelection, buildAutoContext]); }, [input, attachments, selectedModelId, sendMessage, getRecentImageAttachments, clearSelection, buildAutoContext]);
// 处理文件选择 - 上传后立即添加到画布 // 处理文件选择 - 上传后立即添加到画布
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -990,7 +999,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
data: { data: {
src: imageUrl, src: imageUrl,
status: 'completed', status: 'completed',
prompt: '用户上传', title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""),
}, },
connections: [], connections: [],
}); });

View File

@ -40,8 +40,19 @@ export function CanvasWorkspace() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 }); const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
const [marqueeStart, setMarqueeStart] = useState<Position | null>(null);
const [marqueeEnd, setMarqueeEnd] = useState<Position | null>(null);
const [isFileDragActive, setIsFileDragActive] = useState(false);
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 }); const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const dragPointerRef = useRef<Position>({ x: 0, y: 0 });
const groupDragRef = useRef<{
nodeId: string;
startX: number;
startY: number;
originPositions: Record<string, Position>;
} | null>(null);
// Store state // Store state
const nodes = useCanvasStore(canvasSelectors.nodes); const nodes = useCanvasStore(canvasSelectors.nodes);
@ -117,6 +128,7 @@ export function CanvasWorkspace() {
// Store actions // Store actions
const setViewport = useCanvasStore((state) => state.setViewport); const setViewport = useCanvasStore((state) => state.setViewport);
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const clearSelection = useCanvasStore((state) => state.clearSelection); const clearSelection = useCanvasStore((state) => state.clearSelection);
const addNode = useCanvasStore((state) => state.addNode); const addNode = useCanvasStore((state) => state.addNode);
const moveNode = useCanvasStore((state) => state.moveNode); const moveNode = useCanvasStore((state) => state.moveNode);
@ -126,12 +138,26 @@ export function CanvasWorkspace() {
const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId); const clearLastAddedNodeId = useCanvasStore((state) => state.clearLastAddedNodeId);
const centerTimeoutRef = useRef<number | null>(null); const centerTimeoutRef = useRef<number | null>(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) => { const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) { if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) {
clearSelection(); clearSelection();
} }
}, [clearSelection]); }, [clearSelection, isMarqueeSelecting, isPointOnNodeElement]);
// 处理画布平移开始 // 处理画布平移开始
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
@ -139,23 +165,70 @@ export function CanvasWorkspace() {
setIsPanning(true); setIsPanning(true);
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y }); setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
e.preventDefault(); 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) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
if (isPanning) { if (isPanning) {
setViewport({ setViewport({
x: e.clientX - panStart.x, x: e.clientX - panStart.x,
y: e.clientY - panStart.y, 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(() => { const handleMouseUp = useCallback(() => {
setIsPanning(false); 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) => { const centerNodeInView = useCallback((nodeId: string) => {
@ -194,31 +267,29 @@ export function CanvasWorkspace() {
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
// Shift + 滚轮 = 平移 // mac 触控板优化:
if (e.shiftKey) { // 1) 双指滚动默认平移画布
// 2) pinch(通常会触发 ctrlKey) 或 Cmd/Ctrl + 滚轮时缩放
const shouldZoom = e.ctrlKey || e.metaKey;
if (!shouldZoom) {
setViewport({ setViewport({
x: viewport.x - e.deltaY, x: viewport.x - e.deltaX,
y: viewport.y - e.deltaX, y: viewport.y - e.deltaY,
}); });
return; return;
} }
// 默认滚轮 = 缩放(以鼠标位置为中心) const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05;
const delta = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * zoomFactor));
const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * delta));
const rect = containerRef.current?.getBoundingClientRect(); const rect = containerRef.current?.getBoundingClientRect();
if (rect) { if (!rect) return;
const mouseX = e.clientX - rect.left; const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; const mouseY = e.clientY - rect.top;
const scaleChange = newZoom / viewport.zoom;
const scaleChange = newZoom / viewport.zoom; const newX = mouseX - (mouseX - viewport.x) * scaleChange;
const newX = mouseX - (mouseX - viewport.x) * scaleChange; const newY = mouseY - (mouseY - viewport.y) * scaleChange;
const newY = mouseY - (mouseY - viewport.y) * scaleChange; setViewport({ x: newX, y: newY, zoom: newZoom });
}, [setViewport, viewport.x, viewport.y, viewport.zoom]);
setViewport({ x: newX, y: newY, zoom: newZoom });
}
}, [viewport, setViewport]);
const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number }> => { const getImageDimensionsFromFile = useCallback((file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -250,45 +321,24 @@ export function CanvasWorkspace() {
}); });
}, []); }, []);
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => { const processDroppedFiles = useCallback(async (files: File[], dropClientX: number, dropClientY: number) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragCounterRef.current += 1;
}, [isFileDrag]);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
}, [isFileDrag]);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}, [isFileDrag]);
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
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/"));
if (files.length === 0) return; if (files.length === 0) return;
const rect = containerRef.current?.getBoundingClientRect(); const rect = containerRef.current?.getBoundingClientRect();
const baseX = rect ? (e.clientX - rect.left - viewport.x) / viewport.zoom : 100; const baseX = rect ? (dropClientX - rect.left - viewport.x) / viewport.zoom : 100;
const baseY = rect ? (e.clientY - rect.top - viewport.y) / viewport.zoom : 100; const baseY = rect ? (dropClientY - rect.top - viewport.y) / viewport.zoom : 100;
const spacing = 20; const spacing = 20;
for (let index = 0; index < files.length; index++) { for (let index = 0; index < files.length; index += 1) {
const file = files[index]; const file = files[index];
if (!file.type.startsWith("image/")) continue;
if (file.size > 50 * 1024 * 1024) { if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB"); toast.error("图片大小不能超过 50MB");
continue; continue;
} }
const dimensions = await getImageDimensionsFromFile(file); const dimensions = await getImageDimensionsFromFile(file);
const cleanName = file.name.replace(/\.[^.]+$/, "");
const placeholderId = addNode({ const placeholderId = addNode({
type: "image", type: "image",
position: { x: baseX + index * spacing, y: baseY + index * spacing }, position: { x: baseX + index * spacing, y: baseY + index * spacing },
@ -296,7 +346,8 @@ export function CanvasWorkspace() {
data: { data: {
src: "", src: "",
status: "generating", status: "generating",
prompt: file.name || "拖拽上传", prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
}, },
connections: [], connections: [],
}); });
@ -308,7 +359,8 @@ export function CanvasWorkspace() {
data: { data: {
src: result.data.url, src: result.data.url,
status: "completed", status: "completed",
prompt: file.name || "拖拽上传", prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
}, },
}); });
toast.success("图片已添加到画布"); toast.success("图片已添加到画布");
@ -317,23 +369,85 @@ export function CanvasWorkspace() {
data: { data: {
src: "", src: "",
status: "failed", status: "failed",
prompt: file.name || "拖拽上传", prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
}, },
}); });
toast.error(result.error || "图片上传失败"); toast.error(result.error || "图片上传失败");
} }
} catch (error) { } catch (_error) {
updateNode(placeholderId, { updateNode(placeholderId, {
data: { data: {
src: "", src: "",
status: "failed", status: "failed",
prompt: file.name || "拖拽上传", prompt: cleanName || "拖拽上传",
title: cleanName || "拖拽上传",
}, },
}); });
toast.error("图片上传失败"); 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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) => { const renderNode = (node: CanvasNode) => {
@ -354,12 +468,64 @@ export function CanvasWorkspace() {
onDragStart={() => { onDragStart={() => {
if (!isSelected) { if (!isSelected) {
selectNode(node.id); selectNode(node.id);
groupDragRef.current = null;
return;
}
if (selectedNodeIds.length > 1) {
const originPositions: Record<string, Position> = {};
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) => { 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 newX = (d.x - viewport.x) / viewport.zoom;
const newY = (d.y - viewport.y) / viewport.zoom; const newY = (d.y - viewport.y) / viewport.zoom;
moveNode(node.id, { x: newX, y: newY }); moveNode(node.id, { x: newX, y: newY });
groupDragRef.current = null;
pushHistory(); pushHistory();
}} }}
onResizeStop={(e, direction, ref, delta, position) => { onResizeStop={(e, direction, ref, delta, position) => {
@ -376,9 +542,9 @@ export function CanvasWorkspace() {
enableResizing={isSelected && tool === 'select'} enableResizing={isSelected && tool === 'select'}
disableDragging={tool !== 'select'} disableDragging={tool !== 'select'}
className={cn( className={cn(
"absolute", "absolute"
isSelected && "ring-2 ring-primary ring-offset-2"
)} )}
data-canvas-node-id={node.id}
style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部 style={{ overflow: 'visible' }} // 允许工具栏显示在节点外部
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -424,6 +590,8 @@ export function CanvasWorkspace() {
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
onWheel={handleWheel} onWheel={handleWheel}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
> >
@ -444,6 +612,26 @@ export function CanvasWorkspace() {
}} }}
/> />
{isFileDragActive && (
<div className="absolute inset-0 pointer-events-none z-30 flex items-center justify-center bg-primary/5 border-2 border-dashed border-primary/40 rounded-lg">
<div className="px-4 py-2 rounded-md bg-background/90 text-sm text-primary shadow-sm">
松开鼠标即可添加图片到画布
</div>
</div>
)}
{isMarqueeSelecting && marqueeStart && marqueeEnd && (
<div
className="absolute z-20 pointer-events-none border border-primary/50 bg-primary/10"
style={{
left: Math.min(marqueeStart.x, marqueeEnd.x),
top: Math.min(marqueeStart.y, marqueeEnd.y),
width: Math.abs(marqueeEnd.x - marqueeStart.x),
height: Math.abs(marqueeEnd.y - marqueeStart.y),
}}
/>
)}
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */} {/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
<div className="absolute inset-0" style={{ overflow: 'visible' }}> <div className="absolute inset-0" style={{ overflow: 'visible' }}>
{visibleNodes.map(renderNode)} {visibleNodes.map(renderNode)}

View File

@ -60,6 +60,28 @@ const normalizeTextEditItems = (items: ImageTextEditItem[] | undefined): ImageTe
const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url); 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 * 在图片上绘制标记点,返回带标记的图片 base64
*/ */
@ -168,6 +190,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [isDetectingText, setIsDetectingText] = useState(false); const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false); const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [deletedTextItems, setDeletedTextItems] = useState<Array<{ id: string; originalText: string }>>([]); const [deletedTextItems, setDeletedTextItems] = useState<Array<{ id: string; originalText: string }>>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
// 文件输入的 ref // 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -210,9 +234,16 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
useEffect(() => { useEffect(() => {
if (!isSelected) { if (!isSelected) {
setTextEditPanelOpen(false); setTextEditPanelOpen(false);
setIsEditingTitle(false);
} }
}, [isSelected]); }, [isSelected]);
useEffect(() => {
if (!isEditingTitle) {
setDraftTitle(getImageNodeTitle(data, node.id));
}
}, [data, isEditingTitle, node.id]);
// 🆕 处理图片点击标注 // 🆕 处理图片点击标注
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => { const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
if (!isThisNodePicking || isPickerLoading || !data.src) return; if (!isThisNodePicking || isPickerLoading || !data.src) return;
@ -315,6 +346,21 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
return { top, left }; 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( const detectTextItems = useCallback(
async (forceRefresh: boolean = false) => { async (forceRefresh: boolean = false) => {
if (!data.src) { if (!data.src) {
@ -487,6 +533,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: "", src: "",
status: "generating", status: "generating",
prompt: "文字替换处理中...", prompt: "文字替换处理中...",
title: `${displayTitle}-文字替换`,
originalSrc: data.src, originalSrc: data.src,
}, },
connections: [node.id], connections: [node.id],
@ -507,6 +554,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: result.data.originalUrl, src: result.data.originalUrl,
status: "completed", status: "completed",
prompt: "文字替换结果", prompt: "文字替换结果",
title: `${displayTitle}-文字替换`,
originalSrc: data.src, originalSrc: data.src,
model: result.data.model, model: result.data.model,
}, },
@ -532,6 +580,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: "", src: "",
status: "failed", status: "failed",
prompt: "文字替换失败", prompt: "文字替换失败",
title: `${displayTitle}-文字替换失败`,
originalSrc: data.src, originalSrc: data.src,
}, },
}); });
@ -573,6 +622,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}); });
try { try {
const fileBaseName = file.name.replace(/\.[^.]+$/, "");
// 上传到存储桶 // 上传到存储桶
const result = await imageGenerationService.uploadImage(file); const result = await imageGenerationService.uploadImage(file);
@ -581,7 +631,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
data: { data: {
...data, ...data,
src: result.data.url, src: result.data.url,
status: 'completed' status: 'completed',
title: normalizeImageTitle(data.title) || fileBaseName,
}, },
}); });
toast.success('图片上传成功'); toast.success('图片上传成功');
@ -669,6 +720,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: '', // 暂时没有图片 src: '', // 暂时没有图片
status: 'generating', status: 'generating',
prompt: `${actionName}处理中...`, prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src, originalSrc: data.src,
}, },
connections: [node.id], connections: [node.id],
@ -704,6 +756,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: result.data.url, src: result.data.url,
status: 'completed', status: 'completed',
prompt: `${actionName}结果`, prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src, originalSrc: data.src,
}, },
}); });
@ -717,6 +770,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: '', src: '',
status: 'failed', status: 'failed',
prompt: `${actionName}失败`, prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src, originalSrc: data.src,
}, },
}); });
@ -732,6 +786,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: '', src: '',
status: 'failed', status: 'failed',
prompt: `${actionName}失败`, prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src, originalSrc: data.src,
}, },
}); });
@ -774,6 +829,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: resultUrl, src: resultUrl,
status: 'completed', status: 'completed',
prompt: '角度编辑结果', prompt: '角度编辑结果',
title: `${displayTitle}-角度编辑`,
originalSrc: data.src, originalSrc: data.src,
}, },
connections: [node.id], connections: [node.id],
@ -850,6 +906,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: '', src: '',
status: 'generating', status: 'generating',
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`, prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
title: `${displayTitle}-${resizeWidth}x${resizeHeight}`,
originalSrc: data.src, originalSrc: data.src,
}, },
connections: [node.id], connections: [node.id],
@ -864,6 +921,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: result.data.url, src: result.data.url,
status: 'completed', status: 'completed',
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`, prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
title: `${displayTitle}-${resizeWidth}x${resizeHeight}`,
originalSrc: data.src, originalSrc: data.src,
}, },
}); });
@ -874,6 +932,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: '', src: '',
status: 'failed', status: 'failed',
prompt: '尺寸调整失败', prompt: '尺寸调整失败',
title: `${displayTitle}-尺寸调整失败`,
originalSrc: data.src, originalSrc: data.src,
}, },
}); });
@ -886,6 +945,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
src: '', src: '',
status: 'failed', status: 'failed',
prompt: '尺寸调整失败', prompt: '尺寸调整失败',
title: `${displayTitle}-尺寸调整失败`,
originalSrc: data.src, originalSrc: data.src,
}, },
}); });
@ -916,7 +976,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
// 从 URL 中提取文件扩展名,默认使用 png // 从 URL 中提取文件扩展名,默认使用 png
const urlParts = data.src.split('.'); const urlParts = data.src.split('.');
const ext = urlParts[urlParts.length - 1]?.split('?')[0] || 'png'; const ext = urlParts[urlParts.length - 1]?.split('?')[0] || 'png';
link.download = `image-${node.id}.${ext}`; link.download = `${sanitizeFileName(displayTitle)}.${ext}`;
// 触发下载 // 触发下载
document.body.appendChild(link); document.body.appendChild(link);
@ -941,9 +1001,39 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{/* 选中时在图片上方显示标题和尺寸 */} {/* 选中时在图片上方显示标题和尺寸 */}
{isSelected && data.src && ( {isSelected && data.src && (
<div className="absolute -top-7 left-0 right-0 flex items-center justify-between text-xs text-muted-foreground px-1 z-10"> <div className="absolute -top-7 left-0 right-0 flex items-center justify-between text-xs text-muted-foreground px-1 z-10">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 min-w-0">
<ImagePlus className="h-3 w-3" /> <ImagePlus className="h-3 w-3" />
<span className="truncate max-w-[150px]">{data.prompt || '图片'}</span> {isEditingTitle ? (
<Input
value={draftTitle}
autoFocus
className="h-6 px-2 py-0 text-xs w-[160px]"
onChange={(e) => 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);
}
}}
/>
) : (
<span
className="truncate max-w-[150px] cursor-text"
title="双击重命名"
onDoubleClick={(e) => {
e.stopPropagation();
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
{displayTitle}
</span>
)}
</div> </div>
<span>{node.size.width} × {node.size.height}</span> <span>{node.size.width} × {node.size.height}</span>
</div> </div>
@ -953,7 +1043,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
ref={containerRef} ref={containerRef}
className={cn( className={cn(
"relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors", "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)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
@ -963,7 +1055,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<img <img
src={data.src} src={data.src}
alt={data.prompt || "图片"} alt={displayTitle}
className={cn( className={cn(
"w-full h-full object-contain bg-muted/30", "w-full h-full object-contain bg-muted/30",
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer" isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
@ -1100,6 +1192,15 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<Type className="h-4 w-4 mr-2" /> <Type className="h-4 w-4 mr-2" />
编辑文字 编辑文字
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
<Pencil className="h-4 w-4 mr-2" />
重命名
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}> <DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
@ -1214,7 +1315,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onClick={() => { onClick={() => {
addPendingChatAttachment({ addPendingChatAttachment({
url: data.src!, url: data.src!,
name: data.prompt || '画布图片', name: displayTitle,
contentType: 'image/jpeg', contentType: 'image/jpeg',
}); });
toast.success('已添加到对话框'); toast.success('已添加到对话框');

View File

@ -41,6 +41,7 @@ export interface ImageTextEditItem {
// 图片节点数据 // 图片节点数据
export interface ImageNodeData { export interface ImageNodeData {
src: string; src: string;
title?: string;
prompt?: string; prompt?: string;
model?: string; model?: string;
status: NodeStatus; status: NodeStatus;