Skip to content

Commit

Permalink
Merge branch 'main' into fix-small-image-border
Browse files Browse the repository at this point in the history
  • Loading branch information
SamyPesse authored Mar 25, 2024
2 parents 6fc18d7 + c5a28f8 commit 91104e7
Show file tree
Hide file tree
Showing 9 changed files with 313 additions and 36 deletions.
Binary file modified bun.lockb
Binary file not shown.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 0 additions & 2 deletions src/components/Search/SearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
26 changes: 14 additions & 12 deletions src/components/utils/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ?? ''} />;
}
3 changes: 0 additions & 3 deletions src/components/utils/Zoom.css

This file was deleted.

13 changes: 0 additions & 13 deletions src/components/utils/Zoom.tsx

This file was deleted.

19 changes: 19 additions & 0 deletions src/components/utils/ZoomImage.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
280 changes: 280 additions & 0 deletions src/components/utils/ZoomImage.tsx
Original file line number Diff line number Diff line change
@@ -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
);
}
5 changes: 0 additions & 5 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down

0 comments on commit 91104e7

Please sign in to comment.