Skip to content

Commit d286f54

Browse files
authored
Add copy page button (#1462)
1 parent c0e21cf commit d286f54

File tree

6 files changed

+411
-3
lines changed

6 files changed

+411
-3
lines changed

docusaurus.config.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import rehypeCodeblockMeta from './src/plugins/rehype-codeblock-meta.mjs';
55
import rehypeStaticToDynamic from './src/plugins/rehype-static-to-dynamic.mjs';
66
import rehypeVideoAspectRatio from './src/plugins/rehype-video-aspect-ratio.mjs';
77
import remarkNpm2Yarn from './src/plugins/remark-npm2yarn.mjs';
8+
import remarkRawMarkdown from './src/plugins/remark-raw-markdown.mjs';
89
import darkTheme from './src/themes/react-navigation-dark';
910
import lightTheme from './src/themes/react-navigation-light';
1011

@@ -166,7 +167,7 @@ const config: Config = {
166167
},
167168
breadcrumbs: false,
168169
sidebarCollapsed: false,
169-
remarkPlugins: [[remarkNpm2Yarn, { sync: true }]],
170+
remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]],
170171
rehypePlugins: [
171172
[
172173
rehypeCodeblockMeta,
@@ -177,10 +178,10 @@ const config: Config = {
177178
],
178179
},
179180
blog: {
180-
remarkPlugins: [[remarkNpm2Yarn, { sync: true }]],
181+
remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]],
181182
},
182183
pages: {
183-
remarkPlugins: [[remarkNpm2Yarn, { sync: true }]],
184+
remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]],
184185
},
185186
theme: {
186187
customCss: './src/css/custom.css',
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { useDoc } from '@docusaurus/plugin-content-docs/client';
2+
import { useEffect, useRef, useState } from 'react';
3+
import styles from './styles.module.css';
4+
5+
const ACTIONS = [
6+
{ label: 'Copy Markdown', value: 'copy' },
7+
{ label: 'Open in ChatGPT', value: 'chatgpt', href: 'https://chatgpt.com' },
8+
{ label: 'Open in Claude', value: 'claude', href: 'https://claude.ai/new' },
9+
] as const;
10+
11+
type ActionType = (typeof ACTIONS)[number];
12+
13+
export function CopyButton() {
14+
const { frontMatter } = useDoc();
15+
16+
const markdown =
17+
'rawMarkdown' in frontMatter && typeof frontMatter.rawMarkdown === 'string'
18+
? frontMatter.rawMarkdown
19+
: null;
20+
21+
const [isOpen, setIsOpen] = useState(false);
22+
const [isVisible, setIsVisible] = useState(false);
23+
const [copied, setCopied] = useState(false);
24+
25+
const containerRef = useRef<HTMLDivElement>(null);
26+
const dropdownRef = useRef<HTMLDivElement>(null);
27+
const buttonRef = useRef<HTMLButtonElement>(null);
28+
29+
useEffect(() => {
30+
if (!isOpen) {
31+
return;
32+
}
33+
34+
const onClickOutside = (event: MouseEvent) => {
35+
if (
36+
containerRef.current &&
37+
!containerRef.current.contains(event.target as Node)
38+
) {
39+
setIsOpen(false);
40+
}
41+
};
42+
43+
document.addEventListener('mousedown', onClickOutside);
44+
45+
return () => document.removeEventListener('mousedown', onClickOutside);
46+
}, [isOpen]);
47+
48+
const onClose = () => {
49+
setIsOpen(false);
50+
51+
buttonRef.current?.focus();
52+
};
53+
54+
const onAnimationEnd = () => {
55+
if (!isOpen) {
56+
setIsVisible(false);
57+
}
58+
};
59+
60+
const onAction = (action: ActionType) => {
61+
const prompt = `Read from ${window.location.href} so I can ask questions about it.`;
62+
63+
switch (action.value) {
64+
case 'copy':
65+
if (markdown) {
66+
navigator.clipboard.writeText(markdown).then(() => {
67+
setCopied(true);
68+
setTimeout(() => setCopied(false), 2000);
69+
});
70+
}
71+
72+
break;
73+
case 'chatgpt':
74+
case 'claude':
75+
window.open(
76+
`${action.href}?q=${encodeURIComponent(prompt)}`,
77+
78+
'_blank'
79+
);
80+
81+
break;
82+
}
83+
84+
onClose();
85+
};
86+
87+
const onKeyDown = (event: React.KeyboardEvent) => {
88+
if (!isOpen || !dropdownRef.current) {
89+
return;
90+
}
91+
92+
const items = Array.from(dropdownRef.current.querySelectorAll('button'));
93+
const currentIndex = items.indexOf(
94+
document.activeElement as HTMLButtonElement
95+
);
96+
97+
switch (event.key) {
98+
case 'Escape':
99+
event.preventDefault();
100+
101+
onClose();
102+
103+
break;
104+
case 'ArrowUp':
105+
case 'ArrowDown':
106+
event.preventDefault();
107+
108+
const nextIndex =
109+
event.key === 'ArrowDown'
110+
? (currentIndex + 1) % items.length
111+
: currentIndex === -1
112+
? items.length - 1
113+
: (currentIndex - 1 + items.length) % items.length;
114+
115+
items[nextIndex]?.focus();
116+
117+
break;
118+
}
119+
};
120+
121+
const onButtonClick = () => {
122+
if (isOpen) {
123+
setIsOpen(false);
124+
} else {
125+
setIsOpen(true);
126+
setIsVisible(true);
127+
}
128+
};
129+
130+
if (!markdown) {
131+
return null;
132+
}
133+
134+
return (
135+
<div className={styles.container} ref={containerRef} onKeyDown={onKeyDown}>
136+
<button
137+
ref={buttonRef}
138+
type="button"
139+
onClick={onButtonClick}
140+
className={styles.button}
141+
title="Copy page"
142+
aria-expanded={isOpen}
143+
aria-haspopup="menu"
144+
>
145+
<span className={styles.iconContainer}>
146+
<svg
147+
className={`${styles.icon} ${copied ? styles.visible : styles.hidden}`}
148+
xmlns="http://www.w3.org/2000/svg"
149+
width="16"
150+
height="16"
151+
viewBox="0 0 24 24"
152+
fill="none"
153+
stroke="currentColor"
154+
strokeWidth="2"
155+
strokeLinecap="round"
156+
strokeLinejoin="round"
157+
>
158+
<polyline points="20 6 9 17 4 12" />
159+
</svg>
160+
161+
<svg
162+
className={`${styles.icon} ${copied ? styles.hidden : styles.visible}`}
163+
xmlns="http://www.w3.org/2000/svg"
164+
width="16"
165+
height="16"
166+
viewBox="0 0 24 24"
167+
fill="none"
168+
stroke="currentColor"
169+
strokeWidth="2"
170+
strokeLinecap="round"
171+
strokeLinejoin="round"
172+
>
173+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
174+
175+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
176+
</svg>
177+
</span>
178+
Copy page
179+
<svg
180+
className={`${styles.chevron} ${isOpen ? styles.open : ''}`}
181+
xmlns="http://www.w3.org/2000/svg"
182+
width="12"
183+
height="12"
184+
viewBox="0 0 24 24"
185+
fill="none"
186+
stroke="currentColor"
187+
strokeWidth="2"
188+
strokeLinecap="round"
189+
strokeLinejoin="round"
190+
>
191+
<polyline points="6 9 12 15 18 9" />
192+
</svg>
193+
</button>
194+
195+
{isVisible && (
196+
<div
197+
ref={dropdownRef}
198+
className={`${styles.dropdown} ${!isOpen ? styles.closing : ''}`}
199+
onAnimationEnd={onAnimationEnd}
200+
role="menu"
201+
>
202+
{ACTIONS.map((action) => (
203+
<button
204+
key={action.value}
205+
type="button"
206+
className={styles.item}
207+
onClick={() => onAction(action)}
208+
onMouseEnter={(e) => e.currentTarget.focus()}
209+
role="menuitem"
210+
tabIndex={-1}
211+
>
212+
{action.label}
213+
</button>
214+
))}
215+
</div>
216+
)}
217+
</div>
218+
);
219+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
.container {
2+
position: relative;
3+
}
4+
5+
.button {
6+
display: inline-flex;
7+
align-items: center;
8+
gap: 0.5rem;
9+
color: var(--ifm-menu-color);
10+
font-size: 0.85rem;
11+
font-weight: 500;
12+
border: 0;
13+
padding: 0.5rem 0.75rem;
14+
cursor: pointer;
15+
border-radius: var(--ifm-global-radius);
16+
background-color: transparent;
17+
transition: background-color var(--ifm-transition-fast)
18+
var(--ifm-transition-timing-default);
19+
20+
&:hover {
21+
background-color: var(--ifm-menu-color-background-hover);
22+
}
23+
}
24+
25+
.iconContainer {
26+
position: relative;
27+
width: 16px;
28+
height: 16px;
29+
}
30+
31+
.icon {
32+
position: absolute;
33+
inset: 0;
34+
transition:
35+
opacity 0.15s ease-out,
36+
transform 0.15s ease-out;
37+
38+
&.visible {
39+
opacity: 1;
40+
transform: scale(1);
41+
}
42+
43+
&.hidden {
44+
opacity: 0;
45+
transform: scale(0.5);
46+
}
47+
}
48+
49+
.chevron {
50+
transition: transform 0.15s ease-out;
51+
52+
&.open {
53+
transform: rotate(180deg);
54+
}
55+
}
56+
57+
.dropdown {
58+
position: absolute;
59+
top: 100%;
60+
left: 0;
61+
z-index: 100;
62+
min-width: 160px;
63+
margin-top: 0.25rem;
64+
padding: 0.25rem 0;
65+
background-color: var(--ifm-background-surface-color);
66+
border: 1px solid var(--ifm-color-emphasis-200);
67+
border-radius: var(--ifm-global-radius);
68+
box-shadow: var(--ifm-global-shadow-md);
69+
animation: dropdownFadeIn 0.15s ease-out;
70+
71+
&.closing {
72+
animation: dropdownFadeOut 0.15s ease-out forwards;
73+
}
74+
}
75+
76+
@keyframes dropdownFadeIn {
77+
from {
78+
opacity: 0;
79+
transform: translateY(-4px);
80+
}
81+
to {
82+
opacity: 1;
83+
transform: translateY(0);
84+
}
85+
}
86+
87+
@keyframes dropdownFadeOut {
88+
from {
89+
opacity: 1;
90+
transform: translateY(0);
91+
}
92+
to {
93+
opacity: 0;
94+
transform: translateY(-4px);
95+
}
96+
}
97+
98+
.item {
99+
display: block;
100+
width: 100%;
101+
padding: 0.5rem 0.75rem;
102+
font-size: 0.85rem;
103+
color: var(--ifm-menu-color);
104+
text-align: left;
105+
background: none;
106+
border: none;
107+
cursor: pointer;
108+
outline: none;
109+
transition: background-color var(--ifm-transition-fast)
110+
var(--ifm-transition-timing-default);
111+
112+
&:focus {
113+
background-color: var(--ifm-menu-color-background-hover);
114+
}
115+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// @ts-check
2+
3+
/** @type {import('unified').Plugin} */
4+
const plugin = () => {
5+
return (tree, file) => {
6+
// Add raw markdown to frontMatter so it's accessible via useDoc()
7+
file.data.frontMatter = file.data.frontMatter || {};
8+
// @ts-expect-error: we are adding a custom field
9+
file.data.frontMatter.rawMarkdown = file.value;
10+
};
11+
};
12+
13+
export default plugin;

0 commit comments

Comments
 (0)