优化画布交互并支持图片重命名同步
This commit is contained in:
parent
800017b78c
commit
6cf0597227
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|
||||||
{/* 快捷工具 - 仅在选中图片时显示 */}
|
{/* 快捷工具 - 仅在选中图片时显示 */}
|
||||||
|
|||||||
@ -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: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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('已添加到对话框');
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user