Skip to content

Commit

Permalink
add pan and zoom control to image viewer
Browse files Browse the repository at this point in the history
  • Loading branch information
ajbura committed Aug 31, 2023
1 parent e71b8a4 commit 6467f5f
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 39 deletions.
2 changes: 2 additions & 0 deletions src/app/components/image-viewer/ImageViewer.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,7 @@ export const ImageViewerImg = style([
objectFit: 'contain',
width: '100%',
height: '100%',
backgroundColor: color.Surface.Container,
transition: 'transform 100ms linear',
},
]);
68 changes: 29 additions & 39 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,22 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React, { MouseEventHandler, useState } from 'react';
import React from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';

export type ImageViewerProps = {
alt: string;
src: string;
requestClose: () => void;
};

type Zoom = {
scale: number;
translateX: number;
translateY: number;
};

const INITIAL_ZOOM = {
scale: 1,
translateX: 0,
translateY: 0,
};

const useZoom = () => {
const [zoom, setZoom] = useState<Zoom>(INITIAL_ZOOM);

const onMouseDown: MouseEventHandler<HTMLElement> = () => {
setZoom((z) => {
if (z.scale === 1) {
return {
...z,
scale: 2,
};
}
return INITIAL_ZOOM;
});
};

return {
zoom,
onMouseDown,
};
};

export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, onMouseDown } = useZoom();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);

const handleDownload = () => {
FileSaver.saveAs(src, alt);
Expand All @@ -69,8 +39,28 @@ export const ImageViewer = as<'div', ImageViewerProps>(
</Text>
</Box>
<Box alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={() => window.open(src)}>
<Icon size="50" src={Icons.External} />
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
Expand All @@ -91,8 +81,8 @@ export const ImageViewer = as<'div', ImageViewerProps>(
<img
className={css.ImageViewerImg}
style={{
cursor: zoom.scale === 1 ? 'zoom-in' : 'zoom-out',
transform: `scale(${zoom.scale}) translate(${zoom.translateX}, ${zoom.translateY})`,
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
src={src}
alt={alt}
Expand Down
62 changes: 62 additions & 0 deletions src/app/hooks/usePan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { MouseEventHandler, useEffect, useState } from 'react';

export type Pan = {
translateX: number;
translateY: number;
};

const INITIAL_PAN = {
translateX: 0,
translateY: 0,
};

export const usePan = (active: boolean) => {
const [pan, setPan] = useState<Pan>(INITIAL_PAN);
const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
active ? 'grab' : 'initial'
);

useEffect(() => {
setCursor(active ? 'grab' : 'initial');
}, [active]);

const handleMouseMove = (evt: MouseEvent) => {
evt.preventDefault();
evt.stopPropagation();

setPan((p) => {
const { translateX, translateY } = p;
const mX = translateX + evt.movementX;
const mY = translateY + evt.movementY;

return { translateX: mX, translateY: mY };
});
};

const handleMouseUp = (evt: MouseEvent) => {
evt.preventDefault();
setCursor('grab');

document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};

const handleMouseDown: MouseEventHandler<HTMLElement> = (evt) => {
if (!active) return;
evt.preventDefault();
setCursor('grabbing');

document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};

useEffect(() => {
if (!active) setPan(INITIAL_PAN);
}, [active]);

return {
pan,
cursor,
onMouseDown: handleMouseDown,
};
};
26 changes: 26 additions & 0 deletions src/app/hooks/useZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useState } from 'react';

export const useZoom = (step: number, min = 0.1, max = 5) => {
const [zoom, setZoom] = useState<number>(1);

const zoomIn = () => {
setZoom((z) => {
const newZ = z + step;
return newZ > max ? z : newZ;
});
};

const zoomOut = () => {
setZoom((z) => {
const newZ = z - step;
return newZ < min ? z : newZ;
});
};

return {
zoom,
setZoom,
zoomIn,
zoomOut,
};
};

0 comments on commit 6467f5f

Please sign in to comment.