feat(canvas): animate image fly-to-chat when adding attachment
This commit is contained in:
parent
b5991d2a2a
commit
99dfab0ca0
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user