feat: refine canvas chat layout and add date separators
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-21 01:00:25 +08:00
parent 2d09e39878
commit 4a0880a4c5
2 changed files with 668 additions and 318 deletions

View File

@ -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);

View File

@ -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)}