Skip to content

Commit 450a8d7

Browse files
authored
Support expandable images (#31)
Closes #26 Eject `MDXComponents/Img` using Docusaurus swizzling. Adapt the expandable image logic from the `Image` component in `gravitational/docs` to the Docusaurus site, copying the `ModalImage` and `PlainImage` components, plus their supporting hooks, into the ejected `MDXImg` component.
1 parent 3fda3f4 commit 450a8d7

File tree

4 files changed

+175
-0
lines changed

4 files changed

+175
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@docusaurus/theme-classic": "^3.5.0",
4444
"@inkeep/widgets": "^0.2.288",
4545
"@mdx-js/react": "^3.0.0",
46+
"classnames": "^2.3",
4647
"clsx": "^2.1.1",
4748
"date-fns": "^3.6.0",
4849
"prism-react-renderer": "^2.3.0",

src/theme/MDXComponents/Img/index.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import cn from "classnames";
2+
import { React, useEffect, useState, useCallback, useRef } from "react";
3+
import clsx from "clsx";
4+
import type { Props } from "@theme/MDXComponents/Img";
5+
6+
import styles from "./styles.module.css";
7+
8+
function useClickInside(ref, handler) {
9+
useEffect(() => {
10+
const listener = (e: MouseEvent) => {
11+
if (ref.current) {
12+
handler(e);
13+
}
14+
};
15+
document.addEventListener("mousedown", listener);
16+
return () => {
17+
document.removeEventListener("mousedown", listener);
18+
};
19+
}, [ref, handler]);
20+
}
21+
22+
function useEscape(handler) {
23+
useEffect(() => {
24+
const listener = (e: KeyboardEvent) => {
25+
if (e.key === "Escape") {
26+
handler(e);
27+
}
28+
};
29+
document.addEventListener("keydown", listener);
30+
return () => {
31+
document.removeEventListener("keydown", listener);
32+
};
33+
}, [handler]);
34+
}
35+
36+
function useDisableBodyScroll(shouldDisable) {
37+
useEffect(() => {
38+
if (shouldDisable) {
39+
document.body.style.overflow = "hidden";
40+
} else {
41+
document.body.style.overflow = "unset";
42+
}
43+
}, [shouldDisable]);
44+
}
45+
46+
function transformImgClassName(className?: string): string {
47+
return clsx(className, styles.img);
48+
}
49+
50+
//** Maximum width of content block where images are placed.
51+
/* If the original image is smaller, then there is no sense to expand the image by clicking. */
52+
const MAX_CONTENT_WIDTH = 900;
53+
54+
type ModalImageProps = {
55+
setShowExpandedImage: Dispatch<SetStateAction<boolean>>;
56+
} & ImageProps;
57+
58+
const ModalImage = ({ setShowExpandedImage, ...props }: ModalImageProps) => {
59+
const closeHandler = useCallback(
60+
() => setShowExpandedImage(false),
61+
[setShowExpandedImage]
62+
);
63+
const modalRef = useRef<HTMLDivElement>();
64+
useClickInside(modalRef, closeHandler);
65+
useEscape(closeHandler);
66+
67+
return (
68+
<div ref={modalRef}>
69+
<div className={styles.overlay} />
70+
<div className={styles.dialog}>
71+
<img
72+
decoding="async"
73+
loading="lazy"
74+
{...props}
75+
className={transformImgClassName(props.className)}
76+
/>
77+
</div>
78+
</div>
79+
);
80+
};
81+
82+
export default function MDXImg(props: Props): JSX.Element {
83+
const [showExpandedImage, setShowExpandedImage] = useState(false);
84+
const shouldExpand = props.width > MAX_CONTENT_WIDTH;
85+
useDisableBodyScroll(showExpandedImage);
86+
const handleClickImage = () => {
87+
if (shouldExpand) {
88+
setShowExpandedImage(true);
89+
}
90+
};
91+
const PlainImage = () => {
92+
if (shouldExpand) {
93+
return (
94+
<button onClick={handleClickImage} className={styles.zoomable}>
95+
<img
96+
decoding="async"
97+
loading="lazy"
98+
{...props}
99+
className={transformImgClassName(props.className)}
100+
/>
101+
</button>
102+
);
103+
} else
104+
return (
105+
<img
106+
decoding="async"
107+
loading="lazy"
108+
{...props}
109+
className={transformImgClassName(props.className)}
110+
/>
111+
);
112+
};
113+
114+
return (
115+
<>
116+
<span className={cn(styles.wrapper)}>
117+
<PlainImage />
118+
</span>
119+
{showExpandedImage && (
120+
<ModalImage setShowExpandedImage={setShowExpandedImage} {...props} />
121+
)}
122+
</>
123+
);
124+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
.img {
2+
height: auto;
3+
}
4+
5+
.overlay {
6+
background-color: rgba(0, 0, 0, 0.7);
7+
width: 100vw;
8+
height: 100%;
9+
min-width: 100%;
10+
top: 50%;
11+
left: 50%;
12+
z-index: 3002;
13+
transform: translate(-50%, -50%);
14+
position: fixed;
15+
cursor: zoom-out;
16+
}
17+
18+
.zoomable {
19+
/*
20+
* The background-color and border styles override browser defaults for the
21+
* button element.
22+
*/
23+
background-color: inherit;
24+
border: inherit;
25+
26+
cursor: zoom-in;
27+
cursor: -moz-zoom-in;
28+
cursor: -webkit-zoom-in;
29+
}
30+
31+
.dialog {
32+
position: fixed;
33+
top: 50%;
34+
left: 50%;
35+
transform: translate(-50%, -50%);
36+
background: rgba(0,0,0,0);
37+
cursor: zoom-out;
38+
cursor: -moz-zoom-out;
39+
cursor: -webkit-zoom-out;
40+
z-index: 3002;
41+
& .img {
42+
max-width: 95vw;
43+
max-height: 95vh;
44+
}
45+
}

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5138,6 +5138,11 @@ ci-info@^3.2.0:
51385138
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
51395139
integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==
51405140

5141+
classnames@^2.3:
5142+
version "2.5.1"
5143+
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
5144+
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
5145+
51415146
clean-css@^5.2.2, clean-css@^5.3.2, clean-css@~5.3.2:
51425147
version "5.3.3"
51435148
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd"

0 commit comments

Comments
 (0)