diff --git a/bun.lockb b/bun.lockb
index f6d6c7c34..2d9493196 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index c3e64851e..29da9ba4b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/Search/SearchModal.tsx b/src/components/Search/SearchModal.tsx
index df1c37f1e..bfc2c6adf 100644
--- a/src/components/Search/SearchModal.tsx
+++ b/src/components/Search/SearchModal.tsx
@@ -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]);
diff --git a/src/components/utils/Image.tsx b/src/components/utils/Image.tsx
index 5575c57ce..6fad1371b 100644
--- a/src/components/utils/Image.tsx
+++ b/src/components/utils/Image.tsx
@@ -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 = (
-
- );
+ const imgProps: ImgDOMPropsWithSrc = {
+ alt,
+ style,
+ loading,
+ fetchPriority,
+ ...rest,
+ ...attrs,
+ };
- return zoom ? {img} : img;
+ return zoom ? :
;
}
diff --git a/src/components/utils/Zoom.css b/src/components/utils/Zoom.css
deleted file mode 100644
index 2d436e393..000000000
--- a/src/components/utils/Zoom.css
+++ /dev/null
@@ -1,3 +0,0 @@
-html.dark [data-rmiz-modal-overlay='visible'] {
- background-color: rgb(var(--dark-base));
-}
diff --git a/src/components/utils/Zoom.tsx b/src/components/utils/Zoom.tsx
deleted file mode 100644
index 99fa40db8..000000000
--- a/src/components/utils/Zoom.tsx
+++ /dev/null
@@ -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 ;
-}
diff --git a/src/components/utils/ZoomImage.module.css b/src/components/utils/ZoomImage.module.css
new file mode 100644
index 000000000..d47fa51e4
--- /dev/null
+++ b/src/components/utils/ZoomImage.module.css
@@ -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;
+}
diff --git a/src/components/utils/ZoomImage.tsx b/src/components/utils/ZoomImage.tsx
new file mode 100644
index 000000000..73d0b26cd
--- /dev/null
+++ b/src/components/utils/ZoomImage.tsx
@@ -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
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(null);
+ const [zoomable, setZoomable] = React.useState(false);
+ const [active, setActive] = React.useState(false);
+ const [opened, setOpened] = React.useState(false);
+ const [placeholderRect, setPlaceholderRect] = React.useState(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
+
+ ) : null}
+
+ {ReactDOM.createPortal(
+ ,
+ document.body,
+ )}
+ >
+ ) : (
+ // When zooming, remove the image from the DOM to let the browser animates it with View Transition.
+
{
+ 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(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 (
+
+

+
+
+
+ );
+}
+
+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
+ );
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 525e4e743..9c6b8e92f 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -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.
*/