mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 19:32:07 +00:00
Fix zoom on small images with custom zoom component (#2228)
This commit is contained in:
@@ -47,7 +47,6 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-medium-image-zoom": "^5.1.10",
|
||||
"recoil": "^0.7.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.0",
|
||||
|
||||
@@ -45,12 +45,10 @@ export function SearchModal(props: SearchModalProps) {
|
||||
const isSearchOpened = state !== null;
|
||||
React.useEffect(() => {
|
||||
if (isSearchOpened) {
|
||||
document.body.classList.add('search-open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('search-open');
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [isSearchOpened]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { checkIsHttpURL, getImageSize, getResizedImageURL } from '@/lib/images';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { PolymorphicComponentProp } from './types';
|
||||
import { Zoom } from './Zoom';
|
||||
import { ZoomImage } from './ZoomImage';
|
||||
|
||||
export type ImageSize = { width: number; height: number };
|
||||
|
||||
@@ -87,6 +87,10 @@ interface ImageCommonProps {
|
||||
inlineStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface ImgDOMPropsWithSrc extends React.ComponentPropsWithoutRef<'img'> {
|
||||
src: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an image that will be swapped depending on the theme.
|
||||
* We don't use the `next/image` component because we need to load images from external sources,
|
||||
@@ -271,16 +275,14 @@ async function ImagePicture(
|
||||
});
|
||||
}
|
||||
|
||||
const img = (
|
||||
<img
|
||||
alt={alt}
|
||||
style={style}
|
||||
loading={loading}
|
||||
fetchPriority={fetchPriority}
|
||||
{...rest}
|
||||
{...attrs}
|
||||
/>
|
||||
);
|
||||
const imgProps: ImgDOMPropsWithSrc = {
|
||||
alt,
|
||||
style,
|
||||
loading,
|
||||
fetchPriority,
|
||||
...rest,
|
||||
...attrs,
|
||||
};
|
||||
|
||||
return zoom ? <Zoom wrapElement={inline ? 'span' : 'div'}>{img}</Zoom> : img;
|
||||
return zoom ? <ZoomImage {...imgProps} /> : <img {...imgProps} alt={imgProps.alt ?? ''} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
html.dark [data-rmiz-modal-overlay='visible'] {
|
||||
background-color: rgb(var(--dark-base));
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import ReactZoom, { UncontrolledProps } from 'react-medium-image-zoom';
|
||||
import 'react-medium-image-zoom/dist/styles.css';
|
||||
import './Zoom.css';
|
||||
|
||||
/**
|
||||
* Client component to zoom on an image.
|
||||
* See https://github.com/rpearce/react-medium-image-zoom
|
||||
*/
|
||||
export function Zoom(props: UncontrolledProps) {
|
||||
return <ReactZoom {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
html:has(.zoomModal) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zoomImg {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.zoomImageActive {
|
||||
view-transition-name: zoom-image;
|
||||
}
|
||||
|
||||
.zoomModal {
|
||||
}
|
||||
|
||||
.zoomModal img {
|
||||
view-transition-name: zoom-image;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
'use client';
|
||||
|
||||
import IconMinimize from '@geist-ui/icons/minimize';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import styles from './ZoomImage.module.css';
|
||||
|
||||
/**
|
||||
* Replacement for an <img> tag that allows zooming.
|
||||
* The implementation uses the experimental View Transition API in Chrome for a smooth transition.
|
||||
*/
|
||||
export function ZoomImage(
|
||||
props: React.ComponentPropsWithoutRef<'img'> & {
|
||||
src: string;
|
||||
},
|
||||
) {
|
||||
const { src, alt, width } = props;
|
||||
|
||||
const imgRef = React.useRef<HTMLImageElement>(null);
|
||||
const [zoomable, setZoomable] = React.useState(false);
|
||||
const [active, setActive] = React.useState(false);
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
const [placeholderRect, setPlaceholderRect] = React.useState<DOMRect | null>(null);
|
||||
|
||||
// Only allow zooming when image will not actually be larger and on mobile
|
||||
React.useEffect(() => {
|
||||
if (isTouchDevice()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageWidth = typeof width === 'number' ? width : 0;
|
||||
let viewWidth = 0;
|
||||
|
||||
const mediaQueryList = window.matchMedia('(min-width: 768px)');
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver((entries) => {
|
||||
const imgEntry = entries[0];
|
||||
|
||||
// Since the image is removed from the DOM when the modal is opened,
|
||||
// We only care when the size is defined.
|
||||
if (imgEntry && imgEntry.contentRect.width !== 0) {
|
||||
viewWidth = entries[0]?.contentRect.width;
|
||||
setPlaceholderRect(entries[0].contentRect);
|
||||
onChange();
|
||||
}
|
||||
})
|
||||
: null;
|
||||
|
||||
const onChange = () => {
|
||||
if (!mediaQueryList.matches) {
|
||||
// Don't allow zooming on mobile
|
||||
setZoomable(false);
|
||||
} else if (resizeObserver && imageWidth && viewWidth && imageWidth <= viewWidth) {
|
||||
// Image can't be zoomed if it's already rendered as it's largest size
|
||||
setZoomable(false);
|
||||
} else {
|
||||
setZoomable(true);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQueryList.addEventListener('change', onChange);
|
||||
if (imgRef.current) {
|
||||
resizeObserver?.observe(imgRef.current);
|
||||
}
|
||||
|
||||
if (!resizeObserver) {
|
||||
// When resizeObserver is available, it'll take care of calling the changelog as soon as the element is observed
|
||||
onChange();
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
mediaQueryList.removeEventListener('change', onChange);
|
||||
};
|
||||
}, [imgRef, width]);
|
||||
|
||||
// Preload the image that will be displayed in the modal
|
||||
if (zoomable) {
|
||||
ReactDOM.preload(src, {
|
||||
as: 'image',
|
||||
});
|
||||
}
|
||||
const preloadImage = React.useCallback(
|
||||
(onLoad?: () => void) => {
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
|
||||
image.onload = () => {
|
||||
onLoad?.();
|
||||
};
|
||||
},
|
||||
[src],
|
||||
);
|
||||
|
||||
// When closing the modal, animate the transition back to the original image
|
||||
const onClose = React.useCallback(() => {
|
||||
startViewTransition(
|
||||
() => {
|
||||
setOpened(false);
|
||||
},
|
||||
() => {
|
||||
setActive(false);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{opened ? (
|
||||
<>
|
||||
{placeholderRect ? (
|
||||
// Placeholder to keep the layout stable when the image is removed from the DOM
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
width: placeholderRect.width,
|
||||
height: placeholderRect.height,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{ReactDOM.createPortal(
|
||||
<ZoomImageModal src={src} alt={alt ?? ''} onClose={onClose} />,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// When zooming, remove the image from the DOM to let the browser animates it with View Transition.
|
||||
<img
|
||||
ref={imgRef}
|
||||
{...props}
|
||||
alt={alt ?? ''}
|
||||
onMouseEnter={() => {
|
||||
if (zoomable) {
|
||||
preloadImage();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!zoomable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preload the image before opening the modal to ensure the animation is smooth
|
||||
preloadImage(() => {
|
||||
const change = () => {
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
ReactDOM.flushSync(() => setActive(true));
|
||||
startViewTransition(change);
|
||||
});
|
||||
}}
|
||||
className={classNames(
|
||||
props.className,
|
||||
zoomable ? styles.zoomImg : null,
|
||||
active ? styles.zoomImageActive : null,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomImageModal(props: { src: string; alt: string; onClose: () => void }) {
|
||||
const { src, alt, onClose } = props;
|
||||
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.zoomModal,
|
||||
tcls(
|
||||
'fixed',
|
||||
'inset-0',
|
||||
'z-50',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
'p-8',
|
||||
),
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={tcls(
|
||||
'max-w-full',
|
||||
'max-h-full',
|
||||
'object-contain',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={tcls(
|
||||
'absolute',
|
||||
'top-5',
|
||||
'right-5',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-sm',
|
||||
'text-dark/6',
|
||||
'dark:text-light/5',
|
||||
'hover:text-primary',
|
||||
'p-4',
|
||||
'dark:text-light/5',
|
||||
'rounded-full',
|
||||
'bg-white',
|
||||
'dark:bg-dark/3',
|
||||
'shadow-sm',
|
||||
'hover:shadow-md',
|
||||
'border-slate-300',
|
||||
'dark:border-dark/2',
|
||||
'border',
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconMinimize />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function startViewTransition(callback: () => void, onEnd?: () => void) {
|
||||
// @ts-ignore
|
||||
if (document.startViewTransition) {
|
||||
// @ts-ignore
|
||||
const transition = document.startViewTransition(() => {
|
||||
ReactDOM.flushSync(() => callback());
|
||||
});
|
||||
transition.finished.then(() => {
|
||||
if (onEnd) {
|
||||
onEnd();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
callback();
|
||||
onEnd?.();
|
||||
}
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return (
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
// @ts-ignore
|
||||
navigator.msMaxTouchPoints > 0
|
||||
);
|
||||
}
|
||||
@@ -199,11 +199,6 @@ const config: Config = {
|
||||
*/
|
||||
addVariant('navigation-open', 'body.navigation-open &');
|
||||
|
||||
/**
|
||||
* Variant when the search overlay is open.
|
||||
*/
|
||||
addVariant('search-open', 'body.search-open &');
|
||||
|
||||
/**
|
||||
* Variant when a header is displayed.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user