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 = ( - {alt} - ); + const imgProps: ImgDOMPropsWithSrc = { + alt, + style, + loading, + fetchPriority, + ...rest, + ...attrs, + }; - return zoom ? {img} : img; + return zoom ? : {imgProps.alt; } 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. + {alt { + 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 ( +
+ {alt} + + +
+ ); +} + +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. */