feat(canvas): add two-stage add-to-chat fly animation with bounce
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:30:24 +08:00
parent 99dfab0ca0
commit ec00b35658

View File

@ -396,6 +396,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
return false;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return false;
}
const target = document.querySelector('[data-canvas-chat-input-anchor="true"]') as HTMLElement | null;
if (!target) return false;
@ -408,72 +412,195 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
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 waitForAnimation = (animation: Animation | null, fallbackMs: number) =>
new Promise<void>((resolve) => {
if (!animation) {
resolve();
return;
}
let finished = false;
const done = () => {
if (finished) return;
finished = true;
resolve();
};
const timeout = window.setTimeout(done, fallbackMs);
animation.addEventListener(
"finish",
() => {
window.clearTimeout(timeout);
done();
},
{ once: true }
);
animation.addEventListener(
"cancel",
() => {
window.clearTimeout(timeout);
done();
},
{ once: true }
);
});
const sourceCenterX = sourceRect.left + sourceRect.width / 2;
const sourceCenterY = sourceRect.top + sourceRect.height / 2;
const labelText = displayTitle.length > 12 ? `${displayTitle.slice(0, 10)}...` : displayTitle;
const tagWidth = Math.min(172, Math.max(112, 58 + labelText.length * 8));
const tagHeight = 34;
const tagLeft = Math.min(
Math.max(sourceCenterX - tagWidth / 2, 8),
window.innerWidth - tagWidth - 8
);
const defaultTagTop = sourceRect.top - 46;
const tagTop =
defaultTagTop < 10
? Math.min(sourceRect.bottom + 10, window.innerHeight - tagHeight - 10)
: defaultTagTop;
const flightTag = document.createElement("div");
flightTag.style.position = "fixed";
flightTag.style.left = `${sourceRect.left}px`;
flightTag.style.top = `${sourceRect.top}px`;
flightTag.style.width = `${sourceRect.width}px`;
flightTag.style.height = `${sourceRect.height}px`;
flightTag.style.borderRadius = "14px";
flightTag.style.overflow = "hidden";
flightTag.style.pointerEvents = "none";
flightTag.style.zIndex = "99999";
flightTag.style.background = "#ffffff";
flightTag.style.border = "1px solid rgba(148, 163, 184, 0.24)";
flightTag.style.boxShadow = "0 14px 30px rgba(15, 23, 42, 0.28)";
flightTag.style.transformOrigin = "center center";
flightTag.style.willChange = "transform, opacity, filter";
const previewImage = document.createElement("img");
previewImage.src = data.src;
previewImage.alt = "";
previewImage.style.width = "100%";
previewImage.style.height = "100%";
previewImage.style.objectFit = "cover";
flightTag.appendChild(previewImage);
document.body.appendChild(flightTag);
const compressDx = tagLeft + tagWidth / 2 - sourceCenterX;
const compressDy = tagTop + tagHeight / 2 - sourceCenterY;
const compressScaleX = tagWidth / sourceRect.width;
const compressScaleY = tagHeight / sourceRect.height;
const compressAnimation = flightTag.animate(
[
{
transform: "translate(0px, 0px) scale(1, 1)",
borderRadius: "14px",
boxShadow: "0 14px 30px rgba(15, 23, 42, 0.28)",
},
{
offset: 0.72,
transform: `translate(${compressDx * 1.03}px, ${compressDy * 1.03}px) scale(${compressScaleX * 1.05}, ${compressScaleY * 1.08})`,
borderRadius: "999px",
boxShadow: "0 10px 24px rgba(59, 130, 246, 0.24)",
},
{
transform: `translate(${compressDx}px, ${compressDy}px) scale(${compressScaleX}, ${compressScaleY})`,
borderRadius: "999px",
boxShadow: "0 8px 20px rgba(59, 130, 246, 0.2)",
},
],
{
duration: 320,
easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)",
fill: "forwards",
}
);
await waitForAnimation(compressAnimation, 420);
flightTag.style.left = `${tagLeft}px`;
flightTag.style.top = `${tagTop}px`;
flightTag.style.width = `${tagWidth}px`;
flightTag.style.height = `${tagHeight}px`;
flightTag.style.transform = "none";
flightTag.style.borderRadius = "999px";
flightTag.style.boxShadow = "0 8px 20px rgba(59, 130, 246, 0.2)";
flightTag.style.display = "flex";
flightTag.style.alignItems = "center";
flightTag.style.gap = "6px";
flightTag.style.padding = "4px 10px 4px 4px";
flightTag.style.background = "rgba(255,255,255,0.96)";
flightTag.style.backdropFilter = "blur(6px)";
flightTag.innerHTML = "";
const chipImage = document.createElement("img");
chipImage.src = data.src;
chipImage.alt = "";
chipImage.style.width = "24px";
chipImage.style.height = "24px";
chipImage.style.objectFit = "cover";
chipImage.style.borderRadius = "999px";
chipImage.style.border = "1px solid rgba(148, 163, 184, 0.35)";
const chipLabel = document.createElement("span");
chipLabel.textContent = labelText || "图片";
chipLabel.style.fontSize = "12px";
chipLabel.style.fontWeight = "500";
chipLabel.style.color = "rgb(51, 65, 85)";
chipLabel.style.whiteSpace = "nowrap";
chipLabel.style.overflow = "hidden";
chipLabel.style.textOverflow = "ellipsis";
flightTag.appendChild(chipImage);
flightTag.appendChild(chipLabel);
const tagCenterX = tagLeft + tagWidth / 2;
const tagCenterY = tagTop + tagHeight / 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)));
const targetCenterY = targetRect.top + targetRect.height * 0.55;
const flyDx = targetCenterX - tagCenterX;
const flyDy = targetCenterY - tagCenterY;
const flyAnimation = flightTag.animate(
[
{ transform: "translate(0px, 0px) scale(1)", opacity: 1, filter: "blur(0px)" },
{
offset: 0.55,
transform: `translate(${flyDx * 0.64}px, ${flyDy * 0.62 - 46}px) scale(0.94)`,
opacity: 0.98,
filter: "blur(0px)",
},
{
offset: 0.84,
transform: `translate(${flyDx + 9}px, ${flyDy - 7}px) scale(0.74)`,
opacity: 0.82,
filter: "blur(0.4px)",
},
{
transform: `translate(${flyDx}px, ${flyDy}px) scale(0.64)`,
opacity: 0.15,
filter: "blur(1.2px)",
},
],
{
duration: 760,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards",
}
);
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)" },
{ offset: 0.58, boxShadow: "0 0 0 8px rgba(59,130,246,0.14)", transform: "scale(1.02)" },
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
],
{ duration: 520, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
{ duration: 720, easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)" }
);
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)";
});
});
});
await waitForAnimation(flyAnimation, 860);
flightTag.remove();
return true;
}, [data.src]);
}, [data.src, displayTitle]);
const handleAddToChat = useCallback(async () => {
if (!data.src || isProcessing || isAddingToChat) return;