Skip to content

Commit f4346b7

Browse files
docs: added a CopyMarkdownButton component to copy page contents (#469)
Co-authored-by: Tanner Linsley <[email protected]>
1 parent deb56ed commit f4346b7

File tree

2 files changed

+124
-24
lines changed

2 files changed

+124
-24
lines changed

src/components/CopyMarkdownButton.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use client'
2+
import { useState, useTransition } from 'react'
3+
import { FaCheck, FaCopy } from 'react-icons/fa'
4+
5+
import { type MouseEventHandler, useEffect, useRef } from 'react'
6+
7+
export function useCopyButton(
8+
onCopy: () => void | Promise<void>
9+
): [checked: boolean, onClick: MouseEventHandler] {
10+
const [checked, setChecked] = useState(false)
11+
const timeoutRef = useRef<number | null>(null)
12+
13+
const onClick: MouseEventHandler = async () => {
14+
if (timeoutRef.current) window.clearTimeout(timeoutRef.current)
15+
const res = Promise.resolve(onCopy())
16+
17+
void res.then(() => {
18+
setChecked(true)
19+
timeoutRef.current = window.setTimeout(() => {
20+
setChecked(false)
21+
}, 1500)
22+
})
23+
}
24+
25+
// avoid updates after being unmounted
26+
useEffect(() => {
27+
return () => {
28+
if (timeoutRef.current) window.clearTimeout(timeoutRef.current)
29+
}
30+
}, [])
31+
32+
return [checked, onClick]
33+
}
34+
35+
const cache = new Map<string, string>()
36+
37+
interface CopyMarkdownButtonProps {
38+
repo: string
39+
branch: string
40+
filePath: string
41+
}
42+
43+
export function CopyMarkdownButton({
44+
repo,
45+
branch,
46+
filePath,
47+
}: CopyMarkdownButtonProps) {
48+
const [isLoading, startTransition] = useTransition()
49+
const [checked, onClick] = useCopyButton(async () => {
50+
startTransition(() => {
51+
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${filePath}`
52+
const cached = cache.get(url)
53+
54+
if (cached) {
55+
navigator.clipboard.writeText(cached)
56+
} else {
57+
fetch(url)
58+
.then((response) => response.text())
59+
.then((content) => {
60+
cache.set(url, content)
61+
return navigator.clipboard.writeText(content)
62+
})
63+
.catch(() => {
64+
// fallback: try to copy current page content if available
65+
const pageContent =
66+
document.querySelector('.styled-markdown-content')?.textContent ||
67+
''
68+
return navigator.clipboard.writeText(pageContent)
69+
})
70+
}
71+
})
72+
})
73+
74+
return (
75+
<button
76+
disabled={isLoading}
77+
className="py-1 px-2 text-sm bg-white/70 text-black dark:bg-gray-500/40 dark:text-white shadow-lg shadow-black/20 flex items-center justify-center backdrop-blur-sm z-20 rounded-lg overflow-hidden"
78+
onClick={onClick}
79+
title="Copy markdown source"
80+
>
81+
<div className="flex gap-2 items-center">
82+
{checked ? (
83+
<FaCheck className="w-3 h-3" />
84+
) : (
85+
<FaCopy className="w-3 h-3" />
86+
)}
87+
Copy Markdown
88+
</div>
89+
</button>
90+
)
91+
}

src/components/Doc.tsx

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import * as React from 'react'
2-
import { FaEdit } from 'react-icons/fa'
31
import { marked } from 'marked'
42
import markedAlert from 'marked-alert'
5-
import { gfmHeadingId, getHeadingList } from 'marked-gfm-heading-id'
6-
import { DocTitle } from '~/components/DocTitle'
7-
import { Markdown } from '~/components/Markdown'
8-
import { Toc } from './Toc'
9-
import { twMerge } from 'tailwind-merge'
10-
import { TocMobile } from './TocMobile'
11-
import { GamLeader } from './Gam'
12-
import { AdGate } from '~/contexts/AdsContext'
13-
import { useWidthToggle } from '~/components/DocsLayout'
3+
import { getHeadingList, gfmHeadingId } from 'marked-gfm-heading-id'
4+
import * as React from 'react'
145
import {
156
BsArrowsCollapseVertical,
167
BsArrowsExpandVertical,
178
} from 'react-icons/bs'
9+
import { FaEdit } from 'react-icons/fa'
10+
import { twMerge } from 'tailwind-merge'
11+
import { useWidthToggle } from '~/components/DocsLayout'
12+
import { DocTitle } from '~/components/DocTitle'
13+
import { Markdown } from '~/components/Markdown'
14+
import { AdGate } from '~/contexts/AdsContext'
15+
import { CopyMarkdownButton } from './CopyMarkdownButton'
16+
import { GamLeader } from './Gam'
17+
import { Toc } from './Toc'
18+
import { TocMobile } from './TocMobile'
1819

1920
type DocProps = {
2021
title: string
@@ -130,19 +131,27 @@ export function Doc({
130131
{title ? (
131132
<div className="flex items-center justify-between gap-4">
132133
<DocTitle>{title}</DocTitle>
133-
{setIsFullWidth && (
134-
<button
135-
onClick={() => setIsFullWidth(!isFullWidth)}
136-
className="p-2 mr-4 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shrink-0 hidden [@media(min-width:1800px)]:inline-flex"
137-
title={isFullWidth ? 'Constrain width' : 'Expand width'}
138-
>
139-
{isFullWidth ? (
140-
<BsArrowsCollapseVertical className="w-4 h-4" />
141-
) : (
142-
<BsArrowsExpandVertical className="w-4 h-4" />
143-
)}
144-
</button>
145-
)}
134+
<div className="flex items-center gap-4">
135+
<CopyMarkdownButton
136+
repo={repo}
137+
branch={branch}
138+
filePath={filePath}
139+
/>
140+
141+
{setIsFullWidth && (
142+
<button
143+
onClick={() => setIsFullWidth(!isFullWidth)}
144+
className="p-2 mr-4 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shrink-0 hidden [@media(min-width:1800px)]:inline-flex"
145+
title={isFullWidth ? 'Constrain width' : 'Expand width'}
146+
>
147+
{isFullWidth ? (
148+
<BsArrowsCollapseVertical className="w-4 h-4" />
149+
) : (
150+
<BsArrowsExpandVertical className="w-4 h-4" />
151+
)}
152+
</button>
153+
)}
154+
</div>
146155
</div>
147156
) : null}
148157
<div className="h-4" />

0 commit comments

Comments
 (0)