From c98d5aad356cee85030bdbf4fa2a2613c3c40c55 Mon Sep 17 00:00:00 2001 From: maxnorm Date: Sun, 22 Feb 2026 12:34:19 -0500 Subject: [PATCH 1/3] fix md clipboard functionality for iOS Safari support (same copy activation) --- .../docs/MarkdownActionsDropdown/index.js | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/website/src/components/docs/MarkdownActionsDropdown/index.js b/website/src/components/docs/MarkdownActionsDropdown/index.js index 802acb3b..599ca500 100644 --- a/website/src/components/docs/MarkdownActionsDropdown/index.js +++ b/website/src/components/docs/MarkdownActionsDropdown/index.js @@ -48,12 +48,26 @@ export default function MarkdownActionsDropdown() { const handleCopyMarkdown = async () => { try { - const response = await fetch(markdownUrl); - if (!response.ok) { - throw new Error('Failed to fetch markdown'); + // iOS Safari requires clipboard API to be invoked synchronously from the user + // gesture; after await fetch() the transient activation is lost and writeText + // throws NotAllowedError. Passing a Promise into ClipboardItem and calling + // clipboard.write() immediately keeps the gesture context (Safari/Chrome). + if (typeof ClipboardItem !== 'undefined') { + const item = new ClipboardItem({ + 'text/plain': fetch(markdownUrl) + .then((r) => { + if (!r.ok) throw new Error('Failed to fetch markdown'); + return r.text(); + }) + .then((text) => new Blob([text], { type: 'text/plain' })), + }); + await navigator.clipboard.write([item]); + } else { + const response = await fetch(markdownUrl); + if (!response.ok) throw new Error('Failed to fetch markdown'); + const markdown = await response.text(); + await navigator.clipboard.writeText(markdown); } - const markdown = await response.text(); - await navigator.clipboard.writeText(markdown); setCopied(true); setTimeout(() => { From e5fec3b69599a001041b5cee220be7b39c304104 Mon Sep 17 00:00:00 2001 From: maxnorm Date: Sun, 22 Feb 2026 13:18:24 -0500 Subject: [PATCH 2/3] refactor to overide default breadcrumb instead of injecting md action --- .../docs/MarkdownActionsDropdown/index.js | 27 +++--- .../hooks/useInjectMarkdownActionsDropdown.js | 42 --------- website/src/theme/DocBreadcrumbs/index.js | 86 +++++++++++++++++++ .../theme/DocBreadcrumbs/styles.module.css | 11 +++ website/src/theme/Root.js | 4 - 5 files changed, 114 insertions(+), 56 deletions(-) delete mode 100644 website/src/hooks/useInjectMarkdownActionsDropdown.js create mode 100644 website/src/theme/DocBreadcrumbs/index.js create mode 100644 website/src/theme/DocBreadcrumbs/styles.module.css diff --git a/website/src/components/docs/MarkdownActionsDropdown/index.js b/website/src/components/docs/MarkdownActionsDropdown/index.js index 599ca500..dcaeed1d 100644 --- a/website/src/components/docs/MarkdownActionsDropdown/index.js +++ b/website/src/components/docs/MarkdownActionsDropdown/index.js @@ -2,19 +2,26 @@ * Markdown actions dropdown (view/copy as .md). * Local override of docusaurus-markdown-source-plugin's dropdown so that * category index pages (e.g. /docs/foundations/) resolve to index.md instead - * of intro.md (which only exists for the root /docs/ page). + * of intro.md (which only exists for the root /docs & /docs/ page). */ import React, { useState, useRef, useEffect } from 'react'; +/** Normalize path so /docs is treated as /docs/ for URL building. */ +function normalizeDocsPath(path) { + if (path === '/docs') return '/docs/'; + return path; +} + function getMarkdownUrl(currentPath) { - if (!currentPath.startsWith('/docs/')) return null; - if (currentPath.endsWith('/')) { + const path = normalizeDocsPath(currentPath); + if (path !== '/docs/' && !path.startsWith('/docs/')) return null; + if (path.endsWith('/')) { // Root docs index is intro; all other category indexes are index.md - return currentPath === '/docs/' - ? `${currentPath}intro.md` - : `${currentPath}index.md`; + return path === '/docs/' + ? `${path}intro.md` + : `${path}index.md`; } - return `${currentPath}.md`; + return `${path}.md`; } export default function MarkdownActionsDropdown() { @@ -22,9 +29,9 @@ export default function MarkdownActionsDropdown() { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const currentPath = typeof window !== 'undefined' ? window.location.pathname : ''; - const isDocsPage = currentPath.startsWith('/docs/'); - const markdownUrl = getMarkdownUrl(currentPath); + const rawPath = typeof window !== 'undefined' ? window.location.pathname : ''; + const isDocsPage = rawPath === '/docs' || rawPath.startsWith('/docs/'); + const markdownUrl = getMarkdownUrl(rawPath); useEffect(() => { if (!isOpen) return; diff --git a/website/src/hooks/useInjectMarkdownActionsDropdown.js b/website/src/hooks/useInjectMarkdownActionsDropdown.js deleted file mode 100644 index 655cd00b..00000000 --- a/website/src/hooks/useInjectMarkdownActionsDropdown.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Injects the markdown actions dropdown on doc pages: same row as breadcrumbs - * (breadcrumbs left, dropdown right). Runs at 0ms, 100ms, 300ms to handle - * async DOM from Docusaurus. - */ -import React, { useEffect } from 'react'; -import { useLocation } from '@docusaurus/router'; -import { createRoot } from 'react-dom/client'; -import MarkdownActionsDropdown from '@site/src/components/docs/MarkdownActionsDropdown'; - -export default function useInjectMarkdownActionsDropdown() { - const { pathname } = useLocation(); - - useEffect(() => { - const injectDropdown = () => { - if (!pathname.startsWith('/docs/')) return; - const article = document.querySelector('article'); - const breadcrumbsNav = article?.querySelector( - 'nav.theme-doc-breadcrumbs, nav[aria-label="Breadcrumbs"]' - ); - if (!article || !breadcrumbsNav) return; - if (document.querySelector('.markdown-actions-breadcrumbs-row')) return; - - const row = document.createElement('div'); - row.className = 'markdown-actions-breadcrumbs-row'; - article.insertBefore(row, article.firstChild); - row.appendChild(breadcrumbsNav); - - const container = document.createElement('div'); - container.className = 'markdown-actions-container'; - row.appendChild(container); - - const root = createRoot(container); - root.render(); - }; - - const timeouts = [0, 100, 300].map((delay) => - setTimeout(injectDropdown, delay) - ); - return () => timeouts.forEach(clearTimeout); - }, [pathname]); -} diff --git a/website/src/theme/DocBreadcrumbs/index.js b/website/src/theme/DocBreadcrumbs/index.js new file mode 100644 index 00000000..ac62f903 --- /dev/null +++ b/website/src/theme/DocBreadcrumbs/index.js @@ -0,0 +1,86 @@ +/** + * Swizzled DocBreadcrumbs: same row as breadcrumbs + markdown actions dropdown. + * Renders the dropdown in the React tree (no DOM injection). + */ +import React from 'react'; +import clsx from 'clsx'; +import {ThemeClassNames} from '@docusaurus/theme-common'; +import {useSidebarBreadcrumbs} from '@docusaurus/plugin-content-docs/client'; +import {useHomePageRoute} from '@docusaurus/theme-common/internal'; +import Link from '@docusaurus/Link'; +import {translate} from '@docusaurus/Translate'; +import HomeBreadcrumbItem from '@theme/DocBreadcrumbs/Items/Home'; +import DocBreadcrumbsStructuredData from '@theme/DocBreadcrumbs/StructuredData'; +import MarkdownActionsDropdown from '@site/src/components/docs/MarkdownActionsDropdown'; +import styles from './styles.module.css'; + +function BreadcrumbsItemLink({children, href, isLast}) { + const className = 'breadcrumbs__link'; + if (isLast) { + return {children}; + } + return href ? ( + + {children} + + ) : ( + {children} + ); +} + +function BreadcrumbsItem({children, active}) { + return ( +
  • + {children} +
  • + ); +} + +export default function DocBreadcrumbs() { + const breadcrumbs = useSidebarBreadcrumbs(); + const homePageRoute = useHomePageRoute(); + if (!breadcrumbs) { + return null; + } + return ( + <> + +
    + +
    + +
    +
    + + ); +} diff --git a/website/src/theme/DocBreadcrumbs/styles.module.css b/website/src/theme/DocBreadcrumbs/styles.module.css new file mode 100644 index 00000000..a400c5d9 --- /dev/null +++ b/website/src/theme/DocBreadcrumbs/styles.module.css @@ -0,0 +1,11 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.breadcrumbsContainer { + --ifm-breadcrumb-size-multiplier: 0.8; + margin-bottom: 0.8rem; +} diff --git a/website/src/theme/Root.js b/website/src/theme/Root.js index 67f64354..50ba9317 100644 --- a/website/src/theme/Root.js +++ b/website/src/theme/Root.js @@ -5,11 +5,7 @@ import React from 'react'; import { Toaster } from 'react-hot-toast'; import NavbarEnhancements from '@site/src/components/navigation/NavbarEnhancements'; -import useInjectMarkdownActionsDropdown from '@site/src/hooks/useInjectMarkdownActionsDropdown'; - export default function Root({children}) { - useInjectMarkdownActionsDropdown(); - return ( <> From e032499ba9130d22aca04ed49fe9747a73951fd0 Mon Sep 17 00:00:00 2001 From: maxnorm Date: Sun, 22 Feb 2026 13:39:48 -0500 Subject: [PATCH 3/3] clean up md actions component --- .../docs/MarkdownActionsDropdown/index.js | 98 ++++++++----------- website/src/hooks/useClickOutside.js | 20 ++++ website/src/hooks/useResolvedMarkdownUrl.js | 65 ++++++++++++ 3 files changed, 126 insertions(+), 57 deletions(-) create mode 100644 website/src/hooks/useClickOutside.js create mode 100644 website/src/hooks/useResolvedMarkdownUrl.js diff --git a/website/src/components/docs/MarkdownActionsDropdown/index.js b/website/src/components/docs/MarkdownActionsDropdown/index.js index dcaeed1d..87f4a323 100644 --- a/website/src/components/docs/MarkdownActionsDropdown/index.js +++ b/website/src/components/docs/MarkdownActionsDropdown/index.js @@ -3,26 +3,12 @@ * Local override of docusaurus-markdown-source-plugin's dropdown so that * category index pages (e.g. /docs/foundations/) resolve to index.md instead * of intro.md (which only exists for the root /docs & /docs/ page). + * When path has a trailing slash (e.g. /docs/contribution/code-style-guide/#hash), + * we try single-doc URL first (code-style-guide.md), then category index (index.md). */ -import React, { useState, useRef, useEffect } from 'react'; - -/** Normalize path so /docs is treated as /docs/ for URL building. */ -function normalizeDocsPath(path) { - if (path === '/docs') return '/docs/'; - return path; -} - -function getMarkdownUrl(currentPath) { - const path = normalizeDocsPath(currentPath); - if (path !== '/docs/' && !path.startsWith('/docs/')) return null; - if (path.endsWith('/')) { - // Root docs index is intro; all other category indexes are index.md - return path === '/docs/' - ? `${path}intro.md` - : `${path}index.md`; - } - return `${path}.md`; -} +import React, { useState, useRef, useCallback } from 'react'; +import { useResolvedMarkdownUrl } from '../../../hooks/useResolvedMarkdownUrl'; +import { useClickOutside } from '../../../hooks/useClickOutside'; export default function MarkdownActionsDropdown() { const [copied, setCopied] = useState(false); @@ -31,49 +17,46 @@ export default function MarkdownActionsDropdown() { const rawPath = typeof window !== 'undefined' ? window.location.pathname : ''; const isDocsPage = rawPath === '/docs' || rawPath.startsWith('/docs/'); - const markdownUrl = getMarkdownUrl(rawPath); + const { candidates, urlReady, markdownUrl } = useResolvedMarkdownUrl(rawPath); + const closeDropdown = useCallback(() => setIsOpen(false), []); - useEffect(() => { - if (!isOpen) return; - const handleClickOutside = (event) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { - setIsOpen(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [isOpen]); + useClickOutside(dropdownRef, isOpen, closeDropdown); - if (!isDocsPage || !markdownUrl) { + if (!isDocsPage || !candidates) { return null; } const handleOpenMarkdown = () => { + if (!urlReady || !markdownUrl) return; window.open(markdownUrl, '_blank'); setIsOpen(false); }; const handleCopyMarkdown = async () => { + if (!urlReady || !markdownUrl) return; try { - // iOS Safari requires clipboard API to be invoked synchronously from the user - // gesture; after await fetch() the transient activation is lost and writeText - // throws NotAllowedError. Passing a Promise into ClipboardItem and calling - // clipboard.write() immediately keeps the gesture context (Safari/Chrome). + const urlToFetch = markdownUrl; + const fetchMarkdown = () => + fetch(urlToFetch).then((r) => { + if (r.ok) return r.text(); + if (candidates.fallback && urlToFetch === candidates.primary) { + return fetch(candidates.fallback).then((r2) => { + if (!r2.ok) throw new Error('Failed to fetch markdown'); + return r2.text(); + }); + } + throw new Error('Failed to fetch markdown'); + }); + // iOS Safari: clipboard.write() must run in the same user gesture as the click; + // ClipboardItem + Promise keeps the gesture context after fetch(). if (typeof ClipboardItem !== 'undefined') { const item = new ClipboardItem({ - 'text/plain': fetch(markdownUrl) - .then((r) => { - if (!r.ok) throw new Error('Failed to fetch markdown'); - return r.text(); - }) - .then((text) => new Blob([text], { type: 'text/plain' })), + 'text/plain': fetchMarkdown().then((text) => new Blob([text], { type: 'text/plain' })), }); await navigator.clipboard.write([item]); } else { - const response = await fetch(markdownUrl); - if (!response.ok) throw new Error('Failed to fetch markdown'); - const markdown = await response.text(); - await navigator.clipboard.writeText(markdown); + const text = await fetchMarkdown(); + await navigator.clipboard.writeText(text); } setCopied(true); @@ -99,8 +82,8 @@ export default function MarkdownActionsDropdown() { aria-expanded={isOpen} > Open Markdown - - + + @@ -109,10 +92,11 @@ export default function MarkdownActionsDropdown() { @@ -121,21 +105,21 @@ export default function MarkdownActionsDropdown() {