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
5 changes: 5 additions & 0 deletions .changeset/pr-602-centered-zoom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Image zooming is now centered on the cursor position
5 changes: 5 additions & 0 deletions .changeset/pr-602-multiplicative-zoom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Image zooming is now multiplicative instead of additive, resulting in a consistent "zooming speed".
5 changes: 5 additions & 0 deletions .changeset/pr-602-zoom-buttons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Image zoom buttons now zoom towards the center of the screen
8 changes: 7 additions & 1 deletion src/app/components/Pdf-viewer/PdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ export const PdfViewer = as<'div', PdfViewerProps>(
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);

const { zoom, zoomIn, zoomOut, setZoom, onPointerDown } = useImageGestures(true, 0.2);
const {
transforms: { zoom },
zoomIn,
zoomOut,
setZoom,
onPointerDown,
} = useImageGestures(true, 0.2);

const [pdfJSState, loadPdfJS] = usePdfJSLoader();
const [docState, loadPdfDocument] = usePdfDocumentLoader(
Expand Down
33 changes: 9 additions & 24 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { WheelEvent } from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
Expand All @@ -14,28 +13,14 @@ export type ImageViewerProps = {

export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, pan, cursor, onPointerDown, setZoom, zoomIn, zoomOut } = useImageGestures(
true,
0.2
);
const { transforms, cursor, handleWheel, onPointerDown, resetTransforms, zoomIn, zoomOut } =
useImageGestures(true, 0.2);

const handleDownload = async () => {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
};

const handleWheel = (e: WheelEvent) => {
const { deltaY } = e;
// Mouse wheel scrolls only by integer delta values, therefore
// If deltaY is an integer, then it's a mouse wheel action
if (Number.isInteger(deltaY)) {
if (deltaY < 0) {
zoomIn();
} else zoomOut();
}
// If it's not an integer, then it's a touchpad action, do nothing and let the browser handle the zooming
};

return (
<Box
className={classNames(css.ImageViewer, className)}
Expand All @@ -54,21 +39,21 @@ export const ImageViewer = as<'div', ImageViewerProps>(
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
variant={transforms.zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={transforms.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 variant="SurfaceVariant" radii="Pill" onClick={resetTransforms}>
<Text size="B300">{Math.round(transforms.zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
variant={transforms.zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={transforms.zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
Expand Down Expand Up @@ -104,7 +89,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
userSelect: 'none',
touchAction: 'none',
willChange: 'transform',
transform: `translate(${pan.translateX}px, ${pan.translateY}px) scale(${zoom})`,
transform: `translate(${transforms.pan.x}px, ${transforms.pan.y}px) scale(${transforms.zoom})`,
}}
src={src}
alt={alt}
Expand Down
206 changes: 175 additions & 31 deletions src/app/hooks/useImageGestures.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,35 @@
import { useState, useCallback, useRef, useEffect } from 'react';

interface Vector2 {
x: number;
y: number;
}

interface Transforms {
zoom: number;
pan: Vector2;
}

// calculate pointer position relative to the image center
//
// use container rect & manually apply transforms as if we get two+ events quickly,
// the second one might use an outdated image rect (before new transforms are applied)
function getCursorOffsetFromImageCenter(
event: React.MouseEvent,
containerRect: DOMRect,
pan: Vector2
): Vector2 {
return {
x: containerRect.width / 2 - (event.clientX - containerRect.x - pan.x),
y: containerRect.height / 2 - (event.clientY - containerRect.y - pan.y),
};
}

export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5) => {
const [zoom, setZoom] = useState<number>(1);
const [pan, setPan] = useState({ translateX: 0, translateY: 0 });
const [transforms, setTransforms] = useState<Transforms>({
zoom: 1,
pan: { x: 0, y: 0 },
});
const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
active ? 'grab' : 'initial'
);
Expand All @@ -11,29 +38,82 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
const initialDist = useRef<number>(0);
const lastTapRef = useRef<number>(0);

const onPointerDown = (e: React.PointerEvent) => {
if (!active) return;

e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);

const now = Date.now();
if (now - lastTapRef.current < 300) {
setZoom(zoom === 1 ? 2 : 1);
setPan({ translateX: 0, translateY: 0 });
lastTapRef.current = 0;
return;
}
lastTapRef.current = now;
const setZoom = useCallback((next: number | ((prev: number) => number)) => {
setTransforms((prev) => {
if (typeof next === 'function') {
return {
...prev,
zoom: next(prev.zoom),
};
}
return {
...prev,
zoom: next,
};
});
}, []);

const setPan = useCallback((next: Vector2 | ((prev: Vector2) => Vector2)) => {
setTransforms((prev) => {
if (typeof next === 'function') {
return {
...prev,
pan: next(prev.pan),
};
}
return {
...prev,
pan: next,
};
});
}, []);

const resetTransforms = useCallback(() => {
setTransforms({ zoom: 1, pan: { x: 0, y: 0 } });
}, []);

const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (!active) return;

e.stopPropagation();
const target = e.target as HTMLElement;
target.setPointerCapture(e.pointerId);

const now = Date.now();
if (now - lastTapRef.current < 300) {
const container = target.parentElement ?? target;
const containerRect = container.getBoundingClientRect();
setTransforms((prev) => {
if (prev.zoom !== 1) {
return { zoom: 1, pan: { x: 0, y: 0 } };
}

// pan using the pointer's offset relative to the center of the image
const offset = getCursorOffsetFromImageCenter(e, containerRect, prev.pan);
return {
zoom: 2,
pan: {
x: offset.x + prev.pan.x,
y: offset.y + prev.pan.y,
},
};
});
lastTapRef.current = 0;
return;
}
lastTapRef.current = now;

activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
setCursor('grabbing');
activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
setCursor('grabbing');

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);
}
};
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]
);

const handlePointerMove = useCallback(
(e: PointerEvent) => {
Expand All @@ -53,12 +133,12 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5

if (activePointers.current.size === 1) {
setPan((p) => ({
translateX: p.translateX + e.movementX,
translateY: p.translateY + e.movementY,
x: p.x + e.movementX,
y: p.y + e.movementY,
}));
}
},
[min, max]
[setZoom, min, max, setPan]
);

const handlePointerUp = useCallback(
Expand Down Expand Up @@ -86,20 +166,84 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
}, [handlePointerMove, handlePointerUp]);

const zoomIn = useCallback(() => {
setZoom((z) => Math.min(z + step, max));
setTransforms((prev) => {
const newZoom = Math.min(prev.zoom * (1 + step), max);
const zoomMult = newZoom / prev.zoom;

return {
zoom: newZoom,
pan: {
x: prev.pan.x * zoomMult,
y: prev.pan.y * zoomMult,
},
};
});
}, [step, max]);

const zoomOut = useCallback(() => {
setZoom((z) => Math.max(z - step, min));
}, [step, min]);
setTransforms((prev) => {
const newZoom = Math.min(prev.zoom / (1 + step), max);
const zoomMult = newZoom / prev.zoom;

return {
zoom: newZoom,
pan: {
x: prev.pan.x * zoomMult,
y: prev.pan.y * zoomMult,
},
};
});
}, [step, max]);

const handleWheel = useCallback(
(e: React.WheelEvent) => {
const { deltaY } = e;
// Mouse wheel scrolls only by integer delta values, therefore
// If deltaY is an integer, then it's a mouse wheel action
if (!Number.isInteger(deltaY)) {
// If it's not an integer, then it's a touchpad action, do nothing and let the browser handle the zooming
return;
}

// the wheel handler is attached to the container element, not the image
const containerRect = e.currentTarget.getBoundingClientRect();

setTransforms((prev) => {
// calculate multiplicative zoom
const newZoom =
deltaY < 0
? Math.min(prev.zoom * (1 + step), max)
: Math.max(prev.zoom / (1 + step), min);
const zoomMult = newZoom / prev.zoom - 1;

// calculate pointer position relative to the image center
//
// manually apply transforms as if we get two+ wheel events quickly,
// the second one might use an outdated image rect (before new transforms are applied)
const offset = getCursorOffsetFromImageCenter(e, containerRect, prev.pan);

return {
zoom: newZoom,
// magic math that happens to do what i want it to do
pan: {
x: offset.x * zoomMult + prev.pan.x,
y: offset.y * zoomMult + prev.pan.y,
},
};
});
},
[max, min, step]
);

return {
zoom,
pan,
transforms,
cursor,
onPointerDown,
handleWheel,
setZoom,
setPan,
setTransforms,
resetTransforms,
zoomIn,
zoomOut,
};
Expand Down
Loading