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
93 changes: 49 additions & 44 deletions website/src/components/docs/MarkdownActionsDropdown/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,62 @@
* 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).
* 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';

function getMarkdownUrl(currentPath) {
if (!currentPath.startsWith('/docs/')) return null;
if (currentPath.endsWith('/')) {
// Root docs index is intro; all other category indexes are index.md
return currentPath === '/docs/'
? `${currentPath}intro.md`
: `${currentPath}index.md`;
}
return `${currentPath}.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);
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 { 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 {
const response = await fetch(markdownUrl);
if (!response.ok) {
throw new Error('Failed to fetch markdown');
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': fetchMarkdown().then((text) => new Blob([text], { type: 'text/plain' })),
});
await navigator.clipboard.write([item]);
} else {
const text = await fetchMarkdown();
await navigator.clipboard.writeText(text);
}
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);

setCopied(true);
setTimeout(() => {
Expand All @@ -78,8 +82,8 @@ export default function MarkdownActionsDropdown() {
aria-expanded={isOpen}
>
Open Markdown
<svg width="14" height="14" viewBox="0 0 16 16" style={{marginLeft: '4px'}}>
<path fill="currentColor" d="M4.427 6.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396a.25.25 0 00-.177-.427H4.604a.25.25 0 00-.177.427z"/>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginLeft: '4px' }}>
<path fill="currentColor" d="M4.427 6.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396a.25.25 0 00-.177-.427H4.604a.25.25 0 00-.177.427z" />
</svg>
</button>

Expand All @@ -88,10 +92,11 @@ export default function MarkdownActionsDropdown() {
<button
className="dropdown__link"
onClick={handleOpenMarkdown}
style={{cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left'}}
disabled={!urlReady}
style={{ cursor: urlReady ? 'pointer' : 'not-allowed', border: 'none', background: 'none', width: '100%', textAlign: 'left', opacity: urlReady ? 1 : 0.7 }}
>
<svg width="16" height="16" viewBox="0 0 24 24" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M12.0001 5.25C9.22586 5.25 6.79699 6.91121 5.12801 8.44832C4.28012 9.22922 3.59626 10.0078 3.12442 10.5906C2.88804 10.8825 2.70368 11.1268 2.57736 11.2997C2.51417 11.3862 2.46542 11.4549 2.43187 11.5029C2.41509 11.5269 2.4021 11.5457 2.393 11.559L2.38227 11.5747L2.37911 11.5794L2.10547 12.0132L2.37809 12.4191L2.37911 12.4206L2.38227 12.4253L2.393 12.441C2.4021 12.4543 2.41509 12.4731 2.43187 12.4971C2.46542 12.5451 2.51417 12.6138 2.57736 12.7003C2.70368 12.8732 2.88804 13.1175 3.12442 13.4094C3.59626 13.9922 4.28012 14.7708 5.12801 15.5517C6.79699 17.0888 9.22586 18.75 12.0001 18.75C14.7743 18.75 17.2031 17.0888 18.8721 15.5517C19.72 14.7708 20.4039 13.9922 20.8757 13.4094C21.1121 13.1175 21.2964 12.8732 21.4228 12.7003C21.4859 12.6138 21.5347 12.5451 21.5682 12.4971C21.585 12.4731 21.598 12.4543 21.6071 12.441L21.6178 12.4253L21.621 12.4206L21.6224 12.4186L21.9035 12L21.622 11.5809L21.621 11.5794L21.6178 11.5747L21.6071 11.559C21.598 11.5457 21.585 11.5269 21.5682 11.5029C21.5347 11.4549 21.4859 11.3862 21.4228 11.2997C21.2964 11.1268 21.1121 10.8825 20.8757 10.5906C20.4039 10.0078 19.72 9.22922 18.8721 8.44832C17.2031 6.91121 14.7743 5.25 12.0001 5.25ZM4.29022 12.4656C4.14684 12.2885 4.02478 12.1311 3.92575 12C4.02478 11.8689 4.14684 11.7115 4.29022 11.5344C4.72924 10.9922 5.36339 10.2708 6.14419 9.55168C7.73256 8.08879 9.80369 6.75 12.0001 6.75C14.1964 6.75 16.2676 8.08879 17.8559 9.55168C18.6367 10.2708 19.2709 10.9922 19.7099 11.5344C19.8533 11.7115 19.9753 11.8689 20.0744 12C19.9753 12.1311 19.8533 12.2885 19.7099 12.4656C19.2709 13.0078 18.6367 13.7292 17.8559 14.4483C16.2676 15.9112 14.1964 17.25 12.0001 17.25C9.80369 17.25 7.73256 15.9112 6.14419 14.4483C5.36339 13.7292 4.72924 13.0078 4.29022 12.4656ZM14.25 12C14.25 13.2426 13.2427 14.25 12 14.25C10.7574 14.25 9.75005 13.2426 9.75005 12C9.75005 10.7574 10.7574 9.75 12 9.75C13.2427 9.75 14.25 10.7574 14.25 12ZM15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92898 15.75 8.25005 14.0711 8.25005 12C8.25005 9.92893 9.92898 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z"/>
<svg width="16" height="16" viewBox="0 0 24 24" style={{ marginRight: '8px', verticalAlign: 'middle' }}>
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M12.0001 5.25C9.22586 5.25 6.79699 6.91121 5.12801 8.44832C4.28012 9.22922 3.59626 10.0078 3.12442 10.5906C2.88804 10.8825 2.70368 11.1268 2.57736 11.2997C2.51417 11.3862 2.46542 11.4549 2.43187 11.5029C2.41509 11.5269 2.4021 11.5457 2.393 11.559L2.38227 11.5747L2.37911 11.5794L2.10547 12.0132L2.37809 12.4191L2.37911 12.4206L2.38227 12.4253L2.393 12.441C2.4021 12.4543 2.41509 12.4731 2.43187 12.4971C2.46542 12.5451 2.51417 12.6138 2.57736 12.7003C2.70368 12.8732 2.88804 13.1175 3.12442 13.4094C3.59626 13.9922 4.28012 14.7708 5.12801 15.5517C6.79699 17.0888 9.22586 18.75 12.0001 18.75C14.7743 18.75 17.2031 17.0888 18.8721 15.5517C19.72 14.7708 20.4039 13.9922 20.8757 13.4094C21.1121 13.1175 21.2964 12.8732 21.4228 12.7003C21.4859 12.6138 21.5347 12.5451 21.5682 12.4971C21.585 12.4731 21.598 12.4543 21.6071 12.441L21.6178 12.4253L21.621 12.4206L21.6224 12.4186L21.9035 12L21.622 11.5809L21.621 11.5794L21.6178 11.5747L21.6071 11.559C21.598 11.5457 21.585 11.5269 21.5682 11.5029C21.5347 11.4549 21.4859 11.3862 21.4228 11.2997C21.2964 11.1268 21.1121 10.8825 20.8757 10.5906C20.4039 10.0078 19.72 9.22922 18.8721 8.44832C17.2031 6.91121 14.7743 5.25 12.0001 5.25ZM4.29022 12.4656C4.14684 12.2885 4.02478 12.1311 3.92575 12C4.02478 11.8689 4.14684 11.7115 4.29022 11.5344C4.72924 10.9922 5.36339 10.2708 6.14419 9.55168C7.73256 8.08879 9.80369 6.75 12.0001 6.75C14.1964 6.75 16.2676 8.08879 17.8559 9.55168C18.6367 10.2708 19.2709 10.9922 19.7099 11.5344C19.8533 11.7115 19.9753 11.8689 20.0744 12C19.9753 12.1311 19.8533 12.2885 19.7099 12.4656C19.2709 13.0078 18.6367 13.7292 17.8559 14.4483C16.2676 15.9112 14.1964 17.25 12.0001 17.25C9.80369 17.25 7.73256 15.9112 6.14419 14.4483C5.36339 13.7292 4.72924 13.0078 4.29022 12.4656ZM14.25 12C14.25 13.2426 13.2427 14.25 12 14.25C10.7574 14.25 9.75005 13.2426 9.75005 12C9.75005 10.7574 10.7574 9.75 12 9.75C13.2427 9.75 14.25 10.7574 14.25 12ZM15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92898 15.75 8.25005 14.0711 8.25005 12C8.25005 9.92893 9.92898 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z" />
</svg>
View
</button>
Expand All @@ -100,21 +105,21 @@ export default function MarkdownActionsDropdown() {
<button
className="dropdown__link"
onClick={handleCopyMarkdown}
disabled={copied}
style={{cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left'}}
disabled={copied || !urlReady}
style={{ cursor: 'pointer', border: 'none', background: 'none', width: '100%', textAlign: 'left' }}
>
{copied ? (
<>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: '8px', verticalAlign: 'middle' }}>
<path fill="currentColor" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
</svg>
Copied!
</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 16 16" style={{marginRight: '8px', verticalAlign: 'middle'}}>
<path fill="currentColor" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/>
<path fill="currentColor" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: '8px', verticalAlign: 'middle' }}>
<path fill="currentColor" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z" />
<path fill="currentColor" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z" />
</svg>
Copy
</>
Expand Down
20 changes: 20 additions & 0 deletions website/src/hooks/useClickOutside.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect } from 'react';

/**
* Calls onClose when a mousedown happens outside the element attached to ref.
* @param {React.RefObject} ref
* @param {boolean} isActive - only listen when true
* @param {() => void} onClose
*/
export function useClickOutside(ref, isActive, onClose) {
useEffect(() => {
if (!isActive) return;
const handleClickOutside = (event) => {
if (ref.current && !ref.current.contains(event.target)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [ref, isActive, onClose]);
}
42 changes: 0 additions & 42 deletions website/src/hooks/useInjectMarkdownActionsDropdown.js

This file was deleted.

65 changes: 65 additions & 0 deletions website/src/hooks/useResolvedMarkdownUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';

/** Normalize path so /docs is treated as /docs/ for URL building. */
function normalizeDocsPath(path) {
if (path === '/docs') return '/docs/';
return path;
}

/**
* Returns { primary, fallback } for markdown URL(s).
* pathname must not include hash (use location.pathname).
*/
function getMarkdownUrlCandidates(currentPath) {
const path = normalizeDocsPath(currentPath);
if (path !== '/docs/' && !path.startsWith('/docs/')) return null;
if (path === '/docs/') {
return { primary: `${path}intro.md` };
}
if (path.endsWith('/')) {
return {
primary: path.slice(0, -1) + '.md',
fallback: path + 'index.md',
};
}
return { primary: path + '.md' };
}

/**
* Resolves the markdown URL for the current docs path. When path has a trailing
* slash, performs a HEAD request to choose between single-doc (.md) and category
* index (index.md).
* @param {string} pathname - location.pathname (no hash)
* @returns {{ candidates: object | null, resolvedUrl: string | null, urlReady: boolean, markdownUrl: string | null }}
*/
export function useResolvedMarkdownUrl(pathname) {
const [resolvedUrl, setResolvedUrl] = useState(null);
const candidates = pathname ? getMarkdownUrlCandidates(pathname) : null;

useEffect(() => {
if (!candidates) {
setResolvedUrl(null);
return;
}
if (!candidates.fallback) {
setResolvedUrl(candidates.primary);
return;
}
let cancelled = false;
const { primary, fallback } = candidates;
fetch(primary, { method: 'HEAD' })
.then((r) => {
if (cancelled) return;
setResolvedUrl(r.ok ? primary : fallback);
})
.catch(() => {
if (!cancelled) setResolvedUrl(fallback);
});
return () => { cancelled = true; };
}, [pathname]);

const markdownUrl = resolvedUrl ?? candidates?.primary ?? null;
const urlReady = !candidates?.fallback || resolvedUrl != null;

return { candidates, resolvedUrl, urlReady, markdownUrl };
}
86 changes: 86 additions & 0 deletions website/src/theme/DocBreadcrumbs/index.js
Original file line number Diff line number Diff line change
@@ -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 <span className={className}>{children}</span>;
}
return href ? (
<Link className={className} href={href}>
<span>{children}</span>
</Link>
) : (
<span className={className}>{children}</span>
);
}

function BreadcrumbsItem({children, active}) {
return (
<li
className={clsx('breadcrumbs__item', {
'breadcrumbs__item--active': active,
})}>
{children}
</li>
);
}

export default function DocBreadcrumbs() {
const breadcrumbs = useSidebarBreadcrumbs();
const homePageRoute = useHomePageRoute();
if (!breadcrumbs) {
return null;
}
return (
<>
<DocBreadcrumbsStructuredData breadcrumbs={breadcrumbs} />
<div className="markdown-actions-breadcrumbs-row">
<nav
className={clsx(
ThemeClassNames.docs.docBreadcrumbs,
styles.breadcrumbsContainer,
)}
aria-label={translate({
id: 'theme.docs.breadcrumbs.navAriaLabel',
message: 'Breadcrumbs',
description: 'The ARIA label for the breadcrumbs',
})}>
<ul className="breadcrumbs">
{homePageRoute && <HomeBreadcrumbItem />}
{breadcrumbs.map((item, idx) => {
const isLast = idx === breadcrumbs.length - 1;
const href =
item.type === 'category' && item.linkUnlisted
? undefined
: item.href;
return (
<BreadcrumbsItem key={idx} active={isLast}>
<BreadcrumbsItemLink href={href} isLast={isLast}>
{item.label}
</BreadcrumbsItemLink>
</BreadcrumbsItem>
);
})}
</ul>
</nav>
<div className="markdown-actions-container">
<MarkdownActionsDropdown />
</div>
</div>
</>
);
}
11 changes: 11 additions & 0 deletions website/src/theme/DocBreadcrumbs/styles.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 0 additions & 4 deletions website/src/theme/Root.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<NavbarEnhancements />
Expand Down