@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
403 lines (402 loc) • 11.6 kB
JavaScript
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