From 1f0156a1ae83db0aad7e69139a0eb64b567c6b2a Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Tue, 20 Jan 2026 12:37:50 +0100 Subject: [PATCH] Add copy markdown button --- docusaurus.config.ts | 7 +- src/components/CopyMarkdownButton/index.tsx | 219 ++++++++++++++++++ .../CopyMarkdownButton/styles.module.css | 115 +++++++++ src/plugins/remark-raw-markdown.mjs | 13 ++ src/theme/DocItem/Content/index.tsx | 40 ++++ src/theme/DocItem/Content/styles.module.css | 20 ++ 6 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 src/components/CopyMarkdownButton/index.tsx create mode 100644 src/components/CopyMarkdownButton/styles.module.css create mode 100644 src/plugins/remark-raw-markdown.mjs create mode 100644 src/theme/DocItem/Content/index.tsx create mode 100644 src/theme/DocItem/Content/styles.module.css diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 4e5e38af443..5ca93ba85af 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -5,6 +5,7 @@ import rehypeCodeblockMeta from './src/plugins/rehype-codeblock-meta.mjs'; import rehypeStaticToDynamic from './src/plugins/rehype-static-to-dynamic.mjs'; import rehypeVideoAspectRatio from './src/plugins/rehype-video-aspect-ratio.mjs'; import remarkNpm2Yarn from './src/plugins/remark-npm2yarn.mjs'; +import remarkRawMarkdown from './src/plugins/remark-raw-markdown.mjs'; import darkTheme from './src/themes/react-navigation-dark'; import lightTheme from './src/themes/react-navigation-light'; @@ -166,7 +167,7 @@ const config: Config = { }, breadcrumbs: false, sidebarCollapsed: false, - remarkPlugins: [[remarkNpm2Yarn, { sync: true }]], + remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]], rehypePlugins: [ [ rehypeCodeblockMeta, @@ -177,10 +178,10 @@ const config: Config = { ], }, blog: { - remarkPlugins: [[remarkNpm2Yarn, { sync: true }]], + remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]], }, pages: { - remarkPlugins: [[remarkNpm2Yarn, { sync: true }]], + remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]], }, theme: { customCss: './src/css/custom.css', diff --git a/src/components/CopyMarkdownButton/index.tsx b/src/components/CopyMarkdownButton/index.tsx new file mode 100644 index 00000000000..6475d9a76ae --- /dev/null +++ b/src/components/CopyMarkdownButton/index.tsx @@ -0,0 +1,219 @@ +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import { useEffect, useRef, useState } from 'react'; +import styles from './styles.module.css'; + +const ACTIONS = [ + { label: 'Copy Markdown', value: 'copy' }, + { label: 'Open in ChatGPT', value: 'chatgpt', href: 'https://chatgpt.com' }, + { label: 'Open in Claude', value: 'claude', href: 'https://claude.ai/new' }, +] as const; + +type ActionType = (typeof ACTIONS)[number]; + +export function CopyButton() { + const { frontMatter } = useDoc(); + + const markdown = + 'rawMarkdown' in frontMatter && typeof frontMatter.rawMarkdown === 'string' + ? frontMatter.rawMarkdown + : null; + + const [isOpen, setIsOpen] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const [copied, setCopied] = useState(false); + + const containerRef = useRef(null); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + const onClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', onClickOutside); + + return () => document.removeEventListener('mousedown', onClickOutside); + }, [isOpen]); + + const onClose = () => { + setIsOpen(false); + + buttonRef.current?.focus(); + }; + + const onAnimationEnd = () => { + if (!isOpen) { + setIsVisible(false); + } + }; + + const onAction = (action: ActionType) => { + const prompt = `Read from ${window.location.href} so I can ask questions about it.`; + + switch (action.value) { + case 'copy': + if (markdown) { + navigator.clipboard.writeText(markdown).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + break; + case 'chatgpt': + case 'claude': + window.open( + `${action.href}?q=${encodeURIComponent(prompt)}`, + + '_blank' + ); + + break; + } + + onClose(); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (!isOpen || !dropdownRef.current) { + return; + } + + const items = Array.from(dropdownRef.current.querySelectorAll('button')); + const currentIndex = items.indexOf( + document.activeElement as HTMLButtonElement + ); + + switch (event.key) { + case 'Escape': + event.preventDefault(); + + onClose(); + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + + const nextIndex = + event.key === 'ArrowDown' + ? (currentIndex + 1) % items.length + : currentIndex === -1 + ? items.length - 1 + : (currentIndex - 1 + items.length) % items.length; + + items[nextIndex]?.focus(); + + break; + } + }; + + const onButtonClick = () => { + if (isOpen) { + setIsOpen(false); + } else { + setIsOpen(true); + setIsVisible(true); + } + }; + + if (!markdown) { + return null; + } + + return ( +
+ + + {isVisible && ( +
+ {ACTIONS.map((action) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/CopyMarkdownButton/styles.module.css b/src/components/CopyMarkdownButton/styles.module.css new file mode 100644 index 00000000000..1c0cfc4cc03 --- /dev/null +++ b/src/components/CopyMarkdownButton/styles.module.css @@ -0,0 +1,115 @@ +.container { + position: relative; +} + +.button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--ifm-menu-color); + font-size: 0.85rem; + font-weight: 500; + border: 0; + padding: 0.5rem 0.75rem; + cursor: pointer; + border-radius: var(--ifm-global-radius); + background-color: transparent; + transition: background-color var(--ifm-transition-fast) + var(--ifm-transition-timing-default); + + &:hover { + background-color: var(--ifm-menu-color-background-hover); + } +} + +.iconContainer { + position: relative; + width: 16px; + height: 16px; +} + +.icon { + position: absolute; + inset: 0; + transition: + opacity 0.15s ease-out, + transform 0.15s ease-out; + + &.visible { + opacity: 1; + transform: scale(1); + } + + &.hidden { + opacity: 0; + transform: scale(0.5); + } +} + +.chevron { + transition: transform 0.15s ease-out; + + &.open { + transform: rotate(180deg); + } +} + +.dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 100; + min-width: 160px; + margin-top: 0.25rem; + padding: 0.25rem 0; + background-color: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: var(--ifm-global-radius); + box-shadow: var(--ifm-global-shadow-md); + animation: dropdownFadeIn 0.15s ease-out; + + &.closing { + animation: dropdownFadeOut 0.15s ease-out forwards; + } +} + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes dropdownFadeOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-4px); + } +} + +.item { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + color: var(--ifm-menu-color); + text-align: left; + background: none; + border: none; + cursor: pointer; + outline: none; + transition: background-color var(--ifm-transition-fast) + var(--ifm-transition-timing-default); + + &:focus { + background-color: var(--ifm-menu-color-background-hover); + } +} diff --git a/src/plugins/remark-raw-markdown.mjs b/src/plugins/remark-raw-markdown.mjs new file mode 100644 index 00000000000..3f8d2cde6e3 --- /dev/null +++ b/src/plugins/remark-raw-markdown.mjs @@ -0,0 +1,13 @@ +// @ts-check + +/** @type {import('unified').Plugin} */ +const plugin = () => { + return (tree, file) => { + // Add raw markdown to frontMatter so it's accessible via useDoc() + file.data.frontMatter = file.data.frontMatter || {}; + // @ts-expect-error: we are adding a custom field + file.data.frontMatter.rawMarkdown = file.value; + }; +}; + +export default plugin; diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx new file mode 100644 index 00000000000..dd9aa708d7c --- /dev/null +++ b/src/theme/DocItem/Content/index.tsx @@ -0,0 +1,40 @@ +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import { ThemeClassNames } from '@docusaurus/theme-common'; +import { CopyButton } from '@site/src/components/CopyMarkdownButton'; +import Heading from '@theme-original/Heading'; +import MDXContent from '@theme-original/MDXContent'; +import clsx from 'clsx'; +import { type ReactNode } from 'react'; +import styles from './styles.module.css'; + +function useSyntheticTitle() { + const { metadata, frontMatter, contentTitle } = useDoc(); + const shouldRender = + !frontMatter.hide_title && typeof contentTitle === 'undefined'; + + if (!shouldRender) { + return null; + } + + return metadata.title; +} + +type Props = { + children: ReactNode; +}; + +export default function DocItemContent({ children }: Props): ReactNode { + const syntheticTitle = useSyntheticTitle(); + + return ( +
+ {syntheticTitle && ( +
+ {syntheticTitle} + +
+ )} + {children} +
+ ); +} diff --git a/src/theme/DocItem/Content/styles.module.css b/src/theme/DocItem/Content/styles.module.css new file mode 100644 index 00000000000..7020749cc7d --- /dev/null +++ b/src/theme/DocItem/Content/styles.module.css @@ -0,0 +1,20 @@ +.header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem 5rem; + margin-top: var(--ifm-heading-margin-top); + margin-bottom: calc( + var(--ifm-h1-vertical-rhythm-bottom) * var(--ifm-leading) + ); + + h1 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + h1 + div { + margin-left: -0.75rem; + } +}