fix(app): fit media to its real box and gate the zoom buttons (#1945)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:50:48 +02:00
1 parent 9cbe0efbbb
commit 793e853eb3
9 files changed
+255 -245

No files matched your search

@@ -17,6 +17,7 @@ import {
MobileMediaViewer, MobileMediaViewer,
} from '@app/features/messaging/components/modals/media_modal/MediaViewers'; } from '@app/features/messaging/components/modals/media_modal/MediaViewers';
import {MobileMediaActions} from '@app/features/messaging/components/modals/media_modal/MobileMediaActions'; import {MobileMediaActions} from '@app/features/messaging/components/modals/media_modal/MobileMediaActions';
import {MIN_ZOOM_SCALE} from '@app/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath';
import type {PanZoomSurfaceHandle} from '@app/features/messaging/components/modals/media_modal/pan_zoom/PanZoomSurface'; import type {PanZoomSurfaceHandle} from '@app/features/messaging/components/modals/media_modal/pan_zoom/PanZoomSurface';
import type {PanZoomTransformSnapshot} from '@app/features/messaging/components/modals/media_modal/pan_zoom/usePanZoomSurface'; import type {PanZoomTransformSnapshot} from '@app/features/messaging/components/modals/media_modal/pan_zoom/usePanZoomSurface';
import { import {
@@ -50,6 +51,38 @@ import {observer} from 'mobx-react-lite';
import type {CSSProperties, FC, KeyboardEvent as ReactKeyboardEvent} from 'react'; import type {CSSProperties, FC, KeyboardEvent as ReactKeyboardEvent} from 'react';
import {createElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {createElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
interface PanZoomInfo {
zoomPercent: number;
isDefault: boolean;
canZoomIn: boolean;
canZoomOut: boolean;
}
function buildPanZoomInfo(snapshot: PanZoomTransformSnapshot): PanZoomInfo {
return {
zoomPercent: Math.round(snapshot.scale * 100),
isDefault: snapshot.isDefault,
canZoomIn: snapshot.canZoomIn,
canZoomOut: snapshot.canZoomOut,
};
}
function isSamePanZoomInfo(a: PanZoomInfo, b: PanZoomInfo): boolean {
return (
a.zoomPercent === b.zoomPercent &&
a.isDefault === b.isDefault &&
a.canZoomIn === b.canZoomIn &&
a.canZoomOut === b.canZoomOut
);
}
const FITTED_PAN_ZOOM_INFO: PanZoomInfo = {
zoomPercent: Math.round(MIN_ZOOM_SCALE * 100),
isDefault: true,
canZoomIn: true,
canZoomOut: false,
};
export const MediaModal: FC<MediaModalProps> = observer( export const MediaModal: FC<MediaModalProps> = observer(
({ ({
title, title,
@@ -82,7 +115,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
const prefersReducedMotion = Accessibility.useReducedMotion; const prefersReducedMotion = Accessibility.useReducedMotion;
const [zoomState, setZoomState] = useState<ZoomState>('fit'); const [zoomState, setZoomState] = useState<ZoomState>('fit');
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
const [panZoomInfo, setPanZoomInfo] = useState({zoomPercent: 100, isDefault: true}); const [panZoomInfo, setPanZoomInfo] = useState<PanZoomInfo>(FITTED_PAN_ZOOM_INFO);
const [isHudHovered, setIsHudHovered] = useState(false); const [isHudHovered, setIsHudHovered] = useState(false);
const [viewportPadding, setViewportPadding] = useState(getViewportPadding); const [viewportPadding, setViewportPadding] = useState(getViewportPadding);
const [nativeTitlebarHeight, setNativeTitlebarHeight] = useState(getNativeTitlebarHeight); const [nativeTitlebarHeight, setNativeTitlebarHeight] = useState(getNativeTitlebarHeight);
@@ -92,8 +125,6 @@ export const MediaModal: FC<MediaModalProps> = observer(
const bottomInfoBarRef = useRef<HTMLDivElement>(null); const bottomInfoBarRef = useRef<HTMLDivElement>(null);
const thumbnailCarouselRef = useRef<HTMLDivElement>(null); const thumbnailCarouselRef = useRef<HTMLDivElement>(null);
const latestIndexRef = useRef(currentIndex ?? 0); const latestIndexRef = useRef(currentIndex ?? 0);
const transformFrameRef = useRef<number | null>(null);
const pendingPanZoomInfoRef = useRef(panZoomInfo);
const [topOverlayHeight, setTopOverlayHeight] = useState(0); const [topOverlayHeight, setTopOverlayHeight] = useState(0);
const [bottomOverlayHeight, setBottomOverlayHeight] = useState(0); const [bottomOverlayHeight, setBottomOverlayHeight] = useState(0);
const measureOverlayHeights = useCallback(() => { const measureOverlayHeights = useCallback(() => {
@@ -123,28 +154,8 @@ export const MediaModal: FC<MediaModalProps> = observer(
setZoomState((previousState) => (previousState === state ? previousState : state)); setZoomState((previousState) => (previousState === state ? previousState : state));
}, []); }, []);
const handleTransformChange = useCallback((snapshot: PanZoomTransformSnapshot) => { const handleTransformChange = useCallback((snapshot: PanZoomTransformSnapshot) => {
const nextInfo = { const nextInfo = buildPanZoomInfo(snapshot);
zoomPercent: Math.round(snapshot.scale * 100), setPanZoomInfo((previousInfo) => (isSamePanZoomInfo(previousInfo, nextInfo) ? previousInfo : nextInfo));
isDefault: snapshot.isDefault,
};
const pendingInfo = pendingPanZoomInfoRef.current;
if (pendingInfo.zoomPercent === nextInfo.zoomPercent && pendingInfo.isDefault === nextInfo.isDefault) {
return;
}
pendingPanZoomInfoRef.current = nextInfo;
if (transformFrameRef.current != null) {
return;
}
transformFrameRef.current = window.requestAnimationFrame(() => {
transformFrameRef.current = null;
setPanZoomInfo((previousInfo) => {
const latestInfo = pendingPanZoomInfoRef.current;
if (previousInfo.zoomPercent === latestInfo.zoomPercent && previousInfo.isDefault === latestInfo.isDefault) {
return previousInfo;
}
return latestInfo;
});
});
}, []); }, []);
const handleResetMedia = useCallback(() => { const handleResetMedia = useCallback(() => {
panZoomHandleRef.current?.reset(); panZoomHandleRef.current?.reset();
@@ -168,22 +179,10 @@ export const MediaModal: FC<MediaModalProps> = observer(
const handleHudPointerLeave = useCallback(() => { const handleHudPointerLeave = useCallback(() => {
setIsHudHovered(false); setIsHudHovered(false);
}, []); }, []);
useEffect(() => {
pendingPanZoomInfoRef.current = panZoomInfo;
}, [panZoomInfo]);
useEffect(() => {
return () => {
if (transformFrameRef.current != null) {
window.cancelAnimationFrame(transformFrameRef.current);
}
};
}, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (currentIndex !== undefined) { if (currentIndex !== undefined) {
latestIndexRef.current = currentIndex; latestIndexRef.current = currentIndex;
const defaultInfo = {zoomPercent: 100, isDefault: true}; setPanZoomInfo(FITTED_PAN_ZOOM_INFO);
pendingPanZoomInfoRef.current = defaultInfo;
setPanZoomInfo(defaultInfo);
setRotation(0); setRotation(0);
} }
}, [currentIndex]); }, [currentIndex]);
@@ -359,14 +358,14 @@ export const MediaModal: FC<MediaModalProps> = observer(
<div <div
className={styles.mediaContainer} className={styles.mediaContainer}
style={{'--media-rotation': `${rotation}deg`} as CSSProperties} style={{'--media-rotation': `${rotation}deg`} as CSSProperties}
data-media-rotation-active={hasCustomRotation ? 'true' : undefined}
data-rotation-swapped={isRotationSwapped ? 'true' : undefined} data-rotation-swapped={isRotationSwapped ? 'true' : undefined}
data-pan-zoom-measured-box="true"
data-flx="messaging.media-modal.wrapped-children.media-container" data-flx="messaging.media-modal.wrapped-children.media-container"
> >
{children} {children}
</div> </div>
), ),
[children, rotation, hasCustomRotation, isRotationSwapped], [children, rotation, isRotationSwapped],
); );
const mediaContent = isMobileVideo const mediaContent = isMobileVideo
? createElement(MobileVideoViewer, { ? createElement(MobileVideoViewer, {
@@ -621,6 +620,8 @@ export const MediaModal: FC<MediaModalProps> = observer(
onForward={onForward} onForward={onForward}
onClose={handleClose} onClose={handleClose}
canReset={!panZoomInfo.isDefault || hasCustomRotation} canReset={!panZoomInfo.isDefault || hasCustomRotation}
canZoomIn={panZoomInfo.canZoomIn}
canZoomOut={panZoomInfo.canZoomOut}
enableZoomControls={enablePanZoom} enableZoomControls={enablePanZoom}
onPointerEnter={handleHudPointerEnter} onPointerEnter={handleHudPointerEnter}
onPointerLeave={handleHudPointerLeave} onPointerLeave={handleHudPointerLeave}
@@ -644,6 +645,8 @@ export const MediaModal: FC<MediaModalProps> = observer(
onForward={onForward} onForward={onForward}
onClose={handleClose} onClose={handleClose}
canReset={!panZoomInfo.isDefault || hasCustomRotation} canReset={!panZoomInfo.isDefault || hasCustomRotation}
canZoomIn={panZoomInfo.canZoomIn}
canZoomOut={panZoomInfo.canZoomOut}
enableZoomControls={enablePanZoom} enableZoomControls={enablePanZoom}
onPointerEnter={handleHudPointerEnter} onPointerEnter={handleHudPointerEnter}
onPointerLeave={handleHudPointerLeave} onPointerLeave={handleHudPointerLeave}
@@ -128,6 +128,8 @@ interface MediaOverlayActionsProps {
onForward?: () => void; onForward?: () => void;
onClose: () => void; onClose: () => void;
canReset?: boolean; canReset?: boolean;
canZoomIn?: boolean;
canZoomOut?: boolean;
enableZoomControls?: boolean; enableZoomControls?: boolean;
onPointerEnter?: () => void; onPointerEnter?: () => void;
onPointerLeave?: () => void; onPointerLeave?: () => void;
@@ -151,6 +153,8 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
onForward, onForward,
onClose, onClose,
canReset = false, canReset = false,
canZoomIn = true,
canZoomOut = true,
enableZoomControls = false, enableZoomControls = false,
onPointerEnter, onPointerEnter,
onPointerLeave, onPointerLeave,
@@ -304,7 +308,7 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
} }
label={i18n._(ZOOM_IN_DESCRIPTOR)} label={i18n._(ZOOM_IN_DESCRIPTOR)}
onClick={onZoomIn ?? (() => {})} onClick={onZoomIn ?? (() => {})}
disabled={!enableZoomControls || !onZoomIn} disabled={!enableZoomControls || !onZoomIn || !canZoomIn}
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button--4" data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button--4"
/> />
<OverlayTooltipButton <OverlayTooltipButton
@@ -317,7 +321,7 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
} }
label={i18n._(ZOOM_OUT_DESCRIPTOR)} label={i18n._(ZOOM_OUT_DESCRIPTOR)}
onClick={onZoomOut ?? (() => {})} onClick={onZoomOut ?? (() => {})}
disabled={!enableZoomControls || !onZoomOut} disabled={!enableZoomControls || !onZoomOut || !canZoomOut}
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button--5" data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button--5"
/> />
{(onReply || onForward) && ( {(onReply || onForward) && (
@@ -33,6 +33,14 @@ describe('media viewer pan zoom stability', () => {
expect(modalSource).toContain('const isMobileVideo = Boolean(isMobile && mediaType ==='); expect(modalSource).toContain('const isMobileVideo = Boolean(isMobile && mediaType ===');
expect(modalSource).toContain('{isMobile && !isMobileVideo ? ('); expect(modalSource).toContain('{isMobile && !isMobileVideo ? (');
}); });
it('stamps the pan-zoom measured-box marker on the rotation container', () => {
const hookSource = sourceFile('pan_zoom/usePanZoomSurface.ts');
const modalSource = sourceFile('../MediaModal.tsx');
const selectorMatch = hookSource.match(/MEASURED_BOX_SELECTOR = '\[([a-z-]+)\]'/);
expect(selectorMatch).not.toBeNull();
const markerAttribute = selectorMatch?.[1] ?? 'missing-marker';
expect(modalSource).toMatch(new RegExp(`styles\\.mediaContainer[^<]*\\b${markerAttribute}=`));
});
it('floors the media-fit clamps with a length-typed zero so they resolve to valid lengths', () => { it('floors the media-fit clamps with a length-typed zero so they resolve to valid lengths', () => {
const modalCss = sourceFile('../MediaModal.module.css'); const modalCss = sourceFile('../MediaModal.module.css');
expect(modalCss).toMatch(/--media-fit-max-width:\s*max\(0px,/); expect(modalCss).toMatch(/--media-fit-max-width:\s*max\(0px,/);
@@ -33,7 +33,6 @@ export const DesktopMediaViewer = memo(
zoomState={zoomState} zoomState={zoomState}
zoomedScale={2.5} zoomedScale={2.5}
maxScale={5} maxScale={5}
preferNaturalZoomScale
tapToToggleZoom tapToToggleZoom
doubleClickEnabled={false} doubleClickEnabled={false}
resetKey={resetKey} resetKey={resetKey}
@@ -67,11 +66,10 @@ export const MobileMediaViewer = memo(
className={styles.mobileViewerContainer} className={styles.mobileViewerContainer}
contentClassName={styles.mobileViewerContent} contentClassName={styles.mobileViewerContent}
zoomState={zoomState} zoomState={zoomState}
zoomedScale={2.4} zoomedScale={2.5}
maxScale={5} maxScale={5}
preferNaturalZoomScale tapToToggleZoom
doubleClickEnabled doubleClickEnabled={false}
tapToToggleZoom={false}
resetKey={resetKey} resetKey={resetKey}
onZoomStateChange={onZoomStateChange} onZoomStateChange={onZoomStateChange}
onTransformChange={onTransformChange} onTransformChange={onTransformChange}
@@ -64,6 +64,8 @@ interface MobileMediaActionsProps {
onForward?: () => void; onForward?: () => void;
onClose: () => void; onClose: () => void;
canReset?: boolean; canReset?: boolean;
canZoomIn?: boolean;
canZoomOut?: boolean;
enableZoomControls?: boolean; enableZoomControls?: boolean;
onPointerEnter?: () => void; onPointerEnter?: () => void;
onPointerLeave?: () => void; onPointerLeave?: () => void;
@@ -93,6 +95,8 @@ export const MobileMediaActions: FC<MobileMediaActionsProps> = observer(
onForward, onForward,
onClose, onClose,
canReset = false, canReset = false,
canZoomIn = true,
canZoomOut = true,
enableZoomControls = false, enableZoomControls = false,
onPointerEnter, onPointerEnter,
onPointerLeave, onPointerLeave,
@@ -276,7 +280,7 @@ export const MobileMediaActions: FC<MobileMediaActionsProps> = observer(
), ),
label: i18n._(ZOOM_IN_DESCRIPTOR), label: i18n._(ZOOM_IN_DESCRIPTOR),
action: onZoomIn ?? (() => undefined), action: onZoomIn ?? (() => undefined),
disabled: !onZoomIn, disabled: !onZoomIn || !canZoomIn,
}), }),
createItem({ createItem({
id: 'media-zoom-out', id: 'media-zoom-out',
@@ -289,7 +293,7 @@ export const MobileMediaActions: FC<MobileMediaActionsProps> = observer(
), ),
label: i18n._(ZOOM_OUT_DESCRIPTOR), label: i18n._(ZOOM_OUT_DESCRIPTOR),
action: onZoomOut ?? (() => undefined), action: onZoomOut ?? (() => undefined),
disabled: !onZoomOut, disabled: !onZoomOut || !canZoomOut,
}), }),
] ]
: [], : [],
@@ -333,6 +337,8 @@ export const MobileMediaActions: FC<MobileMediaActionsProps> = observer(
]), ]),
[ [
canReset, canReset,
canZoomIn,
canZoomOut,
createItem, createItem,
enableZoomControls, enableZoomControls,
favoriteLabel, favoriteLabel,
@@ -8,10 +8,10 @@ import {
getAnchoredZoomPoint, getAnchoredZoomPoint,
getCentroid, getCentroid,
getDistance, getDistance,
getWheelZoomFactor,
isDefaultTransform, isDefaultTransform,
MIN_ZOOM_SCALE, MIN_ZOOM_SCALE,
type PanZoomMetrics, type PanZoomMetrics,
TAP_MOVE_THRESHOLD,
} from './PanZoomMath'; } from './PanZoomMath';
const METRICS: PanZoomMetrics = { const METRICS: PanZoomMetrics = {
@@ -42,10 +42,12 @@ describe('PanZoomMath', () => {
expect(clampScale(2.5, 1, 5)).toBe(2.5); expect(clampScale(2.5, 1, 5)).toBe(2.5);
expect(clampScale(8, 1, 5)).toBe(5); expect(clampScale(8, 1, 5)).toBe(5);
}); });
it('accelerates trackpad pinch wheel zoom without changing normal wheel speed', () => { it('treats a drag under twenty pixels as a tap', () => {
expect(getWheelZoomFactor(-5, false)).toBeCloseTo(1.0075, 4); expect(TAP_MOVE_THRESHOLD).toBe(20);
expect(getWheelZoomFactor(-5, true)).toBeGreaterThan(1.06); });
expect(getWheelZoomFactor(-200, true)).toBeCloseTo(Math.exp(0.22), 5); it('pins an axis whose zoomed content still fits inside the viewport', () => {
const portrait: PanZoomMetrics = {viewportWidth: 800, viewportHeight: 600, contentWidth: 300, contentHeight: 600};
expect(clampPanForScale({x: 400, y: 600}, 2.5, portrait)).toEqual({x: 0, y: 450});
}); });
it('detects the centered default transform with a small epsilon', () => { it('detects the centered default transform with a small epsilon', () => {
expect(isDefaultTransform({scale: 1.005, x: 0.004, y: -0.003})).toBe(true); expect(isDefaultTransform({scale: 1.005, x: 0.004, y: -0.003})).toBe(true);
@@ -5,10 +5,7 @@ export const DEFAULT_ZOOM_SCALE = 2.5;
export const MAX_ZOOM_SCALE = 5; export const MAX_ZOOM_SCALE = 5;
export const ZOOM_STEP = 1.25; export const ZOOM_STEP = 1.25;
export const ZOOM_STATE_EPSILON = 0.015; export const ZOOM_STATE_EPSILON = 0.015;
export const TAP_MOVE_THRESHOLD = 8; export const TAP_MOVE_THRESHOLD = 20;
export const WHEEL_ZOOM_SPEED = 0.0015;
export const WHEEL_PINCH_ZOOM_SPEED = 0.0125;
export const WHEEL_PINCH_MAX_EXPONENT = 0.22;
export interface Point { export interface Point {
x: number; x: number;
@@ -27,14 +24,6 @@ function clamp(value: number, min: number, max: number): number {
return Object.is(clamped, -0) ? 0 : clamped; return Object.is(clamped, -0) ? 0 : clamped;
} }
export function getWheelZoomFactor(deltaY: number, isPinchGesture: boolean): number {
const exponent = -deltaY * (isPinchGesture ? WHEEL_PINCH_ZOOM_SPEED : WHEEL_ZOOM_SPEED);
if (!isPinchGesture) {
return Math.exp(exponent);
}
return Math.exp(clamp(exponent, -WHEEL_PINCH_MAX_EXPONENT, WHEEL_PINCH_MAX_EXPONENT));
}
export function clampScale(value: number, minScale = MIN_ZOOM_SCALE, maxScale = MAX_ZOOM_SCALE): number { export function clampScale(value: number, minScale = MIN_ZOOM_SCALE, maxScale = MAX_ZOOM_SCALE): number {
return clamp(value, minScale, maxScale); return clamp(value, minScale, maxScale);
} }
@@ -30,7 +30,6 @@ interface PanZoomSurfaceProps
minScale?: number; minScale?: number;
maxScale?: number; maxScale?: number;
zoomedScale?: number; zoomedScale?: number;
preferNaturalZoomScale?: boolean;
disabled?: boolean; disabled?: boolean;
panDisabled?: boolean; panDisabled?: boolean;
wheelEnabled?: boolean; wheelEnabled?: boolean;
@@ -79,7 +78,6 @@ export const PanZoomSurface = memo(
minScale, minScale,
maxScale, maxScale,
zoomedScale, zoomedScale,
preferNaturalZoomScale,
disabled, disabled,
panDisabled, panDisabled,
wheelEnabled, wheelEnabled,
@@ -100,7 +98,6 @@ export const PanZoomSurface = memo(
minScale, minScale,
maxScale, maxScale,
zoomedScale, zoomedScale,
preferNaturalZoomScale,
disabled, disabled,
panDisabled, panDisabled,
wheelEnabled, wheelEnabled,
@@ -3,7 +3,7 @@
import type {ZoomState} from '@app/features/messaging/components/modals/media_modal/shared'; import type {ZoomState} from '@app/features/messaging/components/modals/media_modal/shared';
import {wasPointerDownInside} from '@app/lib/overlay/DismissGuard'; import {wasPointerDownInside} from '@app/lib/overlay/DismissGuard';
import type {AnimationPlaybackControls, MotionValue} from 'framer-motion'; import type {AnimationPlaybackControls, MotionValue} from 'framer-motion';
import {animate, useMotionValue, useMotionValueEvent, useReducedMotion} from 'framer-motion'; import {animate, useMotionValue, useReducedMotion} from 'framer-motion';
import type React from 'react'; import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import { import {
@@ -14,7 +14,6 @@ import {
getCentroid, getCentroid,
getDistance, getDistance,
getViewportPoint, getViewportPoint,
getWheelZoomFactor,
isDefaultTransform, isDefaultTransform,
MAX_ZOOM_SCALE, MAX_ZOOM_SCALE,
MIN_ZOOM_SCALE, MIN_ZOOM_SCALE,
@@ -39,6 +38,7 @@ interface PanGesture {
moved: boolean; moved: boolean;
startedOnContent: boolean; startedOnContent: boolean;
startedOnBackdrop: boolean; startedOnBackdrop: boolean;
followsPinch: boolean;
} }
interface PinchGesture { interface PinchGesture {
@@ -60,6 +60,8 @@ export interface PanZoomTransformSnapshot {
zoomState: ZoomState; zoomState: ZoomState;
isDragging: boolean; isDragging: boolean;
isDefault: boolean; isDefault: boolean;
canZoomIn: boolean;
canZoomOut: boolean;
} }
export interface UsePanZoomSurfaceOptions { export interface UsePanZoomSurfaceOptions {
@@ -67,7 +69,6 @@ export interface UsePanZoomSurfaceOptions {
minScale?: number; minScale?: number;
maxScale?: number; maxScale?: number;
zoomedScale?: number; zoomedScale?: number;
preferNaturalZoomScale?: boolean;
disabled?: boolean; disabled?: boolean;
panDisabled?: boolean; panDisabled?: boolean;
wheelEnabled?: boolean; wheelEnabled?: boolean;
@@ -107,7 +108,6 @@ export interface PanZoomSurfaceController {
viewportBindings: PanZoomSurfaceBindings; viewportBindings: PanZoomSurfaceBindings;
contentBindings: PanZoomContentBindings; contentBindings: PanZoomContentBindings;
zoomTo: (state: ZoomState, origin?: Point) => void; zoomTo: (state: ZoomState, origin?: Point) => void;
zoomBy: (factor: number, origin?: Point) => void;
zoomIn: (origin?: Point) => void; zoomIn: (origin?: Point) => void;
zoomOut: (origin?: Point) => void; zoomOut: (origin?: Point) => void;
reset: () => void; reset: () => void;
@@ -115,11 +115,14 @@ export interface PanZoomSurfaceController {
} }
const ZOOM_TRANSITION = { const ZOOM_TRANSITION = {
type: 'tween' as const, type: 'spring' as const,
duration: 0.18, stiffness: 300,
ease: [0.22, 1, 0.36, 1] as const, damping: 30,
mass: 1,
}; };
const NON_PASSIVE_EVENT_LISTENER_OPTIONS: AddEventListenerOptions = {passive: false}; const NON_PASSIVE_EVENT_LISTENER_OPTIONS: AddEventListenerOptions = {passive: false};
const MEASURED_BOX_SELECTOR = '[data-pan-zoom-measured-box]';
const MEASURED_ELEMENT_SELECTOR = 'img, video, canvas, svg';
function toZoomState(scale: number, minScale: number): ZoomState { function toZoomState(scale: number, minScale: number): ZoomState {
return scale <= minScale + ZOOM_STATE_EPSILON ? 'fit' : 'zoomed'; return scale <= minScale + ZOOM_STATE_EPSILON ? 'fit' : 'zoomed';
@@ -133,13 +136,31 @@ function getPointerRecords(records: Map<number, PointerRecord>): Array<PointerRe
return Array.from(records.values()); return Array.from(records.values());
} }
function getMediaCandidates(content: HTMLElement): Array<Element> {
const measuredBox = content.querySelector<HTMLElement>(MEASURED_BOX_SELECTOR) ?? content;
return Array.from(measuredBox.querySelectorAll(MEASURED_ELEMENT_SELECTOR));
}
function findMediaElement(content: HTMLElement): Element | null {
let widest: Element | null = null;
let widestArea = 0;
for (const candidate of getMediaCandidates(content)) {
const rect = candidate.getBoundingClientRect();
const area = rect.width * rect.height;
if (area > widestArea) {
widestArea = area;
widest = candidate;
}
}
return widest;
}
export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSurfaceController { export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSurfaceController {
const { const {
zoomState: controlledZoomState, zoomState: controlledZoomState,
minScale = MIN_ZOOM_SCALE, minScale = MIN_ZOOM_SCALE,
maxScale = MAX_ZOOM_SCALE, maxScale = MAX_ZOOM_SCALE,
zoomedScale = DEFAULT_ZOOM_SCALE, zoomedScale = DEFAULT_ZOOM_SCALE,
preferNaturalZoomScale = false,
disabled = false, disabled = false,
panDisabled = false, panDisabled = false,
wheelEnabled = true, wheelEnabled = true,
@@ -157,6 +178,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
const x = useMotionValue(0); const x = useMotionValue(0);
const y = useMotionValue(0); const y = useMotionValue(0);
const scale = useMotionValue(minScale); const scale = useMotionValue(minScale);
const requestedTransformRef = useRef({scale: minScale, x: 0, y: 0});
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const [internalZoomState, setInternalZoomState] = useState<ZoomState>(controlledZoomState ?? 'fit'); const [internalZoomState, setInternalZoomState] = useState<ZoomState>(controlledZoomState ?? 'fit');
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -166,7 +188,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
const metricsRef = useRef<PanZoomMetrics | null>(null); const metricsRef = useRef<PanZoomMetrics | null>(null);
const viewportRectRef = useRef<DOMRectReadOnly | null>(null); const viewportRectRef = useRef<DOMRectReadOnly | null>(null);
const animationControlsRef = useRef<Array<AnimationPlaybackControls>>([]); const animationControlsRef = useRef<Array<AnimationPlaybackControls>>([]);
const transformFrameRef = useRef<number | null>(null); const metricsFrameRef = useRef<number | null>(null);
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
const lastCommittedZoomStateRef = useRef<ZoomState>(controlledZoomState ?? 'fit'); const lastCommittedZoomStateRef = useRef<ZoomState>(controlledZoomState ?? 'fit');
const controlledZoomStateRef = useLatestRef(controlledZoomState); const controlledZoomStateRef = useLatestRef(controlledZoomState);
@@ -178,61 +200,42 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
const minScaleRef = useLatestRef(minScale); const minScaleRef = useLatestRef(minScale);
const maxScaleRef = useLatestRef(maxScale); const maxScaleRef = useLatestRef(maxScale);
const zoomedScaleRef = useLatestRef(zoomedScale); const zoomedScaleRef = useLatestRef(zoomedScale);
const preferNaturalZoomScaleRef = useLatestRef(preferNaturalZoomScale);
const disabledRef = useLatestRef(disabled); const disabledRef = useLatestRef(disabled);
const panDisabledRef = useLatestRef(panDisabled); const panDisabledRef = useLatestRef(panDisabled);
const stopAnimations = useCallback(() => { const stopAnimations = useCallback(() => {
if (animationControlsRef.current.length === 0) return;
for (const controls of animationControlsRef.current) { for (const controls of animationControlsRef.current) {
controls.stop(); controls.stop();
} }
animationControlsRef.current = []; animationControlsRef.current = [];
}, []); requestedTransformRef.current = {scale: scale.get(), x: x.get(), y: y.get()};
}, [scale, x, y]);
const measureMetrics = useCallback((): PanZoomMetrics | null => { const measureMetrics = useCallback((): PanZoomMetrics | null => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
const content = contentRef.current; const content = contentRef.current;
if (!viewport || !content) return null; if (!viewport || !content) return null;
const viewportRect = viewport.getBoundingClientRect(); const viewportRect = viewport.getBoundingClientRect();
viewportRectRef.current = viewportRect; viewportRectRef.current = viewportRect;
const contentWidth = content.offsetWidth || viewportRect.width; const activeScale = scale.get();
const contentHeight = content.offsetHeight || viewportRect.height; const inverseScale = activeScale > 0 ? 1 / activeScale : 1;
const mediaRect = findMediaElement(content)?.getBoundingClientRect();
const hasMediaRect = mediaRect != null && mediaRect.width > 0 && mediaRect.height > 0;
const metrics = { const metrics = {
viewportWidth: viewportRect.width, viewportWidth: viewportRect.width,
viewportHeight: viewportRect.height, viewportHeight: viewportRect.height,
contentWidth, contentWidth: hasMediaRect ? mediaRect.width * inverseScale : content.offsetWidth || viewportRect.width,
contentHeight, contentHeight: hasMediaRect ? mediaRect.height * inverseScale : content.offsetHeight || viewportRect.height,
}; };
metricsRef.current = metrics; metricsRef.current = metrics;
return metrics; return metrics;
}, []); }, [scale]);
const getMetrics = useCallback((): PanZoomMetrics | null => metricsRef.current ?? measureMetrics(), [measureMetrics]); const getMetrics = useCallback((): PanZoomMetrics | null => metricsRef.current ?? measureMetrics(), [measureMetrics]);
const updateDragging = useCallback((nextIsDragging: boolean) => { const scheduleMetricsRefresh = useCallback(() => {
if (isDraggingRef.current === nextIsDragging) return; if (typeof window === 'undefined' || metricsFrameRef.current != null) return;
isDraggingRef.current = nextIsDragging; metricsFrameRef.current = window.requestAnimationFrame(() => {
setIsDragging(nextIsDragging); metricsFrameRef.current = null;
}, []);
useLayoutEffect(() => {
measureMetrics();
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return;
const ownerWindow = viewport.ownerDocument.defaultView;
const handleResize = () => {
measureMetrics(); measureMetrics();
}; });
ownerWindow?.addEventListener('resize', handleResize);
const ResizeObserverCtor = ownerWindow?.ResizeObserver;
if (!ResizeObserverCtor) {
return () => {
ownerWindow?.removeEventListener('resize', handleResize);
};
}
const observer = new ResizeObserverCtor(handleResize);
observer.observe(viewport);
observer.observe(content);
return () => {
observer.disconnect();
ownerWindow?.removeEventListener('resize', handleResize);
};
}, [measureMetrics]); }, [measureMetrics]);
const commitZoomState = useCallback( const commitZoomState = useCallback(
(nextZoomState: ZoomState, emit = true) => { (nextZoomState: ZoomState, emit = true) => {
@@ -247,75 +250,101 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
}, },
[controlledZoomStateRef, onZoomStateChangeRef], [controlledZoomStateRef, onZoomStateChangeRef],
); );
const getSnapshot = useCallback( const canStepScale = useCallback(
(): PanZoomTransformSnapshot => ({ (fromScale: number, factor: number) =>
scale: scale.get(), clampScale(fromScale * factor, minScaleRef.current, maxScaleRef.current) !== fromScale,
x: x.get(), [maxScaleRef, minScaleRef],
y: y.get(), );
const getSnapshot = useCallback((): PanZoomTransformSnapshot => {
const requested = requestedTransformRef.current;
return {
scale: requested.scale,
x: requested.x,
y: requested.y,
zoomState: lastCommittedZoomStateRef.current, zoomState: lastCommittedZoomStateRef.current,
isDragging: gestureRef.current.mode !== 'idle', isDragging: gestureRef.current.mode !== 'idle',
isDefault: isDefaultTransform({ isDefault: isDefaultTransform({
scale: scale.get(), scale: requested.scale,
x: x.get(), x: requested.x,
y: y.get(), y: requested.y,
minScale: minScaleRef.current, minScale: minScaleRef.current,
}), }),
}), canZoomIn: canStepScale(requested.scale, ZOOM_STEP),
[minScaleRef, scale, x, y], canZoomOut: canStepScale(requested.scale, 1 / ZOOM_STEP),
};
}, [canStepScale, minScaleRef]);
const commitRequestedTransform = useCallback(
(next: {scale: number; x: number; y: number}) => {
requestedTransformRef.current = next;
commitZoomState(toZoomState(next.scale, minScaleRef.current));
onTransformChangeRef.current?.(getSnapshot());
},
[commitZoomState, getSnapshot, minScaleRef, onTransformChangeRef],
); );
const emitTransformChange = useCallback(() => { const clampPanToMetrics = useCallback(
if (typeof window === 'undefined') { (metrics: PanZoomMetrics | null) => {
onTransformChangeRef.current?.(getSnapshot()); if (!metrics) return;
return; const currentX = x.get();
} const currentY = y.get();
if (transformFrameRef.current != null) { const clamped = clampPanForScale({x: currentX, y: currentY}, scale.get(), metrics);
return; if (clamped.x === currentX && clamped.y === currentY) return;
} x.set(clamped.x);
transformFrameRef.current = window.requestAnimationFrame(() => { y.set(clamped.y);
transformFrameRef.current = null; commitRequestedTransform({scale: requestedTransformRef.current.scale, x: clamped.x, y: clamped.y});
onTransformChangeRef.current?.(getSnapshot()); },
}); [commitRequestedTransform, scale, x, y],
}, [getSnapshot, onTransformChangeRef]); );
const getNaturalContentZoomScale = useCallback((): number | null => { const updateDragging = useCallback((nextIsDragging: boolean) => {
if (isDraggingRef.current === nextIsDragging) return;
isDraggingRef.current = nextIsDragging;
setIsDragging(nextIsDragging);
}, []);
useLayoutEffect(() => {
measureMetrics();
const viewport = viewportRef.current;
const content = contentRef.current; const content = contentRef.current;
if (!content) return null; if (!viewport || !content) return;
const ownerWindow = content.ownerDocument.defaultView; const ownerWindow = viewport.ownerDocument.defaultView;
if (!ownerWindow) return null; const handleResize = () => {
const media = content.querySelector('img, video, canvas, svg'); clampPanToMetrics(measureMetrics());
if (!(media instanceof ownerWindow.HTMLElement || media instanceof ownerWindow.SVGSVGElement)) return null; };
const renderedWidth = media instanceof ownerWindow.SVGSVGElement ? media.clientWidth : media.offsetWidth; const handleSettledTransform = (event: Event) => {
const renderedHeight = media instanceof ownerWindow.SVGSVGElement ? media.clientHeight : media.offsetHeight; if ((event as TransitionEvent).propertyName !== 'transform') return;
if (renderedWidth <= 0 || renderedHeight <= 0) return null; if (event.target === content) return;
let naturalWidth = 0; clampPanToMetrics(measureMetrics());
let naturalHeight = 0; };
if (media instanceof ownerWindow.HTMLImageElement) { ownerWindow?.addEventListener('resize', handleResize);
naturalWidth = media.naturalWidth; content.addEventListener('transitionend', handleSettledTransform);
naturalHeight = media.naturalHeight; content.addEventListener('transitioncancel', handleSettledTransform);
} else if (media instanceof ownerWindow.HTMLVideoElement) { const ResizeObserverCtor = ownerWindow?.ResizeObserver;
naturalWidth = media.videoWidth; if (!ResizeObserverCtor) {
naturalHeight = media.videoHeight; return () => {
} else if (media instanceof ownerWindow.HTMLCanvasElement) { ownerWindow?.removeEventListener('resize', handleResize);
naturalWidth = media.width; content.removeEventListener('transitionend', handleSettledTransform);
naturalHeight = media.height; content.removeEventListener('transitioncancel', handleSettledTransform);
} else if (media instanceof ownerWindow.SVGSVGElement) { };
naturalWidth = media.viewBox.baseVal.width || media.clientWidth;
naturalHeight = media.viewBox.baseVal.height || media.clientHeight;
} }
if (naturalWidth <= 0 || naturalHeight <= 0) return null; const observer = new ResizeObserverCtor(handleResize);
const naturalScale = Math.max(naturalWidth / renderedWidth, naturalHeight / renderedHeight, minScaleRef.current); observer.observe(viewport);
return Number.isFinite(naturalScale) ? naturalScale : null; observer.observe(content);
}, [minScaleRef]); for (const candidate of getMediaCandidates(content)) {
const getZoomedScale = useCallback(() => { observer.observe(candidate);
const naturalScale = preferNaturalZoomScaleRef.current ? getNaturalContentZoomScale() : null; }
const targetScale = return () => {
naturalScale != null && naturalScale > minScaleRef.current + ZOOM_STATE_EPSILON observer.disconnect();
? naturalScale ownerWindow?.removeEventListener('resize', handleResize);
: zoomedScaleRef.current; content.removeEventListener('transitionend', handleSettledTransform);
return clampScale(targetScale, minScaleRef.current, maxScaleRef.current); content.removeEventListener('transitioncancel', handleSettledTransform);
}, [getNaturalContentZoomScale, maxScaleRef, minScaleRef, preferNaturalZoomScaleRef, zoomedScaleRef]); };
}, [clampPanToMetrics, measureMetrics, resetKey]);
const getZoomedScale = useCallback(
() => clampScale(zoomedScaleRef.current, minScaleRef.current, maxScaleRef.current),
[maxScaleRef, minScaleRef, zoomedScaleRef],
);
const setTransform = useCallback( const setTransform = useCallback(
(next: {scale: number; x: number; y: number}, animated: boolean) => { (next: {scale: number; x: number; y: number}, animated: boolean) => {
stopAnimations(); stopAnimations();
commitRequestedTransform(next);
if (animated && !prefersReducedMotion) { if (animated && !prefersReducedMotion) {
const start = { const start = {
scale: scale.get(), scale: scale.get(),
@@ -333,7 +362,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
x.set(next.x); x.set(next.x);
y.set(next.y); y.set(next.y);
scale.set(next.scale); scale.set(next.scale);
commitZoomState(toZoomState(next.scale, minScaleRef.current)); animationControlsRef.current = [];
}, },
}); });
animationControlsRef.current = [controls]; animationControlsRef.current = [controls];
@@ -341,61 +370,54 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
x.set(next.x); x.set(next.x);
y.set(next.y); y.set(next.y);
scale.set(next.scale); scale.set(next.scale);
commitZoomState(toZoomState(next.scale, minScaleRef.current));
} }
}, },
[commitZoomState, minScaleRef, prefersReducedMotion, scale, stopAnimations, x, y], [commitRequestedTransform, prefersReducedMotion, scale, stopAnimations, x, y],
); );
const zoomTo = useCallback( const zoomTo = useCallback(
(nextZoomState: ZoomState, origin?: Point) => { (nextZoomState: ZoomState, origin?: Point) => {
const metrics = getMetrics(); const metrics = measureMetrics();
if (!metrics) return; if (!metrics) return;
const nextScale = nextZoomState === 'fit' ? minScaleRef.current : getZoomedScale(); const nextScale = nextZoomState === 'fit' ? minScaleRef.current : getZoomedScale();
const resolvedZoomState = toZoomState(nextScale, minScaleRef.current); const requested = requestedTransformRef.current;
const currentScale = scale.get();
const currentPoint = {x: x.get(), y: y.get()};
const nextPoint = const nextPoint =
nextZoomState === 'fit' nextZoomState === 'fit'
? {x: 0, y: 0} ? {x: 0, y: 0}
: getAnchoredZoomPoint({ : getAnchoredZoomPoint({
origin: origin ?? {x: 0, y: 0}, origin: origin ?? {x: 0, y: 0},
current: currentPoint, current: {x: requested.x, y: requested.y},
currentScale, currentScale: requested.scale,
nextScale, nextScale,
metrics, metrics,
}); });
commitZoomState(resolvedZoomState);
setTransform({scale: nextScale, x: nextPoint.x, y: nextPoint.y}, true); setTransform({scale: nextScale, x: nextPoint.x, y: nextPoint.y}, true);
}, },
[commitZoomState, getMetrics, getZoomedScale, minScaleRef, scale, setTransform, x, y], [getZoomedScale, measureMetrics, minScaleRef, setTransform],
); );
const zoomBy = useCallback( const zoomBy = useCallback(
(factor: number, origin?: Point) => { (factor: number, origin?: Point) => {
const metrics = getMetrics(); const metrics = measureMetrics();
if (!metrics) return; if (!metrics) return;
const currentScale = scale.get(); const requested = requestedTransformRef.current;
const currentScale = requested.scale;
const nextScale = clampScale(currentScale * factor, minScaleRef.current, maxScaleRef.current); const nextScale = clampScale(currentScale * factor, minScaleRef.current, maxScaleRef.current);
const currentPoint = {x: x.get(), y: y.get()}; if (nextScale === currentScale) return;
const nextPoint = const nextPoint = getAnchoredZoomPoint({
nextScale <= minScaleRef.current + ZOOM_STATE_EPSILON origin: origin ?? {x: 0, y: 0},
? {x: 0, y: 0} current: {x: requested.x, y: requested.y},
: getAnchoredZoomPoint({ currentScale,
origin: origin ?? {x: 0, y: 0}, nextScale,
current: currentPoint, metrics,
currentScale, });
nextScale,
metrics,
});
setTransform({scale: nextScale, x: nextPoint.x, y: nextPoint.y}, true); setTransform({scale: nextScale, x: nextPoint.x, y: nextPoint.y}, true);
}, },
[getMetrics, maxScaleRef, minScaleRef, scale, setTransform, x, y], [maxScaleRef, measureMetrics, minScaleRef, setTransform],
); );
const zoomIn = useCallback((origin?: Point) => zoomBy(ZOOM_STEP, origin), [zoomBy]); const zoomIn = useCallback((origin?: Point) => zoomBy(ZOOM_STEP, origin), [zoomBy]);
const zoomOut = useCallback((origin?: Point) => zoomBy(1 / ZOOM_STEP, origin), [zoomBy]); const zoomOut = useCallback((origin?: Point) => zoomBy(1 / ZOOM_STEP, origin), [zoomBy]);
const reset = useCallback(() => { const reset = useCallback(() => {
commitZoomState('fit');
setTransform({scale: minScaleRef.current, x: 0, y: 0}, true); setTransform({scale: minScaleRef.current, x: 0, y: 0}, true);
}, [commitZoomState, minScaleRef, setTransform]); }, [minScaleRef, setTransform]);
useEffect(() => { useEffect(() => {
if (controlledZoomState === undefined) return; if (controlledZoomState === undefined) return;
if (lastCommittedZoomStateRef.current === controlledZoomState) return; if (lastCommittedZoomStateRef.current === controlledZoomState) return;
@@ -403,25 +425,20 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
zoomTo(controlledZoomState); zoomTo(controlledZoomState);
}, [commitZoomState, controlledZoomState, zoomTo]); }, [commitZoomState, controlledZoomState, zoomTo]);
useEffect(() => { useEffect(() => {
measureMetrics();
reset(); reset();
}, [reset, resetKey]); }, [measureMetrics, reset, resetKey]);
useEffect(() => { useEffect(() => {
return () => stopAnimations(); return () => stopAnimations();
}, [stopAnimations]); }, [stopAnimations]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (transformFrameRef.current != null && typeof window !== 'undefined') { if (typeof window === 'undefined') return;
window.cancelAnimationFrame(transformFrameRef.current); if (metricsFrameRef.current != null) {
window.cancelAnimationFrame(metricsFrameRef.current);
} }
}; };
}, []); }, []);
useMotionValueEvent(scale, 'change', (latestScale) => {
const nextZoomState = toZoomState(latestScale, minScaleRef.current);
commitZoomState(nextZoomState);
emitTransformChange();
});
useMotionValueEvent(x, 'change', emitTransformChange);
useMotionValueEvent(y, 'change', emitTransformChange);
const updatePointerRecord = useCallback((event: React.PointerEvent<HTMLDivElement>) => { const updatePointerRecord = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
pointerRecordsRef.current.set(event.pointerId, { pointerRecordsRef.current.set(event.pointerId, {
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -443,51 +460,34 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
); );
const handleWheel = useCallback( const handleWheel = useCallback(
(event: WheelEvent) => { (event: WheelEvent) => {
if (disabledRef.current || !wheelEnabled) return; if (disabledRef.current || !wheelEnabled || event.ctrlKey || panDisabledRef.current) return;
const currentScale = scale.get();
if (currentScale <= minScaleRef.current + ZOOM_STATE_EPSILON) return;
const metrics = getMetrics(); const metrics = getMetrics();
const origin = getViewportRelativePoint(event.clientX, event.clientY); if (!metrics) return;
if (!metrics || !origin) return;
event.preventDefault(); event.preventDefault();
stopAnimations(); stopAnimations();
const currentScale = scale.get(); const requested = requestedTransformRef.current;
if (!event.ctrlKey && !panDisabledRef.current && currentScale > minScaleRef.current + ZOOM_STATE_EPSILON) { const pannedPoint = clampPanForScale(
const pannedPoint = clampPanForScale( {x: requested.x - event.deltaX, y: requested.y - event.deltaY},
{x: x.get() - event.deltaX, y: y.get() - event.deltaY}, requested.scale,
currentScale, metrics,
metrics,
);
x.set(pannedPoint.x);
y.set(pannedPoint.y);
return;
}
const nextScale = clampScale(
currentScale * getWheelZoomFactor(event.deltaY, event.ctrlKey),
minScaleRef.current,
maxScaleRef.current,
); );
const currentPoint = {x: x.get(), y: y.get()}; x.set(pannedPoint.x);
const nextPoint = y.set(pannedPoint.y);
nextScale <= minScaleRef.current + ZOOM_STATE_EPSILON commitRequestedTransform({scale: requested.scale, x: pannedPoint.x, y: pannedPoint.y});
? {x: 0, y: 0} scheduleMetricsRefresh();
: getAnchoredZoomPoint({origin, current: currentPoint, currentScale, nextScale, metrics});
x.set(nextPoint.x);
y.set(nextPoint.y);
scale.set(nextScale);
commitZoomState(toZoomState(nextScale, minScaleRef.current));
}, },
[ [
commitZoomState, commitRequestedTransform,
disabledRef, disabledRef,
getMetrics, getMetrics,
getViewportRelativePoint,
maxScaleRef,
minScaleRef, minScaleRef,
panDisabledRef, panDisabledRef,
scale, scale,
scheduleMetricsRefresh,
stopAnimations, stopAnimations,
wheelEnabled, wheelEnabled,
x,
y,
], ],
); );
useEffect(() => { useEffect(() => {
@@ -522,6 +522,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
moved: false, moved: false,
startedOnContent, startedOnContent,
startedOnBackdrop, startedOnBackdrop,
followsPinch: false,
}; };
updateDragging( updateDragging(
!disabledRef.current && !panDisabledRef.current && currentScale > minScaleRef.current + ZOOM_STATE_EPSILON, !disabledRef.current && !panDisabledRef.current && currentScale > minScaleRef.current + ZOOM_STATE_EPSILON,
@@ -583,6 +584,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
); );
x.set(nextPoint.x); x.set(nextPoint.x);
y.set(nextPoint.y); y.set(nextPoint.y);
commitRequestedTransform({scale: requestedTransformRef.current.scale, x: nextPoint.x, y: nextPoint.y});
updateDragging(true); updateDragging(true);
return; return;
} }
@@ -615,10 +617,10 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
scale.set(nextScale); scale.set(nextScale);
x.set(nextPoint.x); x.set(nextPoint.x);
y.set(nextPoint.y); y.set(nextPoint.y);
commitZoomState(toZoomState(nextScale, minScaleRef.current)); commitRequestedTransform({scale: nextScale, x: nextPoint.x, y: nextPoint.y});
}, },
[ [
commitZoomState, commitRequestedTransform,
disabledRef, disabledRef,
getMetrics, getMetrics,
maxScaleRef, maxScaleRef,
@@ -643,7 +645,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
const moved = gesture.moved || Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD; const moved = gesture.moved || Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD;
updateDragging(false); updateDragging(false);
gestureRef.current = {mode: 'idle'}; gestureRef.current = {mode: 'idle'};
if (!moved) { if (!moved && !gesture.followsPinch) {
if (gesture.startedOnBackdrop && !wasPointerDownInside(contentRef.current)) { if (gesture.startedOnBackdrop && !wasPointerDownInside(contentRef.current)) {
onBackdropTapRef.current?.(); onBackdropTapRef.current?.();
return; return;
@@ -668,6 +670,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
moved: false, moved: false,
startedOnContent: true, startedOnContent: true,
startedOnBackdrop: false, startedOnBackdrop: false,
followsPinch: true,
}; };
updateDragging(scale.get() > minScaleRef.current + ZOOM_STATE_EPSILON); updateDragging(scale.get() > minScaleRef.current + ZOOM_STATE_EPSILON);
return; return;
@@ -712,9 +715,10 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
const cursor = useMemo(() => { const cursor = useMemo(() => {
if (disabled) return 'default'; if (disabled) return 'default';
if (!isHoveringContent) return 'default'; if (!isHoveringContent) return 'default';
if (tapToToggleZoom) return activeZoomState === 'zoomed' ? 'zoom-out' : 'zoom-in';
if (isDragging) return 'grabbing'; if (isDragging) return 'grabbing';
if (activeZoomState === 'zoomed') return tapToToggleZoom ? 'zoom-out' : 'grab'; if (activeZoomState === 'zoomed') return 'grab';
return tapToToggleZoom || doubleClickEnabled ? 'zoom-in' : 'default'; return doubleClickEnabled ? 'zoom-in' : 'default';
}, [activeZoomState, disabled, doubleClickEnabled, isDragging, isHoveringContent, tapToToggleZoom]); }, [activeZoomState, disabled, doubleClickEnabled, isDragging, isHoveringContent, tapToToggleZoom]);
return { return {
x, x,
@@ -738,7 +742,6 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur
onPointerLeave: () => setIsHoveringContent(false), onPointerLeave: () => setIsHoveringContent(false),
}, },
zoomTo, zoomTo,
zoomBy,
zoomIn, zoomIn,
zoomOut, zoomOut,
reset, reset,