UNPKG

@lobehub/ui

Version:

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

403 lines (402 loc) 11.6 kB
import { anchoredZoom, clampPan, clampScale, computeFit, doubleClickTarget, naturalScale, normalizeRotation, normalizeWheelDelta, panBounds, resolveInitialScale, rubberBand, wheelZoomFactor } from "./geometry.mjs"; import { useCallback, useEffect, useRef, useState } from "react"; import { animate, motionValue } from "motion/react"; //#region src/Image/viewer/useZoomPan.ts const WHEEL_CLOSE_THRESHOLD = 100; const WHEEL_IDLE_MS = 300; const ZOOM_STEP = 1.5; const RESET_TRANSITION = { damping: 30, stiffness: 300, type: "spring" }; const CLEAN_EPSILON = .01; const useZoomPan = ({ natural, viewport, maxScale, onCloseRequest, isClosing, isTransitioning, fillViewport, defaultZoom, autoZoomThreshold }) => { const naturalRef = useRef(natural); const viewportRef = useRef(viewport); const maxScaleRef = useRef(maxScale ?? 8); maxScaleRef.current = maxScale ?? 8; const fillViewportRef = useRef(fillViewport ?? false); fillViewportRef.current = fillViewport ?? false; const policyRef = useRef(defaultZoom ?? "auto"); policyRef.current = defaultZoom ?? "auto"; const thresholdRef = useRef(autoZoomThreshold ?? 2); thresholdRef.current = autoZoomThreshold ?? 2; const computeInitialScale = useCallback((rotation = 0) => { const fitRect = computeFit(naturalRef.current, viewportRef.current, rotation, fillViewportRef.current); return resolveInitialScale(policyRef.current, naturalRef.current, fitRect, rotation, thresholdRef.current); }, []); const [initialScaleSeed] = useState(computeInitialScale); const initialScaleRef = useRef(initialScaleSeed); const getInitialScale = useCallback(() => initialScaleRef.current, []); const [{ scale, x, y, rotate, flipX, flipY }] = useState(() => ({ flipX: motionValue(false), flipY: motionValue(false), rotate: motionValue(0), scale: motionValue(initialScaleRef.current), x: motionValue(0), y: motionValue(0) })); const effectiveMaxScale = useCallback(() => Math.max(maxScaleRef.current, initialScaleRef.current), []); const onCloseRequestRef = useRef(onCloseRequest); onCloseRequestRef.current = onCloseRequest; const isClosingRef = useRef(isClosing); isClosingRef.current = isClosing; const isTransitioningRef = useRef(isTransitioning); isTransitioningRef.current = isTransitioning; const closeAccumRef = useRef(0); const disarmedRef = useRef(false); const idleTimerRef = useRef(null); const resetCloseTracking = useCallback(() => { if (idleTimerRef.current !== null) { clearTimeout(idleTimerRef.current); idleTimerRef.current = null; } closeAccumRef.current = 0; disarmedRef.current = false; }, []); const closing = useCallback(() => isClosingRef.current?.() ?? false, []); const isAtFitFloor = useCallback(() => scale.get() <= 1.01, [scale]); const isOrientationClean = useCallback(() => Math.abs(rotate.get()) < CLEAN_EPSILON && !flipX.get() && !flipY.get(), [ flipX, flipY, rotate ]); const isCleanState = useCallback(() => Math.abs(scale.get() - initialScaleRef.current) < CLEAN_EPSILON && isOrientationClean(), [isOrientationClean, scale]); const computeDerived = useCallback(() => { const currentScale = scale.get(); return { canZoomIn: currentScale < effectiveMaxScale(), canZoomOut: currentScale > 1, isClean: isCleanState(), isZoomed: currentScale > 1, rotation: normalizeRotation(rotate.get()) }; }, [ effectiveMaxScale, isCleanState, rotate, scale ]); const [derived, setDerived] = useState(computeDerived); const syncDerived = useCallback(() => { setDerived((prev) => { const next = computeDerived(); if (prev.canZoomIn === next.canZoomIn && prev.canZoomOut === next.canZoomOut && prev.isClean === next.isClean && prev.isZoomed === next.isZoomed && prev.rotation === next.rotation) return prev; return next; }); }, [computeDerived]); useEffect(() => { const unsubscribers = [ scale, rotate, flipX, flipY ].map((value) => value.on("change", syncDerived)); syncDerived(); return () => { for (const unsubscribe of unsubscribers) unsubscribe(); }; }, [ flipX, flipY, rotate, scale, syncDerived ]); const getFitRect = useCallback(() => computeFit(naturalRef.current, viewportRef.current, normalizeRotation(rotate.get()), fillViewportRef.current), [rotate]); const applyTransform = useCallback((next, fitRect) => { const clamped = clampPan(next, fitRect, viewportRef.current); scale.jump(next.scale); x.jump(clamped.x); y.jump(clamped.y); }, [ scale, x, y ]); const animateTransform = useCallback((next, fitRect) => { const clamped = clampPan(next, fitRect, viewportRef.current); animate(scale, next.scale, RESET_TRANSITION); animate(x, clamped.x, RESET_TRANSITION); animate(y, clamped.y, RESET_TRANSITION); }, [ scale, x, y ]); const zoomBy = useCallback((factor) => { if (closing()) return; const currentScale = scale.get(); const targetScale = clampScale(currentScale * factor, effectiveMaxScale()); const fitRect = getFitRect(); const anchor = { x: viewportRef.current.width / 2, y: viewportRef.current.height / 2 }; const next = anchoredZoom({ scale: currentScale, x: x.get(), y: y.get() }, targetScale, anchor, fitRect); animateTransform(next, fitRect); }, [ animateTransform, closing, effectiveMaxScale, getFitRect, scale, x, y ]); const zoomIn = useCallback(() => zoomBy(ZOOM_STEP), [zoomBy]); const zoomOut = useCallback(() => zoomBy(1 / ZOOM_STEP), [zoomBy]); const handleDoubleClick = useCallback((point) => { if (closing()) return; const currentScale = scale.get(); const fitRect = getFitRect(); const target = doubleClickTarget(currentScale, naturalRef.current, fitRect, normalizeRotation(rotate.get())); const targetScale = clampScale(target, effectiveMaxScale()); const next = anchoredZoom({ scale: currentScale, x: x.get(), y: y.get() }, targetScale, point, fitRect); animateTransform(next, fitRect); }, [ animateTransform, closing, effectiveMaxScale, getFitRect, rotate, scale, x, y ]); const toggleActualSize = useCallback(() => { if (closing()) return; const currentScale = scale.get(); const fitRect = getFitRect(); const actual = naturalScale(naturalRef.current, fitRect, normalizeRotation(rotate.get())); const atActual = Math.abs(currentScale - actual) < CLEAN_EPSILON; const targetScale = clampScale(atActual ? 1 : actual, effectiveMaxScale()); const anchor = { x: viewportRef.current.width / 2, y: viewportRef.current.height / 2 }; const next = anchoredZoom({ scale: currentScale, x: x.get(), y: y.get() }, targetScale, anchor, fitRect); animateTransform(next, fitRect); }, [ animateTransform, closing, effectiveMaxScale, getFitRect, rotate, scale, x, y ]); const handleWheel = useCallback((event) => { event.preventDefault?.(); if (closing()) return; if (idleTimerRef.current !== null) clearTimeout(idleTimerRef.current); idleTimerRef.current = setTimeout(() => { idleTimerRef.current = null; closeAccumRef.current = 0; disarmedRef.current = false; }, WHEEL_IDLE_MS); const delta = normalizeWheelDelta(event.deltaY, event.deltaMode, viewportRef.current.height); const currentScale = scale.get(); if (!!(event.ctrlKey || event.metaKey) || delta < 0 || !isAtFitFloor()) { disarmedRef.current = true; closeAccumRef.current = 0; const rawTarget = currentScale * wheelZoomFactor(delta); if (rawTarget <= 1) { animate(scale, 1, RESET_TRANSITION); animate(x, 0, RESET_TRANSITION); animate(y, 0, RESET_TRANSITION); return; } const targetScale = clampScale(rawTarget, effectiveMaxScale()); const fitRect = getFitRect(); const anchor = { x: event.clientX, y: event.clientY }; const next = anchoredZoom({ scale: currentScale, x: x.get(), y: y.get() }, targetScale, anchor, fitRect); applyTransform(next, fitRect); return; } if (disarmedRef.current || !isOrientationClean()) return; closeAccumRef.current += Math.abs(delta); if (closeAccumRef.current >= WHEEL_CLOSE_THRESHOLD) { closeAccumRef.current = 0; onCloseRequestRef.current?.(); } }, [ applyTransform, closing, effectiveMaxScale, getFitRect, isAtFitFloor, isOrientationClean, scale, x, y ]); const rotateBy = useCallback((delta) => { if (closing()) return; rotate.jump(normalizeRotation(rotate.get() + delta)); scale.jump(1); x.jump(0); y.jump(0); }, [ closing, rotate, scale, x, y ]); const rotateLeft = useCallback(() => rotateBy(-90), [rotateBy]); const rotateRight = useCallback(() => rotateBy(90), [rotateBy]); const flipHorizontal = useCallback(() => { if (closing()) return; flipX.set(!flipX.get()); }, [closing, flipX]); const flipVertical = useCallback(() => { if (closing()) return; flipY.set(!flipY.get()); }, [closing, flipY]); const reset = useCallback(() => { if (closing()) return; animate(scale, initialScaleRef.current, RESET_TRANSITION); animate(x, 0, RESET_TRANSITION); animate(y, 0, RESET_TRANSITION); animate(rotate, 0, RESET_TRANSITION); flipX.set(false); flipY.set(false); }, [ closing, flipX, flipY, rotate, scale, x, y ]); const dragBy = useCallback((delta) => { if (closing()) return; const fitRect = getFitRect(); const bounds = panBounds({ scale: scale.get(), x: 0, y: 0 }, fitRect, viewportRef.current); x.set(rubberBand(x.get() + delta.x, bounds.x.min, bounds.x.max)); y.set(rubberBand(y.get() + delta.y, bounds.y.min, bounds.y.max)); }, [ closing, getFitRect, scale, x, y ]); const dragEnd = useCallback(() => { if (closing()) return; const fitRect = getFitRect(); const clamped = clampPan({ scale: scale.get(), x: x.get(), y: y.get() }, fitRect, viewportRef.current); animate(x, clamped.x, RESET_TRANSITION); animate(y, clamped.y, RESET_TRANSITION); }, [ closing, getFitRect, scale, x, y ]); const escIntent = useCallback(() => isCleanState() ? "close" : "reset", [isCleanState]); const setViewport = useCallback((next) => { viewportRef.current = next; const fitRect = computeFit(naturalRef.current, next, normalizeRotation(rotate.get()), fillViewportRef.current); const clamped = clampPan({ scale: scale.get(), x: x.get(), y: y.get() }, fitRect, next); x.set(clamped.x); y.set(clamped.y); }, [ rotate, scale, x, y ]); const setNatural = useCallback((next) => { const wasClean = isCleanState(); naturalRef.current = next; const rotation = normalizeRotation(rotate.get()); initialScaleRef.current = computeInitialScale(rotation); if (wasClean && !closing() && !isTransitioningRef.current?.()) scale.jump(initialScaleRef.current); const fitRect = computeFit(next, viewportRef.current, rotation, fillViewportRef.current); const clamped = clampPan({ scale: scale.get(), x: x.get(), y: y.get() }, fitRect, viewportRef.current); x.set(clamped.x); y.set(clamped.y); }, [ closing, computeInitialScale, isCleanState, rotate, scale, x, y ]); return { canZoomIn: derived.canZoomIn, canZoomOut: derived.canZoomOut, dragBy, dragEnd, escIntent, flipHorizontal, flipVertical, flipX, flipY, getInitialScale, handleDoubleClick, handleWheel, isClean: derived.isClean, isZoomed: derived.isZoomed, reset, resetCloseTracking, rotate, rotateLeft, rotateRight, rotation: derived.rotation, scale, setNatural, setViewport, toggleActualSize, x, y, zoomIn, zoomOut }; }; //#endregion export { useZoomPan }; //# sourceMappingURL=useZoomPan.mjs.map