feat: refine canvas chat layout and add date separators
This commit is contained in:
parent
2d09e39878
commit
4a0880a4c5
@ -5,10 +5,9 @@ import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useSWR from "swr";
|
||||
import { Send, Loader2, ImagePlus, Paperclip, X, User, Bot, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain } from "lucide-react";
|
||||
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -144,6 +143,47 @@ function stripMediaMarkup(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getMessageTimestamp(message: ChatMessage): number | null {
|
||||
const rawValue =
|
||||
(message as any).createdAt ??
|
||||
(message as any).created_at ??
|
||||
(message as any).timestamp ??
|
||||
null;
|
||||
|
||||
if (rawValue instanceof Date) {
|
||||
return rawValue.getTime();
|
||||
}
|
||||
|
||||
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
const parsed = Date.parse(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMessageDayKey(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatMessageDateLabel(timestamp: number): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function normalizeVideoParamsForProvider(
|
||||
params: VideoGenerationParams | null | undefined,
|
||||
providerType?: string
|
||||
@ -3844,6 +3884,206 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
handleDetailGeneration,
|
||||
]);
|
||||
|
||||
const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => {
|
||||
if (!firstImageUrl || hasSetCanvasTitle.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasSetCanvasTitle.current = true;
|
||||
console.log('[CanvasChat] 开始调用 auto-title API...');
|
||||
|
||||
try {
|
||||
console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl);
|
||||
const response = await fetch('/api/canvas/auto-title', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl: firstImageUrl }),
|
||||
});
|
||||
|
||||
console.log('[CanvasChat] auto-title 响应状态:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const title = data.title || '未命名画布';
|
||||
console.log('[CanvasChat] 识别到的标题:', title);
|
||||
|
||||
const rawId = chatId.startsWith('canvas-')
|
||||
? chatId.replace('canvas-', '')
|
||||
: chatId;
|
||||
|
||||
console.log('[CanvasChat] 更新画布:', rawId);
|
||||
|
||||
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
thumbnail: firstImageUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
|
||||
} else {
|
||||
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CanvasChat] 自动命名失败:', error);
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
const uploadImageFileAndAddToCanvas = useCallback(async (
|
||||
file: File,
|
||||
nodeIndex: number
|
||||
): Promise<{ imageUrl: string; name: string; contentType: string } | null> => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('请选择图片文件');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast.error('图片大小不能超过 50MB');
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = file.type.split('/')[1] || 'png';
|
||||
const fileName = (file.name || '').trim() || `粘贴图片-${Date.now()}-${nodeIndex + 1}.${ext}`;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const imageUrl = data.url as string;
|
||||
const dimensions = await getImageDimensions(imageUrl);
|
||||
const spacing = 20;
|
||||
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
|
||||
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
|
||||
|
||||
addNode({
|
||||
type: 'image',
|
||||
position: { x, y },
|
||||
size: { width: dimensions.width, height: dimensions.height },
|
||||
data: {
|
||||
src: imageUrl,
|
||||
status: 'completed',
|
||||
title: fileName.replace(/\.[^.]+$/, ""),
|
||||
prompt: fileName.replace(/\.[^.]+$/, ""),
|
||||
metadata: {
|
||||
originalWidth: dimensions.originalWidth,
|
||||
originalHeight: dimensions.originalHeight,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
name: fileName,
|
||||
contentType: file.type || 'image/jpeg',
|
||||
};
|
||||
} catch (_error) {
|
||||
toast.error('图片上传失败');
|
||||
return null;
|
||||
}
|
||||
}, [addNode]);
|
||||
|
||||
const handlePastedImages = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
let firstImageUrl: string | null = null;
|
||||
const baseNodeCount = nodes.length;
|
||||
let uploadIndex = 0;
|
||||
|
||||
if (isVideoModel && videoFrameSlots.length > 0) {
|
||||
const slotCount = videoFrameSlots.length;
|
||||
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
|
||||
let handledCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
|
||||
if (targetSlotIndex < 0) break;
|
||||
|
||||
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
|
||||
if (!uploaded) continue;
|
||||
|
||||
const attachment: PickedObjectAttachment = {
|
||||
url: uploaded.imageUrl,
|
||||
name: uploaded.name,
|
||||
contentType: uploaded.contentType,
|
||||
};
|
||||
updateAttachmentAtVideoSlot(targetSlotIndex, attachment);
|
||||
currentSlots[targetSlotIndex] = attachment;
|
||||
handledCount += 1;
|
||||
uploadIndex += 1;
|
||||
if (!firstImageUrl) {
|
||||
firstImageUrl = uploaded.imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (handledCount > 0) {
|
||||
toast.success(handledCount === 1 ? "已粘贴图片到视频槽位" : `已粘贴 ${handledCount} 张图片到视频槽位`);
|
||||
}
|
||||
if (handledCount < files.length) {
|
||||
toast.info("视频模式图片槽位已满,剩余图片未添加");
|
||||
}
|
||||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
let handledCount = 0;
|
||||
for (const file of files) {
|
||||
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
|
||||
if (!uploaded) continue;
|
||||
|
||||
handledCount += 1;
|
||||
uploadIndex += 1;
|
||||
if (!firstImageUrl) {
|
||||
firstImageUrl = uploaded.imageUrl;
|
||||
}
|
||||
|
||||
setAttachments((prev) => {
|
||||
const newIndex = prev.length;
|
||||
setTimeout(() => {
|
||||
insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
|
||||
(textareaRef.current as HTMLDivElement | null)?.focus();
|
||||
}, 0);
|
||||
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
url: uploaded.imageUrl,
|
||||
name: uploaded.name,
|
||||
contentType: uploaded.contentType,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (handledCount > 0) {
|
||||
toast.success(handledCount === 1 ? "图片已添加到画布" : `已粘贴并添加 ${handledCount} 张图片`);
|
||||
}
|
||||
|
||||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||||
}, [
|
||||
attachments,
|
||||
insertImageTagInEditor,
|
||||
isVideoModel,
|
||||
maybeAutoSetCanvasTitle,
|
||||
nodes.length,
|
||||
updateAttachmentAtVideoSlot,
|
||||
uploadImageFileAndAddToCanvas,
|
||||
videoFrameSlots.length,
|
||||
]);
|
||||
|
||||
// 处理文件选择 - 上传后立即添加到画布
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
@ -3935,66 +4175,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是第一次上传图片,自动识别并设置画布标题和缩略图
|
||||
console.log('[CanvasChat] 检查是否需要自动命名:', {
|
||||
firstImageUrl: !!firstImageUrl,
|
||||
hasSetCanvasTitle: hasSetCanvasTitle.current
|
||||
console.log('[CanvasChat] 检查是否需要自动命名:', {
|
||||
firstImageUrl: !!firstImageUrl,
|
||||
hasSetCanvasTitle: hasSetCanvasTitle.current
|
||||
});
|
||||
|
||||
if (firstImageUrl && !hasSetCanvasTitle.current) {
|
||||
hasSetCanvasTitle.current = true;
|
||||
console.log('[CanvasChat] 开始调用 auto-title API...');
|
||||
|
||||
// 异步调用 AI 识别图片主体并更新画布
|
||||
(async () => {
|
||||
try {
|
||||
// 调用 auto-title API 识别图片主体
|
||||
console.log('[CanvasChat] 发送 auto-title 请求:', firstImageUrl);
|
||||
const response = await fetch('/api/canvas/auto-title', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl: firstImageUrl }),
|
||||
});
|
||||
|
||||
console.log('[CanvasChat] auto-title 响应状态:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const title = data.title || '未命名画布';
|
||||
console.log('[CanvasChat] 识别到的标题:', title);
|
||||
|
||||
// 提取纯 UUID(去掉 canvas- 前缀)
|
||||
const rawId = chatId.startsWith('canvas-')
|
||||
? chatId.replace('canvas-', '')
|
||||
: chatId;
|
||||
|
||||
console.log('[CanvasChat] 更新画布:', rawId);
|
||||
|
||||
// 更新画布标题和缩略图
|
||||
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
thumbnail: firstImageUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
|
||||
|
||||
if (updateResponse.ok) {
|
||||
console.log('[CanvasChat] 画布已自动命名为:', title);
|
||||
} else {
|
||||
console.error('[CanvasChat] 画布更新失败');
|
||||
}
|
||||
} else {
|
||||
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CanvasChat] 自动命名失败:', error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||||
|
||||
e.target.value = '';
|
||||
};
|
||||
@ -4367,7 +4552,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderableVisibleMessages.map((message) => {
|
||||
{renderableVisibleMessages.map((message, index) => {
|
||||
const assistantTextParts = message.role === "assistant"
|
||||
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
|
||||
: [];
|
||||
@ -4384,211 +4569,205 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
message.role === "assistant" &&
|
||||
(hasAssistantImages || hasAssistantVideos) &&
|
||||
!hasAssistantText;
|
||||
|
||||
const messageTimestamp = getMessageTimestamp(message);
|
||||
const prevMessageTimestamp =
|
||||
index > 0 ? getMessageTimestamp(renderableVisibleMessages[index - 1] as ChatMessage) : null;
|
||||
const showDateSeparator = Boolean(
|
||||
messageTimestamp &&
|
||||
(!prevMessageTimestamp ||
|
||||
getMessageDayKey(prevMessageTimestamp) !== getMessageDayKey(messageTimestamp))
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 gap-3",
|
||||
message.role === 'user' ? "flex-row-reverse" : "flex-row"
|
||||
)}
|
||||
>
|
||||
{/* 头像 */}
|
||||
<Avatar className="h-8 w-8 shrink-0 border shadow-sm">
|
||||
{message.role === 'user' ? (
|
||||
<>
|
||||
<AvatarImage src="/placeholder-user.jpg" />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AvatarImage src="/placeholder-bot.jpg" />
|
||||
<AvatarFallback className="bg-muted text-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
|
||||
{/* 消息气泡 */}
|
||||
<div className={cn(
|
||||
"flex min-w-0 flex-col gap-2",
|
||||
message.role === 'user'
|
||||
? "ml-auto max-w-[85%] items-end"
|
||||
: "mr-auto max-w-[85%] items-start"
|
||||
)}>
|
||||
{message.role === 'user' ? (
|
||||
// 🆕 用户消息:使用图片标签样式(类似竞品,按 parts 原始顺序渲染)
|
||||
<div className="bg-card border rounded-2xl rounded-tr-sm px-3 py-2 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(() => {
|
||||
const renderedUserParts: React.ReactNode[] = [];
|
||||
|
||||
message.parts?.forEach((part: any, idx: number) => {
|
||||
const fileUrl =
|
||||
typeof part?.url === "string" && part.url.trim()
|
||||
? part.url
|
||||
: typeof part?.data === "string" && part.data.trim()
|
||||
? part.data
|
||||
: "";
|
||||
|
||||
if (part.type === 'file' && fileUrl) {
|
||||
renderedUserParts.push(
|
||||
<span
|
||||
key={`part-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded-lg text-xs"
|
||||
>
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt=""
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
/>
|
||||
<span className="max-w-[80px] truncate text-foreground">{part.name || 'Image'}</span>
|
||||
</span>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'text' && part.text) {
|
||||
const text = String(part.text)
|
||||
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
|
||||
.trim();
|
||||
if (!text) return;
|
||||
renderedUserParts.push(
|
||||
<span key={`part-${idx}`} className="text-sm text-foreground">{text}</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!message.parts?.some((p: any) => p.type === 'file') &&
|
||||
(message as any).attachments?.length > 0
|
||||
) {
|
||||
(message as any).attachments.forEach((att: any, idx: number) => {
|
||||
if (!att?.url) return;
|
||||
renderedUserParts.push(
|
||||
<span
|
||||
key={`att-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded-lg text-xs"
|
||||
>
|
||||
<img
|
||||
src={att.url}
|
||||
alt=""
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
/>
|
||||
<span className="max-w-[80px] truncate text-foreground">{att.name || 'Image'}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (renderedUserParts.length === 0) {
|
||||
const fallbackText =
|
||||
extractUserTextPrompt(message as any) ||
|
||||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
|
||||
renderedUserParts.push(
|
||||
<span key="fallback-text" className="text-sm text-foreground">
|
||||
{fallbackText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return renderedUserParts;
|
||||
})()}
|
||||
</div>
|
||||
<div key={message.id} className="space-y-3">
|
||||
{showDateSeparator && messageTimestamp ? (
|
||||
<div className="text-[14px] font-medium text-[#a6adba]">
|
||||
{formatMessageDateLabel(messageTimestamp)}
|
||||
</div>
|
||||
) : (
|
||||
// AI 消息:保持原样式
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full text-card-foreground text-sm",
|
||||
isImageOnlyAssistant
|
||||
? "bg-transparent border-0 rounded-none px-0 py-0 shadow-none"
|
||||
: "bg-card border rounded-2xl rounded-tl-sm px-4 py-2.5 shadow-sm"
|
||||
)}
|
||||
>
|
||||
{/* 消息内容 */}
|
||||
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
|
||||
{message.parts?.map((part: any, i: number) => {
|
||||
if (part.type === 'text') {
|
||||
// 提取并显示图片/视频
|
||||
const rawText = part.text || '';
|
||||
const imageUrls = extractImageUrls(rawText);
|
||||
const videoUrls = extractVideoUrls(rawText);
|
||||
const textWithoutImages = stripMediaMarkup(rawText);
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
{textWithoutImages && (
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{textWithoutImages}
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-0",
|
||||
message.role === 'user' ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{/* 消息区域 */}
|
||||
<div className={cn(
|
||||
"flex min-w-0 flex-col gap-2",
|
||||
message.role === 'user'
|
||||
? "max-w-[88%] items-end"
|
||||
: "w-full max-w-[88%] items-start"
|
||||
)}>
|
||||
{message.role === 'user' ? (
|
||||
<div className="rounded-2xl bg-[#f1f2f4] px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(() => {
|
||||
const renderedUserParts: React.ReactNode[] = [];
|
||||
|
||||
message.parts?.forEach((part: any, idx: number) => {
|
||||
const fileUrl =
|
||||
typeof part?.url === "string" && part.url.trim()
|
||||
? part.url
|
||||
: typeof part?.data === "string" && part.data.trim()
|
||||
? part.data
|
||||
: "";
|
||||
|
||||
if (part.type === 'file' && fileUrl) {
|
||||
renderedUserParts.push(
|
||||
<span
|
||||
key={`part-${idx}`}
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-white/90 px-2 py-1 text-xs"
|
||||
>
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt=""
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
/>
|
||||
<span className="max-w-[80px] truncate text-foreground">{part.name || 'Image'}</span>
|
||||
</span>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'text' && part.text) {
|
||||
const text = String(part.text)
|
||||
.replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '')
|
||||
.trim();
|
||||
if (!text) return;
|
||||
renderedUserParts.push(
|
||||
<span key={`part-${idx}`} className="text-sm text-foreground">{text}</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!message.parts?.some((p: any) => p.type === 'file') &&
|
||||
(message as any).attachments?.length > 0
|
||||
) {
|
||||
(message as any).attachments.forEach((att: any, idx: number) => {
|
||||
if (!att?.url) return;
|
||||
renderedUserParts.push(
|
||||
<span
|
||||
key={`att-${idx}`}
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-white/90 px-2 py-1 text-xs"
|
||||
>
|
||||
<img
|
||||
src={att.url}
|
||||
alt=""
|
||||
className="w-5 h-5 object-cover rounded"
|
||||
/>
|
||||
<span className="max-w-[80px] truncate text-foreground">{att.name || 'Image'}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (renderedUserParts.length === 0) {
|
||||
const fallbackText =
|
||||
extractUserTextPrompt(message as any) ||
|
||||
(isVideoModel ? "已发送视频生成请求" : "已发送请求");
|
||||
renderedUserParts.push(
|
||||
<span key="fallback-text" className="text-sm text-foreground">
|
||||
{fallbackText}
|
||||
</span>
|
||||
)}
|
||||
{imageUrls.length > 0 && (
|
||||
<div className={cn("flex flex-wrap gap-2", textWithoutImages ? "mt-3" : "mt-0")}>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"relative group overflow-hidden border transition-all",
|
||||
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
|
||||
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
style={{ maxHeight: "260px" }}
|
||||
onClick={() => setFullscreenPreview({ url })}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFullscreenPreview({ url });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return renderedUserParts;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full text-[15px] text-[#2f3640]",
|
||||
isImageOnlyAssistant
|
||||
? "bg-transparent px-0 py-0"
|
||||
: "bg-transparent px-0 py-0"
|
||||
)}
|
||||
>
|
||||
{/* 消息内容 */}
|
||||
<div className="w-full min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere] leading-relaxed">
|
||||
{message.parts?.map((part: any, i: number) => {
|
||||
if (part.type === 'text') {
|
||||
// 提取并显示图片/视频
|
||||
const rawText = part.text || '';
|
||||
const imageUrls = extractImageUrls(rawText);
|
||||
const videoUrls = extractVideoUrls(rawText);
|
||||
const textWithoutImages = stripMediaMarkup(rawText);
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
{textWithoutImages && (
|
||||
<span className="break-words [overflow-wrap:anywhere]">
|
||||
{textWithoutImages}
|
||||
</span>
|
||||
)}
|
||||
{imageUrls.length > 0 && (
|
||||
<div className={cn("flex flex-wrap gap-2", textWithoutImages ? "mt-3" : "mt-0")}>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"relative group overflow-hidden border transition-all",
|
||||
"rounded-2xl border-black/5 dark:border-white/10 bg-white/70 dark:bg-zinc-900/50",
|
||||
"shadow-[0_8px_24px_-16px_rgba(15,23,42,0.45)] hover:shadow-[0_12px_28px_-14px_rgba(15,23,42,0.5)]"
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="block max-w-full h-auto cursor-zoom-in transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
style={{ maxHeight: "260px" }}
|
||||
onClick={() => setFullscreenPreview({ url })}
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFullscreenPreview({ url });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{videoUrls.length > 0 && (
|
||||
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
|
||||
{videoUrls.map((url, idx) => (
|
||||
<div
|
||||
key={`video-${idx}`}
|
||||
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
|
||||
>
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="block w-full rounded-xl bg-black"
|
||||
style={{ maxHeight: "320px" }}
|
||||
/>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
下载视频
|
||||
</a>
|
||||
)}
|
||||
{videoUrls.length > 0 && (
|
||||
<div className={cn("space-y-2", textWithoutImages || imageUrls.length > 0 ? "mt-3" : "mt-0")}>
|
||||
{videoUrls.map((url, idx) => (
|
||||
<div
|
||||
key={`video-${idx}`}
|
||||
className="overflow-hidden rounded-2xl border border-black/10 bg-black/5 p-2"
|
||||
>
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
className="block w-full rounded-xl bg-black"
|
||||
style={{ maxHeight: "320px" }}
|
||||
/>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
下载视频
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
})}
|
||||
|
||||
{shouldShowImageLoading && (
|
||||
@ -4601,13 +4780,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
)}
|
||||
|
||||
{isLoading && !shouldShowImageLoading && (
|
||||
<div className="flex gap-3">
|
||||
<Avatar className="h-8 w-8 shrink-0 border shadow-sm">
|
||||
<AvatarFallback className="bg-muted text-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="bg-card border text-card-foreground rounded-2xl rounded-tl-sm px-4 py-2.5 shadow-sm flex items-center gap-2">
|
||||
<div className="flex w-full justify-start">
|
||||
<div className="rounded-xl bg-[#f1f2f4] px-3 py-2 text-card-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">正在思考中...</span>
|
||||
</div>
|
||||
@ -5208,7 +5382,18 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
// 只粘贴纯文本
|
||||
const clipboardItems = Array.from(e.clipboardData?.items || []);
|
||||
const imageFiles = clipboardItems
|
||||
.filter((item) => item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
void handlePastedImages(imageFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
|
||||
@ -43,6 +43,12 @@ export function CanvasWorkspace() {
|
||||
const SELECTION_STROKE_WIDTH = 2;
|
||||
const IMAGE_HANDLE_SIZE = 12;
|
||||
const IMAGE_HANDLE_OFFSET = IMAGE_HANDLE_SIZE / 2;
|
||||
const SNAP_GUIDE_COLOR = "#ff8a3d";
|
||||
|
||||
type SnapGuideLines = {
|
||||
vertical?: { x: number; top: number; bottom: number };
|
||||
horizontal?: { y: number; left: number; right: number };
|
||||
};
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||
@ -51,6 +57,7 @@ export function CanvasWorkspace() {
|
||||
const [marqueeEnd, setMarqueeEnd] = useState<Position | null>(null);
|
||||
const [isFileDragActive, setIsFileDragActive] = useState(false);
|
||||
const [isDownloadSubmitting, setIsDownloadSubmitting] = useState(false);
|
||||
const [snapGuides, setSnapGuides] = useState<SnapGuideLines | null>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
|
||||
const dragCounterRef = useRef(0);
|
||||
const dragPointerRef = useRef<Position>({ x: 0, y: 0 });
|
||||
@ -183,14 +190,14 @@ export function CanvasWorkspace() {
|
||||
return Boolean(document.querySelector('[role="dialog"][data-state="open"]'));
|
||||
}, []);
|
||||
|
||||
const getSnappedCanvasPosition = useCallback(
|
||||
const getSnapPreview = useCallback(
|
||||
(
|
||||
node: CanvasNode,
|
||||
target: Position,
|
||||
ignoreNodeIds: Set<string> = new Set(),
|
||||
): Position => {
|
||||
): { position: Position; guides: SnapGuideLines | null } => {
|
||||
if (node.type !== "image") {
|
||||
return target;
|
||||
return { position: target, guides: null };
|
||||
}
|
||||
|
||||
const candidates = nodes.filter(
|
||||
@ -201,13 +208,25 @@ export function CanvasWorkspace() {
|
||||
);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return target;
|
||||
return { position: target, guides: null };
|
||||
}
|
||||
|
||||
let bestOffsetX = 0;
|
||||
let bestDeltaX = Number.POSITIVE_INFINITY;
|
||||
let bestOffsetY = 0;
|
||||
let bestDeltaY = Number.POSITIVE_INFINITY;
|
||||
let bestMatchX: {
|
||||
offset: number;
|
||||
delta: number;
|
||||
guide?: SnapGuideLines["vertical"];
|
||||
} = {
|
||||
offset: 0,
|
||||
delta: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
let bestMatchY: {
|
||||
offset: number;
|
||||
delta: number;
|
||||
guide?: SnapGuideLines["horizontal"];
|
||||
} = {
|
||||
offset: 0,
|
||||
delta: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
|
||||
const selfLeft = target.x;
|
||||
const selfCenterX = target.x + node.size.width / 2;
|
||||
@ -216,19 +235,25 @@ export function CanvasWorkspace() {
|
||||
const selfCenterY = target.y + node.size.height / 2;
|
||||
const selfBottom = target.y + node.size.height;
|
||||
|
||||
const pickBestX = (delta: number) => {
|
||||
const pickBestX = (delta: number, guide?: SnapGuideLines["vertical"]) => {
|
||||
const abs = Math.abs(delta);
|
||||
if (abs <= SNAP_THRESHOLD && abs < bestDeltaX) {
|
||||
bestDeltaX = abs;
|
||||
bestOffsetX = delta;
|
||||
if (abs <= SNAP_THRESHOLD && abs < bestMatchX.delta) {
|
||||
bestMatchX = {
|
||||
offset: delta,
|
||||
delta: abs,
|
||||
guide,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const pickBestY = (delta: number) => {
|
||||
const pickBestY = (delta: number, guide?: SnapGuideLines["horizontal"]) => {
|
||||
const abs = Math.abs(delta);
|
||||
if (abs <= SNAP_THRESHOLD && abs < bestDeltaY) {
|
||||
bestDeltaY = abs;
|
||||
bestOffsetY = delta;
|
||||
if (abs <= SNAP_THRESHOLD && abs < bestMatchY.delta) {
|
||||
bestMatchY = {
|
||||
offset: delta,
|
||||
delta: abs,
|
||||
guide,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -240,27 +265,70 @@ export function CanvasWorkspace() {
|
||||
const targetCenterY = candidate.position.y + candidate.size.height / 2;
|
||||
const targetBottom = candidate.position.y + candidate.size.height;
|
||||
|
||||
pickBestX(targetLeft - selfLeft);
|
||||
pickBestX(targetCenterX - selfCenterX);
|
||||
pickBestX(targetRight - selfRight);
|
||||
pickBestX(targetLeft - selfLeft, {
|
||||
x: targetLeft,
|
||||
top: Math.min(selfTop, targetTop),
|
||||
bottom: Math.max(selfBottom, targetBottom),
|
||||
});
|
||||
pickBestX(targetCenterX - selfCenterX, {
|
||||
x: targetCenterX,
|
||||
top: Math.min(selfTop, targetTop),
|
||||
bottom: Math.max(selfBottom, targetBottom),
|
||||
});
|
||||
pickBestX(targetRight - selfRight, {
|
||||
x: targetRight,
|
||||
top: Math.min(selfTop, targetTop),
|
||||
bottom: Math.max(selfBottom, targetBottom),
|
||||
});
|
||||
pickBestX(targetRight + SNAP_GAP - selfLeft);
|
||||
pickBestX(targetLeft - SNAP_GAP - selfRight);
|
||||
|
||||
pickBestY(targetTop - selfTop);
|
||||
pickBestY(targetCenterY - selfCenterY);
|
||||
pickBestY(targetBottom - selfBottom);
|
||||
pickBestY(targetTop - selfTop, {
|
||||
y: targetTop,
|
||||
left: Math.min(selfLeft, targetLeft),
|
||||
right: Math.max(selfRight, targetRight),
|
||||
});
|
||||
pickBestY(targetCenterY - selfCenterY, {
|
||||
y: targetCenterY,
|
||||
left: Math.min(selfLeft, targetLeft),
|
||||
right: Math.max(selfRight, targetRight),
|
||||
});
|
||||
pickBestY(targetBottom - selfBottom, {
|
||||
y: targetBottom,
|
||||
left: Math.min(selfLeft, targetLeft),
|
||||
right: Math.max(selfRight, targetRight),
|
||||
});
|
||||
pickBestY(targetBottom + SNAP_GAP - selfTop);
|
||||
pickBestY(targetTop - SNAP_GAP - selfBottom);
|
||||
}
|
||||
|
||||
const snappedPosition = {
|
||||
x: target.x + (Number.isFinite(bestMatchX.delta) ? bestMatchX.offset : 0),
|
||||
y: target.y + (Number.isFinite(bestMatchY.delta) ? bestMatchY.offset : 0),
|
||||
};
|
||||
|
||||
return {
|
||||
x: target.x + (Number.isFinite(bestDeltaX) ? bestOffsetX : 0),
|
||||
y: target.y + (Number.isFinite(bestDeltaY) ? bestOffsetY : 0),
|
||||
position: snappedPosition,
|
||||
guides: {
|
||||
vertical: bestMatchX.guide,
|
||||
horizontal: bestMatchY.guide,
|
||||
},
|
||||
};
|
||||
},
|
||||
[nodes, SNAP_GAP],
|
||||
);
|
||||
|
||||
const getSnappedCanvasPosition = useCallback(
|
||||
(
|
||||
node: CanvasNode,
|
||||
target: Position,
|
||||
ignoreNodeIds: Set<string> = new Set(),
|
||||
): Position => {
|
||||
return getSnapPreview(node, target, ignoreNodeIds).position;
|
||||
},
|
||||
[getSnapPreview],
|
||||
);
|
||||
|
||||
const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return { x: 0, y: 0 };
|
||||
@ -337,6 +405,7 @@ export function CanvasWorkspace() {
|
||||
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
|
||||
if (e && !isEventFromCanvasContainer(e.target)) return;
|
||||
setIsPanning(false);
|
||||
setSnapGuides(null);
|
||||
|
||||
if (isMarqueeSelecting && marqueeStart && marqueeEnd) {
|
||||
const left = Math.min(marqueeStart.x, marqueeEnd.x);
|
||||
@ -752,6 +821,7 @@ export function CanvasWorkspace() {
|
||||
}}
|
||||
scale={1}
|
||||
onDragStart={() => {
|
||||
setSnapGuides(null);
|
||||
if (!isSelected) {
|
||||
selectNode(node.id);
|
||||
groupDragRef.current = null;
|
||||
@ -777,20 +847,47 @@ export function CanvasWorkspace() {
|
||||
}}
|
||||
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,
|
||||
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;
|
||||
const origin = group.originPositions[node.id];
|
||||
const rawAnchor = origin
|
||||
? {
|
||||
x: origin.x + deltaCanvasX,
|
||||
y: origin.y + deltaCanvasY,
|
||||
}
|
||||
: {
|
||||
x: node.position.x + deltaCanvasX,
|
||||
y: node.position.y + deltaCanvasY,
|
||||
};
|
||||
const preview = getSnapPreview(
|
||||
node,
|
||||
rawAnchor,
|
||||
new Set(selectedNodeIds),
|
||||
);
|
||||
setSnapGuides(preview.guides);
|
||||
selectedNodeIds.forEach((id) => {
|
||||
const itemOrigin = group.originPositions[id];
|
||||
if (!itemOrigin) return;
|
||||
moveNode(id, {
|
||||
x: itemOrigin.x + deltaCanvasX,
|
||||
y: itemOrigin.y + deltaCanvasY,
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeIds.length > 1) return;
|
||||
|
||||
const rawPosition = {
|
||||
x: (d.x - viewport.x) / viewport.zoom,
|
||||
y: (d.y - viewport.y) / viewport.zoom,
|
||||
};
|
||||
const preview = getSnapPreview(node, rawPosition);
|
||||
setSnapGuides(preview.guides);
|
||||
}}
|
||||
onDragStop={(e, d) => {
|
||||
setSnapGuides(null);
|
||||
const group = groupDragRef.current;
|
||||
if (group && group.nodeId === node.id && selectedNodeIds.length > 1) {
|
||||
const deltaCanvasX = (d.x - group.startX) / viewport.zoom;
|
||||
@ -902,23 +999,6 @@ export function CanvasWorkspace() {
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 点阵背景 - 浅色模式(固定大小,不随缩放变化) */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none dark:hidden"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(rgba(0,0,0,0.08) 1px, transparent 1px)`,
|
||||
backgroundSize: `20px 20px`,
|
||||
}}
|
||||
/>
|
||||
{/* 深色模式点阵(固定大小,不随缩放变化) */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none hidden dark:block"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)`,
|
||||
backgroundSize: `20px 20px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{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">
|
||||
@ -939,6 +1019,91 @@ export function CanvasWorkspace() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{snapGuides?.vertical && (
|
||||
<>
|
||||
<div
|
||||
className="absolute z-20 pointer-events-none"
|
||||
style={{
|
||||
left: snapGuides.vertical.x * viewport.zoom + viewport.x,
|
||||
top: snapGuides.vertical.top * viewport.zoom + viewport.y,
|
||||
width: 1,
|
||||
height: Math.max(
|
||||
1,
|
||||
(snapGuides.vertical.bottom - snapGuides.vertical.top) * viewport.zoom,
|
||||
),
|
||||
backgroundColor: SNAP_GUIDE_COLOR,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute z-20 pointer-events-none"
|
||||
style={{
|
||||
left: snapGuides.vertical.x * viewport.zoom + viewport.x - 4,
|
||||
top: snapGuides.vertical.top * viewport.zoom + viewport.y - 4,
|
||||
color: SNAP_GUIDE_COLOR,
|
||||
fontSize: 11,
|
||||
lineHeight: "11px",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</div>
|
||||
<div
|
||||
className="absolute z-20 pointer-events-none"
|
||||
style={{
|
||||
left: snapGuides.vertical.x * viewport.zoom + viewport.x - 4,
|
||||
top:
|
||||
snapGuides.vertical.bottom * viewport.zoom + viewport.y - 4,
|
||||
color: SNAP_GUIDE_COLOR,
|
||||
fontSize: 11,
|
||||
lineHeight: "11px",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{snapGuides?.horizontal && (
|
||||
<>
|
||||
<div
|
||||
className="absolute z-20 pointer-events-none"
|
||||
style={{
|
||||
left: snapGuides.horizontal.left * viewport.zoom + viewport.x,
|
||||
top: snapGuides.horizontal.y * viewport.zoom + viewport.y,
|
||||
width: Math.max(
|
||||
1,
|
||||
(snapGuides.horizontal.right - snapGuides.horizontal.left) * viewport.zoom,
|
||||
),
|
||||
height: 1,
|
||||
backgroundColor: SNAP_GUIDE_COLOR,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute z-20 pointer-events-none"
|
||||
style={{
|
||||
left: snapGuides.horizontal.left * viewport.zoom + viewport.x - 4,
|
||||
top: snapGuides.horizontal.y * viewport.zoom + viewport.y - 4,
|
||||
color: SNAP_GUIDE_COLOR,
|
||||
fontSize: 11,
|
||||
lineHeight: "11px",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</div>
|
||||
<div
|
||||
className="absolute z-20 pointer-events-none"
|
||||
style={{
|
||||
left: snapGuides.horizontal.right * viewport.zoom + viewport.x - 4,
|
||||
top: snapGuides.horizontal.y * viewport.zoom + viewport.y - 4,
|
||||
color: SNAP_GUIDE_COLOR,
|
||||
fontSize: 11,
|
||||
lineHeight: "11px",
|
||||
}}
|
||||
>
|
||||
x
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 节点层 - 使用虚拟化渲染,允许工具栏溢出 */}
|
||||
<div className="absolute inset-0" style={{ overflow: 'visible' }}>
|
||||
{visibleNodes.map(renderNode)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user