feat(canvas): animate image fly-to-chat when adding attachment
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-12 15:26:16 +08:00
parent b5991d2a2a
commit 99dfab0ca0
2 changed files with 114 additions and 13 deletions

View File

@ -1846,7 +1846,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div className="p-4 bg-background/80 backdrop-blur-md border-t">
<div className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background">
<div
className="relative bg-card rounded-3xl border transition-all duration-200 focus-within:border-primary/50 focus-within:bg-background"
data-canvas-chat-input-anchor="true"
>
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div className="relative px-4 pt-3 pb-12 min-h-[60px]">
<div

View File

@ -253,6 +253,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftNote, setDraftNote] = useState("");
const [isAddingToChat, setIsAddingToChat] = useState(false);
// 文件输入的 ref
const fileInputRef = useRef<HTMLInputElement>(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<void>((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) {
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
disabled={isProcessing || isAddingToChat}
className="h-8 w-8 rounded-full hover:bg-blue-50 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:pointer-events-none"
onClick={() => {
addPendingChatAttachment({
url: data.src!,
name: displayTitle,
contentType: 'image/jpeg',
});
toast.success('已添加到对话框');
}}
onClick={handleAddToChat}
>
<MessageSquarePlus className="h-4 w-4" />
{isAddingToChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquarePlus className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">添加到对话</TooltipContent>