diff --git a/.changeset/feat_image_viewer_changes.md b/.changeset/feat_image_viewer_changes.md new file mode 100644 index 000000000..70a0eb579 --- /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 the zoom pill allow entering custom values. +- Added a button that zooms you to the original size of the image, and a button to return to the size that fills the container. +- Added a pixelated image scaling setting: choose Both, Chat, Image viewer (default), or Neither for crisp nearest-neighbor rendering. +- 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..058c6ce68 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -28,15 +28,34 @@ 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', objectFit: 'contain', width: 'auto', height: 'auto', - maxWidth: '100%', - maxHeight: '100%', + maxWidth: 'none', + maxHeight: 'none', backgroundColor: color.Surface.Container, transition: 'transform 100ms linear', + willChange: 'transform', }, ]); + +export const ImageViewerImgPixelated = style({ + imageRendering: 'pixelated', +}); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 54b878c76..214347b3d 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,7 +1,10 @@ +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 { useSetting } from '$state/hooks/settings'; +import { isPixelatedViewerRendering, settingsAtom } from '$state/settings'; import { downloadMedia } from '$utils/matrix'; import * as css from './ImageViewer.css'; @@ -13,8 +16,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 [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); + + 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 +84,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 +198,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( radii="Pill" onClick={zoomIn} aria-label="Zoom In" + title="Zoom In" > @@ -66,6 +207,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( onClick={handleDownload} radii="300" before={} + outlined > Download @@ -73,27 +215,34 @@ export const ImageViewer = as<'div', ImageViewerProps>( {alt}) => { + handleImageLoad(event); + setIsImageReady(true); + }} /> diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index 6970c70bb..df684b237 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -1,10 +1,25 @@ import type { ImgHTMLAttributes } from 'react'; import { forwardRef } from 'react'; import classNames from 'classnames'; +import { useSetting } from '$state/hooks/settings'; +import { isPixelatedChatRendering, settingsAtom } from '$state/settings'; import * as css from './media.css'; export const Image = forwardRef>( - ({ className, alt, ...props }, ref) => ( - {alt} - ) + ({ className, alt, ...props }, ref) => { + const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); + + return ( + {alt} + ); + } ); diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts index aad194fdf..4253b52d2 100644 --- a/src/app/components/media/media.css.ts +++ b/src/app/components/media/media.css.ts @@ -10,6 +10,10 @@ export const Image = style([ }, ]); +export const ImagePixelated = style({ + imageRendering: 'pixelated', +}); + export const Video = style([ DefaultReset, { diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index e00e5f425..7bf5300e6 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; -import { SettingMenuSelector } from '$components/setting-menu-selector'; +import { SettingMenuSelector, type SettingMenuOption } from '$components/setting-menu-selector'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { @@ -29,7 +29,7 @@ import { } from '$plugins/arborium'; import { ThemeKind, useActiveTheme } from '$hooks/useTheme'; import { useSetting } from '$state/hooks/settings'; -import type { ShowRoomIcon } from '$state/settings'; +import type { PixelatedImageRenderingMode, ShowRoomIcon } from '$state/settings'; import { settingsAtom } from '$state/settings'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { ThemeAppearanceSection } from './ThemeAppearanceSection'; @@ -216,6 +216,16 @@ function ThemeVisualPreferences() { const [autoplayGifs, setAutoplayGifs] = useSetting(settingsAtom, 'autoplayGifs'); const [autoplayStickers, setAutoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); const [autoplayEmojis, setAutoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis'); + const [pixelatedImageRendering, setPixelatedImageRendering] = useSetting( + settingsAtom, + 'pixelatedImageRendering' + ); + const pixelatedImageRenderingOptions: SettingMenuOption[] = [ + { value: 'both', label: 'Both' }, + { value: 'chat', label: 'Chat' }, + { value: 'viewer', label: 'Image viewer' }, + { value: 'none', label: 'Neither' }, + ]; const [incomingInlineImagesDefaultHeight, setIncomingInlineImagesDefaultHeight] = useSetting( settingsAtom, 'incomingInlineImagesDefaultHeight' @@ -327,6 +337,20 @@ function ThemeVisualPreferences() { after={} /> + + + } + /> + { +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, }; }; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b1b744c1f..a9e1729c5 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -50,6 +50,17 @@ export type ThemeRemoteTweakFavorite = { /** Custom profile card hero colors: which brightness schemes to honor. */ export type RenderUserCardsMode = 'both' | 'light' | 'dark' | 'none'; +/** Where to use crisp nearest-neighbor (pixelated) image scaling. */ +export type PixelatedImageRenderingMode = 'both' | 'chat' | 'viewer' | 'none'; + +export function isPixelatedChatRendering(mode: PixelatedImageRenderingMode): boolean { + return mode === 'both' || mode === 'chat'; +} + +export function isPixelatedViewerRendering(mode: PixelatedImageRenderingMode): boolean { + return mode === 'both' || mode === 'viewer'; +} + export function shouldApplyUserHeroCards( mode: RenderUserCardsMode, brightness: string | undefined @@ -146,6 +157,7 @@ export interface Settings { autoplayGifs: boolean; autoplayStickers: boolean; autoplayEmojis: boolean; + pixelatedImageRendering: PixelatedImageRenderingMode; incomingInlineImagesDefaultHeight: number; incomingInlineImagesMaxHeight: number; linkPreviewImageMaxHeight: number; @@ -278,6 +290,7 @@ export const defaultSettings: Settings = { autoplayGifs: true, autoplayStickers: true, autoplayEmojis: true, + pixelatedImageRendering: 'viewer', incomingInlineImagesDefaultHeight: 32, incomingInlineImagesMaxHeight: 64, linkPreviewImageMaxHeight: 640, @@ -352,6 +365,17 @@ function migrateParsedLocalStorage(parsed: Record): void { parsed.renderUserCards = 'both'; } + if (typeof parsed.pixelatedImageRendering === 'boolean') { + parsed.pixelatedImageRendering = parsed.pixelatedImageRendering ? 'both' : 'none'; + } else if ( + parsed.pixelatedImageRendering !== 'both' && + parsed.pixelatedImageRendering !== 'chat' && + parsed.pixelatedImageRendering !== 'viewer' && + parsed.pixelatedImageRendering !== 'none' + ) { + delete parsed.pixelatedImageRendering; + } + if ( typeof parsed.themeChatAutoPreviewAnyUrl !== 'boolean' && typeof parsed.themeChatPreviewAnyUrl === 'boolean' @@ -475,6 +499,10 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { return val === 'both' || val === 'light' || val === 'dark' || val === 'none' ? val : undefined; + case 'pixelatedImageRendering': + return val === 'both' || val === 'chat' || val === 'viewer' || val === 'none' + ? val + : undefined; case 'jumboEmojiSize': return typeof val === 'string' && JUMBO_EMOJI_VALUES.has(val as JumboEmojiSize) ? val