Skip to content
Open
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 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
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
20 changes: 18 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,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',
},
]);
159 changes: 151 additions & 8 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<HTMLInputElement>(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);
Expand All @@ -38,18 +81,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 +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>
Expand All @@ -66,34 +204,39 @@ 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}
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
1 change: 1 addition & 0 deletions src/app/components/media/media.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const Image = style([
objectFit: 'contain',
width: '100%',
height: '100%',
imageRendering: 'pixelated',
},
]);

Expand Down
Loading
Loading