From 74ed06ea4a50861ed17ca9a7e319f07303210f86 Mon Sep 17 00:00:00 2001 From: Septicity Date: Fri, 10 Apr 2026 03:56:35 -0500 Subject: [PATCH 01/10] Initial rework of Image Viewer zoom --- .../image-viewer/ImageViewer.css.ts | 8 +++-- .../components/image-viewer/ImageViewer.tsx | 32 ++++++++++++++++--- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index d688afcb7..6c3ef61bc 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -31,12 +31,16 @@ export const ImageViewerContent = style([ export const ImageViewerImg = style([ DefaultReset, { + userSelect: 'none', + touchAction: 'none', + display: 'block', objectFit: 'contain', width: 'auto', height: 'auto', - maxWidth: '100%', - maxHeight: '100%', + maxWidth: 'none', + maxHeight: 'none', backgroundColor: color.Surface.Container, transition: 'transform 100ms linear', + willChange: 'transform', }, ]); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 54b878c76..9bf649da6 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -13,8 +13,16 @@ export type ImageViewerProps = { export const ImageViewer = as<'div', ImageViewerProps>( ({ className, alt, src, requestClose, ...props }, ref) => { - const { transforms, cursor, handleWheel, onPointerDown, resetTransforms, zoomIn, zoomOut } = - useImageGestures(true, 0.2); + const { + transforms, + cursor, + handleWheel, + onPointerDown, + resetTransforms, + zoomIn, + zoomOut, + setZoom, + } = useImageGestures(true, 0.2); const handleDownload = async () => { const fileContent = await downloadMedia(src); @@ -86,14 +94,28 @@ export const ImageViewer = as<'div', ImageViewerProps>( data-gestures="ignore" style={{ cursor, - userSelect: 'none', - touchAction: 'none', - willChange: 'transform', transform: `translate(${transforms.pan.x}px, ${transforms.pan.y}px) scale(${transforms.zoom})`, }} src={src} alt={alt} onPointerDown={onPointerDown} + onLoad={(event: React.SyntheticEvent) => { + // Fit the image to the container on load + const img = event.currentTarget; + const container = img.parentElement; + if (!container) return; + + const imgHeight = img.naturalHeight; + const imgWidth = img.naturalWidth; + const containerHeight = container.clientHeight || 0; + const containerWidth = container.clientWidth || 0; + + const heightRatio = containerHeight / imgHeight; + const widthRatio = containerWidth / imgWidth; + const fitZoom = Math.min(heightRatio, widthRatio, 1); + + setZoom(fitZoom); + }} /> From 81358cba599d9bf7eb9fcca31a6d85dadf6d254e Mon Sep 17 00:00:00 2001 From: Septicity Date: Fri, 10 Apr 2026 03:57:05 -0500 Subject: [PATCH 02/10] Change default Image Viewer rendering style to "pixelated" --- src/app/components/image-viewer/ImageViewer.css.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index 6c3ef61bc..cf303c7e5 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -34,6 +34,7 @@ export const ImageViewerImg = style([ userSelect: 'none', touchAction: 'none', display: 'block', + imageRendering: 'pixelated', // Possibly allow for a custom setting later? objectFit: 'contain', width: 'auto', height: 'auto', From 76a59e389cc7bfc37a862ba125d86b57d7d85ed8 Mon Sep 17 00:00:00 2001 From: Septicity Date: Fri, 10 Apr 2026 05:09:22 -0500 Subject: [PATCH 03/10] Fix immediate zoom animation when opened image is larger than the viewer container container --- src/app/components/image-viewer/ImageViewer.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 9bf649da6..ef98785a5 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; @@ -24,6 +25,11 @@ export const ImageViewer = as<'div', ImageViewerProps>( setZoom, } = useImageGestures(true, 0.2); + const [isImageReady, setIsImageReady] = useState(false); + useEffect(() => { + setIsImageReady(false); + }, [src]); + const handleDownload = async () => { const fileContent = await downloadMedia(src); FileSaver.saveAs(fileContent, alt); @@ -94,6 +100,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( data-gestures="ignore" style={{ cursor, + opacity: isImageReady ? 1 : 0, // Hide image until fit to container transform: `translate(${transforms.pan.x}px, ${transforms.pan.y}px) scale(${transforms.zoom})`, }} src={src} @@ -102,6 +109,9 @@ export const ImageViewer = as<'div', ImageViewerProps>( onLoad={(event: React.SyntheticEvent) => { // Fit the image to the container on load const img = event.currentTarget; + + img.style.transition = 'none'; + const container = img.parentElement; if (!container) return; @@ -115,6 +125,13 @@ export const ImageViewer = as<'div', ImageViewerProps>( const fitZoom = Math.min(heightRatio, widthRatio, 1); setZoom(fitZoom); + setIsImageReady(true); + + // This should be enough time for the browser to apply the transform + // without the transition, so we can re-enable it for future interactions + setTimeout(() => { + img.style.transition = ''; + }, 15); }} /> From 37f1105ca295f9c74e3be6d482850e9e4ffb3930 Mon Sep 17 00:00:00 2001 From: Septicity Date: Fri, 10 Apr 2026 05:10:10 -0500 Subject: [PATCH 04/10] Make dragging images more responsive (Disable transition when dragging) --- src/app/components/image-viewer/ImageViewer.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index ef98785a5..959571750 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -105,7 +105,20 @@ export const ImageViewer = as<'div', ImageViewerProps>( }} src={src} alt={alt} - onPointerDown={onPointerDown} + onPointerDown={(event: React.PointerEvent) => { + // Disable transition while dragging the image + + // Note: This disables the smooth zooming when scrolling while dragging + // or when double-clicking to zoom + const img = event.currentTarget; + img.style.transition = 'none'; + onPointerDown(event); + }} + onPointerUp={(event: React.PointerEvent) => { + // Re-enable transition after dragging + const img = event.currentTarget; + img.style.transition = ''; + }} onLoad={(event: React.SyntheticEvent) => { // Fit the image to the container on load const img = event.currentTarget; From 1233421e0ff2ce38ba2719d16340e0b755d8335d Mon Sep 17 00:00:00 2001 From: Septicity Date: Fri, 10 Apr 2026 06:51:51 -0500 Subject: [PATCH 05/10] Add reset button to image viewer zoom controls --- .../components/image-viewer/ImageViewer.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 959571750..41c05b690 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -25,8 +25,10 @@ export const ImageViewer = as<'div', ImageViewerProps>( setZoom, } = useImageGestures(true, 0.2); + const [fitRatio, setFitRatio] = useState(1); const [isImageReady, setIsImageReady] = useState(false); useEffect(() => { + setFitRatio(1); setIsImageReady(false); }, [src]); @@ -52,6 +54,27 @@ export const ImageViewer = as<'div', ImageViewerProps>( + { + resetTransforms(); + setZoom(fitRatio); + }} + aria-label="Refresh View" + > + + ( const widthRatio = containerWidth / imgWidth; const fitZoom = Math.min(heightRatio, widthRatio, 1); + setFitRatio(fitZoom); setZoom(fitZoom); setIsImageReady(true); From 2b20dcf8e62b1ffe5a83debd75b0822ab61ba691 Mon Sep 17 00:00:00 2001 From: Septicity Date: Sat, 11 Apr 2026 05:56:38 -0500 Subject: [PATCH 06/10] Make zoom input editable by typing (moved View Original Size functionality to new button) --- .../image-viewer/ImageViewer.css.ts | 11 ++ .../components/image-viewer/ImageViewer.tsx | 109 ++++++++++++++++-- 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index cf303c7e5..939198405 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -28,6 +28,17 @@ export const ImageViewerContent = style([ }, ]); +export const ImageViewerInput = style([ + DefaultReset, + { + all: 'unset', + fieldSizing: 'content', + textAlign: 'center', + font: 'inherit', + color: 'inherit', + }, +]); + export const ImageViewerImg = style([ DefaultReset, { diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 41c05b690..3c9edd1b8 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; @@ -25,13 +25,30 @@ export const ImageViewer = as<'div', ImageViewerProps>( setZoom, } = useImageGestures(true, 0.2); - const [fitRatio, setFitRatio] = useState(1); const [isImageReady, setIsImageReady] = useState(false); + const [isEditingZoom, setIsEditingZoom] = useState(false); + const [fitRatio, setFitRatio] = useState(1); + const [zoomInput, setZoomInput] = useState('100'); useEffect(() => { setFitRatio(1); setIsImageReady(false); + setIsEditingZoom(false); + setZoomInput('100'); }, [src]); + useEffect(() => { + if (!isEditingZoom) { + setZoomInput(Math.round(transforms.zoom * 100).toString()); + } + }, [isEditingZoom, transforms.zoom]); + + const zoomInputRef = useRef(null); + useEffect(() => { + if (isEditingZoom) { + zoomInputRef.current?.focus(); + } + }, [isEditingZoom]); + const handleDownload = async () => { const fileContent = await downloadMedia(src); FileSaver.saveAs(fileContent, alt); @@ -57,13 +74,28 @@ export const ImageViewer = as<'div', ImageViewerProps>( { + setZoom(1); + }} + aria-label="View Original Size" + > + + + ( resetTransforms(); setZoom(fitRatio); }} - aria-label="Refresh View" + aria-label="Reset Zoom" > @@ -85,8 +117,61 @@ export const ImageViewer = as<'div', ImageViewerProps>( > - - {Math.round(transforms.zoom * 100)}% + { + setZoomInput(Math.round(transforms.zoom * 100).toString()); + setIsEditingZoom(true); + }} + > + + {isEditingZoom ? ( + + { + setZoomInput(e.target.value); + }} + onBlur={() => { + const next = parseInt(zoomInput, 10); + if (!Number.isNaN(next)) { + setZoom(next / 100); + } + setIsEditingZoom(false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const next = parseInt(zoomInput, 10); + if (!Number.isNaN(next)) { + setZoom(next / 100); + } + setIsEditingZoom(false); + } + }} + /> + % + + ) : ( + `${Math.round(transforms.zoom * 100)}%` + )} + 1 ? 'Success' : 'SurfaceVariant'} From 30ab99f1ba4b2431e572fab5a950b17f514db5db Mon Sep 17 00:00:00 2001 From: Septicity Date: Mon, 13 Apr 2026 16:10:49 -0500 Subject: [PATCH 07/10] Restore original image viewer behavior by automatically refitting to container until manually moved --- .../components/image-viewer/ImageViewer.tsx | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 3c9edd1b8..633bc8177 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; import { useImageGestures } from '$hooks/useImageGestures'; +import { useElementSizeObserver } from '$hooks/useElementSizeObserver'; import { downloadMedia } from '$utils/matrix'; import * as css from './ImageViewer.css'; @@ -25,16 +26,29 @@ export const ImageViewer = as<'div', ImageViewerProps>( setZoom, } = useImageGestures(true, 0.2); + const zoomInputRef = useRef(null); + const containerRef = useRef(null); + const imageRef = useRef(null); + const shouldResizeWithWindowRef = useRef(true); + const [isImageReady, setIsImageReady] = useState(false); const [isEditingZoom, setIsEditingZoom] = useState(false); + const [shouldResizeWithWindow, setShouldResizeWithWindowState] = useState(true); const [fitRatio, setFitRatio] = useState(1); const [zoomInput, setZoomInput] = useState('100'); + + const setShouldResizeWithWindow = useCallback((next: boolean) => { + shouldResizeWithWindowRef.current = next; + setShouldResizeWithWindowState(next); + }, []); useEffect(() => { setFitRatio(1); setIsImageReady(false); + setShouldResizeWithWindow(true); setIsEditingZoom(false); setZoomInput('100'); - }, [src]); + imageRef.current = null; + }, [src, setShouldResizeWithWindow]); useEffect(() => { if (!isEditingZoom) { @@ -42,13 +56,31 @@ export const ImageViewer = as<'div', ImageViewerProps>( } }, [isEditingZoom, transforms.zoom]); - const zoomInputRef = useRef(null); useEffect(() => { if (isEditingZoom) { zoomInputRef.current?.focus(); } }, [isEditingZoom]); + const handleContainerResize = useCallback( + (width: number, height: number) => { + const img = imageRef.current; + const shouldResize = shouldResizeWithWindowRef.current && shouldResizeWithWindow; + if (!img || !shouldResize || !img.naturalWidth || !img.naturalHeight) return; + + const heightRatio = height / img.naturalHeight; + const widthRatio = width / img.naturalWidth; + const fitZoom = Math.min(heightRatio, widthRatio, 1); + + setFitRatio(fitZoom); + setZoom(fitZoom); + img.style.transition = 'none'; + }, + [setZoom, shouldResizeWithWindow] + ); + + useElementSizeObserver(() => containerRef.current, handleContainerResize); + const handleDownload = async () => { const fileContent = await downloadMedia(src); FileSaver.saveAs(fileContent, alt); @@ -83,6 +115,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( radii="Pill" onClick={() => { setZoom(1); + setShouldResizeWithWindow(false); }} aria-label="View Original Size" > @@ -102,6 +135,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( onClick={() => { resetTransforms(); setZoom(fitRatio); + setShouldResizeWithWindow(true); }} aria-label="Reset Zoom" > @@ -112,7 +146,10 @@ export const ImageViewer = as<'div', ImageViewerProps>( outlined={transforms.zoom < 1} size="300" radii="Pill" - onClick={zoomOut} + onClick={() => { + zoomOut(); + setShouldResizeWithWindow(false); + }} aria-label="Zoom Out" > @@ -153,6 +190,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const next = parseInt(zoomInput, 10); if (!Number.isNaN(next)) { setZoom(next / 100); + setShouldResizeWithWindow(false); } setIsEditingZoom(false); }} @@ -161,6 +199,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const next = parseInt(zoomInput, 10); if (!Number.isNaN(next)) { setZoom(next / 100); + setShouldResizeWithWindow(false); } setIsEditingZoom(false); } @@ -178,7 +217,10 @@ export const ImageViewer = as<'div', ImageViewerProps>( outlined={transforms.zoom > 1} size="300" radii="Pill" - onClick={zoomIn} + onClick={() => { + zoomIn(); + setShouldResizeWithWindow(false); + }} aria-label="Zoom In" > @@ -195,7 +237,16 @@ export const ImageViewer = as<'div', ImageViewerProps>( { + const img = imageRef.current; + if (!img) return; + + img.style.transition = ''; + + setShouldResizeWithWindow(false); + handleWheel(event); + }} className={css.ImageViewerContent} data-gestures="ignore" justifyContent="Center" @@ -221,6 +272,8 @@ export const ImageViewer = as<'div', ImageViewerProps>( const img = event.currentTarget; img.style.transition = 'none'; onPointerDown(event); + + setShouldResizeWithWindow(false); }} onPointerUp={(event: React.PointerEvent) => { // Re-enable transition after dragging @@ -230,6 +283,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( onLoad={(event: React.SyntheticEvent) => { // Fit the image to the container on load const img = event.currentTarget; + imageRef.current = img; img.style.transition = 'none'; From e7539b5570f78e46b42c94ee48023acad54966ad Mon Sep 17 00:00:00 2001 From: Septicity Date: Tue, 14 Apr 2026 04:49:23 -0500 Subject: [PATCH 08/10] Move image/container resize logic to `useImageGestures.ts`; make double tap to zoom work properly again --- .../components/image-viewer/ImageViewer.tsx | 134 ++++------------- src/app/hooks/useImageGestures.ts | 136 +++++++++++++++++- 2 files changed, 159 insertions(+), 111 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 633bc8177..d92cb5caf 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; import { useImageGestures } from '$hooks/useImageGestures'; -import { useElementSizeObserver } from '$hooks/useElementSizeObserver'; import { downloadMedia } from '$utils/matrix'; import * as css from './ImageViewer.css'; @@ -15,6 +14,12 @@ export type ImageViewerProps = { export const ImageViewer = as<'div', ImageViewerProps>( ({ className, alt, src, requestClose, ...props }, ref) => { + const zoomInputRef = useRef(null); + + const [isImageReady, setIsImageReady] = useState(false); + const [isEditingZoom, setIsEditingZoom] = useState(false); + const [zoomInput, setZoomInput] = useState('100'); + const { transforms, cursor, @@ -24,63 +29,37 @@ export const ImageViewer = as<'div', ImageViewerProps>( zoomIn, zoomOut, setZoom, - } = useImageGestures(true, 0.2); - - const zoomInputRef = useRef(null); - const containerRef = useRef(null); - const imageRef = useRef(null); - const shouldResizeWithWindowRef = useRef(true); - - const [isImageReady, setIsImageReady] = useState(false); - const [isEditingZoom, setIsEditingZoom] = useState(false); - const [shouldResizeWithWindow, setShouldResizeWithWindowState] = useState(true); - const [fitRatio, setFitRatio] = useState(1); - const [zoomInput, setZoomInput] = useState('100'); - - const setShouldResizeWithWindow = useCallback((next: boolean) => { - shouldResizeWithWindowRef.current = next; - setShouldResizeWithWindowState(next); - }, []); + setZoomSilently, + fitRatio, + imageRef, + containerRef, + handleImageLoad, + enableResizeWithWindow, + } = useImageGestures(true, 0.2, 0.1, 5); useEffect(() => { - setFitRatio(1); setIsImageReady(false); - setShouldResizeWithWindow(true); + enableResizeWithWindow(); setIsEditingZoom(false); setZoomInput('100'); - imageRef.current = null; - }, [src, setShouldResizeWithWindow]); + if (imageRef.current) { + imageRef.current = null; + } + }, [src, enableResizeWithWindow, imageRef]); + // When not actively editing the zoom input, keep it in sync with the current zoom level. useEffect(() => { if (!isEditingZoom) { setZoomInput(Math.round(transforms.zoom * 100).toString()); } }, [isEditingZoom, transforms.zoom]); + // When entering zoom edit mode, focus the input automatically. useEffect(() => { if (isEditingZoom) { zoomInputRef.current?.focus(); } }, [isEditingZoom]); - const handleContainerResize = useCallback( - (width: number, height: number) => { - const img = imageRef.current; - const shouldResize = shouldResizeWithWindowRef.current && shouldResizeWithWindow; - if (!img || !shouldResize || !img.naturalWidth || !img.naturalHeight) return; - - const heightRatio = height / img.naturalHeight; - const widthRatio = width / img.naturalWidth; - const fitZoom = Math.min(heightRatio, widthRatio, 1); - - setFitRatio(fitZoom); - setZoom(fitZoom); - img.style.transition = 'none'; - }, - [setZoom, shouldResizeWithWindow] - ); - - useElementSizeObserver(() => containerRef.current, handleContainerResize); - const handleDownload = async () => { const fileContent = await downloadMedia(src); FileSaver.saveAs(fileContent, alt); @@ -115,7 +94,6 @@ export const ImageViewer = as<'div', ImageViewerProps>( radii="Pill" onClick={() => { setZoom(1); - setShouldResizeWithWindow(false); }} aria-label="View Original Size" > @@ -134,8 +112,8 @@ export const ImageViewer = as<'div', ImageViewerProps>( radii="Pill" onClick={() => { resetTransforms(); - setZoom(fitRatio); - setShouldResizeWithWindow(true); + enableResizeWithWindow(); + setZoomSilently(fitRatio); }} aria-label="Reset Zoom" > @@ -146,10 +124,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( outlined={transforms.zoom < 1} size="300" radii="Pill" - onClick={() => { - zoomOut(); - setShouldResizeWithWindow(false); - }} + onClick={zoomOut} aria-label="Zoom Out" > @@ -190,7 +165,6 @@ export const ImageViewer = as<'div', ImageViewerProps>( const next = parseInt(zoomInput, 10); if (!Number.isNaN(next)) { setZoom(next / 100); - setShouldResizeWithWindow(false); } setIsEditingZoom(false); }} @@ -199,7 +173,6 @@ export const ImageViewer = as<'div', ImageViewerProps>( const next = parseInt(zoomInput, 10); if (!Number.isNaN(next)) { setZoom(next / 100); - setShouldResizeWithWindow(false); } setIsEditingZoom(false); } @@ -217,10 +190,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( outlined={transforms.zoom > 1} size="300" radii="Pill" - onClick={() => { - zoomIn(); - setShouldResizeWithWindow(false); - }} + onClick={zoomIn} aria-label="Zoom In" > @@ -238,15 +208,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( { - const img = imageRef.current; - if (!img) return; - - img.style.transition = ''; - - setShouldResizeWithWindow(false); - handleWheel(event); - }} + onWheel={handleWheel} className={css.ImageViewerContent} data-gestures="ignore" justifyContent="Center" @@ -264,50 +226,10 @@ export const ImageViewer = as<'div', ImageViewerProps>( }} src={src} alt={alt} - onPointerDown={(event: React.PointerEvent) => { - // Disable transition while dragging the image - - // Note: This disables the smooth zooming when scrolling while dragging - // or when double-clicking to zoom - const img = event.currentTarget; - img.style.transition = 'none'; - onPointerDown(event); - - setShouldResizeWithWindow(false); - }} - onPointerUp={(event: React.PointerEvent) => { - // Re-enable transition after dragging - const img = event.currentTarget; - img.style.transition = ''; - }} + onPointerDown={onPointerDown} onLoad={(event: React.SyntheticEvent) => { - // Fit the image to the container on load - const img = event.currentTarget; - imageRef.current = img; - - img.style.transition = 'none'; - - const container = img.parentElement; - if (!container) return; - - const imgHeight = img.naturalHeight; - const imgWidth = img.naturalWidth; - const containerHeight = container.clientHeight || 0; - const containerWidth = container.clientWidth || 0; - - const heightRatio = containerHeight / imgHeight; - const widthRatio = containerWidth / imgWidth; - const fitZoom = Math.min(heightRatio, widthRatio, 1); - - setFitRatio(fitZoom); - setZoom(fitZoom); + handleImageLoad(event); setIsImageReady(true); - - // This should be enough time for the browser to apply the transform - // without the transition, so we can re-enable it for future interactions - setTimeout(() => { - img.style.transition = ''; - }, 15); }} /> diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index 9c10d676d..f52d666ca 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useRef, useEffect } from 'react'; +import { useElementSizeObserver } from './useElementSizeObserver'; interface Vector2 { x: number; @@ -33,12 +34,38 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>( active ? 'grab' : 'initial' ); + const [shouldResizeWithWindow, setShouldResizeWithWindowState] = useState(true); + const shouldResizeWithWindowRef = useRef(true); + const [fitRatio, setFitRatio] = useState(1); + const containerRef = useRef(null); + const imageRef = useRef(null); + + const setShouldResizeWithWindow = useCallback((next: boolean) => { + shouldResizeWithWindowRef.current = next; + setShouldResizeWithWindowState(next); + }, []); + + const enableResizeWithWindow = useCallback( + () => setShouldResizeWithWindow(true), + [setShouldResizeWithWindow] + ); + const disableResizeWithWindow = useCallback( + () => setShouldResizeWithWindow(false), + [setShouldResizeWithWindow] + ); const activePointers = useRef>(new Map()); const initialDist = useRef(0); const lastTapRef = useRef(0); - const setZoom = useCallback((next: number | ((prev: number) => number)) => { + const prepareForTransform = useCallback(() => { + const img = imageRef.current; + if (img) { + img.style.transition = ''; + } + }, []); + + const updateZoom = useCallback((next: number | ((prev: number) => number)) => { setTransforms((prev) => { if (typeof next === 'function') { return { @@ -53,6 +80,23 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 }); }, []); + const setZoom = useCallback( + (next: number | ((prev: number) => number)) => { + disableResizeWithWindow(); + prepareForTransform(); + updateZoom(next); + }, + [disableResizeWithWindow, prepareForTransform, updateZoom] + ); + + const setZoomSilently = useCallback( + (next: number | ((prev: number) => number)) => { + prepareForTransform(); + updateZoom(next); + }, + [prepareForTransform, updateZoom] + ); + const setPan = useCallback((next: Vector2 | ((prev: Vector2) => Vector2)) => { setTransforms((prev) => { if (typeof next === 'function') { @@ -76,10 +120,13 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 (e: React.PointerEvent) => { if (!active || (e.pointerType === 'mouse' && e.button === 2)) return; + disableResizeWithWindow(); + prepareForTransform(); e.stopPropagation(); const target = e.target as HTMLElement; target.setPointerCapture(e.pointerId); + // Double click zoom const now = Date.now(); if (now - lastTapRef.current < 300) { const container = target.parentElement ?? target; @@ -107,12 +154,13 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); setCursor('grabbing'); + // Initialize pinch zoom if (activePointers.current.size === 2) { const points = Array.from(activePointers.current.values()); initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); } }, - [active] + [active, disableResizeWithWindow, prepareForTransform] ); const handlePointerMove = useCallback( @@ -121,6 +169,12 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + // Disable transitions for responsive movement + if (e.target instanceof HTMLElement) { + e.target.style.transition = 'none'; + } + + // Pinch zoom if (activePointers.current.size === 2) { const points = Array.from(activePointers.current.values()); const currentDist = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); @@ -131,6 +185,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 return; } + // Pan if (activePointers.current.size === 1) { setPan((p) => ({ x: p.x + e.movementX, @@ -165,7 +220,64 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 }; }, [handlePointerMove, handlePointerUp]); + // When the size of the container changes, zoom without a transition. + const handleContainerResize = useCallback( + (width: number, height: number) => { + const img = imageRef.current; + if ( + !img || // Image not loaded + !shouldResizeWithWindowRef.current || // Resizing disabled + !img.naturalWidth || + !img.naturalHeight // Invalid image dimensions + ) { + return; + } + const heightRatio = height / img.naturalHeight; + const widthRatio = width / img.naturalWidth; + const fitZoom = Math.min(heightRatio, widthRatio, 1); + + img.style.transition = 'none'; + setFitRatio(fitZoom); + updateZoom(fitZoom); + setTimeout(() => { + img.style.transition = ''; + }, 15); + }, + [updateZoom] + ); + + useElementSizeObserver(() => containerRef.current, handleContainerResize); + + const handleImageLoad = useCallback( + (event: React.SyntheticEvent) => { + const img = event.currentTarget; + imageRef.current = img; + + const container = containerRef.current; + if (!container) return; + + const imgHeight = img.naturalHeight; + const imgWidth = img.naturalWidth; + const containerHeight = container.clientHeight || 0; + const containerWidth = container.clientWidth || 0; + + const heightRatio = containerHeight / imgHeight; + const widthRatio = containerWidth / imgWidth; + const fitZoom = Math.min(heightRatio, widthRatio, 1); + + img.style.transition = 'none'; + setFitRatio(fitZoom); + updateZoom(fitZoom); + setTimeout(() => { + img.style.transition = ''; + }, 15); + }, + [updateZoom] + ); + const zoomIn = useCallback(() => { + disableResizeWithWindow(); + prepareForTransform(); setTransforms((prev) => { const newZoom = Math.min(prev.zoom * (1 + step), max); const zoomMult = newZoom / prev.zoom; @@ -178,9 +290,11 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 }, }; }); - }, [step, max]); + }, [step, max, disableResizeWithWindow, prepareForTransform]); const zoomOut = useCallback(() => { + disableResizeWithWindow(); + prepareForTransform(); setTransforms((prev) => { const newZoom = Math.min(prev.zoom / (1 + step), max); const zoomMult = newZoom / prev.zoom; @@ -193,7 +307,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 }, }; }); - }, [step, max]); + }, [step, max, disableResizeWithWindow, prepareForTransform]); const handleWheel = useCallback( (e: React.WheelEvent) => { @@ -205,6 +319,9 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 return; } + disableResizeWithWindow(); + prepareForTransform(); + // the wheel handler is attached to the container element, not the image const containerRect = e.currentTarget.getBoundingClientRect(); @@ -232,7 +349,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 }; }); }, - [max, min, step] + [max, min, step, disableResizeWithWindow, prepareForTransform] ); return { @@ -240,11 +357,20 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 cursor, onPointerDown, handleWheel, + handleImageLoad, setZoom, + setZoomSilently, setPan, setTransforms, resetTransforms, zoomIn, zoomOut, + fitRatio, + imageRef, + containerRef, + shouldResizeWithWindow, + shouldResizeWithWindowRef, + enableResizeWithWindow, + disableResizeWithWindow, }; }; From 2d57445fe0409eff3e54e0a4c06abfc8a2dfd939 Mon Sep 17 00:00:00 2001 From: Septicity Date: Tue, 14 Apr 2026 05:23:38 -0500 Subject: [PATCH 09/10] Make pinch zoom gesture not trigger double tap gesture --- src/app/hooks/useImageGestures.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index f52d666ca..e748b9c7c 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -129,6 +129,13 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 // Double click zoom const now = Date.now(); if (now - lastTapRef.current < 300) { + // If two cursors are active, this isn't a double click. + if (activePointers.current.size === 2) { + const points = Array.from(activePointers.current.values()); + initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); + return; + } + const container = target.parentElement ?? target; const containerRect = container.getBoundingClientRect(); setTransforms((prev) => { From 47ccb529d4bbdb416c7c2b43b08add45ba767fb9 Mon Sep 17 00:00:00 2001 From: Septicity Date: Tue, 14 Apr 2026 05:25:33 -0500 Subject: [PATCH 10/10] Make double tap gestures ignore clicks that are *too* fast (accidental, finger skidding, etc.) --- src/app/hooks/useImageGestures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index e748b9c7c..f779a74f2 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -128,7 +128,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 // Double click zoom const now = Date.now(); - if (now - lastTapRef.current < 300) { + if (now - lastTapRef.current < 300 && now - lastTapRef.current > 30) { // If two cursors are active, this isn't a double click. if (activePointers.current.size === 2) { const points = Array.from(activePointers.current.values());