@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
324 lines (323 loc) • 9.05 kB
JavaScript
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { animate, motionValue } from "motion/react";
//#region src/Image/viewer/useFlipTransition.ts
const OPEN_SPRING = {
bounce: .15,
type: "spring",
visualDuration: .3
};
const PUSH_SPRING = {
bounce: 0,
type: "spring",
visualDuration: .3
};
const FADE = {
duration: .15,
ease: "easeOut"
};
const CHROME_FADE = {
delay: .15,
duration: .2,
ease: "easeOut"
};
const FADE_SCALE = .92;
const SWITCH_FADE_SCALE = .96;
const SETTLE_FALLBACK_MS = 1e3;
const measureSource = (element) => {
if (!element.isConnected) return null;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
if (rect.bottom <= 0 || rect.right <= 0) return null;
if (rect.top >= window.innerHeight || rect.left >= window.innerWidth) return null;
return rect;
};
const sourceTransform = (rect, fit) => ({
scale: fit.width > 0 && fit.height > 0 ? Math.max(rect.width / fit.width, rect.height / fit.height) : 1,
x: rect.left + rect.width / 2 - (fit.x + fit.width / 2),
y: rect.top + rect.height / 2 - (fit.y + fit.height / 2)
});
const readTransform = (transform) => [
`translate3d(${transform.x.get()}px, ${transform.y.get()}px, 0)`,
`scale(${transform.scale.get()})`,
`rotate(${transform.rotate.get()}deg)`,
`scaleX(${transform.flipX.get() ? -1 : 1})`,
`scaleY(${transform.flipY.get() ? -1 : 1})`
].join(" ");
const useBoundNode = (apply) => {
const nodeRef = useRef(null);
const applyRef = useRef(apply);
applyRef.current = apply;
const setNode = useCallback((node) => {
nodeRef.current = node;
if (node) applyRef.current(node);
}, []);
const refresh = useCallback(() => {
const node = nodeRef.current;
if (node) applyRef.current(node);
}, []);
const getNode = useCallback(() => nodeRef.current, []);
return useMemo(() => ({
getNode,
refresh,
setNode
}), [
getNode,
refresh,
setNode
]);
};
const useFlipTransition = ({ animated, getCloseSource, getFitRect, getInitialScale, getViewportWidth, onClosed, source, transform }) => {
const [opacity] = useState(() => ({
backdrop: motionValue(0),
chrome: motionValue(0),
image: motionValue(animated ? 1 : 0)
}));
const image = useBoundNode(useCallback((node) => {
node.style.transform = readTransform(transform);
node.style.opacity = String(opacity.image.get());
}, [opacity, transform]));
const backdrop = useBoundNode(useCallback((node) => {
node.style.opacity = String(opacity.backdrop.get());
}, [opacity]));
const chrome = useBoundNode(useCallback((node) => {
node.style.opacity = String(opacity.chrome.get());
}, [opacity]));
const runningRef = useRef([]);
const closingRef = useRef(false);
const transitioningRef = useRef(true);
const onClosedRef = useRef(onClosed);
onClosedRef.current = onClosed;
const getFitRectRef = useRef(getFitRect);
getFitRectRef.current = getFitRect;
const getCloseSourceRef = useRef(getCloseSource);
getCloseSourceRef.current = getCloseSource;
const getInitialScaleRef = useRef(getInitialScale);
getInitialScaleRef.current = getInitialScale;
const getViewportWidthRef = useRef(getViewportWidth);
getViewportWidthRef.current = getViewportWidth;
const ghostRef = useRef(null);
const removeGhost = useCallback(() => {
ghostRef.current?.remove();
ghostRef.current = null;
}, []);
const run = useCallback((animations) => {
runningRef.current = [...runningRef.current, ...animations];
}, []);
const stopAll = useCallback(() => {
for (const animation of runningRef.current) animation.stop();
runningRef.current = [];
}, []);
const isTransitioning = useCallback(() => transitioningRef.current, []);
const isClosing = useCallback(() => closingRef.current, []);
const trackTimeout = useCallback((callback, ms) => {
const id = window.setTimeout(callback, ms);
run([{ stop: () => window.clearTimeout(id) }]);
}, [run]);
const animateSettling = useCallback((axes, onSettled) => {
let pending = axes.length;
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
onSettled();
};
const onAxisComplete = () => {
pending -= 1;
if (pending === 0) finish();
};
run(axes.map(([value, target]) => animate(value, target, {
...OPEN_SPRING,
onComplete: onAxisComplete
})));
trackTimeout(finish, SETTLE_FALLBACK_MS);
}, [run, trackTimeout]);
useLayoutEffect(() => {
const unsubscribes = [
...[
transform.flipX,
transform.flipY,
transform.rotate,
transform.scale,
transform.x,
transform.y,
opacity.image
].map((value) => value.on("change", image.refresh)),
opacity.backdrop.on("change", backdrop.refresh),
opacity.chrome.on("change", chrome.refresh)
];
return () => {
for (const unsubscribe of unsubscribes) unsubscribe();
};
}, [
backdrop,
chrome,
image,
opacity,
transform
]);
useLayoutEffect(() => {
const rect = animated ? measureSource(source) : null;
transitioningRef.current = true;
const markSettled = () => {
transitioningRef.current = false;
};
const resting = getInitialScaleRef.current();
if (rect) {
const start = sourceTransform(rect, getFitRectRef.current());
transform.scale.set(start.scale);
transform.x.set(start.x);
transform.y.set(start.y);
opacity.image.set(1);
animateSettling([
[transform.scale, resting],
[transform.x, 0],
[transform.y, 0]
], markSettled);
} else {
opacity.image.set(0);
if (animated) transform.scale.set(resting * FADE_SCALE);
run([animate(opacity.image, 1, {
...FADE,
onComplete: markSettled
})]);
if (animated) run([animate(transform.scale, resting, FADE)]);
trackTimeout(markSettled, SETTLE_FALLBACK_MS);
}
run([animate(opacity.backdrop, 1, FADE), animate(opacity.chrome, 1, CHROME_FADE)]);
return () => {
stopAll();
removeGhost();
};
}, [
animateSettling,
animated,
opacity,
removeGhost,
run,
source,
stopAll,
trackTimeout,
transform
]);
const close = useCallback((options) => {
if (closingRef.current) return;
closingRef.current = true;
transitioningRef.current = true;
stopAll();
removeGhost();
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
transitioningRef.current = false;
onClosedRef.current();
};
const rect = animated && !options?.fade ? measureSource(getCloseSourceRef.current()) : null;
run([animate(opacity.backdrop, 0, FADE), animate(opacity.chrome, 0, FADE)]);
if (rect) {
const target = sourceTransform(rect, getFitRectRef.current());
animateSettling([
[transform.x, target.x],
[transform.y, target.y],
[transform.scale, target.scale]
], finish);
return;
}
if (animated) run([animate(transform.scale, transform.scale.get() * FADE_SCALE, FADE)]);
run([animate(opacity.image, 0, {
...FADE,
onComplete: finish
})]);
trackTimeout(finish, SETTLE_FALLBACK_MS);
}, [
animateSettling,
animated,
opacity,
run,
stopAll,
trackTimeout,
transform
]);
const switchTo = useCallback((apply, direction) => {
if (closingRef.current) return;
stopAll();
removeGhost();
transitioningRef.current = true;
const markSettled = () => {
transitioningRef.current = false;
};
const node = image.getNode();
const width = getViewportWidthRef.current();
if (animated && direction && node && width > 0) {
const ghost = node.cloneNode();
ghost.dataset.ghost = "true";
ghost.setAttribute("aria-hidden", "true");
ghost.style.pointerEvents = "none";
const base = ghost.style.transform;
node.parentElement?.insertBefore(ghost, node);
ghostRef.current = ghost;
const progress = motionValue(0);
const unsubscribe = progress.on("change", (p) => {
ghost.style.transform = `translateX(${-direction * width * p}px) ${base}`;
});
const finishGhost = () => {
unsubscribe();
if (ghostRef.current === ghost) removeGhost();
else ghost.remove();
};
apply();
transform.x.jump(direction * width);
run([
animate(progress, 1, {
...PUSH_SPRING,
onComplete: finishGhost
}),
animate(transform.x, 0, {
...PUSH_SPRING,
onComplete: markSettled
}),
{ stop: finishGhost }
]);
trackTimeout(() => {
finishGhost();
markSettled();
}, SETTLE_FALLBACK_MS);
return;
}
const tasks = [];
if (animated) tasks.push(animate(transform.scale, SWITCH_FADE_SCALE, FADE));
tasks.push(animate(opacity.image, 0, {
...FADE,
onComplete: () => {
apply();
run([animate(opacity.image, 1, {
...FADE,
onComplete: markSettled
})]);
}
}));
run(tasks);
trackTimeout(markSettled, SETTLE_FALLBACK_MS);
}, [
animated,
image,
opacity,
removeGhost,
run,
stopAll,
trackTimeout,
transform
]);
return {
backdropRef: backdrop.setNode,
chromeRef: chrome.setNode,
close,
imageRef: image.setNode,
isClosing,
isTransitioning,
switchTo
};
};
//#endregion
export { OPEN_SPRING, useFlipTransition };
//# sourceMappingURL=useFlipTransition.mjs.map