From b63d0f937fdeeac2a4921dbe3caccdea5b9e4373 Mon Sep 17 00:00:00 2001 From: sezzim <174854242+sezzim@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:22:22 -0700 Subject: [PATCH 1/4] Lightbox: Respond to window resizing --- ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx | 50 ++++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx index 0f2b9df8a..8291d529d 100644 --- a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx +++ b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx @@ -1,4 +1,11 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; +import React, { + MutableRefObject, + useEffect, + useRef, + useState, + useCallback, +} from "react"; +import useResizeObserver from "@react-hook/resize-observer"; import * as GQL from "src/core/generated-graphql"; const ZOOM_STEP = 1.1; @@ -50,6 +57,28 @@ function calculateDefaultZoom( return newZoom; } +interface IDimension { + width: number; + height: number; +} + +export const useContainerDimensions = < + T extends HTMLElement = HTMLDivElement +>(): [MutableRefObject, IDimension] => { + const target = useRef(null); + const [dimension, setDimension] = useState({ + width: 0, + height: 0, + }); + + useResizeObserver(target, (entry) => { + const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]; + setDimension({ width, height }); + }); + + return [target, dimension]; +}; + interface IProps { src: string; width: number; @@ -100,14 +129,13 @@ export const LightboxImage: React.FC = ({ const [positionY, setPositionY] = useState(0); const [imageWidth, setImageWidth] = useState(width); const [imageHeight, setImageHeight] = useState(height); - const [boxWidth, setBoxWidth] = useState(0); - const [boxHeight, setBoxHeight] = useState(0); const dimensionsProvided = width > 0 && height > 0; + const [containerRef, { width: boxWidth, height: boxHeight }] = + useContainerDimensions(); const mouseDownEvent = useRef(); const resetPositionRef = useRef(resetPosition); - const container = React.createRef(); const startPoints = useRef([0, 0]); const pointerCache = useRef([]); const prevDiff = useRef(); @@ -115,15 +143,9 @@ export const LightboxImage: React.FC = ({ const scrollAttempts = useRef(0); useEffect(() => { - const box = container.current; - if (box) { - setBoxWidth(box.offsetWidth); - setBoxHeight(box.offsetHeight); - } - function toggleVideoPlay() { - if (container.current) { - let openVideo = container.current.getElementsByTagName("video"); + if (containerRef.current) { + let openVideo = containerRef.current.getElementsByTagName("video"); if (openVideo.length > 0) { let rect = openVideo[0].getBoundingClientRect(); if (Math.abs(rect.x) < document.body.clientWidth / 2) { @@ -138,7 +160,7 @@ export const LightboxImage: React.FC = ({ setTimeout(() => { toggleVideoPlay(); }, 250); - }, [container]); + }, [containerRef]); useEffect(() => { if (dimensionsProvided) { @@ -547,7 +569,7 @@ export const LightboxImage: React.FC = ({ return (
onContainerScroll(e)} > From c1dc68ed8fe878ea221f17588faad9c1e1575ebd Mon Sep 17 00:00:00 2001 From: sezzim <174854242+sezzim@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:22:22 -0700 Subject: [PATCH 2/4] Lightbox: Stop panning when the edge of the image is reached --- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 6 +- ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx | 134 ++++++++++--------- ui/v2.5/src/hooks/Lightbox/lightbox.scss | 26 ++-- 3 files changed, 88 insertions(+), 78 deletions(-) diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 6e4eb856a..6a7c8fc3e 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -70,6 +70,7 @@ const CLASSNAME_DISPLAY = `${CLASSNAME}-display`; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`; const CLASSNAME_IMAGE = `${CLASSNAME_CAROUSEL}-image`; +const CLASSNAME_IMAGE_CONTAINER = `${CLASSNAME_CAROUSEL}-image-container`; const CLASSNAME_NAVBUTTON = `${CLASSNAME}-navbutton`; const CLASSNAME_NAV = `${CLASSNAME}-nav`; const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`; @@ -857,7 +858,10 @@ export const LightboxComponent: React.FC = ({ ref={carouselRef} > {images.map((image, i) => ( -
+
{i >= currentIndex - 1 && i <= currentIndex + 1 ? ( = ({ }; }, [src, dimensionsProvided]); + const calcPanBounds = useCallback( + (appliedZoom: number) => { + const xRange = Math.max(appliedZoom * imageWidth - boxWidth, 0); + const yRange = Math.max(appliedZoom * imageHeight - boxHeight, 0); + const nonZero = xRange != 0 || yRange != 0; + return { + minX: -xRange / 2, + maxX: xRange / 2, + minY: -yRange / 2, + maxY: yRange / 2, + nonZero, + }; + }, + [imageWidth, boxWidth, imageHeight, boxHeight] + ); + const panBounds = calcPanBounds(defaultZoom * zoom); + const minMaxY = useCallback( (appliedZoom: number) => { - let minY, maxY: number; - const inBounds = appliedZoom * imageHeight <= boxHeight; - - // NOTE: I don't even know how these work, but they do - if (!inBounds) { - if (imageHeight > boxHeight) { - minY = - (appliedZoom * imageHeight - imageHeight) / 2 - - appliedZoom * imageHeight + - boxHeight; - maxY = (appliedZoom * imageHeight - imageHeight) / 2; - } else { - minY = (boxHeight - appliedZoom * imageHeight) / 2; - maxY = (appliedZoom * imageHeight - boxHeight) / 2; - } - } else { - minY = Math.min((boxHeight - imageHeight) / 2, 0); - maxY = minY; - } + const minY = Math.min((boxHeight - appliedZoom * imageHeight) / 2, 0); + const maxY = Math.max((appliedZoom * imageHeight - boxHeight) / 2, 0); return [minY, maxY]; }, @@ -212,33 +214,21 @@ export const LightboxImage: React.FC = ({ const calculateInitialPosition = useCallback( (appliedZoom: number) => { - // Center image from container's center - const newPositionX = Math.min((boxWidth - imageWidth) / 2, 0); - let newPositionY: number; - - if (displayMode === GQL.ImageLightboxDisplayMode.FitXy) { - newPositionY = Math.min((boxHeight - imageHeight) / 2, 0); - } else { - // otherwise, align image with container - const [minY, maxY] = minMaxY(appliedZoom); - if (!alignBottom) { - newPositionY = maxY; - } else { - newPositionY = minY; - } - } + // If image is smaller than container, place in center. Otherwise, align + // the left side of the image with the left side of the container, and + // align either the top or bottom of the image with the corresponding + // edge of container, depending on whether navigation is forwards or + // backwards. + const [minY, maxY] = minMaxY(appliedZoom); + const newPositionX = Math.max( + (appliedZoom * imageWidth - boxWidth) / 2, + 0 + ); + const newPositionY = alignBottom ? minY : maxY; return [newPositionX, newPositionY]; }, - [ - displayMode, - boxWidth, - imageWidth, - boxHeight, - imageHeight, - alignBottom, - minMaxY, - ] + [boxWidth, imageWidth, alignBottom, minMaxY] ); useEffect(() => { @@ -408,6 +398,8 @@ export const LightboxImage: React.FC = ({ } function onImageScroll(ev: React.WheelEvent) { + if (defaultZoom === null) return; + const absDeltaY = Math.abs(ev.deltaY); const firstDeltaY = firstScroll.current; // detect infinite scrolling (mousepad, mouse with infinite scrollwheel) @@ -427,6 +419,9 @@ export const LightboxImage: React.FC = ({ percent = ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; } setZoom(zoom * percent); + const bounds = calcPanBounds(defaultZoom * zoom * percent); + setPositionX(Math.max(bounds.minX, Math.min(bounds.maxX, positionX))); + setPositionY(Math.max(bounds.minY, Math.min(bounds.maxY, positionY))); break; case GQL.ImageLightboxScrollMode.PanY: onImageScrollPanY(ev, infinite); @@ -452,12 +447,21 @@ export const LightboxImage: React.FC = ({ return; } - const posX = ev.pageX - startPoints.current[0]; - const posY = ev.pageY - startPoints.current[1]; + const deltaX = ev.pageX - startPoints.current[0]; + const deltaY = ev.pageY - startPoints.current[1]; startPoints.current = [ev.pageX, ev.pageY]; - setPositionX(positionX + posX); - setPositionY(positionY + posY); + const newPositionX = Math.max( + panBounds.minX, + Math.min(panBounds.maxX, positionX + deltaX) + ); + const newPositionY = Math.max( + panBounds.minY, + Math.min(panBounds.maxY, positionY + deltaY) + ); + + setPositionX(newPositionX); + setPositionY(newPositionY); } function onImageMouseDown(ev: React.MouseEvent) { @@ -542,6 +546,8 @@ export const LightboxImage: React.FC = ({ pointerCache.current[cachedIndex] = ev; } + if (defaultZoom === null) return; + // compare the difference between the two pointers if (pointerCache.current.length === 2) { const ev1 = pointerCache.current[0]; @@ -554,11 +560,11 @@ export const LightboxImage: React.FC = ({ const diffDiff = diff - prevDiff.current; const factor = (Math.abs(diffDiff) / 20) * 0.1 + 1; - if (diffDiff > 0) { - setZoom(zoom * factor); - } else if (diffDiff < 0) { - setZoom((zoom * 1) / factor); - } + let newZoom = diffDiff > 0 ? zoom * factor : zoom / factor; + setZoom(newZoom); + const bounds = calcPanBounds(defaultZoom * newZoom); + setPositionX(Math.max(bounds.minX, Math.min(bounds.maxX, positionX))); + setPositionY(Math.max(bounds.minY, Math.min(bounds.maxY, positionY))); } prevDiff.current = diff; @@ -570,25 +576,29 @@ export const LightboxImage: React.FC = ({ return (
onContainerScroll(e)} > {defaultZoom ? ( - - + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} onImageScroll(e) : undefined} onMouseDown={onImageMouseDown} onMouseUp={onImageMouseUp} diff --git a/ui/v2.5/src/hooks/Lightbox/lightbox.scss b/ui/v2.5/src/hooks/Lightbox/lightbox.scss index 95a5fbc42..22b4a8019 100644 --- a/ui/v2.5/src/hooks/Lightbox/lightbox.scss +++ b/ui/v2.5/src/hooks/Lightbox/lightbox.scss @@ -144,28 +144,24 @@ transition-duration: 0ms; } - &-image { + &-image-container { content-visibility: auto; - display: flex; width: 100vw; + } - picture { - display: flex; - margin: auto; - position: relative; - - > div { - display: flex; - height: 100%; - position: absolute; - width: 100%; - } - } + &-image { + height: 100%; + width: 100%; img { - cursor: pointer; object-fit: contain; } + + &-pan { + img { + cursor: pointer; + } + } } } From d78462bdb4041da911da2afa8e73bae549b84106 Mon Sep 17 00:00:00 2001 From: sezzim <174854242+sezzim@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:22:22 -0700 Subject: [PATCH 3/4] Split LightboxCarousel to its own component --- ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 147 +++++++++++++++++------- 1 file changed, 103 insertions(+), 44 deletions(-) diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 6a7c8fc3e..23552f745 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -1,4 +1,11 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + ForwardedRef, + forwardRef, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { Button, Col, @@ -83,6 +90,84 @@ const MIN_ZOOM = 0.1; const SCROLL_ZOOM_TIMEOUT = 250; const ZOOM_NONE_EPSILON = 0.015; +interface ILightboxCarouselProps { + instantTransition: boolean; + currentIndex: number; + images: ILightboxImage[]; + displayMode: GQL.ImageLightboxDisplayMode; + lightboxSettings: GQL.ConfigImageLightboxInput | undefined; + resetPosition?: boolean; + zoom: number; + scrollAttemptsBeforeChange: number; + firstScroll: React.MutableRefObject; + inScrollGroup: React.MutableRefObject; + movingLeft: boolean; + updateZoom: (v: number) => void; + debouncedScrollReset: () => void; + handleLeft: () => void; + handleRight: () => void; +} + +const LightboxCarousel = forwardRef(function ( + { + instantTransition, + currentIndex, + images, + displayMode, + lightboxSettings, + resetPosition, + zoom, + scrollAttemptsBeforeChange, + firstScroll, + inScrollGroup, + movingLeft, + updateZoom, + debouncedScrollReset, + handleLeft, + handleRight, + }: ILightboxCarouselProps, + carouselRef: ForwardedRef +) { + return ( +
+ {images.map((image, i) => ( +
+ {i >= currentIndex - 1 && i <= currentIndex + 1 ? ( + + ) : undefined} +
+ ))} +
+ ); +}); + interface IProps { images: ILightboxImage[]; isVisible: boolean; @@ -145,7 +230,6 @@ export const LightboxComponent: React.FC = ({ const containerRef = useRef(null); const overlayTarget = useRef(null); - const carouselRef = useRef(null); const indicatorRef = useRef(null); const navRef = useRef(null); const clearIntervalCallback = useRef<() => void>(); @@ -849,48 +933,23 @@ export const LightboxComponent: React.FC = ({ )} - -
- {images.map((image, i) => ( -
- {i >= currentIndex - 1 && i <= currentIndex + 1 ? ( - - ) : undefined} -
- ))} -
- + {allowNavigation && ( )} = ({ debouncedScrollReset={debouncedScrollReset} handleLeft={handleLeft} handleRight={handleRight} + overrideTransition={overrideTransition} /> {allowNavigation && (