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;
|
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;
|
const target = document.querySelector('[data-canvas-chat-input-anchor="true"]') as HTMLElement | null;
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
|
|
||||||
@ -408,72 +412,195 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clone = document.createElement("img");
|
const waitForAnimation = (animation: Animation | null, fallbackMs: number) =>
|
||||||
clone.src = data.src;
|
new Promise<void>((resolve) => {
|
||||||
clone.alt = "";
|
if (!animation) {
|
||||||
clone.style.position = "fixed";
|
resolve();
|
||||||
clone.style.left = `${sourceRect.left}px`;
|
return;
|
||||||
clone.style.top = `${sourceRect.top}px`;
|
}
|
||||||
clone.style.width = `${sourceRect.width}px`;
|
let finished = false;
|
||||||
clone.style.height = `${sourceRect.height}px`;
|
const done = () => {
|
||||||
clone.style.objectFit = "cover";
|
if (finished) return;
|
||||||
clone.style.borderRadius = "14px";
|
finished = true;
|
||||||
clone.style.pointerEvents = "none";
|
resolve();
|
||||||
clone.style.zIndex = "99999";
|
};
|
||||||
clone.style.boxShadow = "0 14px 30px rgba(15, 23, 42, 0.28)";
|
const timeout = window.setTimeout(done, fallbackMs);
|
||||||
clone.style.transformOrigin = "center center";
|
animation.addEventListener(
|
||||||
clone.style.transition =
|
"finish",
|
||||||
"transform 560ms cubic-bezier(0.22, 1, 0.36, 1), opacity 560ms ease, filter 560ms ease";
|
() => {
|
||||||
clone.style.willChange = "transform, opacity, filter";
|
window.clearTimeout(timeout);
|
||||||
document.body.appendChild(clone);
|
done();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
animation.addEventListener(
|
||||||
|
"cancel",
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const sourceCenterX = sourceRect.left + sourceRect.width / 2;
|
const sourceCenterX = sourceRect.left + sourceRect.width / 2;
|
||||||
const sourceCenterY = sourceRect.top + sourceRect.height / 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 targetCenterX = targetRect.left + targetRect.width / 2;
|
||||||
const targetCenterY = targetRect.top + targetRect.height / 2;
|
const targetCenterY = targetRect.top + targetRect.height * 0.55;
|
||||||
const scale = Math.max(0.12, Math.min(0.24, Math.min(targetRect.width / sourceRect.width, targetRect.height / sourceRect.height)));
|
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(
|
target.animate(
|
||||||
[
|
[
|
||||||
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
|
{ 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)" },
|
{ 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) => {
|
await waitForAnimation(flyAnimation, 860);
|
||||||
let done = false;
|
flightTag.remove();
|
||||||
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;
|
return true;
|
||||||
}, [data.src]);
|
}, [data.src, displayTitle]);
|
||||||
|
|
||||||
const handleAddToChat = useCallback(async () => {
|
const handleAddToChat = useCallback(async () => {
|
||||||
if (!data.src || isProcessing || isAddingToChat) return;
|
if (!data.src || isProcessing || isAddingToChat) return;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user