diff --git a/docs/plugins/zoom.md b/docs/plugins/zoom.md index 0241941..af9898a 100644 --- a/docs/plugins/zoom.md +++ b/docs/plugins/zoom.md @@ -1,6 +1,7 @@ # Zoom Plugin -The Zoom plugin adds an image zoom feature to the lightbox. +The Zoom plugin adds a zoom feature to the lightbox. Zoom is supported for image +slides by default and can be enabled for custom slide types. The plugin supports the following input devices and gestures: @@ -87,15 +88,6 @@ The plugin supports the following input devices and gestures: -## Requirements - -The plugin currently supports only image slides. Custom render functions are -also supported as long as the slide provides `width` and `height` attributes. - -If you need support for custom slide types, please open a -[discussion](https://github.com/igordanchenko/yet-another-react-lightbox/discussions) -and provide a specific use case. - ## Documentation The Zoom plugin adds the following `Lightbox` properties. @@ -118,6 +110,8 @@ The Zoom plugin adds the following `Lightbox` properties.   pinchZoomDistanceFactor?: number;
  pinchZoomV4?: boolean;
  scrollToZoom?: boolean;
+   supports?: readonly SlideTypeKey[];
+   maxZoom?: number | ((slide: Slide) => number | undefined);
} @@ -135,11 +129,14 @@ The Zoom plugin adds the following `Lightbox` properties.
  • `pinchZoomDistanceFactor` - pinch zoom distance factor (deprecated)
  • `pinchZoomV4` - if `true`, enables the experimental pinch zoom implementation slated for v4
  • `scrollToZoom` - if `true`, enables image zoom via scroll gestures for mouse and trackpad users
  • +
  • `supports` - custom slide types that support zoom (e.g., `["custom-slide"]`)
  • +
  • `maxZoom` - maximum zoom level for custom slide types; when a function, return `undefined` to use the default (default: 8)
  • Default: { minZoom: 1, maxZoomPixelRatio: 1, zoomInMultiplier: 2, doubleTapDelay: 300, doubleClickDelay: 500, doubleClickMaxStops: 2, keyboardMoveDistance: 50, - wheelZoomDistanceFactor: 100, pinchZoomDistanceFactor: 100, scrollToZoom: false } + wheelZoomDistanceFactor: 100, pinchZoomDistanceFactor: 100, scrollToZoom: false, + maxZoom: 8 }

    @@ -192,6 +189,46 @@ The Zoom plugin adds the following `Lightbox` properties. +## Custom Slide Types + +By default, the Zoom plugin supports only image slides. You can enable zoom for +custom slide types using the `supports` and `maxZoom` properties. + +```jsx + (slide.type === "custom-slide" ? 4 : 8), + }} + render={{ + slide: ({ slide, zoom, maxZoom }) => { + if (slide.type === "custom-slide") { + return ; + } + }, + }} +/> +``` + +Custom slide types must provide a `render.slide` function that renders the slide +content. The Zoom plugin wraps the rendered content with a zoom container that +handles all zoom gestures, transformations, and offset clamping automatically. + +For image slides, the maximum zoom level is calculated from the image dimensions +and `maxZoomPixelRatio`. Custom render functions for image slides are also +supported as long as the slide provides `width` and `height` attributes. For +custom slide types, use `maxZoom` to set the maximum zoom level (default: 8). +When `maxZoom` is a function, return `undefined` to use the default value. + +TypeScript users must augment the `SlideTypes` interface to register custom +slide types. See [Custom Slides](/advanced#CustomSlides) for details. + ## Zoom Ref The Zoom plugin provides a ref object to control the plugin features externally. diff --git a/src/plugins/zoom/Zoom.tsx b/src/plugins/zoom/Zoom.tsx index 5d58304..88e1bff 100644 --- a/src/plugins/zoom/Zoom.tsx +++ b/src/plugins/zoom/Zoom.tsx @@ -16,7 +16,11 @@ export const Zoom: Plugin = ({ augment, addModule }) => { render: { ...render, slide: (props) => - isImageSlide(props.slide) ? : render.slide?.(props), + isImageSlide(props.slide) || (props.slide.type != null && zoom.supports?.includes(props.slide.type)) ? ( + + ) : ( + render.slide?.(props) + ), }, controller: { ...controller, preventDefaultWheelY: zoom.scrollToZoom }, ...restProps, diff --git a/src/plugins/zoom/ZoomWrapper.tsx b/src/plugins/zoom/ZoomWrapper.tsx index 78b87c3..6587610 100644 --- a/src/plugins/zoom/ZoomWrapper.tsx +++ b/src/plugins/zoom/ZoomWrapper.tsx @@ -27,12 +27,47 @@ export function ZoomWrapper({ render, slide, offset, rect }: ZoomWrapperProps) { const [imageDimensions, setImageDimensions] = React.useState(); const zoomWrapperRef = React.useRef(null); + const isImage = isImageSlide(slide); + const { zoom, maxZoom, offsetX, offsetY, setZoomWrapper } = useZoom(); const interactive = zoom > 1; const { carousel, on } = useLightboxProps(); const { currentIndex } = useLightboxState(); + useLayoutEffect(() => { + if (offset !== 0 || isImage || !zoomWrapperRef.current) return () => {}; + + const measure = () => { + const wrapper = zoomWrapperRef.current; + if (!wrapper) return; + + let width = 0; + let height = 0; + + for (const child of wrapper.children) { + if (child instanceof HTMLElement) { + width = Math.max(width, child.offsetWidth); + height = Math.max(height, child.offsetHeight); + } + } + + setImageDimensions((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height })); + }; + + measure(); + + if (typeof ResizeObserver === "undefined") return () => {}; + + // observe children present at effect time; dynamically added children won't be tracked + const observer = new ResizeObserver(measure); + for (const child of zoomWrapperRef.current.children) { + observer.observe(child); + } + + return () => observer.disconnect(); + }, [offset, isImage, rect]); + useLayoutEffect(() => { if (offset === 0) { setZoomWrapper({ zoomWrapperRef, imageDimensions }); @@ -41,9 +76,13 @@ export function ZoomWrapper({ render, slide, offset, rect }: ZoomWrapperProps) { return () => {}; }, [offset, imageDimensions, setZoomWrapper]); + // Image slides with custom render functions still require explicit width/height on the slide because the default + // ImageSlide's onLoad (which provides naturalWidth/naturalHeight) doesn't fire, and ResizeObserver only gives rendered + // dimensions — insufficient for computing resolution-based max zoom. Consider adding setZoomDimensions callback to + // render.slide props so custom render functions can report natural dimensions for both image and custom slide types. let rendered = render.slide?.({ slide, offset, rect, zoom, maxZoom }); - if (!rendered && isImageSlide(slide)) { + if (!rendered && isImage) { const slideProps = { slide, offset, diff --git a/src/plugins/zoom/hooks/useZoomImageRect.ts b/src/plugins/zoom/hooks/useZoomImageRect.ts index 320f818..dae7695 100644 --- a/src/plugins/zoom/hooks/useZoomImageRect.ts +++ b/src/plugins/zoom/hooks/useZoomImageRect.ts @@ -3,18 +3,25 @@ import { isImageFitCover, isImageSlide, round, + Slide, useLightboxProps, useLightboxState, } from "../../../index.js"; +import { defaultZoomProps } from "../props.js"; import { useZoomProps } from "./useZoomProps.js"; +function resolveMaxZoom(maxZoom: number | ((slide: Slide) => number | undefined), slide: Slide) { + const resolved = typeof maxZoom === "function" ? (maxZoom(slide) ?? defaultZoomProps.maxZoom) : maxZoom; + return Math.max(resolved, 1); +} + export function useZoomImageRect(slideRect: ContainerRect, imageDimensions?: ContainerRect) { let imageRect: ContainerRect = { width: 0, height: 0 }; let maxImageRect: ContainerRect = { width: 0, height: 0 }; const { currentSlide } = useLightboxState(); const { imageFit } = useLightboxProps().carousel; - const { maxZoomPixelRatio } = useZoomProps(); + const { maxZoomPixelRatio, maxZoom: maxZoomInProps } = useZoomProps(); if (slideRect && currentSlide) { const slide = { ...currentSlide, ...imageDimensions }; @@ -48,10 +55,24 @@ export function useZoomImageRect(slideRect: ContainerRect, imageDimensions?: Con height: Math.round(Math.min(slideRect.height, (slideRect.width / width) * height, height)), }; } + } else if (slideRect.width > 0 && slideRect.height > 0) { + if (imageDimensions && imageDimensions.width > 0 && imageDimensions.height > 0) { + imageRect = { + width: Math.min(slideRect.width, imageDimensions.width), + height: Math.min(slideRect.height, imageDimensions.height), + }; + } else { + imageRect = { width: slideRect.width, height: slideRect.height }; + } } } - const maxZoom = imageRect.width ? Math.max(round(maxImageRect.width / imageRect.width, 5), 1) : 1; + const maxZoom = + currentSlide && imageRect.width + ? isImageSlide(currentSlide) + ? Math.max(round(maxImageRect.width / imageRect.width, 5), 1) + : resolveMaxZoom(maxZoomInProps, currentSlide) + : 1; return { imageRect, maxZoom }; } diff --git a/src/plugins/zoom/hooks/useZoomState.ts b/src/plugins/zoom/hooks/useZoomState.ts index 35961a8..8b2b6e3 100644 --- a/src/plugins/zoom/hooks/useZoomState.ts +++ b/src/plugins/zoom/hooks/useZoomState.ts @@ -27,8 +27,9 @@ export function useZoomState( const { containerRect, slideRect } = useController(); const { minZoom, zoomInMultiplier } = useZoomProps(); + // TODO v4: use `slide.key` to reset zoom on reactive slide replacement for custom slide types const currentSource = currentSlide && isImageSlide(currentSlide) ? currentSlide.src : undefined; - const disabled = !currentSource || !zoomWrapperRef?.current; + const disabled = !zoomWrapperRef?.current; useLayoutEffect(() => { setZoom(1); diff --git a/src/plugins/zoom/index.ts b/src/plugins/zoom/index.ts index 58dff36..44f80a4 100644 --- a/src/plugins/zoom/index.ts +++ b/src/plugins/zoom/index.ts @@ -31,6 +31,10 @@ declare module "../../types.js" { pinchZoomV4?: boolean; /** if `true`, enables image zoom via scroll gestures for mouse and trackpad users */ scrollToZoom?: boolean; + /** custom slide types that support zoom */ + supports?: readonly SlideTypeKey[]; + /** maximum zoom level for custom slide types; when a function, return `undefined` to use the default (default: 8) */ + maxZoom?: number | ((slide: Slide) => number | undefined); }; } diff --git a/src/plugins/zoom/props.ts b/src/plugins/zoom/props.ts index 0db94b5..82a5547 100644 --- a/src/plugins/zoom/props.ts +++ b/src/plugins/zoom/props.ts @@ -12,6 +12,7 @@ export const defaultZoomProps = { pinchZoomDistanceFactor: 100, pinchZoomV4: false, scrollToZoom: false, + maxZoom: 8, }; function validateMinZoom(minZoom: number) { diff --git a/test/types/src/App.tsx b/test/types/src/App.tsx index cfa32d1..9b7a9b6 100644 --- a/test/types/src/App.tsx +++ b/test/types/src/App.tsx @@ -115,6 +115,8 @@ export default function App() { wheelZoomDistanceFactor: 100, pinchZoomDistanceFactor: 100, scrollToZoom: false, + supports: ["video"], + maxZoom: (slide) => (slide.type === "video" ? 4 : undefined), }} toolbar={{ buttons: [ diff --git a/test/unit/plugins/Zoom.spec.ts b/test/unit/plugins/Zoom.spec.ts index 01cad80..4b71116 100644 --- a/test/unit/plugins/Zoom.spec.ts +++ b/test/unit/plugins/Zoom.spec.ts @@ -1,7 +1,26 @@ +import * as React from "react"; import { act, render, screen } from "@testing-library/react"; -import { lightbox, expectLightboxToBeOpen } from "../test-utils.js"; +import { expectLightboxToBeOpen, findCurrentSlide, lightbox } from "../test-utils.js"; import { Zoom } from "../../../src/plugins/index.js"; +import type { GenericSlide, Slide } from "../../../src/types.js"; + +declare module "../../../src/types.js" { + interface SlideTypes { + "custom-slide": CustomSlide; + } +} + +interface CustomSlide extends GenericSlide { + type: "custom-slide"; +} + +function customSlideRenderer({ slide }: { slide: Slide }) { + if (slide.type === "custom-slide") { + return React.createElement("div", { "data-testid": "custom-slide" }); + } + return undefined; +} describe("Zoom", () => { it("renders without crashing", async () => { @@ -26,4 +45,51 @@ describe("Zoom", () => { expectLightboxToBeOpen(); }); + + it("supports custom slide types", () => { + render( + lightbox({ + slides: [{ type: "custom-slide" }], + plugins: [Zoom], + zoom: { supports: ["custom-slide"] }, + render: { slide: customSlideRenderer }, + }), + ); + + const customSlide = findCurrentSlide()?.querySelector("[data-testid='custom-slide']"); + expect(customSlide).toBeInTheDocument(); + expect(customSlide?.closest(".yarl__slide_wrapper")).toBeInTheDocument(); + }); + + it("does not wrap unsupported custom slide types", () => { + render( + lightbox({ + slides: [{ type: "custom-slide" }], + plugins: [Zoom], + render: { slide: customSlideRenderer }, + }), + ); + + const customSlide = findCurrentSlide()?.querySelector("[data-testid='custom-slide']"); + expect(customSlide).toBeInTheDocument(); + expect(customSlide?.closest(".yarl__slide_wrapper")).toBeNull(); + }); + + it("supports maxZoom as a function", () => { + render( + lightbox({ + slides: [{ type: "custom-slide" }], + plugins: [Zoom], + zoom: { + supports: ["custom-slide"], + maxZoom: (slide: Slide) => (slide.type === "custom-slide" ? 5 : undefined), + }, + render: { slide: customSlideRenderer }, + }), + ); + + const customSlide = findCurrentSlide()?.querySelector("[data-testid='custom-slide']"); + expect(customSlide).toBeInTheDocument(); + expect(customSlide?.closest(".yarl__slide_wrapper")).toBeInTheDocument(); + }); });