Skip to content

Commit 9053d96

Browse files
feat(ai): Add copy page button to all doc pages (#14350)
<img width="1588" height="408" alt="image" src="https://github.com/user-attachments/assets/88c4d59c-f8a6-4316-971d-bf53be9c689c" /> as requested by @jshchnz --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 1e961fc commit 9053d96

File tree

3 files changed

+202
-11
lines changed

3 files changed

+202
-11
lines changed

src/components/copyMarkdownButton.tsx

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
'use client';
2+
3+
import {Fragment, useEffect, useRef, useState} from 'react';
4+
import {createPortal} from 'react-dom';
5+
import {Clipboard} from 'react-feather';
6+
import Link from 'next/link';
7+
8+
import {usePlausibleEvent} from 'sentry-docs/hooks/usePlausibleEvent';
9+
import Chevron from 'sentry-docs/icons/Chevron';
10+
import Markdown from 'sentry-docs/icons/Markdown';
11+
12+
interface CopyMarkdownButtonProps {
13+
pathname: string;
14+
}
15+
16+
export function CopyMarkdownButton({pathname}: CopyMarkdownButtonProps) {
17+
const [isLoading, setIsLoading] = useState(false);
18+
const [copied, setCopied] = useState(false);
19+
const [error, setError] = useState(false);
20+
const [isOpen, setIsOpen] = useState(false);
21+
const [isMounted, setIsMounted] = useState(false);
22+
const buttonRef = useRef<HTMLDivElement>(null);
23+
const dropdownRef = useRef<HTMLDivElement>(null);
24+
const {emit} = usePlausibleEvent();
25+
26+
const copyMarkdownToClipboard = async () => {
27+
setIsLoading(true);
28+
setCopied(false);
29+
setError(false);
30+
setIsOpen(false);
31+
32+
emit('Copy Page', {props: {page: pathname, source: 'copy_button'}});
33+
34+
try {
35+
// This doesn't work on local development since we need the generated markdown
36+
// files, and we need to be aware of the origin since we have two different origins.
37+
const response = await fetch(`${window.location.origin}/${pathname}.md`);
38+
if (!response.ok) {
39+
throw new Error(`Failed to fetch markdown content: ${response.status}`);
40+
}
41+
42+
await navigator.clipboard.writeText(await response.text());
43+
setCopied(true);
44+
setTimeout(() => setCopied(false), 2000);
45+
} catch (err) {
46+
setError(true);
47+
setTimeout(() => setError(false), 3000);
48+
} finally {
49+
setIsLoading(false);
50+
}
51+
};
52+
53+
const handleViewMarkdownClick = () => {
54+
emit('View Markdown', {props: {page: pathname, source: 'view_link'}});
55+
setIsOpen(false);
56+
};
57+
58+
const handleDropdownToggle = () => {
59+
setIsOpen(!isOpen);
60+
if (!isOpen) {
61+
emit('Copy Page Dropdown', {props: {page: pathname, action: 'open'}});
62+
}
63+
};
64+
65+
useEffect(() => {
66+
setIsMounted(true);
67+
68+
const handleClickOutside = (event: MouseEvent) => {
69+
if (
70+
buttonRef.current &&
71+
!buttonRef.current.contains(event.target as Node) &&
72+
dropdownRef.current &&
73+
!dropdownRef.current.contains(event.target as Node)
74+
) {
75+
setIsOpen(false);
76+
}
77+
};
78+
79+
document.addEventListener('mousedown', handleClickOutside);
80+
return () => {
81+
document.removeEventListener('mousedown', handleClickOutside);
82+
};
83+
}, []);
84+
85+
const getDropdownPosition = () => {
86+
if (!buttonRef.current) return {top: 0, left: 0};
87+
const rect = buttonRef.current.getBoundingClientRect();
88+
return {
89+
top: rect.bottom + 8,
90+
left: rect.right - 320,
91+
};
92+
};
93+
94+
const buttonClass =
95+
'inline-flex items-center h-full text-gray-700 dark:text-[var(--foreground)] bg-transparent border-none cursor-pointer transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-[var(--gray-a4)] active:bg-gray-100 dark:active:bg-[var(--gray-5)] focus:bg-gray-50 dark:focus:bg-[var(--gray-a4)] outline-none';
96+
const dropdownItemClass =
97+
'flex items-center gap-3 w-full p-2 px-3 text-left bg-transparent rounded-md transition-colors hover:bg-gray-100 dark:hover:bg-[var(--gray-a4)] font-sans text-gray-900 dark:text-[var(--foreground)]';
98+
const iconContainerClass =
99+
'flex items-center justify-center w-7 h-7 bg-gray-100 dark:bg-[var(--gray-a4)] rounded shrink-0';
100+
101+
return (
102+
<Fragment>
103+
<div className="relative inline-block" ref={buttonRef}>
104+
<div className="inline-flex items-center h-9 border border-gray-200 dark:border-[var(--gray-6)] rounded-full overflow-hidden bg-white dark:bg-[var(--gray-2)]">
105+
<button
106+
onClick={copyMarkdownToClipboard}
107+
className={`${buttonClass} gap-2 px-3.5 text-sm font-medium disabled:opacity-50`}
108+
style={{borderRadius: '9999px 0 0 9999px'}}
109+
disabled={isLoading}
110+
>
111+
<Clipboard size={16} />
112+
<span>{error ? 'Failed to copy' : copied ? 'Copied!' : 'Copy page'}</span>
113+
</button>
114+
115+
<div className="w-px h-full bg-gray-200 dark:bg-[var(--gray-6)]" />
116+
117+
<button
118+
onClick={handleDropdownToggle}
119+
className={`${buttonClass} px-3`}
120+
style={{borderRadius: '0 9999px 9999px 0'}}
121+
>
122+
<Chevron
123+
width={16}
124+
height={16}
125+
direction="down"
126+
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : 'rotate-0'}`}
127+
/>
128+
</button>
129+
</div>
130+
</div>
131+
132+
{isMounted &&
133+
isOpen &&
134+
createPortal(
135+
<div
136+
ref={dropdownRef}
137+
className="fixed w-80 bg-white dark:bg-[var(--gray-2)] rounded-xl shadow-lg overflow-hidden z-[9999] border border-gray-300 dark:border-[var(--gray-6)]"
138+
style={{...getDropdownPosition()}}
139+
>
140+
<div className="p-1">
141+
<button
142+
onClick={copyMarkdownToClipboard}
143+
className={`${dropdownItemClass} border-none cursor-pointer disabled:opacity-50`}
144+
disabled={isLoading}
145+
>
146+
<div className={iconContainerClass}>
147+
<Clipboard size={14} />
148+
</div>
149+
<div className="flex-1">
150+
<div className={`font-medium text-sm leading-5`}>
151+
{error ? 'Failed to copy' : 'Copy page'}
152+
</div>
153+
<div className="text-xs leading-4 text-gray-500 dark:text-[var(--foreground-secondary)]">
154+
{error
155+
? 'Network error - please try again'
156+
: 'Copy page as Markdown for LLMs'}
157+
</div>
158+
</div>
159+
</button>
160+
161+
<Link
162+
href={`/${pathname}.md`}
163+
target="_blank"
164+
rel="noopener noreferrer"
165+
className={`${dropdownItemClass} no-underline`}
166+
onClick={handleViewMarkdownClick}
167+
>
168+
<div className={iconContainerClass}>
169+
<Markdown width={14} height={14} />
170+
</div>
171+
<div className="flex-1">
172+
<div className="font-medium text-sm leading-5 text-gray-900 dark:text-[var(--foreground)]">
173+
View as Markdown
174+
</div>
175+
<div className="text-xs leading-4 text-gray-500 dark:text-[var(--foreground-secondary)]">
176+
View this page as plain text
177+
</div>
178+
</div>
179+
</Link>
180+
</div>
181+
</div>,
182+
document.body
183+
)}
184+
</Fragment>
185+
);
186+
}

src/components/docPage/index.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import {ReactNode} from 'react';
2-
import Link from 'next/link';
32

43
import {getCurrentGuide, getCurrentPlatform, nodeForPath} from 'sentry-docs/docTree';
5-
import Markdown from 'sentry-docs/icons/Markdown';
64
import {serverContext} from 'sentry-docs/serverContext';
75
import {FrontMatter} from 'sentry-docs/types';
86
import {PaginationNavNode} from 'sentry-docs/types/paginationNavNode';
@@ -14,6 +12,7 @@ import './type.scss';
1412
import {Banner} from '../banner';
1513
import {Breadcrumbs} from '../breadcrumbs';
1614
import {CodeContextProvider} from '../codeContext';
15+
import {CopyMarkdownButton} from '../copyMarkdownButton';
1716
import {DocFeedback} from '../docFeedback';
1817
import {GitHubCTA} from '../githubCTA';
1918
import {Header} from '../header';
@@ -85,15 +84,9 @@ export function DocPage({
8584
</div>
8685
<div className="overflow-hidden">
8786
{leafNode && <Breadcrumbs leafNode={leafNode} />}{' '}
88-
<Link
89-
rel="nofollow"
90-
className="float-right"
91-
href={`/${pathname}.md`}
92-
data-mdast="ignore"
93-
title="Markdown version of this page"
94-
>
95-
<Markdown className="flex p-0 flex-wrap" width={24} height={24} />
96-
</Link>
87+
<div className="float-right mt-4 sm:mt-0">
88+
<CopyMarkdownButton pathname={pathname} />
89+
</div>
9790
</div>
9891
<div>
9992
<hgroup>

src/hooks/usePlausibleEvent.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ type PlausibleEventProps = {
88
page: string;
99
title: string;
1010
};
11+
['Copy Page']: {
12+
page: string;
13+
source: string;
14+
};
15+
['Copy Page Dropdown']: {
16+
action: string;
17+
page: string;
18+
};
1119
['Doc Feedback']: {
1220
helpful: boolean;
1321
page: string;
@@ -20,6 +28,10 @@ type PlausibleEventProps = {
2028
page: string;
2129
readProgress: ReadProgressMilestone;
2230
};
31+
['View Markdown']: {
32+
page: string;
33+
source: string;
34+
};
2335
};
2436

2537
/**

0 commit comments

Comments
 (0)