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
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',
},
]);
151 changes: 144 additions & 7 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,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 [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);
Expand All @@ -38,6 +82,43 @@ 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"
>
<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();
setZoomSilently(fitRatio);
}}
aria-label="Reset Zoom"
>
<Icon size="50" src={Icons.Reload} />
</IconButton>
<IconButton
variant={transforms.zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={transforms.zoom < 1}
Expand All @@ -48,8 +129,61 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
<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);
}}
>
<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 @@ -73,6 +207,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
</Header>
<Box
grow="Yes"
ref={containerRef}
onWheel={handleWheel}
className={css.ImageViewerContent}
data-gestures="ignore"
Expand All @@ -86,14 +221,16 @@ 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<HTMLImageElement>) => {
handleImageLoad(event);
setIsImageReady(true);
}}
/>
</Box>
</Box>
Expand Down
Loading
Loading