From 793e853eb3b3cb46a3babf8392070def5e31f708 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:50:48 +0200 Subject: [PATCH] fix(app): fit media to its real box and gate the zoom buttons (#1945) --- .../components/modals/MediaModal.tsx | 83 ++-- .../modals/media_modal/MediaControls.tsx | 8 +- .../MediaViewerPanZoomStability.test.ts | 8 + .../modals/media_modal/MediaViewers.tsx | 8 +- .../modals/media_modal/MobileMediaActions.tsx | 10 +- .../media_modal/pan_zoom/PanZoomMath.test.ts | 12 +- .../media_modal/pan_zoom/PanZoomMath.ts | 13 +- .../media_modal/pan_zoom/PanZoomSurface.tsx | 3 - .../media_modal/pan_zoom/usePanZoomSurface.ts | 355 +++++++++--------- 9 files changed, 255 insertions(+), 245 deletions(-) diff --git a/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx b/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx index f55f0d89f..36288b5a1 100644 --- a/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx +++ b/fluxer_app/src/features/messaging/components/modals/MediaModal.tsx @@ -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 = observer( ({ title, @@ -82,7 +115,7 @@ export const MediaModal: FC = observer( const prefersReducedMotion = Accessibility.useReducedMotion; const [zoomState, setZoomState] = useState('fit'); const [rotation, setRotation] = useState(0); - const [panZoomInfo, setPanZoomInfo] = useState({zoomPercent: 100, isDefault: true}); + const [panZoomInfo, setPanZoomInfo] = useState(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 = observer( const bottomInfoBarRef = useRef(null); const thumbnailCarouselRef = useRef(null); const latestIndexRef = useRef(currentIndex ?? 0); - const transformFrameRef = useRef(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 = 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 = 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 = observer(
{children}
), - [children, rotation, hasCustomRotation, isRotationSwapped], + [children, rotation, isRotationSwapped], ); const mediaContent = isMobileVideo ? createElement(MobileVideoViewer, { @@ -621,6 +620,8 @@ export const MediaModal: FC = 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 = observer( onForward={onForward} onClose={handleClose} canReset={!panZoomInfo.isDefault || hasCustomRotation} + canZoomIn={panZoomInfo.canZoomIn} + canZoomOut={panZoomInfo.canZoomOut} enableZoomControls={enablePanZoom} onPointerEnter={handleHudPointerEnter} onPointerLeave={handleHudPointerLeave} diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx index 4d5eead49..dd7f0eea8 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaControls.tsx @@ -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 = observer( onForward, onClose, canReset = false, + canZoomIn = true, + canZoomOut = true, enableZoomControls = false, onPointerEnter, onPointerLeave, @@ -304,7 +308,7 @@ export const MediaOverlayActions: FC = 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" /> = 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) && ( diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewerPanZoomStability.test.ts b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewerPanZoomStability.test.ts index a041c75a9..466d7a067 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewerPanZoomStability.test.ts +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewerPanZoomStability.test.ts @@ -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,/); diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewers.tsx b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewers.tsx index 90c5dea7c..ace04aad1 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewers.tsx +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/MediaViewers.tsx @@ -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} diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/MobileMediaActions.tsx b/fluxer_app/src/features/messaging/components/modals/media_modal/MobileMediaActions.tsx index b41662813..5cb919f1d 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/MobileMediaActions.tsx +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/MobileMediaActions.tsx @@ -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 = observer( onForward, onClose, canReset = false, + canZoomIn = true, + canZoomOut = true, enableZoomControls = false, onPointerEnter, onPointerLeave, @@ -276,7 +280,7 @@ export const MobileMediaActions: FC = 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 = observer( ), label: i18n._(ZOOM_OUT_DESCRIPTOR), action: onZoomOut ?? (() => undefined), - disabled: !onZoomOut, + disabled: !onZoomOut || !canZoomOut, }), ] : [], @@ -333,6 +337,8 @@ export const MobileMediaActions: FC = observer( ]), [ canReset, + canZoomIn, + canZoomOut, createItem, enableZoomControls, favoriteLabel, diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.test.ts b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.test.ts index 021bb3970..b88fe3e3d 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.test.ts +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.test.ts @@ -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); diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.ts b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.ts index d5b79f59b..1af4b3738 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.ts +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomMath.ts @@ -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); } diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomSurface.tsx b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomSurface.tsx index 013781e10..1eda2afad 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomSurface.tsx +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/PanZoomSurface.tsx @@ -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, diff --git a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/usePanZoomSurface.ts b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/usePanZoomSurface.ts index cb5534929..58478bf2e 100644 --- a/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/usePanZoomSurface.ts +++ b/fluxer_app/src/features/messaging/components/modals/media_modal/pan_zoom/usePanZoomSurface.ts @@ -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): Array { + const measuredBox = content.querySelector(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(controlledZoomState ?? 'fit'); const [isDragging, setIsDragging] = useState(false); @@ -166,7 +188,7 @@ export function usePanZoomSurface(options: UsePanZoomSurfaceOptions): PanZoomSur const metricsRef = useRef(null); const viewportRectRef = useRef(null); const animationControlsRef = useRef>([]); - const transformFrameRef = useRef(null); + const metricsFrameRef = useRef(null); const isDraggingRef = useRef(false); const lastCommittedZoomStateRef = useRef(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) => { 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,