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
7 changes: 4 additions & 3 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -166,7 +167,7 @@ const config: Config = {
},
breadcrumbs: false,
sidebarCollapsed: false,
remarkPlugins: [[remarkNpm2Yarn, { sync: true }]],
remarkPlugins: [remarkRawMarkdown, [remarkNpm2Yarn, { sync: true }]],
rehypePlugins: [
[
rehypeCodeblockMeta,
Expand All @@ -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',
Expand Down
219 changes: 219 additions & 0 deletions src/components/CopyMarkdownButton/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<div className={styles.container} ref={containerRef} onKeyDown={onKeyDown}>
<button
ref={buttonRef}
type="button"
onClick={onButtonClick}
className={styles.button}
title="Copy page"
aria-expanded={isOpen}
aria-haspopup="menu"
>
<span className={styles.iconContainer}>
<svg
className={`${styles.icon} ${copied ? styles.visible : styles.hidden}`}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>

<svg
className={`${styles.icon} ${copied ? styles.hidden : styles.visible}`}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />

<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</span>
Copy page
<svg
className={`${styles.chevron} ${isOpen ? styles.open : ''}`}
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>

{isVisible && (
<div
ref={dropdownRef}
className={`${styles.dropdown} ${!isOpen ? styles.closing : ''}`}
onAnimationEnd={onAnimationEnd}
role="menu"
>
{ACTIONS.map((action) => (
<button
key={action.value}
type="button"
className={styles.item}
onClick={() => onAction(action)}
onMouseEnter={(e) => e.currentTarget.focus()}
role="menuitem"
tabIndex={-1}
>
{action.label}
</button>
))}
</div>
)}
</div>
);
}
115 changes: 115 additions & 0 deletions src/components/CopyMarkdownButton/styles.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions src/plugins/remark-raw-markdown.mjs
Original file line number Diff line number Diff line change
@@ -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;
Loading