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/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.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..4f2dda8af 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,50 @@ 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, + fitRatio, + imageRef, + containerRef, + handleImageLoad, + enableResizeWithWindow, + } = useImageGestures(true, 0.2, 0.1); + 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 +81,45 @@ export const ImageViewer = as<'div', ImageViewerProps>( + { + setZoom(1); + }} + aria-label="View Original Size" + title="View Original Size" + > + + + { + resetTransforms(); + enableResizeWithWindow(); + setZoom(fitRatio); + }} + aria-label="Reset Zoom" + title="Zoom to Fill Container" + > + + ( radii="Pill" onClick={zoomOut} aria-label="Zoom Out" + title="Zoom Out" > - - {Math.round(transforms.zoom * 100)}% + { + setZoomInput(Math.round(transforms.zoom * 100).toString()); + setIsEditingZoom(true); + }} + title="Update Zoom" + > + + {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'} @@ -58,6 +195,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( radii="Pill" onClick={zoomIn} aria-label="Zoom In" + title="Zoom In" > @@ -66,6 +204,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( onClick={handleDownload} radii="300" before={} + outlined > Download @@ -73,12 +212,14 @@ 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/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 eebf022c3..e9e6e4687 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; @@ -20,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 }, @@ -28,12 +29,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 { @@ -48,6 +75,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') { @@ -71,12 +115,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) => { @@ -102,12 +156,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( @@ -116,6 +171,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); @@ -126,6 +187,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, @@ -160,7 +222,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); + + 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; @@ -173,9 +292,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; @@ -188,7 +309,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) => { @@ -200,6 +321,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(); @@ -227,7 +351,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 }; }); }, - [max, min, step] + [max, min, step, disableResizeWithWindow, prepareForTransform] ); return { @@ -235,11 +359,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, }; };