Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions docs/plugins/zoom.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -87,15 +88,6 @@ The plugin supports the following input devices and gestures:
</tbody>
</table>

## 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.
Expand All @@ -118,6 +110,8 @@ The Zoom plugin adds the following `Lightbox` properties.
&nbsp;&nbsp;pinchZoomDistanceFactor?: number;<br />
&nbsp;&nbsp;pinchZoomV4?: boolean;<br />
&nbsp;&nbsp;scrollToZoom?: boolean;<br />
&nbsp;&nbsp;supports?: readonly SlideTypeKey[];<br />
&nbsp;&nbsp;maxZoom?: number | ((slide: Slide) =&gt; number | undefined);<br />
&#125;
</td>
<td>
Expand All @@ -135,11 +129,14 @@ The Zoom plugin adds the following `Lightbox` properties.
<li>`pinchZoomDistanceFactor` - pinch zoom distance factor (deprecated)</li>
<li>`pinchZoomV4` - if `true`, enables the experimental pinch zoom implementation slated for v4</li>
<li>`scrollToZoom` - if `true`, enables image zoom via scroll gestures for mouse and trackpad users</li>
<li>`supports` - custom slide types that support zoom (e.g., `["custom-slide"]`)</li>
<li>`maxZoom` - maximum zoom level for custom slide types; when a function, return `undefined` to use the default (default: 8)</li>
</ul>
<p>
Default: <span class="font-mono">&#123; minZoom: 1, maxZoomPixelRatio: 1, zoomInMultiplier: 2,
doubleTapDelay: 300, doubleClickDelay: 500, doubleClickMaxStops: 2, keyboardMoveDistance: 50,
wheelZoomDistanceFactor: 100, pinchZoomDistanceFactor: 100, scrollToZoom: false &#125;</span>
wheelZoomDistanceFactor: 100, pinchZoomDistanceFactor: 100, scrollToZoom: false,
maxZoom: 8 &#125;</span>
</p>
</td>
</tr>
Expand Down Expand Up @@ -192,6 +189,46 @@ The Zoom plugin adds the following `Lightbox` properties.
</tbody>
</table>

## 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
<Lightbox
slides={slides}
plugins={[Zoom]}
zoom={{
// enable zoom for custom slide types
supports: ["custom-slide"],
// maximum zoom level for custom slide types (default: 8)
maxZoom: 4,
// or use a function for per-slide max zoom
// maxZoom: (slide) => (slide.type === "custom-slide" ? 4 : 8),
}}
render={{
slide: ({ slide, zoom, maxZoom }) => {
if (slide.type === "custom-slide") {
return <MyCustomSlide slide={slide} zoom={zoom} maxZoom={maxZoom} />;
}
},
}}
/>
```

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.
Expand Down
6 changes: 5 additions & 1 deletion src/plugins/zoom/Zoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export const Zoom: Plugin = ({ augment, addModule }) => {
render: {
...render,
slide: (props) =>
isImageSlide(props.slide) ? <ZoomWrapper render={render} {...props} /> : render.slide?.(props),
isImageSlide(props.slide) || (props.slide.type != null && zoom.supports?.includes(props.slide.type)) ? (
<ZoomWrapper render={render} {...props} />
) : (
render.slide?.(props)
),
},
controller: { ...controller, preventDefaultWheelY: zoom.scrollToZoom },
...restProps,
Expand Down
41 changes: 40 additions & 1 deletion src/plugins/zoom/ZoomWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,47 @@ export function ZoomWrapper({ render, slide, offset, rect }: ZoomWrapperProps) {
const [imageDimensions, setImageDimensions] = React.useState<ContainerRect>();
const zoomWrapperRef = React.useRef<HTMLDivElement>(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 });
Expand All @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions src/plugins/zoom/hooks/useZoomImageRect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 };
}
3 changes: 2 additions & 1 deletion src/plugins/zoom/hooks/useZoomState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/zoom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}

Expand Down
1 change: 1 addition & 0 deletions src/plugins/zoom/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const defaultZoomProps = {
pinchZoomDistanceFactor: 100,
pinchZoomV4: false,
scrollToZoom: false,
maxZoom: 8,
};

function validateMinZoom(minZoom: number) {
Expand Down
2 changes: 2 additions & 0 deletions test/types/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
68 changes: 67 additions & 1 deletion test/unit/plugins/Zoom.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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();
});
});
Loading