Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/feat_image_viewer_changes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion src/app/components/Pdf-viewer/PdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 21 additions & 2 deletions src/app/components/image-viewer/ImageViewer.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
167 changes: 158 additions & 9 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<HTMLInputElement>(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);
Expand All @@ -38,18 +84,112 @@ export const ImageViewer = as<'div', ImageViewerProps>(
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant="Surface"
style={{
// 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"
title="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"
onClick={() => {
resetTransforms();
enableResizeWithWindow();
setZoom(fitRatio);
}}
aria-label="Reset Zoom"
title="Zoom to Fill Container"
>
<Icon size="50" src={Icons.Reload} />
</IconButton>
<IconButton
variant={transforms.zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={transforms.zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
title="Zoom Out"
>
<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);
}}
title="Update Zoom"
>
<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'}
Expand All @@ -58,6 +198,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>
Expand All @@ -66,34 +207,42 @@ export const ImageViewer = as<'div', ImageViewerProps>(
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
outlined
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
ref={containerRef}
onWheel={handleWheel}
className={css.ImageViewerContent}
data-gestures="ignore"
justifyContent="Center"
alignItems="Center"
style={{ overflow: 'hidden', touchAction: 'none' }}
style={{ overflow: 'hidden', touchAction: 'none', cursor }}
onPointerDown={onPointerDown}
>
<img
className={css.ImageViewerImg}
className={classNames(
css.ImageViewerImg,
isPixelatedViewerRendering(pixelatedImageRendering) && css.ImageViewerImgPixelated
)}
draggable={false}
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<HTMLImageElement>) => {
handleImageLoad(event);
setIsImageReady(true);
}}
/>
</Box>
</Box>
Expand Down
21 changes: 18 additions & 3 deletions src/app/components/media/Image.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement, ImgHTMLAttributes<HTMLImageElement>>(
({ className, alt, ...props }, ref) => (
<img className={classNames(css.Image, className)} alt={alt} {...props} ref={ref} />
)
({ className, alt, ...props }, ref) => {
const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering');

return (
<img
className={classNames(
css.Image,
isPixelatedChatRendering(pixelatedImageRendering) && css.ImagePixelated,
className
)}
alt={alt}
{...props}
ref={ref}
/>
);
}
);
4 changes: 4 additions & 0 deletions src/app/components/media/media.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export const Image = style([
},
]);

export const ImagePixelated = style({
imageRendering: 'pixelated',
});

export const Video = style([
DefaultReset,
{
Expand Down
28 changes: 26 additions & 2 deletions src/app/features/settings/cosmetics/Themes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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<PixelatedImageRenderingMode>[] = [
{ value: 'both', label: 'Both' },
{ value: 'chat', label: 'Chat' },
{ value: 'viewer', label: 'Image viewer' },
{ value: 'none', label: 'Neither' },
];
const [incomingInlineImagesDefaultHeight, setIncomingInlineImagesDefaultHeight] = useSetting(
settingsAtom,
'incomingInlineImagesDefaultHeight'
Expand Down Expand Up @@ -327,6 +337,20 @@ function ThemeVisualPreferences() {
after={<Switch variant="Primary" value={autoplayGifs} onChange={setAutoplayGifs} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Pixelated image scaling"
focusId="pixelated-image-rendering"
description="Use crisp nearest-neighbor scaling where selected. Improves pixel art but makes normal images worse."
after={
<SettingMenuSelector
value={pixelatedImageRendering}
options={pixelatedImageRenderingOptions}
onSelect={setPixelatedImageRendering}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Autoplay Stickers"
Expand Down
Loading
Loading