UNPKG

@lobehub/ui

Version:

Lobe UI is an open-source UI component library for building AIGC web apps

324 lines (323 loc) 9.05 kB
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