feat(canvas): add two-stage add-to-chat fly animation with bounce
This commit is contained in:
parent
99dfab0ca0
commit
ec00b35658
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user