diff --git a/src/components/copyMarkdownButton.tsx b/src/components/copyMarkdownButton.tsx new file mode 100644 index 0000000000000..d3de2536cacbd --- /dev/null +++ b/src/components/copyMarkdownButton.tsx @@ -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(null); + const dropdownRef = useRef(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 ( + +
+
+ + +
+ + +
+
+ + {isMounted && + isOpen && + createPortal( +
+
+ + + +
+ +
+
+
+ View as Markdown +
+
+ View this page as plain text +
+
+ +
+
, + document.body + )} + + ); +} diff --git a/src/components/docPage/index.tsx b/src/components/docPage/index.tsx index 03a72a6a7024f..c2407de9da773 100644 --- a/src/components/docPage/index.tsx +++ b/src/components/docPage/index.tsx @@ -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'; @@ -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'; @@ -85,15 +84,9 @@ export function DocPage({
{leafNode && }{' '} - - - +
+ +
diff --git a/src/hooks/usePlausibleEvent.tsx b/src/hooks/usePlausibleEvent.tsx index 1976dbc6f2d1f..d4a3d06341ff1 100644 --- a/src/hooks/usePlausibleEvent.tsx +++ b/src/hooks/usePlausibleEvent.tsx @@ -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; @@ -20,6 +28,10 @@ type PlausibleEventProps = { page: string; readProgress: ReadProgressMilestone; }; + ['View Markdown']: { + page: string; + source: string; + }; }; /**