Skip to content
Merged
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
238 changes: 230 additions & 8 deletions ts/components/lightbox/Lightbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { CSSProperties, MouseEvent, useRef, type Ref } from 'react';
import {
CSSProperties,
MouseEvent,
useCallback,
useEffect,
useRef,
useState,
type Ref,
} from 'react';

import { isUndefined } from 'lodash';
import useUnmount from 'react-use/lib/useUnmount';
Expand Down Expand Up @@ -28,6 +36,11 @@ type Props = {

const CONTROLS_WIDTH = 50;
const CONTROLS_SPACING = 10;
const MIN_SCALE = 1;
const MAX_SCALE = 10;
const ZOOM_SENSITIVITY = 0.002;
// Successive scales the double-click zoom steps through before cycling back to fit-to-screen.
const DOUBLE_CLICK_ZOOM_STEPS = [2.5, MAX_SCALE];

const styles = {
container: {
Expand Down Expand Up @@ -60,6 +73,7 @@ const styles = {
flexGrow: 1,
display: 'inline-flex',
justifyContent: 'center',
overflow: 'hidden',
} as CSSProperties,
objectParentContainer: {
flexGrow: 1,
Expand Down Expand Up @@ -183,8 +197,128 @@ const LightboxObject = ({
}) => {
const { urlToLoad } = useEncryptedFileFetch(objectURL, contentType, false);

// scale: 1 = fit-to-screen. translate is in px, relative to centered origin.
const [scale, setScale] = useState(1);
const [translate, setTranslate] = useState({ x: 0, y: 0 });
const isDragging = useRef(false);
const didDrag = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);

// Keep the latest scale/translate available to the native wheel listener without
// having to re-register it on every zoom step.
const scaleRef = useRef(scale);
const translateRef = useRef(translate);
scaleRef.current = scale;
translateRef.current = translate;

// Double-click zoom sequence tracking. dblClickStepRef is the index into
// DOUBLE_CLICK_ZOOM_STEPS for the next double-click; lastDblClickScaleRef is the scale we
// last set via double-click (null when the current zoom came from the wheel instead).
const dblClickStepRef = useRef(0);
const lastDblClickScaleRef = useRef<number | null>(null);

// Clamp a translate to the scaled overflow so the image edges can't be dragged past the
// container (which would leave empty space / let the image be lost off-screen).
const clampTranslate = useCallback(
(tx: number, ty: number, atScale: number) => {
const container = containerRef.current;
const img = renderedRef?.current as HTMLElement | null;
if (!container || !img) {
return { x: tx, y: ty };
}
const overflowX = Math.max(0, (img.offsetWidth * atScale - container.clientWidth) / 2);
const overflowY = Math.max(0, (img.offsetHeight * atScale - container.clientHeight) / 2);
return {
x: Math.min(overflowX, Math.max(-overflowX, tx)),
y: Math.min(overflowY, Math.max(-overflowY, ty)),
};
},
[renderedRef]
);

// Zoom to a target scale while keeping the point under (clientX, clientY) fixed on screen.
// Shared by the wheel and double-click handlers.
const applyZoom = useCallback(
(targetScale: number, clientX: number, clientY: number) => {
const container = containerRef.current;
if (!container) {
return;
}
const currentScale = scaleRef.current;
const currentTranslate = translateRef.current;
const clampedScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, targetScale));
if (clampedScale === currentScale) {
return;
}

const rect = container.getBoundingClientRect();
// Pointer position relative to container center (the transform origin)
const pointerX = clientX - rect.left - rect.width / 2;
const pointerY = clientY - rect.top - rect.height / 2;

// Adjust translate so the point under the pointer stays fixed
const scaleFactor = clampedScale / currentScale;
const newTranslateX = pointerX - scaleFactor * (pointerX - currentTranslate.x);
const newTranslateY = pointerY - scaleFactor * (pointerY - currentTranslate.y);

setScale(clampedScale);
// Snap translate back to zero when returning to fit-to-screen
if (clampedScale === MIN_SCALE) {
setTranslate({ x: 0, y: 0 });
} else {
setTranslate(clampTranslate(newTranslateX, newTranslateY, clampedScale));
}
},
[clampTranslate]
);

const resetZoom = useCallback(() => {
setScale(1);
setTranslate({ x: 0, y: 0 });
dblClickStepRef.current = 0;
lastDblClickScaleRef.current = null;
}, []);

// Reset zoom and pan whenever the displayed image changes (prev/next navigation)
useEffect(() => {
resetZoom();
}, [objectURL, resetZoom]);

const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);

// Register the wheel listener natively with { passive: false } so we can call
// preventDefault(). React's onWheel is passive by default, which both prints
// "Unable to preventDefault inside passive event listener invocation" and fails
// to stop the page from scrolling while zooming.
useEffect(() => {
const container = containerRef.current;
if (!container || !isImageTypeSupported) {
return undefined;
}

const handleWheelNative = (e: WheelEvent) => {
// ctrlKey is true for pinch-to-zoom on macOS trackpads as well as Ctrl+scroll on all platforms
if (!e.ctrlKey && !e.metaKey) {
return;
}
e.preventDefault();
e.stopPropagation();

const delta = -e.deltaY * ZOOM_SENSITIVITY;
applyZoom(scaleRef.current * (1 + delta), e.clientX, e.clientY);

// A wheel zoom breaks out of the double-click sequence, so the next double-click resets.
dblClickStepRef.current = 0;
lastDblClickScaleRef.current = null;
};

container.addEventListener('wheel', handleWheelNative, { passive: false });
return () => {
container.removeEventListener('wheel', handleWheelNative);
};
}, [isImageTypeSupported, applyZoom]);

// auto play video on showing a video attachment
useUnmount(() => {
if (!renderedRef?.current) {
Expand All @@ -195,14 +329,102 @@ const LightboxObject = ({
const disableDrag = useDisableDrag();

if (isImageTypeSupported) {
const isZoomed = scale > 1;

const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isZoomed) {
return;
}
isDragging.current = true;
didDrag.current = false;
dragStart.current = { x: e.clientX - translate.x, y: e.clientY - translate.y };
e.preventDefault();
e.stopPropagation();
};

const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging.current) {
return;
}
didDrag.current = true;
e.stopPropagation();
setTranslate(
clampTranslate(
e.clientX - dragStart.current.x,
e.clientY - dragStart.current.y,
scaleRef.current
)
);
};

const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
if (isDragging.current) {
e.stopPropagation();
}
isDragging.current = false;
};

const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();

const currentScale = scaleRef.current;
// Whether the current zoom came from our double-click sequence (vs. a wheel zoom).
const inSequence =
lastDblClickScaleRef.current !== null && currentScale === lastDblClickScaleRef.current;

// Zoomed in some other way (wheel) -> reset straight back to fit-to-screen.
if (currentScale > MIN_SCALE && !inSequence) {
resetZoom();
return;
}

const step = inSequence ? dblClickStepRef.current : 0;
// Past the last zoom step -> cycle back to fit-to-screen.
if (step >= DOUBLE_CLICK_ZOOM_STEPS.length) {
resetZoom();
return;
}

const targetScale = DOUBLE_CLICK_ZOOM_STEPS[step];
applyZoom(targetScale, e.clientX, e.clientY);
dblClickStepRef.current = step + 1;
lastDblClickScaleRef.current = targetScale;
};

const imgStyle: CSSProperties = {
...styles.object,
transform: `scale(${scale}) translate(${translate.x / scale}px, ${translate.y / scale}px)`,
transformOrigin: 'center center',
transition: isDragging.current ? 'none' : 'transform 0.05s ease-out',
cursor: isZoomed ? (isDragging.current ? 'grabbing' : 'grab') : 'default',
// Let the image overflow the container; the container clips it
flexShrink: 0,
userSelect: 'none',
};

return (
<img
style={styles.object as any}
onDragStart={disableDrag}
src={urlToLoad}
alt={AriaLabels.imageSentInConversation}
ref={renderedRef}
/>
<div
ref={containerRef}
style={{ overflow: 'hidden' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleDoubleClick}
>
<img
style={imgStyle as any}
onDragStart={disableDrag}
src={urlToLoad}
alt={AriaLabels.imageSentInConversation}
ref={renderedRef}
onClick={e => {
if (didDrag.current) {
e.stopPropagation();
}
}}
/>
</div>
);
}

Expand Down
Loading