diff --git a/src/components/CopyPageButton/CopyPageButton.jsx b/src/components/CopyPageButton/CopyPageButton.jsx new file mode 100644 index 00000000000..fc968476a6f --- /dev/null +++ b/src/components/CopyPageButton/CopyPageButton.jsx @@ -0,0 +1,284 @@ +import React, { useState, useEffect, useRef } from 'react'; +import styles from './CopyPageButton.module.css'; + +// Helpers shared by both actions +const decodeHTML = (html) => { + const txt = document.createElement('textarea'); + txt.innerHTML = html; + return txt.value; +}; + +const extractInlineText = (element) => { + let result = ''; + element.childNodes.forEach(node => { + if (node.nodeType === Node.TEXT_NODE) { + result += node.textContent; + } else if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === 'CODE' && node.parentElement.tagName !== 'PRE') { + result += `\`${decodeHTML(node.innerHTML)}\``; + } else if (node.tagName === 'STRONG' || node.tagName === 'B') { + result += `**${node.textContent}**`; + } else if (node.tagName === 'EM' || node.tagName === 'I') { + result += `*${node.textContent}*`; + } else if (node.tagName === 'A') { + result += `[${node.textContent}](${node.href})`; + } else if (node.tagName === 'BR') { + result += '\n'; + } else { + result += extractInlineText(node); + } + } + }); + return result; +}; + +const shouldSkipSection = (section, sectionsArray) => { + const tag = section.tagName.toLowerCase(); + // Hidden (tabs/details) + if (section.hidden || section.closest('[hidden]')) return true; + // Tab navigation + if (section.closest('[role="tablist"]')) return true; + // Nested inside lists – will be handled by parent list + if (tag === 'p' || tag === 'pre' || tag === 'blockquote' || tag === 'table') { + const parentList = section.closest('ol, ul'); + if (parentList && sectionsArray.includes(parentList)) return true; + } + // Closed details + const parentDetails = section.closest('details'); + if (parentDetails && !parentDetails.open) return true; + return false; +}; + +const tableToMarkdown = (tableEl) => { + // Build Markdown table: header row + separator + body rows + const rows = Array.from(tableEl.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tr')); + if (rows.length === 0) return ''; + + const firstRowCells = Array.from(rows[0].querySelectorAll('th, td')); + const headers = firstRowCells.map(cell => cell.textContent.trim()); + let md = ''; + + if (headers.length > 0) { + md += `| ${headers.join(' | ')} |\n`; + md += `| ${headers.map(() => '---').join(' | ')} |\n`; + } + + const bodyRows = rows.slice(1); + bodyRows.forEach(tr => { + const cells = Array.from(tr.querySelectorAll('th, td')).map(td => td.textContent.trim()); + md += `| ${cells.join(' | ')} |\n`; + }); + + return `${md}\n`; +}; + +const getContentRoot = () => document.querySelector('article') || document.querySelector('.markdown'); + +const buildMarkdown = () => { + const root = getContentRoot(); + if (!root) return ''; + + const title = document.querySelector('h1')?.textContent || 'Documentation'; + let markdown = `# ${title}\n\n`; + + const sections = root.querySelectorAll('h2, h3, h4, p, ul, ol, pre, blockquote, table'); + const sectionsArray = Array.from(sections); + + sectionsArray.forEach(section => { + if (shouldSkipSection(section, sectionsArray)) return; + const tag = section.tagName.toLowerCase(); + + if (tag === 'h2') { + const text = decodeHTML(section.innerHTML).replace(/<[^>]+>/g, '').trim(); + markdown += `\n## ${text}\n\n`; + return; + } + if (tag === 'h3') { + const text = decodeHTML(section.innerHTML).replace(/<[^>]+>/g, '').trim(); + markdown += `\n### ${text}\n\n`; + return; + } + if (tag === 'h4') { + const text = decodeHTML(section.innerHTML).replace(/<[^>]+>/g, '').trim(); + markdown += `\n#### ${text}\n\n`; + return; + } + if (tag === 'pre') { + const codeElement = section.querySelector('code'); + if (codeElement) { + const languageClass = codeElement.className.match(/language-(\w+)/); + const language = languageClass ? languageClass[1] : ''; + const code = codeElement.innerText; + markdown += `\`\`\`${language}\n${code}\n\`\`\`\n\n`; + } else { + markdown += `\`\`\`\n${section.innerText}\n\`\`\`\n\n`; + } + return; + } + if (tag === 'blockquote') { + const lines = section.textContent.trim().split('\n'); + lines.forEach(line => { + if (line.trim()) markdown += `> ${line.trim()}\n`; + }); + markdown += '\n'; + return; + } + if (tag === 'ul' || tag === 'ol') { + const items = section.querySelectorAll(':scope > li'); + items.forEach((item, index) => { + const prefix = tag === 'ol' ? `${index + 1}.` : '-'; + const text = extractInlineText(item); + markdown += `${prefix} ${text.trim()}\n`; + }); + markdown += '\n'; + return; + } + if (tag === 'p') { + const text = extractInlineText(section); + if (text.trim() && text.trim() !== 'Copy') markdown += `${text.trim()}\n\n`; + return; + } + if (tag === 'table') { + markdown += tableToMarkdown(section); + return; + } + }); + + return markdown; +}; + +export default function CopyPageButton({ standalone = false }) { + const [isOpen, setIsOpen] = useState(false); + const [copied, setCopied] = useState(false); + const containerRef = useRef(null); + const dropdownRef = useRef(null); + const [mobileAlign, setMobileAlign] = useState('center'); // 'center' | 'left' | 'right' + + useEffect(() => { + const handleClickOutside = (event) => { + if (containerRef.current && !containerRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // No H1 wrapping here; handled by a separate wrapper component on doc pages + + // Compute mobile alignment when dropdown opens + useEffect(() => { + if (!isOpen) return; + const compute = () => { + const container = containerRef.current; + const menu = dropdownRef.current; + if (!container || !menu) return; + const vw = window.innerWidth; + const cr = container.getBoundingClientRect(); + const mr = menu.getBoundingClientRect(); + const centerX = cr.left + cr.width / 2; + const margin = 8; + // Try centered + const half = mr.width / 2; + if (centerX + half > vw - margin) { + setMobileAlign('right'); + } else if (centerX - half < margin) { + setMobileAlign('left'); + } else { + setMobileAlign('center'); + } + }; + // Wait for dropdown to render sizes + const id = requestAnimationFrame(compute); + return () => cancelAnimationFrame(id); + }, [isOpen]); + + const handleCopyMarkdown = async () => { + try { + const markdown = buildMarkdown(); + if (!markdown) return; + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => { + setCopied(false); + setIsOpen(false); + }, 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleViewMarkdown = () => { + try { + const markdown = buildMarkdown(); + if (!markdown) return; + const win = window.open('', '_blank'); + const escaped = markdown + .replace(/&/g, '&') + .replace(//g, '>'); + win.document.write(`
${escaped}
`); + } catch (err) { + console.error('Failed to view markdown:', err); + } + }; + + return ( +
+ + + {isOpen && ( +
+ + + +
+ )} +
+ ); +} + diff --git a/src/components/CopyPageButton/CopyPageButton.module.css b/src/components/CopyPageButton/CopyPageButton.module.css new file mode 100644 index 00000000000..75c2c2e8826 --- /dev/null +++ b/src/components/CopyPageButton/CopyPageButton.module.css @@ -0,0 +1,121 @@ +/* H1 Wrapper styles */ +.h1Wrapper { + position: relative; + margin-bottom: 1.5rem; /* Maintain spacing after H1 */ + min-height: 48px; /* Ensure minimum height for button */ +} + +@media (min-width: 997px) { + .h1Wrapper h1 { + /* Don't override the default H1 margin-bottom */ + /* Add padding-right to prevent text from going under the button */ + padding-right: 180px; + } +} + +/* Button container styles */ +.container { + position: relative; + display: inline-block; +} + +.h1Wrapper .container { + position: absolute; + top: 0; + right: 0; +} + +.mainButton { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + background: rgb(255 255 255 / 5%); + border: 1px solid rgb(255 255 255 / 15%); + border-radius: 8px; + cursor: pointer; + font-size: 15px; + font-weight: 500; + color: var(--ifm-font-color-base); + transition: all 0.2s ease; + backdrop-filter: blur(10px); + min-width: 160px; +} + +.mainButton:hover { + background: rgb(255 255 255 / 10%); + border-color: rgb(255 255 255 / 25%); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgb(0 0 0 / 15%); +} + +.arrow { + transition: transform 0.2s ease; +} + +.mainButton:hover .arrow { + transform: translateY(1px); +} + +.dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 280px; + background: var(--ifm-background-surface-color); + border: 1px solid rgb(255 255 255 / 15%); + border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / 25%); + z-index: 1000; + overflow: hidden; + backdrop-filter: blur(10px); +} + +@media (max-width: 996px) { + .container { + display: none; + } +} + +.dropdownItem { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 12px 16px; + background: transparent; + border: none; + border-bottom: 1px solid rgb(255 255 255 / 8%); + cursor: pointer; + text-align: left; + transition: all 0.2s ease; + color: var(--ifm-font-color-base); +} + +.dropdownItem:last-child { + border-bottom: none; +} + +.dropdownItem:hover { + background: rgb(255 255 255 / 5%); + transform: translateX(2px); +} + +.dropdownItem svg { + flex-shrink: 0; + margin-top: 2px; +} + +.itemTitle { + font-weight: 600; + font-size: 14px; + margin-bottom: 2px; + color: var(--ifm-font-color-base); +} + +.itemDescription { + font-size: 12px; + color: var(--ifm-color-emphasis-700); + opacity: 0.7; +} + diff --git a/src/components/CopyPageButton/index.js b/src/components/CopyPageButton/index.js new file mode 100644 index 00000000000..f1ebb61dfee --- /dev/null +++ b/src/components/CopyPageButton/index.js @@ -0,0 +1,2 @@ +export { default } from './CopyPageButton'; + diff --git a/src/components/DocH1CopyPageWrapper/index.jsx b/src/components/DocH1CopyPageWrapper/index.jsx new file mode 100644 index 00000000000..e06a52dfad5 --- /dev/null +++ b/src/components/DocH1CopyPageWrapper/index.jsx @@ -0,0 +1,50 @@ +/** + * DocH1CopyPageWrapper + * + * Purpose: + * - Positions the CopyPageButton next to the document H1 on docs pages. + * + * Rationale: + * - Keeps CopyPageButton focused on presentation + markdown extraction only. + * - Avoids doc-vs-tutorial branching or DOM mutations inside the button. + * - Runs only on Doc pages (wired in DocItem/Layout), so timing/targets are reliable. + * + * Behavior: + * - Wraps the first `article h1` in a container and moves the button into it. + * - Enables absolute positioning via CSS without affecting tutorials. + * - No UI is rendered; this component only performs safe DOM positioning and + * guards against double wrapping. + */ +import React, { useEffect, useRef } from 'react' +import styles from '../CopyPageButton/CopyPageButton.module.css' + +export default function DocH1CopyPageWrapper() { + const containerRef = useRef(null) + + useEffect(() => { + // Find H1 within doc pages + const h1 = document.querySelector('article h1') + if (!h1) return + + // Avoid double-wrapping + if (h1.parentElement?.classList.contains(styles.h1Wrapper)) return + + const wrapper = document.createElement('div') + wrapper.className = styles.h1Wrapper + + // Insert wrapper before H1 and move H1 inside + h1.parentNode.insertBefore(wrapper, h1) + wrapper.appendChild(h1) + + // Move the copy button container into wrapper if present + const btnContainer = document.querySelector('[data-copy-button]') + if (btnContainer) { + wrapper.appendChild(btnContainer) + } + }, []) + + // This component only performs DOM positioning; it renders nothing + return
+} + + diff --git a/src/theme/DocItem/Layout/index.jsx b/src/theme/DocItem/Layout/index.jsx index 1e77c8e0eda..72a55dafbde 100644 --- a/src/theme/DocItem/Layout/index.jsx +++ b/src/theme/DocItem/Layout/index.jsx @@ -10,6 +10,8 @@ import DocItemTOCMobile from '@theme/DocItem/TOC/Mobile' import DocItemTOCDesktop from '@theme/DocItem/TOC/Desktop' import DocItemContent from '@theme/DocItem/Content' import DocBreadcrumbs from '@theme/DocBreadcrumbs' +import CopyPageButton from '@site/src/components/CopyPageButton' +import DocH1CopyPageWrapper from '@site/src/components/DocH1CopyPageWrapper' import styles from './styles.module.css' /** * Decide if the toc should be rendered, on mobile or desktop viewports @@ -41,6 +43,8 @@ export default function DocItemLayout({ children }) { {docTOC.mobile} + + {children} diff --git a/src/theme/MDXPage/index.tsx b/src/theme/MDXPage/index.tsx index ac5c7c652b4..1da6cce2aa8 100644 --- a/src/theme/MDXPage/index.tsx +++ b/src/theme/MDXPage/index.tsx @@ -10,6 +10,7 @@ import { ComponentProps, useState } from 'react' import Bookmark from 'react-bookmark' import DiscourseComment from '../../components/DiscourseComment' +import CopyPageButton from '../../components/CopyPageButton' import styles from './styles.module.css' export default function MDXPage(props: ComponentProps) { @@ -77,6 +78,9 @@ export default function MDXPage(props: ComponentProps) { {author} | {date}
+
+ +
diff --git a/src/theme/MDXPage/styles.module.css b/src/theme/MDXPage/styles.module.css index 242dd0b5f96..b905e1b16a8 100644 --- a/src/theme/MDXPage/styles.module.css +++ b/src/theme/MDXPage/styles.module.css @@ -3,9 +3,9 @@ flex-flow: column wrap; align-items: center; justify-content: center; - overflow: hidden; + overflow: visible; width: 95%; - margin-bottom: 16px; + margin-bottom: 4px; gap: 10px; } @@ -164,6 +164,16 @@ gap: 10px; } +.copyButtonWrapper { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + position: relative; + z-index: 100; + overflow: visible; +} + .authorName { gap: 10px; text-align: left;