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();
+ });
});