diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index d688afcb7..939198405 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -28,15 +28,31 @@ export const ImageViewerContent = style([ }, ]); +export const ImageViewerInput = style([ + DefaultReset, + { + all: 'unset', + fieldSizing: 'content', + textAlign: 'center', + font: 'inherit', + color: 'inherit', + }, +]); + export const ImageViewerImg = style([ DefaultReset, { + userSelect: 'none', + touchAction: 'none', + display: 'block', + imageRendering: 'pixelated', // Possibly allow for a custom setting later? 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..d92cb5caf 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,3 +1,4 @@ +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'; @@ -13,8 +14,51 @@ 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 zoomInputRef = useRef(null); + + const [isImageReady, setIsImageReady] = useState(false); + const [isEditingZoom, setIsEditingZoom] = useState(false); + const [zoomInput, setZoomInput] = useState('100'); + + const { + transforms, + cursor, + handleWheel, + onPointerDown, + resetTransforms, + zoomIn, + zoomOut, + setZoom, + setZoomSilently, + fitRatio, + imageRef, + containerRef, + handleImageLoad, + enableResizeWithWindow, + } = useImageGestures(true, 0.2, 0.1, 5); + useEffect(() => { + setIsImageReady(false); + enableResizeWithWindow(); + setIsEditingZoom(false); + setZoomInput('100'); + 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 handleDownload = async () => { const fileContent = await downloadMedia(src); @@ -38,6 +82,43 @@ export const ImageViewer = as<'div', ImageViewerProps>( + { + setZoom(1); + }} + aria-label="View Original Size" + > + + + { + resetTransforms(); + enableResizeWithWindow(); + setZoomSilently(fitRatio); + }} + aria-label="Reset Zoom" + > + + ( > - - {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'} @@ -73,6 +207,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( ( data-gestures="ignore" style={{ cursor, - userSelect: 'none', - touchAction: 'none', - willChange: 'transform', + 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} alt={alt} onPointerDown={onPointerDown} + onLoad={(event: React.SyntheticEvent) => { + handleImageLoad(event); + setIsImageReady(true); + }} /> diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index 9c10d676d..f779a74f2 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,12 +120,22 @@ 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) { + 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()); + 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) => { @@ -107,12 +161,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 +176,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 +192,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 +227,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 +297,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 +314,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 +326,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 +356,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 +364,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, }; };