diff --git a/ts/components/lightbox/Lightbox.tsx b/ts/components/lightbox/Lightbox.tsx index 73fa3aa6f0..20f55228a8 100644 --- a/ts/components/lightbox/Lightbox.tsx +++ b/ts/components/lightbox/Lightbox.tsx @@ -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'; @@ -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: { @@ -60,6 +73,7 @@ const styles = { flexGrow: 1, display: 'inline-flex', justifyContent: 'center', + overflow: 'hidden', } as CSSProperties, objectParentContainer: { flexGrow: 1, @@ -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(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(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) { @@ -195,14 +329,102 @@ const LightboxObject = ({ const disableDrag = useDisableDrag(); if (isImageTypeSupported) { + const isZoomed = scale > 1; + + const handleMouseDown = (e: React.MouseEvent) => { + 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) => { + 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) => { + if (isDragging.current) { + e.stopPropagation(); + } + isDragging.current = false; + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + 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 ( - {AriaLabels.imageSentInConversation} +
+ {AriaLabels.imageSentInConversation} { + if (didDrag.current) { + e.stopPropagation(); + } + }} + /> +
); }