Skip to content

Commit 808fb8b

Browse files
committed
Add copy markdown element
1 parent c00c84c commit 808fb8b

File tree

7 files changed

+1188
-0
lines changed

7 files changed

+1188
-0
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
"scripts": {
66
"docusaurus": "docusaurus",
77
"start": "docusaurus start",
8+
"dev": "docusaurus clear && docusaurus start",
89
"prebuild": "npm run generate-llms",
910
"build": "docusaurus build",
11+
"rebuild-serve": "npm run build && npm run serve",
1012
"swizzle": "docusaurus swizzle",
1113
"deploy": "docusaurus deploy",
1214
"clear": "docusaurus clear",

src/components/CopyMarkdown.js

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import React, { useState, useEffect, useCallback, useRef } from 'react';
2+
import styles from './CopyMarkdown.module.css';
3+
4+
function CopyMarkdown() {
5+
const [isOpen, setIsOpen] = useState(false);
6+
const [hasContent, setHasContent] = useState(false);
7+
const buttonRef = useRef(null);
8+
const dropdownRef = useRef(null);
9+
10+
// Generate clean markdown from the page content
11+
const generateCleanMarkdown = useCallback(() => {
12+
const content = document.querySelector('.theme-doc-markdown.markdown');
13+
if (!content) {
14+
return 'Could not find content to convert to markdown.';
15+
}
16+
17+
// Clone the content to avoid modifying the original
18+
const contentClone = content.cloneNode(true);
19+
20+
// Get the title
21+
const title = contentClone.querySelector('h1')?.textContent || 'Untitled';
22+
23+
// Remove elements we don't want in the markdown
24+
const elementsToRemove = contentClone.querySelectorAll('.theme-edit-this-page, .pagination-nav, .table-of-contents');
25+
elementsToRemove.forEach(el => el?.remove());
26+
27+
// Convert the HTML content to markdown
28+
let markdown = `# ${title}\n\n`;
29+
30+
// Process paragraphs, headers, lists, etc.
31+
const elements = contentClone.querySelectorAll('p, h2, h3, h4, h5, h6, ul, ol, pre, blockquote, table');
32+
elements.forEach(el => {
33+
// Skip the title as we've already added it
34+
if (el.tagName === 'H1') return;
35+
36+
// Process different element types
37+
switch (el.tagName) {
38+
case 'H2':
39+
markdown += `\n## ${el.textContent}\n\n`;
40+
break;
41+
case 'H3':
42+
markdown += `\n### ${el.textContent}\n\n`;
43+
break;
44+
case 'H4':
45+
markdown += `\n#### ${el.textContent}\n\n`;
46+
break;
47+
case 'H5':
48+
markdown += `\n##### ${el.textContent}\n\n`;
49+
break;
50+
case 'H6':
51+
markdown += `\n###### ${el.textContent}\n\n`;
52+
break;
53+
case 'P':
54+
markdown += `${el.textContent}\n\n`;
55+
break;
56+
case 'UL':
57+
markdown += processListItems(el, '- ');
58+
break;
59+
case 'OL':
60+
markdown += processListItems(el, (i) => `${i+1}. `);
61+
break;
62+
case 'PRE':
63+
const codeElement = el.querySelector('code');
64+
const codeClass = codeElement?.className?.match(/language-(\w+)/)?.[1] || '';
65+
const codeContent = codeElement?.textContent || el.textContent;
66+
markdown += `\`\`\`${codeClass}\n${codeContent}\n\`\`\`\n\n`;
67+
break;
68+
case 'BLOCKQUOTE':
69+
const blockquoteLines = el.textContent.split('\n').map(line => `> ${line}`).join('\n');
70+
markdown += `${blockquoteLines}\n\n`;
71+
break;
72+
case 'TABLE':
73+
markdown += processTable(el);
74+
break;
75+
default:
76+
markdown += `${el.textContent}\n\n`;
77+
}
78+
});
79+
80+
return markdown.trim();
81+
}, []);
82+
83+
// Helper function to process list items
84+
const processListItems = (listElement, prefix) => {
85+
let result = '\n';
86+
const items = listElement.querySelectorAll('li');
87+
items.forEach((item, index) => {
88+
// If prefix is a function, call it with the index
89+
const prefixText = typeof prefix === 'function' ? prefix(index) : prefix;
90+
result += `${prefixText}${item.textContent}\n`;
91+
92+
// Process any nested lists
93+
const nestedLists = item.querySelectorAll('ul, ol');
94+
if (nestedLists.length > 0) {
95+
nestedLists.forEach(nestedList => {
96+
const nestedPrefix = nestedList.tagName === 'UL' ? ' - ' : (i) => ` ${i+1}. `;
97+
result += processListItems(nestedList, nestedPrefix);
98+
});
99+
}
100+
});
101+
return result + '\n';
102+
};
103+
104+
// Helper function to process tables
105+
const processTable = (tableElement) => {
106+
let result = '\n';
107+
const rows = tableElement.querySelectorAll('tr');
108+
109+
// Process header row
110+
const headerCells = rows[0]?.querySelectorAll('th') || [];
111+
if (headerCells.length > 0) {
112+
result += '| ' + Array.from(headerCells).map(cell => cell.textContent).join(' | ') + ' |\n';
113+
result += '| ' + Array.from(headerCells).map(() => '---').join(' | ') + ' |\n';
114+
}
115+
116+
// Process data rows
117+
for (let i = headerCells.length > 0 ? 1 : 0; i < rows.length; i++) {
118+
const cells = rows[i].querySelectorAll('td');
119+
result += '| ' + Array.from(cells).map(cell => cell.textContent).join(' | ') + ' |\n';
120+
}
121+
122+
return result + '\n';
123+
};
124+
125+
// Toggle dropdown
126+
const toggleDropdown = useCallback(() => {
127+
setIsOpen(prev => !prev);
128+
}, []);
129+
130+
// Copy markdown to clipboard
131+
const copyMarkdown = useCallback(async () => {
132+
try {
133+
const markdown = generateCleanMarkdown();
134+
await navigator.clipboard.writeText(markdown);
135+
setIsOpen(false);
136+
showToast('Markdown copied to clipboard!');
137+
} catch (error) {
138+
console.error('Failed to copy markdown:', error);
139+
showToast('Failed to copy. Please try again.', true);
140+
}
141+
}, [generateCleanMarkdown]);
142+
143+
// View as plain text
144+
const viewAsMarkdown = useCallback(() => {
145+
try {
146+
const markdown = generateCleanMarkdown();
147+
const newWindow = window.open();
148+
if (newWindow) {
149+
newWindow.document.write(`<html><head><title>Markdown Content</title></head><body><pre>${markdown}</pre></body></html>`);
150+
newWindow.document.close();
151+
} else {
152+
showToast('Popup was blocked. Please allow popups for this site.', true);
153+
}
154+
setIsOpen(false);
155+
} catch (error) {
156+
console.error('Failed to view markdown:', error);
157+
showToast('Failed to open view. Please try again.', true);
158+
}
159+
}, [generateCleanMarkdown]);
160+
161+
// Open in ChatGPT
162+
const openInChatGpt = useCallback(() => {
163+
try {
164+
const markdown = generateCleanMarkdown();
165+
const baseUrl = 'https://chat.openai.com/';
166+
const newWindow = window.open(baseUrl);
167+
if (newWindow) {
168+
// Also copy to clipboard
169+
navigator.clipboard.writeText(markdown);
170+
showToast("ChatGPT opened. Please paste the copied markdown there.");
171+
} else {
172+
showToast('Popup was blocked. Please allow popups for this site.', true);
173+
}
174+
setIsOpen(false);
175+
} catch (error) {
176+
console.error('Failed to open ChatGPT:', error);
177+
showToast('Failed to open ChatGPT. Please try again.', true);
178+
}
179+
}, [generateCleanMarkdown]);
180+
181+
// Show toast notification
182+
const showToast = (message, isError = false) => {
183+
const toast = document.createElement('div');
184+
toast.className = styles.toast;
185+
if (isError) {
186+
toast.classList.add(styles.errorToast);
187+
}
188+
toast.textContent = message;
189+
document.body.appendChild(toast);
190+
191+
setTimeout(() => {
192+
toast.style.opacity = '0';
193+
setTimeout(() => {
194+
if (document.body.contains(toast)) {
195+
document.body.removeChild(toast);
196+
}
197+
}, 500);
198+
}, 3000);
199+
};
200+
201+
// Handle click outside to close dropdown
202+
const handleClickOutside = useCallback((event) => {
203+
// Only close if clicking outside both the button and dropdown
204+
if (
205+
buttonRef.current &&
206+
!buttonRef.current.contains(event.target) &&
207+
dropdownRef.current &&
208+
!dropdownRef.current.contains(event.target)
209+
) {
210+
setIsOpen(false);
211+
}
212+
}, []);
213+
214+
// Initialize on client side
215+
useEffect(() => {
216+
// Check if we have markdown content
217+
const hasMarkdownContent = !!document.querySelector('.theme-doc-markdown.markdown h1');
218+
setHasContent(hasMarkdownContent);
219+
220+
// Set up click outside handler
221+
document.addEventListener('click', handleClickOutside);
222+
223+
return () => {
224+
// Clean up event listener
225+
document.removeEventListener('click', handleClickOutside);
226+
};
227+
}, [handleClickOutside]);
228+
229+
// Don't render on pages that don't have markdown content
230+
if (!hasContent) {
231+
return null;
232+
}
233+
234+
return (
235+
<div className={styles.container}>
236+
<button
237+
ref={buttonRef}
238+
className={styles.button}
239+
onClick={toggleDropdown}
240+
aria-expanded={isOpen}
241+
aria-haspopup="true"
242+
>
243+
<span>Copy page</span>
244+
<svg className={styles.icon} viewBox="0 0 20 20" fill="currentColor">
245+
<path
246+
fillRule="evenodd"
247+
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
248+
clipRule="evenodd"
249+
/>
250+
</svg>
251+
</button>
252+
253+
{isOpen && (
254+
<div
255+
ref={dropdownRef}
256+
className={styles.dropdown}
257+
role="menu"
258+
aria-orientation="vertical"
259+
>
260+
<ul className={styles.list}>
261+
<li className={styles.item}>
262+
<button
263+
className={styles.actionButton}
264+
onClick={copyMarkdown}
265+
role="menuitem"
266+
>
267+
<span>Copy page as Markdown for LLMs</span>
268+
</button>
269+
</li>
270+
<li className={styles.item}>
271+
<button
272+
className={styles.actionButton}
273+
onClick={viewAsMarkdown}
274+
role="menuitem"
275+
>
276+
<span>View as plain text</span>
277+
</button>
278+
</li>
279+
<li className={styles.item}>
280+
<button
281+
className={styles.actionButton}
282+
onClick={openInChatGpt}
283+
role="menuitem"
284+
>
285+
<span>Open in ChatGPT</span>
286+
</button>
287+
</li>
288+
</ul>
289+
</div>
290+
)}
291+
</div>
292+
);
293+
}
294+
295+
export default CopyMarkdown;

0 commit comments

Comments
 (0)