mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): fit media to its real box and gate the zoom buttons (#1945)
This commit is contained in:
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) && (
|
||||
|
||||
+8
@@ -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}
|
||||
|
||||
+8
-2
@@ -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,
|
||||
|
||||
+7
-5
@@ -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);
|
||||
|
||||
+1
-12
@@ -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);
|
||||
}
|
||||
|
||||
-3
@@ -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,
|
||||
|
||||
+179
-176
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user