Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions src/components/copyMarkdownButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
'use client';

import {Fragment, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {Clipboard} from 'react-feather';
import Link from 'next/link';

import {usePlausibleEvent} from 'sentry-docs/hooks/usePlausibleEvent';
import Chevron from 'sentry-docs/icons/Chevron';
import Markdown from 'sentry-docs/icons/Markdown';

interface CopyMarkdownButtonProps {
pathname: string;
}

export function CopyMarkdownButton({pathname}: CopyMarkdownButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const {emit} = usePlausibleEvent();

const copyMarkdownToClipboard = async () => {
setIsLoading(true);
setCopied(false);
setError(false);
setIsOpen(false);

emit('Copy Page', {props: {page: pathname, source: 'copy_button'}});

try {
// This doesn't work on local development since we need the generated markdown
// files, and we need to be aware of the origin since we have two different origins.
const response = await fetch(`${window.location.origin}/${pathname}.md`);
if (!response.ok) {
throw new Error(`Failed to fetch markdown content: ${response.status}`);
}

await navigator.clipboard.writeText(await response.text());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
setError(true);
setTimeout(() => setError(false), 3000);
} finally {
setIsLoading(false);
}
};

const handleViewMarkdownClick = () => {
emit('View Markdown', {props: {page: pathname, source: 'view_link'}});
setIsOpen(false);
};

const handleDropdownToggle = () => {
setIsOpen(!isOpen);
if (!isOpen) {
emit('Copy Page Dropdown', {props: {page: pathname, action: 'open'}});
}
};

useEffect(() => {
setIsMounted(true);

const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);

const getDropdownPosition = () => {
if (!buttonRef.current) return {top: 0, left: 0};
const rect = buttonRef.current.getBoundingClientRect();
return {
top: rect.bottom + 8,
left: rect.right - 320,
};
};

const buttonClass =
'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';
const dropdownItemClass =
'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)]';
const iconContainerClass =
'flex items-center justify-center w-7 h-7 bg-gray-100 dark:bg-[var(--gray-a4)] rounded shrink-0';

return (
<Fragment>
<div className="relative inline-block" ref={buttonRef}>
<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)]">
<button
onClick={copyMarkdownToClipboard}
className={`${buttonClass} gap-2 px-3.5 text-sm font-medium disabled:opacity-50`}
style={{borderRadius: '9999px 0 0 9999px'}}
disabled={isLoading}
>
<Clipboard size={16} />
<span>{error ? 'Failed to copy' : copied ? 'Copied!' : 'Copy page'}</span>
</button>

<div className="w-px h-full bg-gray-200 dark:bg-[var(--gray-6)]" />

<button
onClick={handleDropdownToggle}
className={`${buttonClass} px-3`}
style={{borderRadius: '0 9999px 9999px 0'}}
>
<Chevron
width={16}
height={16}
direction="down"
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</button>
</div>
</div>

{isMounted &&
isOpen &&
createPortal(
<div
ref={dropdownRef}
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)]"
style={{...getDropdownPosition()}}
>
<div className="p-1">
<button
onClick={copyMarkdownToClipboard}
className={`${dropdownItemClass} border-none cursor-pointer disabled:opacity-50`}
disabled={isLoading}
>
<div className={iconContainerClass}>
<Clipboard size={14} />
</div>
<div className="flex-1">
<div className={`font-medium text-sm leading-5`}>
{error ? 'Failed to copy' : 'Copy page'}
</div>
<div className="text-xs leading-4 text-gray-500 dark:text-[var(--foreground-secondary)]">
{error
? 'Network error - please try again'
: 'Copy page as Markdown for LLMs'}
</div>
</div>
</button>

<Link
href={`/${pathname}.md`}
target="_blank"
rel="noopener noreferrer"
className={`${dropdownItemClass} no-underline`}
onClick={handleViewMarkdownClick}
>
<div className={iconContainerClass}>
<Markdown width={14} height={14} />
</div>
<div className="flex-1">
<div className="font-medium text-sm leading-5 text-gray-900 dark:text-[var(--foreground)]">
View as Markdown
</div>
<div className="text-xs leading-4 text-gray-500 dark:text-[var(--foreground-secondary)]">
View this page as plain text
</div>
</div>
</Link>
</div>
</div>,
document.body
)}
</Fragment>
);
}
15 changes: 4 additions & 11 deletions src/components/docPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {ReactNode} from 'react';
import Link from 'next/link';

import {getCurrentGuide, getCurrentPlatform, nodeForPath} from 'sentry-docs/docTree';
import Markdown from 'sentry-docs/icons/Markdown';
import {serverContext} from 'sentry-docs/serverContext';
import {FrontMatter} from 'sentry-docs/types';
import {PaginationNavNode} from 'sentry-docs/types/paginationNavNode';
Expand All @@ -14,6 +12,7 @@ import './type.scss';
import {Banner} from '../banner';
import {Breadcrumbs} from '../breadcrumbs';
import {CodeContextProvider} from '../codeContext';
import {CopyMarkdownButton} from '../copyMarkdownButton';
import {DocFeedback} from '../docFeedback';
import {GitHubCTA} from '../githubCTA';
import {Header} from '../header';
Expand Down Expand Up @@ -85,15 +84,9 @@ export function DocPage({
</div>
<div className="overflow-hidden">
{leafNode && <Breadcrumbs leafNode={leafNode} />}{' '}
<Link
rel="nofollow"
className="float-right"
href={`/${pathname}.md`}
data-mdast="ignore"
title="Markdown version of this page"
>
<Markdown className="flex p-0 flex-wrap" width={24} height={24} />
</Link>
<div className="float-right mt-4 sm:mt-0">
<CopyMarkdownButton pathname={pathname} />
</div>
</div>
<div>
<hgroup>
Expand Down
12 changes: 12 additions & 0 deletions src/hooks/usePlausibleEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ type PlausibleEventProps = {
page: string;
title: string;
};
['Copy Page']: {
page: string;
source: string;
};
['Copy Page Dropdown']: {
action: string;
page: string;
};
['Doc Feedback']: {
helpful: boolean;
page: string;
Expand All @@ -20,6 +28,10 @@ type PlausibleEventProps = {
page: string;
readProgress: ReadProgressMilestone;
};
['View Markdown']: {
page: string;
source: string;
};
};

/**
Expand Down
Loading