From 74ed06ea4a50861ed17ca9a7e319f07303210f86 Mon Sep 17 00:00:00 2001
From: Septicity <nickmoff@outlook.com>
Date: Fri, 10 Apr 2026 03:56:35 -0500
Subject: [PATCH 01/13] 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<HTMLImageElement>) => {
+              // 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);
+            }}
           />
         </Box>
       </Box>

From 81358cba599d9bf7eb9fcca31a6d85dadf6d254e Mon Sep 17 00:00:00 2001
From: Septicity <nickmoff@outlook.com>
Date: Fri, 10 Apr 2026 03:57:05 -0500
Subject: [PATCH 02/13] 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 <nickmoff@outlook.com>
Date: Fri, 10 Apr 2026 05:09:22 -0500
Subject: [PATCH 03/13] 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<HTMLImageElement>) => {
               // 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);
             }}
           />
         </Box>

From 37f1105ca295f9c74e3be6d482850e9e4ffb3930 Mon Sep 17 00:00:00 2001
From: Septicity <nickmoff@outlook.com>
Date: Fri, 10 Apr 2026 05:10:10 -0500
Subject: [PATCH 04/13] 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<HTMLImageElement>) => {
+              // 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<HTMLImageElement>) => {
+              // Re-enable transition after dragging
+              const img = event.currentTarget;
+              img.style.transition = '';
+            }}
             onLoad={(event: React.SyntheticEvent<HTMLImageElement>) => {
               // Fit the image to the container on load
               const img = event.currentTarget;

From 1233421e0ff2ce38ba2719d16340e0b755d8335d Mon Sep 17 00:00:00 2001
From: Septicity <nickmoff@outlook.com>
Date: Fri, 10 Apr 2026 06:51:51 -0500
Subject: [PATCH 05/13] 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>(
             </Text>
           </Box>
           <Box shrink="No" alignItems="Center" gap="200">
+            <IconButton
+              variant="Surface"
+              style={{
+                visibility: !(
+                  transforms.zoom === fitRatio &&
+                  transforms.pan.x === 0 &&
+                  transforms.pan.y === 0
+                )
+                  ? 'visible'
+                  : 'hidden',
+              }}
+              size="300"
+              radii="Pill"
+              onClick={() => {
+                resetTransforms();
+                setZoom(fitRatio);
+              }}
+              aria-label="Refresh View"
+            >
+              <Icon size="50" src={Icons.Reload} />
+            </IconButton>
             <IconButton
               variant={transforms.zoom < 1 ? 'Success' : 'SurfaceVariant'}
               outlined={transforms.zoom < 1}
@@ -137,6 +160,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
               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 <nickmoff@outlook.com>
Date: Sat, 11 Apr 2026 05:56:38 -0500
Subject: [PATCH 06/13] 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<HTMLInputElement>(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>(
             <IconButton
               variant="Surface"
               style={{
-                visibility: !(
-                  transforms.zoom === fitRatio &&
-                  transforms.pan.x === 0 &&
-                  transforms.pan.y === 0
-                )
-                  ? 'visible'
-                  : 'hidden',
+                // Only show when the image isn't already larger than the container
+                // and isn't already at 100% zoom
+                // (Otherwise, the Reset Zoom button does the same thing)
+                display: fitRatio !== 1 && transforms.zoom !== 1 ? 'flex' : 'none',
+              }}
+              size="300"
+              radii="Pill"
+              onClick={() => {
+                setZoom(1);
+              }}
+              aria-label="View Original Size"
+            >
+              <Icon size="50" src={Icons.Photo} />
+            </IconButton>
+            <IconButton
+              variant="Surface"
+              style={{
+                // Only show when the image has had any transforms applied (zoom or pan)
+                display:
+                  transforms.zoom !== fitRatio || transforms.pan.x !== 0 || transforms.pan.y !== 0
+                    ? 'flex'
+                    : 'none',
               }}
               size="300"
               radii="Pill"
@@ -71,7 +103,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
                 resetTransforms();
                 setZoom(fitRatio);
               }}
-              aria-label="Refresh View"
+              aria-label="Reset Zoom"
             >
               <Icon size="50" src={Icons.Reload} />
             </IconButton>
@@ -85,8 +117,61 @@ export const ImageViewer = as<'div', ImageViewerProps>(
             >
               <Icon size="50" src={Icons.Minus} />
             </IconButton>
-            <Chip variant="SurfaceVariant" radii="Pill" onClick={resetTransforms}>
-              <Text size="B300">{Math.round(transforms.zoom * 100)}%</Text>
+            <Chip
+              variant="SurfaceVariant"
+              radii="Pill"
+              style={{
+                // For zoom levels below 100%, keep the pill at the same size as it would be at 100% zoom.
+                // This prevents the Zoom Out button from moving from the pill changing size.
+                // 4em should be generous enough to fit without manually determining the width of the text.
+                minWidth: '4em',
+              }}
+              onClick={() => {
+                setZoomInput(Math.round(transforms.zoom * 100).toString());
+                setIsEditingZoom(true);
+              }}
+            >
+              <Text
+                size="B300"
+                style={{
+                  cursor: 'text',
+                  margin: 'auto',
+                }}
+              >
+                {isEditingZoom ? (
+                  <span>
+                    <input
+                      className={css.ImageViewerInput}
+                      ref={zoomInputRef}
+                      type="text"
+                      aria-label="Set Zoom Level"
+                      value={zoomInput}
+                      onChange={(e) => {
+                        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);
+                        }
+                      }}
+                    />
+                    <span>%</span>
+                  </span>
+                ) : (
+                  `${Math.round(transforms.zoom * 100)}%`
+                )}
+              </Text>
             </Chip>
             <IconButton
               variant={transforms.zoom > 1 ? 'Success' : 'SurfaceVariant'}

From 30ab99f1ba4b2431e572fab5a950b17f514db5db Mon Sep 17 00:00:00 2001
From: Septicity <nickmoff@outlook.com>
Date: Mon, 13 Apr 2026 16:10:49 -0500
Subject: [PATCH 07/13] 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<HTMLInputElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+    const imageRef = useRef<HTMLImageElement | null>(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<HTMLInputElement>(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"
             >
               <Icon size="50" src={Icons.Minus} />
@@ -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"
             >
               <Icon size="50" src={Icons.Plus} />
@@ -195,7 +237,16 @@ export const ImageViewer = as<'div', ImageViewerProps>(
         </Header>
         <Box
           grow="Yes"
-          onWheel={handleWheel}
+          ref={containerRef}
+          onWheel={(event) => {
+            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<HTMLImageElement>) => {
               // Re-enable transition after dragging
@@ -230,6 +283,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
             onLoad={(event: React.SyntheticEvent<HTMLImageElement>) => {
               // 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 <nickmoff@outlook.com>
Date: Tue, 14 Apr 2026 04:49:23 -0500
Subject: [PATCH 08/13] 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<HTMLInputElement>(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<HTMLInputElement>(null);
-    const containerRef = useRef<HTMLDivElement>(null);
-    const imageRef = useRef<HTMLImageElement | null>(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"
             >
               <Icon size="50" src={Icons.Minus} />
@@ -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"
             >
               <Icon size="50" src={Icons.Plus} />
@@ -238,15 +208,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
         <Box
           grow="Yes"
           ref={containerRef}
-          onWheel={(event) => {
-            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<HTMLImageElement>) => {
-              // 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<HTMLImageElement>) => {
-              // Re-enable transition after dragging
-              const img = event.currentTarget;
-              img.style.transition = '';
-            }}
+            onPointerDown={onPointerDown}
             onLoad={(event: React.SyntheticEvent<HTMLImageElement>) => {
-              // 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);
             }}
           />
         </Box>
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<HTMLDivElement | null>(null);
+  const imageRef = useRef<HTMLImageElement | null>(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<Map<number, { x: number; y: number }>>(new Map());
   const initialDist = useRef<number>(0);
   const lastTapRef = useRef<number>(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<HTMLImageElement>) => {
+      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 <nickmoff@outlook.com>
Date: Tue, 14 Apr 2026 05:23:38 -0500
Subject: [PATCH 09/13] 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 <nickmoff@outlook.com>
Date: Tue, 14 Apr 2026 05:25:33 -0500
Subject: [PATCH 10/13] 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());

From b3c4edcead3de59bd564e7d3a49a4011cee7d613 Mon Sep 17 00:00:00 2001
From: Shea Duma <nu@she-a.eu>
Date: Thu, 14 May 2026 16:02:25 +0300
Subject: [PATCH 11/13] added small qol changes

---
 src/app/components/Pdf-viewer/PdfViewer.tsx     | 2 +-
 src/app/components/image-viewer/ImageViewer.tsx | 5 +++--
 src/app/components/media/media.css.ts           | 1 +
 src/app/hooks/useImageGestures.ts               | 2 +-
 4 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx
index 53b333aca..dbd95ef46 100644
--- a/src/app/components/Pdf-viewer/PdfViewer.tsx
+++ b/src/app/components/Pdf-viewer/PdfViewer.tsx
@@ -44,7 +44,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
       zoomOut,
       setZoom,
       onPointerDown,
-    } = useImageGestures(true, 0.2);
+    } = useImageGestures(true, 0.2, 0.1, 5);
 
     const [pdfJSState, loadPdfJS] = usePdfJSLoader();
     const [docState, loadPdfDocument] = usePdfDocumentLoader(
diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx
index d92cb5caf..86f6d6229 100644
--- a/src/app/components/image-viewer/ImageViewer.tsx
+++ b/src/app/components/image-viewer/ImageViewer.tsx
@@ -35,7 +35,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
       containerRef,
       handleImageLoad,
       enableResizeWithWindow,
-    } = useImageGestures(true, 0.2, 0.1, 5);
+    } = useImageGestures(true, 0.2, 0.1);
     useEffect(() => {
       setIsImageReady(false);
       enableResizeWithWindow();
@@ -213,7 +213,8 @@ export const ImageViewer = as<'div', ImageViewerProps>(
           data-gestures="ignore"
           justifyContent="Center"
           alignItems="Center"
-          style={{ overflow: 'hidden', touchAction: 'none' }}
+          style={{ overflow: 'hidden', touchAction: 'none', cursor }}
+          onPointerDown={onPointerDown}
         >
           <img
             className={css.ImageViewerImg}
diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts
index aad194fdf..3c2be3cda 100644
--- a/src/app/components/media/media.css.ts
+++ b/src/app/components/media/media.css.ts
@@ -7,6 +7,7 @@ export const Image = style([
     objectFit: 'contain',
     width: '100%',
     height: '100%',
+    imageRendering: 'pixelated',
   },
 ]);
 
diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts
index 6b3606f3d..612728858 100644
--- a/src/app/hooks/useImageGestures.ts
+++ b/src/app/hooks/useImageGestures.ts
@@ -21,7 +21,7 @@ function getCursorOffsetFromImageCenter(
   };
 }
 
-export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5) => {
+export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 500) => {
   const [transforms, setTransforms] = useState({
     zoom: 1,
     pan: { x: 0, y: 0 },

From fde563e46e97f53062c85d62a27f87d2f397b37d Mon Sep 17 00:00:00 2001
From: Shea Duma <nu@she-a.eu>
Date: Sat, 16 May 2026 14:00:14 +0300
Subject: [PATCH 12/13] changed it to zoom to fit to container initially
 regardless of size sinze with the button its easy to get to the natural size,
 and it helps for sending pixelart

---
 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 612728858..e9e6e4687 100644
--- a/src/app/hooks/useImageGestures.ts
+++ b/src/app/hooks/useImageGestures.ts
@@ -236,7 +236,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
       }
       const heightRatio = height / img.naturalHeight;
       const widthRatio = width / img.naturalWidth;
-      const fitZoom = Math.min(heightRatio, widthRatio, 1);
+      const fitZoom = Math.min(heightRatio, widthRatio);
 
       img.style.transition = 'none';
       setFitRatio(fitZoom);

From e97b02d7077f63c9f6349dcc7646d5c7c75219b0 Mon Sep 17 00:00:00 2001
From: Shea Duma <nu@she-a.eu>
Date: Sat, 16 May 2026 14:34:25 +0300
Subject: [PATCH 13/13] added changeset, titles to buttons and a border around
 the download button, and fixed 'bug' with resizing the thing not doing the
 animation

---
 .changeset/feat_image_viewer_changes.md         | 14 ++++++++++++++
 src/app/components/image-viewer/ImageViewer.tsx |  9 +++++++--
 2 files changed, 21 insertions(+), 2 deletions(-)
 create mode 100644 .changeset/feat_image_viewer_changes.md

diff --git a/.changeset/feat_image_viewer_changes.md b/.changeset/feat_image_viewer_changes.md
new file mode 100644
index 000000000..6910066a4
--- /dev/null
+++ b/.changeset/feat_image_viewer_changes.md
@@ -0,0 +1,14 @@
+---
+default: minor
+---
+
+Change Image Viewer to feel more natural to use.
+
+#Changes to Image viewer:
+
+- Fixed Zoom Gestures generally not working on mobile.
+- Changed the % number in the top right to reflect the zoom of the original image as opposed to the change from it fitting the container.
+- Made Zoom Pill allow inputing custom values
+- Added a button thta zooms you to the Original Size of the Image, and button to return to the Actual size of the image
+- Made Images have the `pixelated` tag, and start zoomed in when smaller than container to enhance the experience of viewing pixelart, with the pixelated tag being applied to images in the chat aswell.
+- Transitions are now disabled for manual panning to improve responsiveness
diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx
index 86f6d6229..4f2dda8af 100644
--- a/src/app/components/image-viewer/ImageViewer.tsx
+++ b/src/app/components/image-viewer/ImageViewer.tsx
@@ -29,7 +29,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
       zoomIn,
       zoomOut,
       setZoom,
-      setZoomSilently,
       fitRatio,
       imageRef,
       containerRef,
@@ -96,6 +95,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
                 setZoom(1);
               }}
               aria-label="View Original Size"
+              title="View Original Size"
             >
               <Icon size="50" src={Icons.Photo} />
             </IconButton>
@@ -113,9 +113,10 @@ export const ImageViewer = as<'div', ImageViewerProps>(
               onClick={() => {
                 resetTransforms();
                 enableResizeWithWindow();
-                setZoomSilently(fitRatio);
+                setZoom(fitRatio);
               }}
               aria-label="Reset Zoom"
+              title="Zoom to Fill Container"
             >
               <Icon size="50" src={Icons.Reload} />
             </IconButton>
@@ -126,6 +127,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
               radii="Pill"
               onClick={zoomOut}
               aria-label="Zoom Out"
+              title="Zoom Out"
             >
               <Icon size="50" src={Icons.Minus} />
             </IconButton>
@@ -142,6 +144,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
                 setZoomInput(Math.round(transforms.zoom * 100).toString());
                 setIsEditingZoom(true);
               }}
+              title="Update Zoom"
             >
               <Text
                 size="B300"
@@ -192,6 +195,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
               radii="Pill"
               onClick={zoomIn}
               aria-label="Zoom In"
+              title="Zoom In"
             >
               <Icon size="50" src={Icons.Plus} />
             </IconButton>
@@ -200,6 +204,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
               onClick={handleDownload}
               radii="300"
               before={<Icon size="50" src={Icons.Download} />}
+              outlined
             >
               <Text size="B300">Download</Text>
             </Chip>
