diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 396526414..9e7f853fe 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -69,6 +69,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { scaleUp resetZoomOnNav scrollMode + scrollAttemptsBeforeChange } disableDropdownCreate { performer diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index f27870b36..281f133c4 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -217,6 +217,7 @@ input ConfigImageLightboxInput { scaleUp: Boolean resetZoomOnNav: Boolean scrollMode: ImageLightboxScrollMode + scrollAttemptsBeforeChange: Int } type ConfigImageLightboxResult { @@ -225,6 +226,7 @@ type ConfigImageLightboxResult { scaleUp: Boolean resetZoomOnNav: Boolean scrollMode: ImageLightboxScrollMode + scrollAttemptsBeforeChange: Int! } input ConfigInterfaceInput { diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 982dbc462..906378ca5 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -342,6 +342,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. setBool(config.ImageLightboxScaleUp, options.ScaleUp) setBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav) setString(config.ImageLightboxScrollMode, (*string)(options.ScrollMode)) + + if options.ScrollAttemptsBeforeChange != nil { + c.Set(config.ImageLightboxScrollAttemptsBeforeChange, *options.ScrollAttemptsBeforeChange) + } } if input.CSS != nil { diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index bd1235cd5..1dbfa1a62 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -145,12 +145,13 @@ const ( defaultWallPlayback = "video" // Image lightbox options - legacyImageLightboxSlideshowDelay = "slideshow_delay" - ImageLightboxSlideshowDelay = "image_lightbox.slideshow_delay" - ImageLightboxDisplayMode = "image_lightbox.display_mode" - ImageLightboxScaleUp = "image_lightbox.scale_up" - ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav" - ImageLightboxScrollMode = "image_lightbox.scroll_mode" + legacyImageLightboxSlideshowDelay = "slideshow_delay" + ImageLightboxSlideshowDelay = "image_lightbox.slideshow_delay" + ImageLightboxDisplayMode = "image_lightbox.display_mode" + ImageLightboxScaleUp = "image_lightbox.scale_up" + ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav" + ImageLightboxScrollMode = "image_lightbox.scroll_mode" + ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change" defaultImageLightboxSlideshowDelay = 5000 @@ -955,6 +956,9 @@ func (i *Instance) GetImageLightboxOptions() models.ConfigImageLightboxResult { mode := models.ImageLightboxScrollMode(v.GetString(ImageLightboxScrollMode)) ret.ScrollMode = &mode } + if v := i.viperWith(ImageLightboxScrollAttemptsBeforeChange); v != nil { + ret.ScrollAttemptsBeforeChange = v.GetInt(ImageLightboxScrollAttemptsBeforeChange) + } return ret } diff --git a/ui/v2.5/src/components/Changelog/versions/v0150.md b/ui/v2.5/src/components/Changelog/versions/v0150.md index 276d993ad..2aefcd796 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0150.md +++ b/ui/v2.5/src/components/Changelog/versions/v0150.md @@ -1,5 +1,6 @@ ### ✨ New Features * Add support for VTT and SRT captions for scenes. ([#2462](https://github.com/stashapp/stash/pull/2462)) +* Added option to require a number of scroll attempts before navigating to next/previous image in Lightbox. ([#2544](https://github.com/stashapp/stash/pull/2544)) ### 🎨 Improvements * Changed playback rate options to be the same as those provided by YouTube. ([#2550](https://github.com/stashapp/stash/pull/2550)) diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 29b4f8fc7..3431c9d81 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -289,6 +289,15 @@ export const SettingsInterfacePanel: React.FC = () => { ))} + + + saveLightboxSettings({ scrollAttemptsBeforeChange: v }) + } + /> diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index ced79d9f9..ddf5828f7 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -158,6 +158,11 @@ export const LightboxComponent: React.FC = ({ const slideshowDelay = savedDelay ?? configuredDelay ?? DEFAULT_SLIDESHOW_DELAY; + const scrollAttemptsBeforeChange = Math.max( + 0, + config?.interface.imageLightbox.scrollAttemptsBeforeChange ?? 0 + ); + function setSlideshowDelay(v: number) { setLightboxSettings({ slideshowDelay: v }); } @@ -733,6 +738,8 @@ export const LightboxComponent: React.FC = ({ onRight={handleRight} alignBottom={movingLeft} zoom={i === currentIndex ? zoom : 1} + current={i === currentIndex} + scrollAttemptsBeforeChange={scrollAttemptsBeforeChange} setZoom={(v) => setZoom(v)} resetPosition={resetPosition} /> diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx index 72817b3ff..dcddcbe5d 100644 --- a/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx +++ b/ui/v2.5/src/hooks/Lightbox/LightboxImage.tsx @@ -52,6 +52,8 @@ interface IProps { scrollMode: GQL.ImageLightboxScrollMode; resetPosition?: boolean; zoom: number; + scrollAttemptsBeforeChange: number; + current: boolean; // set to true to align image with bottom instead of top alignBottom?: boolean; setZoom: (v: number) => void; @@ -68,6 +70,8 @@ export const LightboxImage: React.FC = ({ scrollMode, alignBottom, zoom, + scrollAttemptsBeforeChange, + current, setZoom, resetPosition, }) => { @@ -88,6 +92,8 @@ export const LightboxImage: React.FC = ({ const pointerCache = useRef[]>([]); const prevDiff = useRef(); + const scrollAttempts = useRef(0); + useEffect(() => { const box = container.current; if (box) { @@ -193,6 +199,12 @@ export const LightboxImage: React.FC = ({ setPositionX(newPositionX); setPositionY(newPositionY); + + if (alignBottom) { + scrollAttempts.current = scrollAttemptsBeforeChange; + } else { + scrollAttempts.current = -scrollAttemptsBeforeChange; + } }, [ width, height, @@ -202,6 +214,7 @@ export const LightboxImage: React.FC = ({ scaleUp, alignBottom, calculateInitialPosition, + scrollAttemptsBeforeChange, ]); useEffect(() => { @@ -241,26 +254,48 @@ export const LightboxImage: React.FC = ({ } function onImageScrollPanY(ev: React.WheelEvent) { - const [minY, maxY] = minMaxY(zoom * defaultZoom); + if (current) { + const [minY, maxY] = minMaxY(zoom * defaultZoom); - let newPositionY = - positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); + const scrollable = positionY !== maxY || positionY !== minY; - // #2389 - if scroll up and at top, then go to previous image - // if scroll down and at bottom, then go to next image - if (newPositionY > maxY && positionY === maxY) { - onLeft(); - } else if (newPositionY < minY && positionY === minY) { - onRight(); - } else { - // ensure image doesn't go offscreen - newPositionY = Math.max(newPositionY, minY); - newPositionY = Math.min(newPositionY, maxY); + let newPositionY = + positionY + (ev.deltaY < 0 ? SCROLL_PAN_STEP : -SCROLL_PAN_STEP); - setPositionY(newPositionY); + // #2389 - if scroll up and at top, then go to previous image + // if scroll down and at bottom, then go to next image + if (newPositionY > maxY && positionY === maxY) { + // #2535 - require additional scrolls before changing page + if ( + !scrollable || + scrollAttempts.current <= -scrollAttemptsBeforeChange + ) { + onLeft(); + } else { + scrollAttempts.current--; + } + } else if (newPositionY < minY && positionY === minY) { + // #2535 - require additional scrolls before changing page + if ( + !scrollable || + scrollAttempts.current >= scrollAttemptsBeforeChange + ) { + onRight(); + } else { + scrollAttempts.current++; + } + } else { + scrollAttempts.current = 0; + + // ensure image doesn't go offscreen + newPositionY = Math.max(newPositionY, minY); + newPositionY = Math.min(newPositionY, maxY); + + setPositionY(newPositionY); + } + + ev.stopPropagation(); } - - ev.stopPropagation(); } function onImageScroll(ev: React.WheelEvent) { @@ -418,7 +453,7 @@ export const LightboxImage: React.FC = ({ alt="" draggable={false} style={{ touchAction: "none" }} - onWheel={(e) => onImageScroll(e)} + onWheel={current ? (e) => onImageScroll(e) : undefined} onMouseDown={(e) => onImageMouseDown(e)} onMouseUp={(e) => onImageMouseUp(e)} onMouseMove={(e) => onImageMouseOver(e)} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e85100746..bd9a61b46 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -522,6 +522,10 @@ "toggle_sound": "Enable sound" } }, + "scroll_attempts_before_change": { + "description": "Number of times to attempt to scroll before moving to the next/previous item. Only applies for Pan Y scroll mode.", + "heading": "Scroll attempts before transition" + }, "slideshow_delay": { "description": "Slideshow is available in galleries when in wall view mode", "heading": "Slideshow Delay (seconds)"