优化画布交互并支持图片重命名同步
This commit is contained in:
parent
800017b78c
commit
6cf0597227
@ -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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -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) {
|
||||
<TooltipContent side="right">平移 (H)</TooltipContent>
|
||||
</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" />
|
||||
|
||||
{/* 快捷工具 - 仅在选中图片时显示 */}
|
||||
|
||||
@ -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<string>(); // 用于去重
|
||||
|
||||
// 辅助函数:添加图片 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<string>();
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
@ -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: [],
|
||||
});
|
||||
|
||||
@ -40,8 +40,19 @@ export function CanvasWorkspace() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
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 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
|
||||
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<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) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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/"));
|
||||
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<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) => {
|
||||
@ -354,12 +468,64 @@ export function CanvasWorkspace() {
|
||||
onDragStart={() => {
|
||||
if (!isSelected) {
|
||||
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) => {
|
||||
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 && (
|
||||
<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' }}>
|
||||
{visibleNodes.map(renderNode)}
|
||||
|
||||
@ -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<Array<{ id: string; originalText: string }>>([]);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
|
||||
// 文件输入的 ref
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLImageElement>) => {
|
||||
@ -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 && (
|
||||
<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" />
|
||||
<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>
|
||||
<span>{node.size.width} × {node.size.height}</span>
|
||||
</div>
|
||||
@ -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) {
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={data.src}
|
||||
alt={data.prompt || "图片"}
|
||||
alt={displayTitle}
|
||||
className={cn(
|
||||
"w-full h-full object-contain bg-muted/30",
|
||||
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
|
||||
@ -1100,6 +1192,15 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<Type className="h-4 w-4 mr-2" />
|
||||
编辑文字
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDraftTitle(displayTitle);
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
@ -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('已添加到对话框');
|
||||
|
||||
@ -41,6 +41,7 @@ export interface ImageTextEditItem {
|
||||
// 图片节点数据
|
||||
export interface ImageNodeData {
|
||||
src: string;
|
||||
title?: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
status: NodeStatus;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user