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