diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 15c1d374d..76149202b 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -457,6 +457,7 @@ def db_toggle_image_favourite_status(image_id: str) -> bool: finally: conn.close() + def db_get_image_by_id(image_id: str) -> Optional[dict]: """ Get a single image by ID with its favorite status. @@ -464,11 +465,14 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]: conn = _connect() cursor = conn.cursor() try: - cursor.execute(""" + cursor.execute( + """ SELECT id, path, folder_id, thumbnailPath, metadata, isTagged, isFavourite FROM images WHERE id = ? - """, (image_id,)) + """, + (image_id,), + ) row = cursor.fetchone() if not row: return None @@ -488,6 +492,7 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]: finally: conn.close() + # ============================================================================ # MEMORIES FEATURE - Location and Time-based Queries # ============================================================================ diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index f00b0a941..3741e13f6 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -106,15 +106,15 @@ def toggle_favourite(req: ToggleFavouriteRequest): success = db_toggle_image_favourite_status(image_id) if not success: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Image not found or failed to toggle" + status_code=status.HTTP_404_NOT_FOUND, + detail="Image not found or failed to toggle", ) # Fetch updated status to return image = db_get_image_by_id(image_id) if not image: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Image not found after toggle" + status_code=status.HTTP_404_NOT_FOUND, + detail="Image not found after toggle", ) return { "success": True, @@ -126,10 +126,11 @@ def toggle_favourite(req: ToggleFavouriteRequest): except Exception as e: logger.error(f"error in /toggle-favourite route: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal server error: {e}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {e}", ) + class ImageInfoResponse(BaseModel): id: str path: str diff --git a/frontend/src/components/Media/ImageViewer.tsx b/frontend/src/components/Media/ImageViewer.tsx index fa878250d..c8d72c6a6 100644 --- a/frontend/src/components/Media/ImageViewer.tsx +++ b/frontend/src/components/Media/ImageViewer.tsx @@ -18,11 +18,15 @@ export const ImageViewer = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { const zoomableImageRef = useRef(null); - useImperativeHandle(ref, () => ({ - zoomIn: () => zoomableImageRef.current?.zoomIn(), - zoomOut: () => zoomableImageRef.current?.zoomOut(), - reset: () => zoomableImageRef.current?.reset(), - })); + useImperativeHandle( + ref, + () => ({ + zoomIn: () => zoomableImageRef.current?.zoomIn(), + zoomOut: () => zoomableImageRef.current?.zoomOut(), + reset: () => zoomableImageRef.current?.reset(), + }), + [], + ); return ( ( ); }, ); + +ImageViewer.displayName = 'ImageViewer'; diff --git a/frontend/src/components/Media/MediaThumbnails.tsx b/frontend/src/components/Media/MediaThumbnails.tsx index b92e646ef..b7c589d38 100644 --- a/frontend/src/components/Media/MediaThumbnails.tsx +++ b/frontend/src/components/Media/MediaThumbnails.tsx @@ -1,5 +1,9 @@ import React, { useRef, useEffect } from 'react'; import { convertFileSrc } from '@tauri-apps/api/core'; +import { + PLACEHOLDER_IMAGE_SRC, + handlePlaceholderImageError, +} from '@/utils/imageFallback'; interface MediaThumbnailsProps { images: Array<{ @@ -117,14 +121,10 @@ export const MediaThumbnails: React.FC = ({ } cursor-pointer transition-all duration-200 hover:scale-105`} > {`thumbnail-${index}`} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = '/placeholder.svg'; - }} + onError={handlePlaceholderImageError} /> ))} diff --git a/frontend/src/components/Media/MediaView.tsx b/frontend/src/components/Media/MediaView.tsx index 7530c8bf4..4a0a79cf5 100644 --- a/frontend/src/components/Media/MediaView.tsx +++ b/frontend/src/components/Media/MediaView.tsx @@ -51,20 +51,25 @@ export function MediaView({ // Custom hooks const { viewState, handlers } = useImageViewControls(); + const resetViewerState = useCallback(() => { + handlers.resetZoom(); + setResetSignal((s) => s + 1); + }, [handlers]); + // Navigation handlers const handleNextImage = useCallback(() => { if (currentViewIndex < images.length - 1) { dispatch(setCurrentViewIndex(currentViewIndex + 1)); - handlers.resetZoom(); + resetViewerState(); } - }, [dispatch, handlers, currentViewIndex, images.length]); + }, [dispatch, resetViewerState, currentViewIndex, images.length]); const handlePreviousImage = useCallback(() => { if (currentViewIndex > 0) { dispatch(setCurrentViewIndex(currentViewIndex - 1)); - handlers.resetZoom(); + resetViewerState(); } - }, [dispatch, handlers, currentViewIndex]); + }, [dispatch, resetViewerState, currentViewIndex]); const handleClose = useCallback(() => { dispatch(closeImageView()); @@ -74,9 +79,9 @@ export function MediaView({ const handleThumbnailClick = useCallback( (index: number) => { dispatch(setCurrentViewIndex(index)); - handlers.resetZoom(); + resetViewerState(); }, - [dispatch, handlers], + [dispatch, resetViewerState], ); const location = useLocation(); @@ -85,8 +90,8 @@ export function MediaView({ // Loop to first image handler for slideshow const handleLoopToStart = useCallback(() => { dispatch(setCurrentViewIndex(0)); - handlers.resetZoom(); - }, [dispatch, handlers]); + resetViewerState(); + }, [dispatch, resetViewerState]); // Slideshow functionality const { isSlideshowActive, toggleSlideshow } = useSlideshow( @@ -142,9 +147,8 @@ export function MediaView({ const handleResetZoom = useCallback(() => { imageViewerRef.current?.reset(); - handlers.resetZoom(); - setResetSignal((s) => s + 1); - }, [handlers]); + resetViewerState(); + }, [resetViewerState]); // Keyboard navigation useKeyboardNavigation({ @@ -164,6 +168,7 @@ export function MediaView({ // Safe variables const currentImagePath = currentImage.path; + const currentImageKey = currentImage.id || currentImage.path; // console.log(currentImage); const currentImageAlt = `image-${currentViewIndex}`; return ( @@ -190,6 +195,7 @@ export function MediaView({ > {type === 'image' && ( ( ({ imagePath, alt, rotation, resetSignal }, ref) => { - const transformRef = useRef(null); - const imageRef = useRef(null); - const [isOverflowing, setIsOverflowing] = useState(false); - const rotationRef = useRef(rotation); - - useEffect(() => { - rotationRef.current = rotation; - }, [rotation]); - - const getEffectiveDimensions = useCallback( - (width: number, height: number) => { - const normalizedRotation = ((rotationRef.current % 360) + 360) % 360; - const isRotated90or270 = - normalizedRotation === 90 || normalizedRotation === 270; - return isRotated90or270 - ? { width: height, height: width } - : { width, height }; - }, - [], - ); - - const getOverflowState = useCallback( - (scale: number, viewportWidth: number, viewportHeight: number) => { - if (!imageRef.current) return { width: false, height: false }; - - const imgElement = imageRef.current; - const renderedWidth = imgElement.clientWidth; - const renderedHeight = imgElement.clientHeight; - - const effectiveDims = getEffectiveDimensions( - renderedWidth, - renderedHeight, - ); - const scaledWidth = effectiveDims.width * scale; - const scaledHeight = effectiveDims.height * scale; - - return { - width: scaledWidth > viewportWidth, - height: scaledHeight > viewportHeight, - }; - }, - [getEffectiveDimensions], + const { + viewportRef, + imageRef, + transformState, + rawDimensions, + contentDimensions, + imageOffset, + cursor, + isButtonZoom, + handleImageLoad, + handlePointerDown, + handlePointerMove, + handlePointerEnd, + handlePointerLeave, + handleZoomTransitionEnd, + zoomIn, + zoomOut, + reset, + } = useZoomTransform({ imagePath, rotation, resetSignal }); + + useImperativeHandle( + ref, + () => ({ + zoomIn, + zoomOut, + reset, + }), + [zoomIn, zoomOut, reset], ); - const handleReset = useCallback( - (duration = 200, animationType: AnimationType = 'easeOut') => { - if ( - !transformRef.current?.instance?.wrapperComponent || - !imageRef.current - ) - return; - - const wrapperRect = - transformRef.current.instance.wrapperComponent.getBoundingClientRect(); - const img = imageRef.current; - - const scale = 1; - const baseW = img.naturalWidth || img.clientWidth; - const baseH = img.naturalHeight || img.clientHeight; - - if (!baseW || !baseH) return; - - const effectiveDims = getEffectiveDimensions(baseW, baseH); - const imgAspect = effectiveDims.width / effectiveDims.height; - const viewAspect = wrapperRect.width / wrapperRect.height; - - let renderedW, renderedH; - if (imgAspect > viewAspect) { - renderedW = Math.min(effectiveDims.width, wrapperRect.width); - renderedH = renderedW / imgAspect; - } else { - renderedH = Math.min(effectiveDims.height, wrapperRect.height); - renderedW = renderedH * imgAspect; - } - - const centerX = (wrapperRect.width - renderedW) / 2; - const centerY = (wrapperRect.height - renderedH) / 2; - - transformRef.current.setTransform( - centerX, - centerY, - scale, - duration, - animationType, - ); - setIsOverflowing(false); - }, - [getEffectiveDimensions], - ); - - useImperativeHandle(ref, () => ({ - zoomIn: () => transformRef.current?.zoomIn(), - zoomOut: () => transformRef.current?.zoomOut(), - reset: () => handleReset(), - })); - - useEffect(() => { - handleReset(); - }, [resetSignal, handleReset]); - - useEffect(() => { - if ( - !transformRef.current?.instance?.wrapperComponent || - !imageRef.current - ) - return; - - const wrapper = transformRef.current.instance.wrapperComponent; - const scale = transformRef.current.instance.transformState.scale; - const rect = wrapper.getBoundingClientRect(); - - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - }, [rotation, getOverflowState]); - - useEffect(() => { - setIsOverflowing(false); - - const img = imageRef.current; - if (!img) return; - - const handleImageLoad = () => { - handleReset(0); - }; - - if (img.complete && img.naturalWidth > 0) { - handleImageLoad(); - } else { - img.addEventListener('load', handleImageLoad); - return () => img.removeEventListener('load', handleImageLoad); - } - }, [imagePath, handleReset]); - - useEffect(() => { - const wrapperElement = transformRef.current?.instance?.wrapperComponent; - if (!wrapperElement) return; - - let cachedWrapperRect = wrapperElement.getBoundingClientRect(); - - const resizeObserver = new ResizeObserver(() => { - cachedWrapperRect = wrapperElement.getBoundingClientRect(); - }); - - resizeObserver.observe(wrapperElement); - - const handleWheelInterceptor = (e: WheelEvent) => { - if (!imageRef.current || !transformRef.current) return; - - const transformState = transformRef.current.instance.transformState; - - const imageRect = imageRef.current.getBoundingClientRect(); - const mouseX = e.clientX - imageRect.left; - const mouseY = e.clientY - imageRect.top; - - const isOverImage = - mouseX >= 0 && - mouseX <= imageRect.width && - mouseY >= 0 && - mouseY <= imageRect.height; - - const isLineMode = e.deltaMode === 1; - const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; - const factor = ZOOM_FACTOR; - - const zoomChange = -e.deltaY * multiplier * factor; - - const currentScale = transformState.scale; - const newScale = Math.max( - MIN_SCALE, - Math.min(MAX_SCALE, currentScale + zoomChange), - ); - - const wrapperRect = cachedWrapperRect; - const newOverflow = getOverflowState( - newScale, - wrapperRect.width, - wrapperRect.height, - ); - - if (zoomChange < 0) { - e.preventDefault(); - e.stopPropagation(); - - setIsOverflowing(newOverflow.width || newOverflow.height); - - const baseW = imageRect.width / currentScale; - const baseH = imageRect.height / currentScale; - - const effectiveDims = getEffectiveDimensions(baseW, baseH); - const finalTargetX = (wrapperRect.width - effectiveDims.width) / 2; - const finalTargetY = (wrapperRect.height - effectiveDims.height) / 2; - - let targetX = finalTargetX; - let targetY = finalTargetY; - - if (currentScale > 1) { - const ratio = (newScale - 1) / (currentScale - 1); - const safeRatio = - isNaN(ratio) || !isFinite(ratio) || ratio < 0 ? 0 : ratio; - - targetX = - transformState.positionX * safeRatio + - finalTargetX * (1 - safeRatio); - targetY = - transformState.positionY * safeRatio + - finalTargetY * (1 - safeRatio); - } - - transformRef.current.setTransform(targetX, targetY, newScale, 0); - return; - } - - if (!newOverflow.width && !newOverflow.height) { - e.preventDefault(); - e.stopPropagation(); - - const baseW = imageRect.width / currentScale; - const baseH = imageRect.height / currentScale; - - const effectiveDims = getEffectiveDimensions(baseW, baseH); - const nextW = effectiveDims.width * newScale; - const nextH = effectiveDims.height * newScale; - - const targetX = (wrapperRect.width - nextW) / 2; - const targetY = (wrapperRect.height - nextH) / 2; - - transformRef.current.setTransform(targetX, targetY, newScale, 0); - return; - } - - if (!isOverImage) { - e.preventDefault(); - e.stopPropagation(); - - const centerX = wrapperRect.width / 2; - const centerY = wrapperRect.height / 2; - - if (currentScale > 0) { - const ratio = newScale / currentScale; - - const targetX = - centerX - (centerX - transformState.positionX) * ratio; - const targetY = - centerY - (centerY - transformState.positionY) * ratio; - - transformRef.current.setTransform(targetX, targetY, newScale, 0); - } - return; - } - }; - - wrapperElement.addEventListener('wheel', handleWheelInterceptor, { - passive: false, - capture: true, - }); - - return () => { - resizeObserver.disconnect(); - wrapperElement.removeEventListener( - 'wheel', - handleWheelInterceptor, - true, - ); - }; - }, [getEffectiveDimensions, getOverflowState]); - return ( - { - const scale = ref.state.scale; - const wrapper = ref.instance.wrapperComponent; - if (!wrapper) return; - - const rect = wrapper.getBoundingClientRect(); - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - }} - onZoom={(ref) => { - const scale = ref.state.scale; - const wrapper = ref.instance.wrapperComponent; - if (!wrapper) return; - - const rect = wrapper.getBoundingClientRect(); - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - }} - onPanning={(ref) => { - const scale = ref.state.scale; - const wrapper = ref.instance.wrapperComponent; - if (!wrapper || !imageRef.current) return; - - const rect = wrapper.getBoundingClientRect(); - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - - const positionX = ref.state.positionX; - const positionY = ref.state.positionY; - const viewW = wrapper.clientWidth; - const viewH = wrapper.clientHeight; - const imgW = imageRef.current.clientWidth; - const imgH = imageRef.current.clientHeight; - - const effectiveDims = getEffectiveDimensions(imgW, imgH); - const scaledW = effectiveDims.width * scale; - const scaledH = effectiveDims.height * scale; - - const limitLeft = -scaledW + PAN_PADDING; - const limitRight = viewW - PAN_PADDING; - const limitTop = -scaledH + PAN_PADDING; - const limitBottom = viewH - PAN_PADDING; - - let finalX = positionX; - let finalY = positionY; - let clamped = false; - - if (positionX < limitLeft) { - finalX = limitLeft; - clamped = true; - } else if (positionX > limitRight) { - finalX = limitRight; - clamped = true; - } - - if (positionY < limitTop) { - finalY = limitTop; - clamped = true; - } else if (positionY > limitBottom) { - finalY = limitBottom; - clamped = true; - } - - if (clamped) { - ref.setTransform(finalX, finalY, scale, 0); - } +
- { + if ( + e.target === e.currentTarget && + e.propertyName === 'transform' + ) { + handleZoomTransitionEnd(); + } }} - contentStyle={{ - width: 'fit-content', - height: 'fit-content', - cursor: isOverflowing ? 'move' : 'default', + style={{ + position: 'relative', + width: contentDimensions + ? `${contentDimensions.width}px` + : 'fit-content', + height: contentDimensions + ? `${contentDimensions.height}px` + : 'fit-content', + transform: `translate3d(${transformState.positionX}px, ${transformState.positionY}px, 0) scale(${transformState.scale})`, + transformOrigin: '0 0', + willChange: 'transform', + transition: isButtonZoom ? 'transform 250ms ease-out' : undefined, }} > {alt} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = '/placeholder.svg'; - }} + onLoad={handleImageLoad} + onError={handlePlaceholderImageError} style={{ - maxWidth: '100vw', - maxHeight: '100vh', + position: contentDimensions ? 'absolute' : 'relative', + left: `${imageOffset.left}px`, + top: `${imageOffset.top}px`, + width: rawDimensions ? `${rawDimensions.width}px` : undefined, + height: rawDimensions ? `${rawDimensions.height}px` : undefined, + // Override the framework's `img { max-width: 100% }` reset so the + // image can scale beyond the viewport while zoomed. + maxWidth: 'none', + maxHeight: 'none', objectFit: 'contain', - zIndex: 50, transform: `rotate(${rotation}deg)`, - cursor: isOverflowing ? 'move' : 'default', + transformOrigin: 'center center', + pointerEvents: 'none', }} /> - - +
+ ); }, ); diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx new file mode 100644 index 000000000..dab8a45bb --- /dev/null +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -0,0 +1,1081 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { ZoomableImage, ZoomableImageRef } from '../ZoomableImage'; +import { MAX_FIT_RETRY_FRAMES } from '@/utils/zoomUtils'; + +jest.mock('@tauri-apps/api/core', () => ({ + convertFileSrc: (path: string) => path, +})); + +class ResizeObserverMock { + observe = jest.fn(); + disconnect = jest.fn(); +} + +const mockElementRect = ( + element: Element, + rect: Partial, + dimensions?: { clientWidth?: number; clientHeight?: number }, +) => { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + left: 0, + top: 0, + right: (rect.left ?? 0) + (rect.width ?? 0), + bottom: (rect.top ?? 0) + (rect.height ?? 0), + x: rect.left ?? 0, + y: rect.top ?? 0, + width: 0, + height: 0, + toJSON: () => undefined, + ...rect, + }) as DOMRect, + }); + + if (dimensions?.clientWidth !== undefined) { + Object.defineProperty(element, 'clientWidth', { + configurable: true, + value: dimensions.clientWidth, + }); + } + + if (dimensions?.clientHeight !== undefined) { + Object.defineProperty(element, 'clientHeight', { + configurable: true, + value: dimensions.clientHeight, + }); + } +}; + +const mockImageDimensions = ( + image: HTMLElement, + dimensions: { width: number; height: number; complete?: boolean }, +) => { + Object.defineProperty(image, 'naturalWidth', { + configurable: true, + value: dimensions.width, + }); + Object.defineProperty(image, 'naturalHeight', { + configurable: true, + value: dimensions.height, + }); + Object.defineProperty(image, 'clientWidth', { + configurable: true, + value: dimensions.width, + }); + Object.defineProperty(image, 'clientHeight', { + configurable: true, + value: dimensions.height, + }); + Object.defineProperty(image, 'complete', { + configurable: true, + value: dimensions.complete ?? true, + }); +}; + +const renderZoomableImage = ( + props: Partial<{ + imagePath: string; + alt: string; + rotation: number; + resetSignal: number; + }> = {}, +) => + render( + , + ); + +const setupScene = ({ + viewportSize, + imageSize, + props, +}: { + viewportSize: { width: number; height: number }; + imageSize: { width: number; height: number }; + props?: Partial<{ + imagePath: string; + alt: string; + rotation: number; + resetSignal: number; + }>; +}) => { + const renderResult = renderZoomableImage(props); + const viewport = screen.getByTestId('zoom-viewport'); + const content = screen.getByTestId('zoom-content'); + const image = screen.getByAltText('test image'); + + mockElementRect( + viewport, + { ...viewportSize, left: 0, top: 0 }, + { clientWidth: viewportSize.width, clientHeight: viewportSize.height }, + ); + mockImageDimensions(image, imageSize); + + fireEvent.load(image); + + return { ...renderResult, viewport, content, image }; +}; + +const getCurrentTransform = () => { + const content = screen.getByTestId('zoom-content'); + const transform = content.style.transform; + const match = transform.match( + /translate3d\((-?[\d.]+)px, (-?[\d.]+)px, 0\) scale\((-?[\d.]+)\)/, + ); + + if (!match) { + throw new Error(`Unexpected transform: ${transform}`); + } + + return { + positionX: Number(match[1]), + positionY: Number(match[2]), + scale: Number(match[3]), + }; +}; + +const expectCurrentTransform = ( + expectedX: number, + expectedY: number, + expectedScale: number, +) => { + const transform = getCurrentTransform(); + + expect(transform.positionX).toBeCloseTo(expectedX); + expect(transform.positionY).toBeCloseTo(expectedY); + expect(transform.scale).toBeCloseTo(expectedScale); +}; + +const firePointerEvent = ( + element: Element, + type: string, + properties: Record = {}, +) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + + Object.entries(properties).forEach(([key, value]) => { + Object.defineProperty(event, key, { + configurable: true, + value, + }); + }); + + fireEvent(element, event); +}; + +const installPointerCaptureMocks = (element: Element) => { + const capturedPointers = new Set(); + + Object.defineProperty(element, 'setPointerCapture', { + configurable: true, + value: jest.fn((pointerId: number) => { + capturedPointers.add(pointerId); + }), + }); + Object.defineProperty(element, 'releasePointerCapture', { + configurable: true, + value: jest.fn((pointerId: number) => { + capturedPointers.delete(pointerId); + }), + }); + Object.defineProperty(element, 'hasPointerCapture', { + configurable: true, + value: jest.fn((pointerId: number) => capturedPointers.has(pointerId)), + }); +}; + +const zoomToOverflow = (viewport: Element) => { + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); +}; + +const startPan = (viewport: Element, pointerId = 5) => { + firePointerEvent(viewport, 'pointerdown', { + button: 0, + buttons: 1, + pointerId, + clientX: 300, + clientY: 300, + }); +}; + +type ManualAnimationFrames = { + frames: Map; + flushNextFrame: () => void; +}; + +// Replaces the synchronous rAF/cAF mocks (installed in beforeEach) with a manual +// queue so a single generation of scheduled fit attempts can be flushed at a +// time. This makes the fit-retry loop deterministic to assert against. +const setupManualAnimationFrames = (): ManualAnimationFrames => { + const frames = new Map(); + let nextFrameId = 1; + + (window.requestAnimationFrame as unknown as jest.Mock).mockImplementation( + (callback: FrameRequestCallback) => { + const id = nextFrameId; + nextFrameId += 1; + frames.set(id, callback); + return id; + }, + ); + (window.cancelAnimationFrame as unknown as jest.Mock).mockImplementation( + (id: number) => { + frames.delete(id); + }, + ); + + const flushNextFrame = () => { + const callbacks = Array.from(frames.values()); + frames.clear(); + act(() => { + callbacks.forEach((callback) => callback(0)); + }); + }; + + return { frames, flushNextFrame }; +}; + +describe('ZoomableImage controlled transform behavior', () => { + beforeEach(() => { + global.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + jest + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('fits a large image to the measured viewport on load', () => { + setupScene({ + viewportSize: { width: 500, height: 400 }, + imageSize: { width: 1000, height: 800 }, + }); + + expectCurrentTransform(0, 0, 0.5); + }); + + test('keeps zoom centered while the image still fits in the viewport', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 200, height: 100 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 550, + }); + + expectCurrentTransform( + 289.4829081924352, + 244.7414540962176, + 1.1051709180756477, + ); + }); + + test('stops at the first viewport edge before mouse anchoring begins', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 760, height: 300 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + + expectCurrentTransform(0, 142.10526315789474, 1.0526315789473684); + }); + + test('stops a newly overflowing axis at the viewport edge before anchoring it', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + + expectCurrentTransform(0, -18.518518518518476, 0.8888888888888888); + }); + + test('anchors horizontally only after width has reached the viewport edge', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 760, height: 300 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + + expectCurrentTransform( + -78.87818855673584, + 125.49932872489774, + 1.1633378085006818, + ); + }); + + test('anchors vertically only after height has reached the viewport edge', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 300, height: 560 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 300, + clientY: 550, + }); + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 300, + clientY: 550, + }); + + expectCurrentTransform( + 222.3832453092709, + -57.84400494160627, + 1.184111697938194, + ); + }); + + test('anchors both axes when width and height overflow', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + + expectCurrentTransform( + -73.61964265295342, + -73.05158715033576, + 0.9823741494005757, + ); + }); + + test('recenters when zooming back to minimum scale', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 400, height: 300 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + fireEvent.wheel(viewport, { + deltaY: 100, + clientX: 700, + clientY: 500, + }); + + expectCurrentTransform(200, 150, 1); + }); + + test('blends overflowing axes back toward center while zooming out', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 1600, height: 1200 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + fireEvent.wheel(viewport, { + deltaY: 50, + clientX: 700, + clientY: 500, + }); + + expectCurrentTransform( + -21.29705614748953, + -15.90707397752716, + 0.5256355481880121, + ); + }); + + test('panning clamps overflowing axes and keeps fitting axes centered', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 760, height: 300 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + firePointerEvent(viewport, 'pointerdown', { + button: 0, + buttons: 1, + pointerId: 1, + clientX: 300, + clientY: 300, + }); + firePointerEvent(viewport, 'pointermove', { + pointerId: 1, + clientX: 1000, + clientY: 1000, + }); + firePointerEvent(viewport, 'pointerup', { + pointerId: 1, + clientX: 1000, + clientY: 1000, + }); + + expectCurrentTransform(0, 125.49932872489774, 1.1633378085006818); + }); + + test('reuses the drag-start geometry while panning', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + const getBoundingClientRect = jest.fn( + () => + ({ + left: 0, + top: 0, + right: 800, + bottom: 600, + x: 0, + y: 0, + width: 800, + height: 600, + toJSON: () => undefined, + }) as DOMRect, + ); + + Object.defineProperty(viewport, 'getBoundingClientRect', { + configurable: true, + value: getBoundingClientRect, + }); + + zoomToOverflow(viewport); + startPan(viewport, 1); + + const callsAfterDragStart = getBoundingClientRect.mock.calls.length; + + firePointerEvent(viewport, 'pointermove', { + pointerId: 1, + clientX: 350, + clientY: 350, + }); + firePointerEvent(viewport, 'pointermove', { + pointerId: 1, + clientX: 400, + clientY: 400, + }); + + expect(getBoundingClientRect).toHaveBeenCalledTimes(callsAfterDragStart); + }); + + test('cleans up panning on pointer leave when pointer capture is unavailable', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerout', { + pointerId: 5, + clientX: 900, + clientY: 700, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('keeps panning when the pointer leaves while capture is held', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + installPointerCaptureMocks(viewport); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerout', { + pointerId: 5, + clientX: 900, + clientY: 700, + }); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 5, + clientX: 900, + clientY: 700, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('cleans up panning on pointer cancel', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointercancel', { + pointerId: 5, + clientX: 300, + clientY: 300, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('cleans up panning when pointer capture is lost', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'lostpointercapture', { + pointerId: 5, + clientX: 300, + clientY: 300, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('cleans up panning on pointer up after pointer capture succeeds', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + installPointerCaptureMocks(viewport); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + expect(viewport.setPointerCapture).toHaveBeenCalledWith(5); + expect(viewport.hasPointerCapture(5)).toBe(true); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 5, + clientX: 300, + clientY: 300, + }); + + expect(viewport.releasePointerCapture).toHaveBeenCalledWith(5); + expect(viewport.hasPointerCapture(5)).toBe(false); + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('does not replace an active drag with a second pointer', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport, 1); + startPan(viewport, 2); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 2, + clientX: 300, + clientY: 300, + }); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 1, + clientX: 300, + clientY: 300, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('does not use client dimensions before the image natural size is ready', () => { + renderZoomableImage(); + + const viewport = screen.getByTestId('zoom-viewport'); + const content = screen.getByTestId('zoom-content'); + const image = screen.getByAltText('test image'); + + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockImageDimensions(image, { + width: 0, + height: 0, + complete: false, + }); + Object.defineProperty(image, 'clientWidth', { + configurable: true, + value: 1000, + }); + Object.defineProperty(image, 'clientHeight', { + configurable: true, + value: 800, + }); + + fireEvent.load(image); + + expect(content.style.width).not.toBe('1000px'); + expect(content.style.height).not.toBe('800px'); + expect(image.style.width).toBe(''); + expect(image.style.height).toBe(''); + }); + + test('starts from the new image fit transform after switching images', () => { + const { viewport, rerender } = setupScene({ + viewportSize: { width: 500, height: 400 }, + imageSize: { width: 1000, height: 800 }, + props: { imagePath: '/tmp/photo-a.jpg' }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 450, + clientY: 350, + }); + + rerender( + , + ); + + const newImage = screen.getByAltText('test image'); + mockImageDimensions(newImage, { width: 200, height: 100 }); + fireEvent.load(newImage); + + expectCurrentTransform(150, 150, 1); + }); + + test('rotation recalculates the fit transform', () => { + const { rerender } = setupScene({ + viewportSize: { width: 600, height: 400 }, + imageSize: { width: 1000, height: 500 }, + props: { rotation: 0 }, + }); + + expectCurrentTransform(0, 50, 0.6); + + rerender( + , + ); + + const image = screen.getByAltText('test image'); + mockImageDimensions(image, { width: 1000, height: 500 }); + fireEvent.load(image); + + expectCurrentTransform(200, 0, 0.4); + }); + + test('imperative reset returns to fit-to-view', () => { + const imageRef = createRef(); + + render( + , + ); + + const viewport = screen.getByTestId('zoom-viewport'); + const image = screen.getByAltText('test image'); + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockImageDimensions(image, { width: 1000, height: 800 }); + fireEvent.load(image); + + act(() => { + imageRef.current?.zoomIn(); + imageRef.current?.reset(); + }); + + expectCurrentTransform(0, 0, 0.5); + }); + + test('applies the line-height multiplier for line-mode wheel events', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 200, height: 100 }, + }); + + // A line-mode wheel (deltaMode === 1) reports scroll in lines, not pixels. + // It must be normalized by LINE_HEIGHT_MULTIPLIER (33), so a 3-line notch + // produces the same zoomRatio as a 99px pixel-mode notch: exp(99 * 0.001). + // Without the multiplier, a 3-line notch would produce exp(3 * 0.001) instead. + fireEvent.wheel(viewport, { + deltaY: -3, + deltaMode: 1, + clientX: 400, + clientY: 300, + }); + + expectCurrentTransform( + 289.5933700441118, + 244.7966850220559, + 1.104066299558882, + ); + }); + + test('retries the fit until the viewport can be measured', () => { + const { flushNextFrame } = setupManualAnimationFrames(); + + renderZoomableImage(); + const viewport = screen.getByTestId('zoom-viewport'); + const image = screen.getByAltText('test image'); + + // The image dimensions are known, but the viewport cannot be measured yet. + mockElementRect( + viewport, + { width: 0, height: 0, left: 0, top: 0 }, + { clientWidth: 0, clientHeight: 0 }, + ); + mockImageDimensions(image, { width: 1000, height: 800 }); + fireEvent.load(image); + + // Fit attempts run but cannot succeed while the viewport has no size, so + // the transform stays at its initial (unfitted) value. + flushNextFrame(); + flushNextFrame(); + expectCurrentTransform(0, 0, 1); + + // Once the viewport reports a size, the next retry fits the image. + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + flushNextFrame(); + + expectCurrentTransform(0, 0, 0.5); + }); + + test('stops retrying after the maximum number of frames and recovers on resize', () => { + const { frames, flushNextFrame } = setupManualAnimationFrames(); + + renderZoomableImage(); + const viewport = screen.getByTestId('zoom-viewport'); + const image = screen.getByAltText('test image'); + + mockElementRect( + viewport, + { width: 0, height: 0, left: 0, top: 0 }, + { clientWidth: 0, clientHeight: 0 }, + ); + mockImageDimensions(image, { width: 1000, height: 800 }); + fireEvent.load(image); + + // Exhaust every retry while the viewport stays unmeasurable. + for (let i = 0; i < MAX_FIT_RETRY_FRAMES + 2; i += 1) { + flushNextFrame(); + } + + // The retry loop has given up: nothing is scheduled and no fit happened. + expect(frames.size).toBe(0); + expectCurrentTransform(0, 0, 1); + + // A now-measurable viewport alone does not revive the abandoned loop. + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + flushNextFrame(); + expectCurrentTransform(0, 0, 1); + + // A fresh fit cycle (resize) resets the retry count and fits the image. + act(() => { + window.dispatchEvent(new Event('resize')); + }); + flushNextFrame(); + + expectCurrentTransform(0, 0, 0.5); + }); + + describe('control button zoom animation', () => { + // jsdom has no TransitionEvent constructor, and fireEvent.transitionEnd does + // not deliver `propertyName` to React's synthetic event. Build the event + // manually (mirroring firePointerEvent) so the handler's property filter + // sees a real value. + const fireTransitionEnd = (element: Element, propertyName: string) => { + const event = new Event('transitionend', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'propertyName', { + configurable: true, + value: propertyName, + }); + fireEvent(element, event); + }; + + const setupSceneWithRef = ( + viewportSize: { width: number; height: number }, + imageSize: { width: number; height: number }, + ) => { + const imageRef = createRef(); + + render( + , + ); + + const viewport = screen.getByTestId('zoom-viewport'); + const content = screen.getByTestId('zoom-content'); + const image = screen.getByAltText('test image'); + + mockElementRect( + viewport, + { ...viewportSize, left: 0, top: 0 }, + { clientWidth: viewportSize.width, clientHeight: viewportSize.height }, + ); + mockImageDimensions(image, imageSize); + fireEvent.load(image); + + return { imageRef, viewport, content, image }; + }; + + test('enables a smooth transition for a button zoom that changes scale', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + + expect(content.style.transition).toBe('transform 250ms ease-out'); + expect(getCurrentTransform().scale).toBeCloseTo(1.5); + }); + + test('switching to the wheel cancels the in-flight button transition', () => { + const { imageRef, viewport, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + expect(content.style.transition).toBe('transform 250ms ease-out'); + + fireEvent.wheel(viewport, { deltaY: -100, clientX: 400, clientY: 300 }); + + // Wheel zoom must stay instant: the transition is cleared immediately. + expect(content.style.transition).toBe(''); + }); + + test('transitionend ends the animation so later transforms are instant', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + expect(content.style.transition).toBe('transform 250ms ease-out'); + + act(() => { + fireTransitionEnd(content, 'transform'); + }); + + expect(content.style.transition).toBe(''); + }); + + test('ignores unrelated transitionend events', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + + // A bubbled, non-transform transitionend must not clear the animation. + act(() => { + fireTransitionEnd(content, 'opacity'); + }); + + expect(content.style.transition).toBe('transform 250ms ease-out'); + }); + + test('does not animate a button zoom that is clamped at maximum scale', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 800, height: 600 }, + ); + + // Saturate at MAX_SCALE; well past the 1.5^n needed to reach 8. + for (let i = 0; i < 12; i += 1) { + act(() => { + imageRef.current?.zoomIn(); + }); + } + + expect(getCurrentTransform().scale).toBeCloseTo(8); + // The final click could not change the transform, so no transition runs. + expect(content.style.transition).toBe(''); + }); + + test('does not animate a button zoom that is clamped at minimum scale', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + // The image already fits at minimum scale; zooming out is a no-op. + act(() => { + imageRef.current?.zoomOut(); + }); + + expect(getCurrentTransform().scale).toBeCloseTo(1); + expect(content.style.transition).toBe(''); + }); + + test('ignores a transform transitionend bubbled from a child element', () => { + const { imageRef, content, image } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + expect(content.style.transition).toBe('transform 250ms ease-out'); + + // A transform transitionend from the child bubbles to the content + // handler, but must be ignored (target !== currentTarget). + act(() => { + fireTransitionEnd(image, 'transform'); + }); + + expect(content.style.transition).toBe('transform 250ms ease-out'); + }); + + test('skips the transition when reduced motion is preferred', () => { + const matchMediaSpy = jest.spyOn(window, 'matchMedia').mockImplementation( + (query: string) => + ({ + matches: query.includes('prefers-reduced-motion'), + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }) as unknown as MediaQueryList, + ); + + try { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + + // The zoom still applies, but without the CSS transition. + expect(getCurrentTransform().scale).toBeCloseTo(1.5); + expect(content.style.transition).toBe(''); + } finally { + matchMediaSpy.mockRestore(); + } + }); + }); +}); diff --git a/frontend/src/hooks/useImageViewControls.ts b/frontend/src/hooks/useImageViewControls.ts index ca2541962..6fb30fa2e 100644 --- a/frontend/src/hooks/useImageViewControls.ts +++ b/frontend/src/hooks/useImageViewControls.ts @@ -1,94 +1,31 @@ import { useState, useCallback } from 'react'; interface ViewState { - scale: number; - position: { x: number; y: number }; rotation: number; - isDragging: boolean; - dragStart: { x: number; y: number }; } export const useImageViewControls = () => { const [viewState, setViewState] = useState({ - scale: 1, - position: { x: 0, y: 0 }, rotation: 0, - isDragging: false, - dragStart: { x: 0, y: 0 }, }); - const handleZoomIn = useCallback(() => { - setViewState((prev) => ({ - ...prev, - scale: Math.min(4, prev.scale + 0.1), - })); - }, []); - - const handleZoomOut = useCallback(() => { - setViewState((prev) => ({ - ...prev, - scale: Math.max(0.5, prev.scale - 0.1), - })); - }, []); - const handleRotate = useCallback(() => { setViewState((prev) => ({ - ...prev, rotation: (prev.rotation + 90) % 360, })); }, []); const resetZoom = useCallback(() => { - setViewState((prev) => ({ - ...prev, - scale: 1, - position: { x: 0, y: 0 }, + setViewState({ rotation: 0, - })); - }, []); - - const handleMouseDown = useCallback((e: React.MouseEvent) => { - setViewState((prev) => ({ - ...prev, - isDragging: true, - dragStart: { - x: e.clientX - prev.position.x, - y: e.clientY - prev.position.y, - }, - })); - }, []); - - const handleMouseMove = useCallback((e: React.MouseEvent) => { - setViewState((prev) => { - if (!prev.isDragging) return prev; - - return { - ...prev, - position: { - x: e.clientX - prev.dragStart.x, - y: e.clientY - prev.dragStart.y, - }, - }; }); }, []); - const handleMouseUp = useCallback(() => { - setViewState((prev) => ({ - ...prev, - isDragging: false, - })); - }, []); - return { viewState, handlers: { - handleZoomIn, - handleZoomOut, handleRotate, resetZoom, - handleMouseDown, - handleMouseMove, - handleMouseUp, }, }; }; diff --git a/frontend/src/hooks/useZoomTransform.ts b/frontend/src/hooks/useZoomTransform.ts new file mode 100644 index 000000000..a6d6e62e8 --- /dev/null +++ b/frontend/src/hooks/useZoomTransform.ts @@ -0,0 +1,560 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { + CONTROL_BUTTON_ZOOM_RATIO, + LINE_HEIGHT_MULTIPLIER, + MAX_FIT_RETRY_FRAMES, + MAX_SCALE, + MIN_SCALE, + SCALE_EPSILON, + WHEEL_ZOOM_SENSITIVITY, + clamp, + computeZoomTransform, + getAxisPosition, + getCenteredAxisPosition, + getEffectiveDimensions, + getElementSize, + getFitTransform, + getMinimumScale, + getOverflowState, + getScaledDimensions, + type Geometry, + type Size, + type TransformState, +} from '@/utils/zoomUtils'; + +type UseZoomTransformParams = { + imagePath: string; + rotation: number; + resetSignal?: number; +}; + +type DragState = { + pointerId: number; + startClientX: number; + startClientY: number; + startTransform: TransformState; + geometry: Geometry; +}; + +export const useZoomTransform = ({ + imagePath, + rotation, + resetSignal, +}: UseZoomTransformParams) => { + const viewportRef = useRef(null); + const imageRef = useRef(null); + const transformStateRef = useRef({ + positionX: 0, + positionY: 0, + scale: MIN_SCALE, + }); + const rawDimensionsRef = useRef(null); + const isFitInitializedRef = useRef(false); + const hasUserInteractedRef = useRef(false); + const fitFrameRef = useRef(null); + const fitRetryCountRef = useRef(0); + const dragStateRef = useRef(null); + const buttonZoomTimeoutRef = useRef | null>( + null, + ); + const rotationRef = useRef(rotation); + const [transformState, setTransformState] = useState( + transformStateRef.current, + ); + const [rawDimensions, setRawDimensions] = useState(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [isPanning, setIsPanning] = useState(false); + const [isButtonZoom, setIsButtonZoom] = useState(false); + + const setRawImageDimensions = useCallback((dimensions: Size | null) => { + rawDimensionsRef.current = dimensions; + setRawDimensions(dimensions); + }, []); + + const readRawDimensions = useCallback((): Size | null => { + const img = imageRef.current; + const fallbackDimensions = rawDimensionsRef.current; + + if (img?.naturalWidth && img.naturalHeight) { + return { width: img.naturalWidth, height: img.naturalHeight }; + } + + if (fallbackDimensions?.width && fallbackDimensions.height) { + return fallbackDimensions; + } + + if (img?.complete && img.clientWidth && img.clientHeight) { + return { width: img.clientWidth, height: img.clientHeight }; + } + + return null; + }, []); + + const getGeometry = useCallback((): Geometry | null => { + const viewport = viewportRef.current; + const rawImageDimensions = readRawDimensions(); + + if (!viewport || !rawImageDimensions) return null; + + const viewportSize = getElementSize(viewport); + + if (!viewportSize.width || !viewportSize.height) return null; + + const baseDimensions = getEffectiveDimensions( + rawImageDimensions.width, + rawImageDimensions.height, + rotationRef.current, + ); + const minScale = getMinimumScale( + baseDimensions, + viewportSize.width, + viewportSize.height, + ); + + return { + viewportWidth: viewportSize.width, + viewportHeight: viewportSize.height, + viewportLeft: viewportSize.left, + viewportTop: viewportSize.top, + rawDimensions: rawImageDimensions, + baseDimensions, + minScale, + }; + }, [readRawDimensions]); + + const setControlledTransform = useCallback( + (nextTransform: TransformState) => { + transformStateRef.current = nextTransform; + setTransformState(nextTransform); + }, + [], + ); + + const applyTransform = useCallback( + (nextTransform: TransformState, geometry = getGeometry()) => { + if (!geometry) return false; + + const scale = clamp(nextTransform.scale, geometry.minScale, MAX_SCALE); + const scaledDimensions = getScaledDimensions( + geometry.baseDimensions, + scale, + ); + const overflow = getOverflowState( + geometry.baseDimensions, + scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + const shouldRecenter = + scale <= geometry.minScale + SCALE_EPSILON || + (!overflow.width && !overflow.height); + + const positionX = shouldRecenter + ? getCenteredAxisPosition( + geometry.viewportWidth, + scaledDimensions.width, + ) + : getAxisPosition( + nextTransform.positionX, + geometry.viewportWidth, + scaledDimensions.width, + overflow.width, + ); + const positionY = shouldRecenter + ? getCenteredAxisPosition( + geometry.viewportHeight, + scaledDimensions.height, + ) + : getAxisPosition( + nextTransform.positionY, + geometry.viewportHeight, + scaledDimensions.height, + overflow.height, + ); + + setIsOverflowing(overflow.width || overflow.height); + setControlledTransform({ positionX, positionY, scale }); + return true; + }, + [getGeometry, setControlledTransform], + ); + + const applyFitTransform = useCallback(() => { + const geometry = getGeometry(); + + if (!geometry) return false; + + const didApply = applyTransform(getFitTransform(geometry), geometry); + + if (didApply) { + isFitInitializedRef.current = true; + hasUserInteractedRef.current = false; + fitRetryCountRef.current = 0; + } + + return didApply; + }, [applyTransform, getGeometry]); + + const clearScheduledFit = useCallback((resetRetryCount = true) => { + if (resetRetryCount) { + fitRetryCountRef.current = 0; + } + + if (fitFrameRef.current === null) return; + + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(fitFrameRef.current); + } else { + window.clearTimeout(fitFrameRef.current); + } + fitFrameRef.current = null; + }, []); + + const scheduleFitTransform = useCallback( + (resetRetryCount = true) => { + clearScheduledFit(resetRetryCount); + + const scheduleFrame: (callback: FrameRequestCallback) => number = + typeof window.requestAnimationFrame === 'function' + ? window.requestAnimationFrame.bind(window) + : (callback) => + window.setTimeout(() => callback(performance.now()), 0); + + fitFrameRef.current = scheduleFrame(() => { + fitFrameRef.current = null; + const applied = applyFitTransform(); + + if (!applied && fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES) { + fitRetryCountRef.current += 1; + scheduleFitTransform(false); + } + }); + }, + [applyFitTransform, clearScheduledFit], + ); + + const clearButtonZoomAnimation = useCallback(() => { + setIsButtonZoom(false); + if (buttonZoomTimeoutRef.current !== null) { + clearTimeout(buttonZoomTimeoutRef.current); + buttonZoomTimeoutRef.current = null; + } + }, []); + + const resetToFit = useCallback(() => { + clearButtonZoomAnimation(); + isFitInitializedRef.current = false; + hasUserInteractedRef.current = false; + fitRetryCountRef.current = 0; + scheduleFitTransform(false); + }, [clearButtonZoomAnimation, scheduleFitTransform]); + + const zoomBy = useCallback( + (zoomRatio: number, clientX?: number, clientY?: number) => { + const geometry = getGeometry(); + + if (!geometry) return false; + + const currentTransform = isFitInitializedRef.current + ? transformStateRef.current + : getFitTransform(geometry); + const didApply = applyTransform( + computeZoomTransform({ + geometry, + currentTransform, + zoomRatio, + clientX, + clientY, + }), + geometry, + ); + + if (didApply) { + isFitInitializedRef.current = true; + hasUserInteractedRef.current = true; + } + + return didApply; + }, + [applyTransform, getGeometry], + ); + + useEffect(() => { + rotationRef.current = rotation; + resetToFit(); + }, [rotation, resetToFit]); + + useEffect(() => { + resetToFit(); + }, [resetSignal, resetToFit]); + + useEffect(() => { + setIsOverflowing(false); + setRawImageDimensions(null); + resetToFit(); + }, [imagePath, resetToFit, setRawImageDimensions]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const handleResize = () => { + if (hasUserInteractedRef.current) { + clearButtonZoomAnimation(); + applyTransform(transformStateRef.current); + } else { + resetToFit(); + } + }; + + let resizeObserver: ResizeObserver | null = null; + + if (typeof ResizeObserver === 'function') { + resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(viewport); + } + + window.addEventListener('resize', handleResize); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', handleResize); + }; + }, [applyTransform, clearButtonZoomAnimation, resetToFit]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + clearButtonZoomAnimation(); + + const isLineMode = e.deltaMode === 1; + const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; + const normalizedDelta = -e.deltaY * multiplier; + const zoomRatio = Math.exp(normalizedDelta * WHEEL_ZOOM_SENSITIVITY); + + zoomBy(zoomRatio, e.clientX, e.clientY); + }; + + viewport.addEventListener('wheel', handleWheel, { + passive: false, + capture: true, + }); + + return () => { + viewport.removeEventListener('wheel', handleWheel, true); + }; + }, [clearButtonZoomAnimation, zoomBy]); + + useEffect( + () => () => { + clearScheduledFit(); + if (buttonZoomTimeoutRef.current !== null) { + clearTimeout(buttonZoomTimeoutRef.current); + } + }, + [clearScheduledFit], + ); + + const handleImageLoad = useCallback(() => { + const dimensions = readRawDimensions(); + + if (dimensions) { + setRawImageDimensions(dimensions); + } + + resetToFit(); + }, [readRawDimensions, resetToFit, setRawImageDimensions]); + + const startDrag = useCallback( + (pointerId: number, clientX: number, clientY: number) => { + const geometry = getGeometry(); + if (!geometry || dragStateRef.current) return false; + + const overflow = getOverflowState( + geometry.baseDimensions, + transformStateRef.current.scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + + if (!overflow.width && !overflow.height) return false; + + clearButtonZoomAnimation(); + + dragStateRef.current = { + pointerId, + startClientX: clientX, + startClientY: clientY, + startTransform: transformStateRef.current, + geometry, + }; + hasUserInteractedRef.current = true; + setIsPanning(true); + return true; + }, + [clearButtonZoomAnimation, getGeometry], + ); + + const updateDrag = useCallback( + (pointerId: number, clientX: number, clientY: number) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return false; + + const deltaX = clientX - dragState.startClientX; + const deltaY = clientY - dragState.startClientY; + + applyTransform( + { + positionX: dragState.startTransform.positionX + deltaX, + positionY: dragState.startTransform.positionY + deltaY, + scale: dragState.startTransform.scale, + }, + dragState.geometry, + ); + return true; + }, + [applyTransform], + ); + + const endDrag = useCallback((pointerId: number) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return false; + + dragStateRef.current = null; + setIsPanning(false); + return true; + }, []); + + const handlePointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button > 0 || (e.buttons && e.buttons !== 1)) return; + + const didStartDrag = startDrag(e.pointerId, e.clientX, e.clientY); + if (!didStartDrag) return; + + try { + e.currentTarget.setPointerCapture?.(e.pointerId); + } catch { + // Pointer capture is a best-effort guard; pointerleave is the fallback. + } + e.preventDefault(); + }, + [startDrag], + ); + + const handlePointerMove = useCallback( + (e: ReactPointerEvent) => { + const didUpdateDrag = updateDrag(e.pointerId, e.clientX, e.clientY); + if (!didUpdateDrag) return; + + e.preventDefault(); + }, + [updateDrag], + ); + + const handlePointerEnd = useCallback( + (e: ReactPointerEvent) => { + const didEndDrag = endDrag(e.pointerId); + if (!didEndDrag) return; + + try { + e.currentTarget.releasePointerCapture?.(e.pointerId); + } catch { + // The pointer may already be released if capture was unavailable. + } + }, + [endDrag], + ); + + const handlePointerLeave = useCallback( + (e: ReactPointerEvent) => { + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) return; + + endDrag(e.pointerId); + }, + [endDrag], + ); + + const startButtonZoom = useCallback( + (ratio: number) => { + clearButtonZoomAnimation(); + + const before = transformStateRef.current; + const didApply = zoomBy(ratio); + const after = transformStateRef.current; + const didChange = + didApply && + (Math.abs(after.scale - before.scale) > SCALE_EPSILON || + Math.abs(after.positionX - before.positionX) > SCALE_EPSILON || + Math.abs(after.positionY - before.positionY) > SCALE_EPSILON); + + if (!didChange) return; + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + return; + + setIsButtonZoom(true); + buttonZoomTimeoutRef.current = setTimeout(() => { + setIsButtonZoom(false); + buttonZoomTimeoutRef.current = null; + }, 300); + }, + [clearButtonZoomAnimation, zoomBy], + ); + const zoomIn = useCallback( + () => startButtonZoom(CONTROL_BUTTON_ZOOM_RATIO), + [startButtonZoom], + ); + const zoomOut = useCallback( + () => startButtonZoom(1 / CONTROL_BUTTON_ZOOM_RATIO), + [startButtonZoom], + ); + const handleZoomTransitionEnd = clearButtonZoomAnimation; + + const contentDimensions = rawDimensions + ? getEffectiveDimensions( + rawDimensions.width, + rawDimensions.height, + rotation, + ) + : null; + const imageOffset = + rawDimensions && contentDimensions + ? { + left: (contentDimensions.width - rawDimensions.width) / 2, + top: (contentDimensions.height - rawDimensions.height) / 2, + } + : { left: 0, top: 0 }; + const cursor = isPanning ? 'grabbing' : isOverflowing ? 'move' : 'default'; + + return { + viewportRef, + imageRef, + transformState, + rawDimensions, + contentDimensions, + imageOffset, + cursor, + isButtonZoom, + handleImageLoad, + handlePointerDown, + handlePointerMove, + handlePointerEnd, + handlePointerLeave, + handleZoomTransitionEnd, + zoomIn, + zoomOut, + reset: resetToFit, + }; +}; diff --git a/frontend/src/utils/__tests__/zoomUtils.test.ts b/frontend/src/utils/__tests__/zoomUtils.test.ts new file mode 100644 index 000000000..d3306b497 --- /dev/null +++ b/frontend/src/utils/__tests__/zoomUtils.test.ts @@ -0,0 +1,195 @@ +import { + computeZoomTransform, + getFitTransform, + getMinimumScale, + type Geometry, +} from '../zoomUtils'; + +const makeGeometry = (overrides: Partial = {}): Geometry => { + const baseDimensions = overrides.baseDimensions ?? { + width: 800, + height: 600, + }; + const viewportWidth = overrides.viewportWidth ?? 800; + const viewportHeight = overrides.viewportHeight ?? 600; + + return { + viewportWidth, + viewportHeight, + viewportLeft: 0, + viewportTop: 0, + rawDimensions: baseDimensions, + baseDimensions, + minScale: getMinimumScale(baseDimensions, viewportWidth, viewportHeight), + ...overrides, + }; +}; + +describe('zoomUtils', () => { + test('computes fit scale and centered transform for a large image', () => { + const geometry = makeGeometry({ + viewportWidth: 500, + viewportHeight: 400, + baseDimensions: { width: 1000, height: 800 }, + rawDimensions: { width: 1000, height: 800 }, + minScale: 0.5, + }); + + expect(getFitTransform(geometry)).toEqual({ + positionX: 0, + positionY: 0, + scale: 0.5, + }); + }); + + test('keeps zoom centered while the image still fits', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + viewportLeft: 20, + viewportTop: 10, + baseDimensions: { width: 200, height: 100 }, + rawDimensions: { width: 200, height: 100 }, + minScale: 1, + }); + + const next = computeZoomTransform({ + geometry, + currentTransform: { positionX: 300, positionY: 250, scale: 1 }, + zoomRatio: 1.1, + clientX: 770, + clientY: 560, + }); + + expect(next.positionX).toBeCloseTo(290); + expect(next.positionY).toBeCloseTo(245); + expect(next.scale).toBeCloseTo(1.1); + }); + + test('stops at the first viewport edge before mouse anchoring begins', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 760, height: 300 }, + rawDimensions: { width: 760, height: 300 }, + minScale: 1, + }); + + const next = computeZoomTransform({ + geometry, + currentTransform: { positionX: 20, positionY: 150, scale: 1 }, + zoomRatio: 1.1, + clientX: 750, + clientY: 300, + }); + + expect(next.positionX).toBeCloseTo(0); + expect(next.positionY).toBeCloseTo(142.10526315789474); + expect(next.scale).toBeCloseTo(1.0526315789473684); + }); + + test('anchors an overflowing axis when the cursor is in viewport padding', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 900, height: 300 }, + rawDimensions: { width: 900, height: 300 }, + minScale: 0.8888888888888888, + }); + + const next = computeZoomTransform({ + geometry, + currentTransform: { positionX: -80, positionY: 135, scale: 1.1 }, + zoomRatio: 1.1, + clientX: 700, + clientY: 50, + }); + + expect(next.positionX).toBeCloseTo(-158); + expect(next.positionY).toBeCloseTo(118.5); + expect(next.scale).toBeCloseTo(1.21); + }); + + test('blends overflowing axes back toward center while zooming out', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 1600, height: 1200 }, + rawDimensions: { width: 1600, height: 1200 }, + minScale: 0.5, + }); + + const zoomedIn = computeZoomTransform({ + geometry, + currentTransform: getFitTransform(geometry), + zoomRatio: 1.1, + clientX: 700, + clientY: 500, + }); + const zoomedOut = computeZoomTransform({ + geometry, + currentTransform: zoomedIn, + zoomRatio: 0.95, + clientX: 700, + clientY: 500, + }); + + expect(zoomedOut.positionX).toBeCloseTo(-18.6075); + expect(zoomedOut.positionY).toBeCloseTo(-13.905); + expect(zoomedOut.scale).toBeCloseTo(0.5225); + }); + + test('anchors one axis or both axes according to overflow state', () => { + const widthOnlyGeometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 760, height: 300 }, + rawDimensions: { width: 760, height: 300 }, + minScale: 1, + }); + + const widthAtEdge = computeZoomTransform({ + geometry: widthOnlyGeometry, + currentTransform: { positionX: 20, positionY: 150, scale: 1 }, + zoomRatio: 1.1, + clientX: 750, + clientY: 300, + }); + const widthAnchored = computeZoomTransform({ + geometry: widthOnlyGeometry, + currentTransform: widthAtEdge, + zoomRatio: 1.1, + clientX: 750, + clientY: 300, + }); + + expect(widthAnchored.positionX).toBeCloseTo(-75); + expect(widthAnchored.positionY).toBeCloseTo(126.31578947368419); + + const bothAxisGeometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 900, height: 700 }, + rawDimensions: { width: 900, height: 700 }, + minScale: 0.8571428571428571, + }); + + const bothAtEdge = computeZoomTransform({ + geometry: bothAxisGeometry, + currentTransform: getFitTransform(bothAxisGeometry), + zoomRatio: 1.1, + clientX: 700, + clientY: 500, + }); + const bothAnchored = computeZoomTransform({ + geometry: bothAxisGeometry, + currentTransform: bothAtEdge, + zoomRatio: 1.1, + clientX: 700, + clientY: 500, + }); + + expect(bothAnchored.positionX).toBeCloseTo(-70); + expect(bothAnchored.positionY).toBeCloseTo(-70.37037037037032); + }); +}); diff --git a/frontend/src/utils/imageFallback.ts b/frontend/src/utils/imageFallback.ts new file mode 100644 index 000000000..2417e1b21 --- /dev/null +++ b/frontend/src/utils/imageFallback.ts @@ -0,0 +1,21 @@ +import type { SyntheticEvent } from 'react'; + +export const PLACEHOLDER_IMAGE_SRC = '/placeholder.svg'; + +/** + * Builds an `` onError handler that swaps in a fallback image exactly once + * and detaches itself, so a broken fallback cannot trigger an error loop. + * + * Centralizes the handler that was previously copy-pasted across the media and + * memories components (each with its own fallback asset). + */ +export const createImageErrorHandler = + (fallbackSrc: string = PLACEHOLDER_IMAGE_SRC) => + (event: SyntheticEvent) => { + const img = event.currentTarget; + img.onerror = null; + img.src = fallbackSrc; + }; + +/** Shared, stable handler for components that fall back to the placeholder. */ +export const handlePlaceholderImageError = createImageErrorHandler(); diff --git a/frontend/src/utils/zoomUtils.ts b/frontend/src/utils/zoomUtils.ts new file mode 100644 index 000000000..b5069690f --- /dev/null +++ b/frontend/src/utils/zoomUtils.ts @@ -0,0 +1,318 @@ +// Browser line-mode wheel events report lines, not pixels. This normalizes one +// line-scroll notch to the commonly used 33px pixel delta. +export const LINE_HEIGHT_MULTIPLIER = 33; +// Sensitivity for exponential wheel zoom: ratio = exp(normalizedDelta * sensitivity). +// A mouse notch (~100px delta) becomes ~10.5% scale change; trackpad stays smooth. +export const WHEEL_ZOOM_SENSITIVITY = 0.001; +export const MAX_SCALE = 8; +export const MIN_SCALE = 1; +export const SCALE_EPSILON = 0.0001; +export const MAX_FIT_RETRY_FRAMES = 12; +// Multiplicative zoom ratio for the zoom-in/out buttons (50% per click). +export const CONTROL_BUTTON_ZOOM_RATIO = 1.5; + +export type Size = { + width: number; + height: number; +}; + +export type OverflowState = { + width: boolean; + height: boolean; +}; + +export type TransformState = { + positionX: number; + positionY: number; + scale: number; +}; + +export type Geometry = { + viewportWidth: number; + viewportHeight: number; + viewportLeft: number; + viewportTop: number; + rawDimensions: Size; + baseDimensions: Size; + minScale: number; +}; + +type ComputeZoomTransformOptions = { + geometry: Geometry; + currentTransform: TransformState; + zoomRatio: number; + clientX?: number; + clientY?: number; +}; + +export const getCenteredAxisPosition = ( + viewportSize: number, + scaledSize: number, +) => (viewportSize - scaledSize) / 2; + +export const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +export const clampOverflowAxisPosition = ( + position: number, + viewportSize: number, + scaledSize: number, +) => { + const minPosition = viewportSize - scaledSize; + const maxPosition = 0; + + return clamp(position, minPosition, maxPosition); +}; + +export const getAxisPosition = ( + anchoredPosition: number, + viewportSize: number, + scaledSize: number, + isOverflowingAxis: boolean, +) => { + const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); + + return isOverflowingAxis + ? clampOverflowAxisPosition(anchoredPosition, viewportSize, scaledSize) + : centeredPosition; +}; + +export const getOverflowRatio = (viewportSize: number, scaledSize: number) => { + if (!viewportSize || scaledSize <= viewportSize) return 0; + + return clamp((scaledSize - viewportSize) / viewportSize, 0, 1); +}; + +export const interpolate = (from: number, to: number, ratio: number) => + from + (to - from) * ratio; + +export const getSmoothedWheelAxisPosition = ({ + anchoredPosition, + viewportSize, + scaledSize, + isOverflowingAxis, + shouldAnchor, + isZoomingOut, +}: { + anchoredPosition: number; + viewportSize: number; + scaledSize: number; + isOverflowingAxis: boolean; + shouldAnchor: boolean; + isZoomingOut: boolean; +}) => { + const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); + + if (!isOverflowingAxis || !shouldAnchor) return centeredPosition; + + const clampedAnchor = clampOverflowAxisPosition( + anchoredPosition, + viewportSize, + scaledSize, + ); + + if (!isZoomingOut) return clampedAnchor; + + const anchorRatio = getOverflowRatio(viewportSize, scaledSize); + + return interpolate(centeredPosition, clampedAnchor, anchorRatio); +}; + +export const axisTouchesViewport = (scaledSize: number, viewportSize: number) => + scaledSize >= viewportSize - SCALE_EPSILON; + +export const getEffectiveDimensions = ( + width: number, + height: number, + rotation: number, +): Size => { + const normalizedRotation = ((rotation % 360) + 360) % 360; + const isRotated90or270 = + normalizedRotation === 90 || normalizedRotation === 270; + + return isRotated90or270 + ? { width: height, height: width } + : { width, height }; +}; + +export const getMinimumScale = ( + baseDimensions: Size, + viewportWidth: number, + viewportHeight: number, +) => { + if ( + !baseDimensions.width || + !baseDimensions.height || + !viewportWidth || + !viewportHeight + ) { + return MIN_SCALE; + } + + return Math.min( + MIN_SCALE, + viewportWidth / baseDimensions.width, + viewportHeight / baseDimensions.height, + ); +}; + +export const getNextViewportEdgeScale = ( + baseDimensions: Size, + viewportWidth: number, + viewportHeight: number, + currentTouchesViewport: OverflowState, +) => { + const edgeScales = []; + + if (!currentTouchesViewport.width) { + edgeScales.push(viewportWidth / baseDimensions.width); + } + + if (!currentTouchesViewport.height) { + edgeScales.push(viewportHeight / baseDimensions.height); + } + + if (!edgeScales.length) return null; + + return Math.min( + MAX_SCALE, + Math.max( + getMinimumScale(baseDimensions, viewportWidth, viewportHeight), + Math.min(...edgeScales), + ), + ); +}; + +export const getScaledDimensions = ( + baseDimensions: Size, + scale: number, +): Size => ({ + width: baseDimensions.width * scale, + height: baseDimensions.height * scale, +}); + +export const getOverflowState = ( + baseDimensions: Size, + scale: number, + viewportWidth: number, + viewportHeight: number, +): OverflowState => { + const scaledDimensions = getScaledDimensions(baseDimensions, scale); + + return { + width: scaledDimensions.width > viewportWidth, + height: scaledDimensions.height > viewportHeight, + }; +}; + +export const getFitTransform = ({ + baseDimensions, + viewportWidth, + viewportHeight, + minScale, +}: Geometry): TransformState => { + const scaledDimensions = getScaledDimensions(baseDimensions, minScale); + + return { + positionX: getCenteredAxisPosition(viewportWidth, scaledDimensions.width), + positionY: getCenteredAxisPosition(viewportHeight, scaledDimensions.height), + scale: minScale, + }; +}; + +export const getElementSize = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + + return { + width: rect.width || element.clientWidth, + height: rect.height || element.clientHeight, + left: rect.left, + top: rect.top, + }; +}; + +export const computeZoomTransform = ({ + geometry, + currentTransform, + zoomRatio, + clientX, + clientY, +}: ComputeZoomTransformOptions): TransformState => { + const desiredScale = clamp( + currentTransform.scale * zoomRatio, + geometry.minScale, + MAX_SCALE, + ); + const currentDimensions = getScaledDimensions( + geometry.baseDimensions, + currentTransform.scale, + ); + const currentTouchesViewport = { + width: axisTouchesViewport(currentDimensions.width, geometry.viewportWidth), + height: axisTouchesViewport( + currentDimensions.height, + geometry.viewportHeight, + ), + }; + const isZoomingIn = desiredScale > currentTransform.scale; + const nextViewportEdgeScale = getNextViewportEdgeScale( + geometry.baseDimensions, + geometry.viewportWidth, + geometry.viewportHeight, + currentTouchesViewport, + ); + const scale = + isZoomingIn && + nextViewportEdgeScale !== null && + desiredScale > nextViewportEdgeScale + ? nextViewportEdgeScale + : desiredScale; + const scaledDimensions = getScaledDimensions(geometry.baseDimensions, scale); + const newOverflow = getOverflowState( + geometry.baseDimensions, + scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + const mouseViewportX = + clientX === undefined + ? geometry.viewportWidth / 2 + : clientX - geometry.viewportLeft; + const mouseViewportY = + clientY === undefined + ? geometry.viewportHeight / 2 + : clientY - geometry.viewportTop; + const isWithinViewport = + mouseViewportX >= 0 && + mouseViewportX <= geometry.viewportWidth && + mouseViewportY >= 0 && + mouseViewportY <= geometry.viewportHeight; + const ratio = currentTransform.scale > 0 ? scale / currentTransform.scale : 1; + const anchoredX = + mouseViewportX - (mouseViewportX - currentTransform.positionX) * ratio; + const anchoredY = + mouseViewportY - (mouseViewportY - currentTransform.positionY) * ratio; + const isZoomingOut = scale < currentTransform.scale; + + return { + positionX: getSmoothedWheelAxisPosition({ + anchoredPosition: anchoredX, + viewportSize: geometry.viewportWidth, + scaledSize: scaledDimensions.width, + isOverflowingAxis: newOverflow.width, + shouldAnchor: + isWithinViewport && currentTouchesViewport.width && newOverflow.width, + isZoomingOut, + }), + positionY: getSmoothedWheelAxisPosition({ + anchoredPosition: anchoredY, + viewportSize: geometry.viewportHeight, + scaledSize: scaledDimensions.height, + isOverflowingAxis: newOverflow.height, + shouldAnchor: + isWithinViewport && currentTouchesViewport.height && newOverflow.height, + isZoomingOut, + }), + scale, + }; +};