Skip to content

Commit bff6326

Browse files
Add copy button (#2432)
* Add button to main layout. Signed-off-by: bgravenorst <[email protected]> * Fix paste issue. Signed-off-by: bgravenorst <[email protected]> * Fix duplication issue. Signed-off-by: bgravenorst <[email protected]> * Add mobile view fix for tutorial pages. Signed-off-by: bgravenorst <[email protected]> * Add support for tables. Signed-off-by: bgravenorst <[email protected]> * Consolidate code. Signed-off-by: bgravenorst <[email protected]> * Fix linter issues. Signed-off-by: bgravenorst <[email protected]> * adjust styles * Update src/components/CopyPageButton/CopyPageButton.module.css Co-authored-by: Alexandra Carrillo <[email protected]> --------- Signed-off-by: bgravenorst <[email protected]> Co-authored-by: Alexandra Carrillo <[email protected]> Co-authored-by: Alexandra Carrillo <[email protected]>
1 parent 516abf8 commit bff6326

File tree

7 files changed

+477
-2
lines changed

7 files changed

+477
-2
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import React, { useState, useEffect, useRef } from 'react';
2+
import styles from './CopyPageButton.module.css';
3+
4+
// Helpers shared by both actions
5+
const decodeHTML = (html) => {
6+
const txt = document.createElement('textarea');
7+
txt.innerHTML = html;
8+
return txt.value;
9+
};
10+
11+
const extractInlineText = (element) => {
12+
let result = '';
13+
element.childNodes.forEach(node => {
14+
if (node.nodeType === Node.TEXT_NODE) {
15+
result += node.textContent;
16+
} else if (node.nodeType === Node.ELEMENT_NODE) {
17+
if (node.tagName === 'CODE' && node.parentElement.tagName !== 'PRE') {
18+
result += `\`${decodeHTML(node.innerHTML)}\``;
19+
} else if (node.tagName === 'STRONG' || node.tagName === 'B') {
20+
result += `**${node.textContent}**`;
21+
} else if (node.tagName === 'EM' || node.tagName === 'I') {
22+
result += `*${node.textContent}*`;
23+
} else if (node.tagName === 'A') {
24+
result += `[${node.textContent}](${node.href})`;
25+
} else if (node.tagName === 'BR') {
26+
result += '\n';
27+
} else {
28+
result += extractInlineText(node);
29+
}
30+
}
31+
});
32+
return result;
33+
};
34+
35+
const shouldSkipSection = (section, sectionsArray) => {
36+
const tag = section.tagName.toLowerCase();
37+
// Hidden (tabs/details)
38+
if (section.hidden || section.closest('[hidden]')) return true;
39+
// Tab navigation
40+
if (section.closest('[role="tablist"]')) return true;
41+
// Nested inside lists – will be handled by parent list
42+
if (tag === 'p' || tag === 'pre' || tag === 'blockquote' || tag === 'table') {
43+
const parentList = section.closest('ol, ul');
44+
if (parentList && sectionsArray.includes(parentList)) return true;
45+
}
46+
// Closed details
47+
const parentDetails = section.closest('details');
48+
if (parentDetails && !parentDetails.open) return true;
49+
return false;
50+
};
51+
52+
const tableToMarkdown = (tableEl) => {
53+
// Build Markdown table: header row + separator + body rows
54+
const rows = Array.from(tableEl.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tr'));
55+
if (rows.length === 0) return '';
56+
57+
const firstRowCells = Array.from(rows[0].querySelectorAll('th, td'));
58+
const headers = firstRowCells.map(cell => cell.textContent.trim());
59+
let md = '';
60+
61+
if (headers.length > 0) {
62+
md += `| ${headers.join(' | ')} |\n`;
63+
md += `| ${headers.map(() => '---').join(' | ')} |\n`;
64+
}
65+
66+
const bodyRows = rows.slice(1);
67+
bodyRows.forEach(tr => {
68+
const cells = Array.from(tr.querySelectorAll('th, td')).map(td => td.textContent.trim());
69+
md += `| ${cells.join(' | ')} |\n`;
70+
});
71+
72+
return `${md}\n`;
73+
};
74+
75+
const getContentRoot = () => document.querySelector('article') || document.querySelector('.markdown');
76+
77+
const buildMarkdown = () => {
78+
const root = getContentRoot();
79+
if (!root) return '';
80+
81+
const title = document.querySelector('h1')?.textContent || 'Documentation';
82+
let markdown = `# ${title}\n\n`;
83+
84+
const sections = root.querySelectorAll('h2, h3, h4, p, ul, ol, pre, blockquote, table');
85+
const sectionsArray = Array.from(sections);
86+
87+
sectionsArray.forEach(section => {
88+
if (shouldSkipSection(section, sectionsArray)) return;
89+
const tag = section.tagName.toLowerCase();
90+
91+
if (tag === 'h2') {
92+
const text = decodeHTML(section.innerHTML).replace(/<[^>]+>/g, '').trim();
93+
markdown += `\n## ${text}\n\n`;
94+
return;
95+
}
96+
if (tag === 'h3') {
97+
const text = decodeHTML(section.innerHTML).replace(/<[^>]+>/g, '').trim();
98+
markdown += `\n### ${text}\n\n`;
99+
return;
100+
}
101+
if (tag === 'h4') {
102+
const text = decodeHTML(section.innerHTML).replace(/<[^>]+>/g, '').trim();
103+
markdown += `\n#### ${text}\n\n`;
104+
return;
105+
}
106+
if (tag === 'pre') {
107+
const codeElement = section.querySelector('code');
108+
if (codeElement) {
109+
const languageClass = codeElement.className.match(/language-(\w+)/);
110+
const language = languageClass ? languageClass[1] : '';
111+
const code = codeElement.innerText;
112+
markdown += `\`\`\`${language}\n${code}\n\`\`\`\n\n`;
113+
} else {
114+
markdown += `\`\`\`\n${section.innerText}\n\`\`\`\n\n`;
115+
}
116+
return;
117+
}
118+
if (tag === 'blockquote') {
119+
const lines = section.textContent.trim().split('\n');
120+
lines.forEach(line => {
121+
if (line.trim()) markdown += `> ${line.trim()}\n`;
122+
});
123+
markdown += '\n';
124+
return;
125+
}
126+
if (tag === 'ul' || tag === 'ol') {
127+
const items = section.querySelectorAll(':scope > li');
128+
items.forEach((item, index) => {
129+
const prefix = tag === 'ol' ? `${index + 1}.` : '-';
130+
const text = extractInlineText(item);
131+
markdown += `${prefix} ${text.trim()}\n`;
132+
});
133+
markdown += '\n';
134+
return;
135+
}
136+
if (tag === 'p') {
137+
const text = extractInlineText(section);
138+
if (text.trim() && text.trim() !== 'Copy') markdown += `${text.trim()}\n\n`;
139+
return;
140+
}
141+
if (tag === 'table') {
142+
markdown += tableToMarkdown(section);
143+
return;
144+
}
145+
});
146+
147+
return markdown;
148+
};
149+
150+
export default function CopyPageButton({ standalone = false }) {
151+
const [isOpen, setIsOpen] = useState(false);
152+
const [copied, setCopied] = useState(false);
153+
const containerRef = useRef(null);
154+
const dropdownRef = useRef(null);
155+
const [mobileAlign, setMobileAlign] = useState('center'); // 'center' | 'left' | 'right'
156+
157+
useEffect(() => {
158+
const handleClickOutside = (event) => {
159+
if (containerRef.current && !containerRef.current.contains(event.target)) {
160+
setIsOpen(false);
161+
}
162+
};
163+
164+
if (isOpen) {
165+
document.addEventListener('mousedown', handleClickOutside);
166+
}
167+
168+
return () => {
169+
document.removeEventListener('mousedown', handleClickOutside);
170+
};
171+
}, [isOpen]);
172+
173+
// No H1 wrapping here; handled by a separate wrapper component on doc pages
174+
175+
// Compute mobile alignment when dropdown opens
176+
useEffect(() => {
177+
if (!isOpen) return;
178+
const compute = () => {
179+
const container = containerRef.current;
180+
const menu = dropdownRef.current;
181+
if (!container || !menu) return;
182+
const vw = window.innerWidth;
183+
const cr = container.getBoundingClientRect();
184+
const mr = menu.getBoundingClientRect();
185+
const centerX = cr.left + cr.width / 2;
186+
const margin = 8;
187+
// Try centered
188+
const half = mr.width / 2;
189+
if (centerX + half > vw - margin) {
190+
setMobileAlign('right');
191+
} else if (centerX - half < margin) {
192+
setMobileAlign('left');
193+
} else {
194+
setMobileAlign('center');
195+
}
196+
};
197+
// Wait for dropdown to render sizes
198+
const id = requestAnimationFrame(compute);
199+
return () => cancelAnimationFrame(id);
200+
}, [isOpen]);
201+
202+
const handleCopyMarkdown = async () => {
203+
try {
204+
const markdown = buildMarkdown();
205+
if (!markdown) return;
206+
await navigator.clipboard.writeText(markdown);
207+
setCopied(true);
208+
setTimeout(() => {
209+
setCopied(false);
210+
setIsOpen(false);
211+
}, 2000);
212+
} catch (err) {
213+
console.error('Failed to copy:', err);
214+
}
215+
};
216+
217+
const handleViewMarkdown = () => {
218+
try {
219+
const markdown = buildMarkdown();
220+
if (!markdown) return;
221+
const win = window.open('', '_blank');
222+
const escaped = markdown
223+
.replace(/&/g, '&amp;')
224+
.replace(/</g, '&lt;')
225+
.replace(/>/g, '&gt;');
226+
win.document.write(`<pre style="white-space: pre-wrap; word-wrap: break-word; font-family: monospace; padding: 20px;">${escaped}</pre>`);
227+
} catch (err) {
228+
console.error('Failed to view markdown:', err);
229+
}
230+
};
231+
232+
return (
233+
<div className={styles.container} ref={containerRef} data-copy-button>
234+
<button
235+
className={styles.mainButton}
236+
onClick={() => setIsOpen(!isOpen)}
237+
aria-label="Copy page options"
238+
>
239+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
240+
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
241+
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
242+
</svg>
243+
<span>Copy page</span>
244+
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" className={styles.arrow}>
245+
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="2" fill="none"/>
246+
</svg>
247+
</button>
248+
249+
{isOpen && (
250+
<div
251+
ref={dropdownRef}
252+
className={
253+
`${styles.dropdown} ` +
254+
`${mobileAlign === 'right' ? styles.alignRight : mobileAlign === 'left' ? styles.alignLeft : styles.alignCenter}`
255+
}>
256+
<button className={styles.dropdownItem} onClick={handleCopyMarkdown}>
257+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
258+
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
259+
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
260+
</svg>
261+
<div>
262+
<div className={styles.itemTitle}>
263+
{copied ? 'Copied!' : 'Copy page'}
264+
</div>
265+
<div className={styles.itemDescription}>Copy page as Markdown for LLMs</div>
266+
</div>
267+
</button>
268+
269+
<button className={styles.dropdownItem} onClick={handleViewMarkdown}>
270+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
271+
<path d="M8 2a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-1 0v-11A.5.5 0 0 1 8 2Z"/>
272+
<path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2Zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2Z"/>
273+
</svg>
274+
<div>
275+
<div className={styles.itemTitle}>View as Markdown</div>
276+
<div className={styles.itemDescription}>View this page as plain text</div>
277+
</div>
278+
</button>
279+
</div>
280+
)}
281+
</div>
282+
);
283+
}
284+

0 commit comments

Comments
 (0)