From 99dfab0ca0827674e6e85ddc7a0e5d1f6291d9d7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Feb 2026 15:26:16 +0800 Subject: [PATCH] feat(canvas): animate image fly-to-chat when adding attachment --- components/canvas/CanvasChatPanel.tsx | 5 +- components/canvas/nodes/ImageNode.tsx | 122 +++++++++++++++++++++++--- 2 files changed, 114 insertions(+), 13 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 8e73fc293..4aa3bb10c 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1846,7 +1846,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF {/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
-
+
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
(null); @@ -388,6 +389,108 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const handleCancelPicking = useCallback(() => { setPickerMode(false); }, [setPickerMode]); + const displayTitle = getImageNodeTitle(data, node.id); + + const animateAddToChat = useCallback(async () => { + if (!containerRef.current || !data.src || typeof window === "undefined") { + return false; + } + + const target = document.querySelector('[data-canvas-chat-input-anchor="true"]') as HTMLElement | null; + if (!target) return false; + + const sourceImage = + (containerRef.current.querySelector("img") as HTMLImageElement | null) || containerRef.current; + const sourceRect = sourceImage.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + + if (sourceRect.width <= 0 || sourceRect.height <= 0 || targetRect.width <= 0 || targetRect.height <= 0) { + return false; + } + + const clone = document.createElement("img"); + clone.src = data.src; + clone.alt = ""; + clone.style.position = "fixed"; + clone.style.left = `${sourceRect.left}px`; + clone.style.top = `${sourceRect.top}px`; + clone.style.width = `${sourceRect.width}px`; + clone.style.height = `${sourceRect.height}px`; + clone.style.objectFit = "cover"; + clone.style.borderRadius = "14px"; + clone.style.pointerEvents = "none"; + clone.style.zIndex = "99999"; + clone.style.boxShadow = "0 14px 30px rgba(15, 23, 42, 0.28)"; + clone.style.transformOrigin = "center center"; + clone.style.transition = + "transform 560ms cubic-bezier(0.22, 1, 0.36, 1), opacity 560ms ease, filter 560ms ease"; + clone.style.willChange = "transform, opacity, filter"; + document.body.appendChild(clone); + + const sourceCenterX = sourceRect.left + sourceRect.width / 2; + const sourceCenterY = sourceRect.top + sourceRect.height / 2; + const targetCenterX = targetRect.left + targetRect.width / 2; + const targetCenterY = targetRect.top + targetRect.height / 2; + const scale = Math.max(0.12, Math.min(0.24, Math.min(targetRect.width / sourceRect.width, targetRect.height / sourceRect.height))); + + target.animate( + [ + { boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" }, + { boxShadow: "0 0 0 6px rgba(59,130,246,0.14)", transform: "scale(1.015)" }, + { boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" }, + ], + { duration: 520, easing: "cubic-bezier(0.22, 1, 0.36, 1)" } + ); + + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clone.remove(); + resolve(); + }; + + const timeout = window.setTimeout(finish, 700); + clone.addEventListener( + "transitionend", + () => { + window.clearTimeout(timeout); + finish(); + }, + { once: true } + ); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const dx = targetCenterX - sourceCenterX; + const dy = targetCenterY - sourceCenterY; + clone.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`; + clone.style.opacity = "0.14"; + clone.style.filter = "blur(1.2px)"; + }); + }); + }); + + return true; + }, [data.src]); + + const handleAddToChat = useCallback(async () => { + if (!data.src || isProcessing || isAddingToChat) return; + setIsAddingToChat(true); + + try { + await animateAddToChat(); + addPendingChatAttachment({ + url: data.src, + name: displayTitle, + contentType: "image/jpeg", + }); + toast.success("已添加到对话框"); + } finally { + setIsAddingToChat(false); + } + }, [addPendingChatAttachment, animateAddToChat, data.src, displayTitle, isAddingToChat, isProcessing]); // 获取工具栏位置 - 在渲染时直接计算 const getToolbarPosition = () => { @@ -431,8 +534,6 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { return { top, left }; }; - const displayTitle = getImageNodeTitle(data, node.id); - const handleSaveTitle = useCallback(() => { const nextTitle = normalizeImageTitle(draftTitle) || displayTitle; updateNode(node.id, { @@ -1564,18 +1665,15 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { 添加到对话