|
| 1 | +import { useState, useRef, useEffect } from 'react'; |
| 2 | +import { Copy, Check, ChevronDown, ExternalLink } from 'lucide-react'; |
| 3 | + |
| 4 | +interface CopyPageDropdownProps { |
| 5 | + pageUrl: string; |
| 6 | + pageTitle: string; |
| 7 | +} |
| 8 | + |
| 9 | +// ChatGPT logo SVG |
| 10 | +const ChatGPTIcon = () => ( |
| 11 | + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 12 | + <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.0993 3.8558L12.602 8.3829l2.0201-1.1638a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.1408 1.6465 4.4708 4.4708 0 0 1 .5765 3.0175zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="currentColor"/> |
| 13 | + </svg> |
| 14 | +); |
| 15 | + |
| 16 | +// Claude logo SVG (official from Wikimedia) |
| 17 | +const ClaudeIcon = () => ( |
| 18 | + <svg width="16" height="16" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 19 | + <path d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z" fill="#d97757"/> |
| 20 | + </svg> |
| 21 | +); |
| 22 | + |
| 23 | +export function CopyPageDropdown({ pageUrl, pageTitle }: CopyPageDropdownProps) { |
| 24 | + const [isOpen, setIsOpen] = useState(false); |
| 25 | + const [copied, setCopied] = useState(false); |
| 26 | + const dropdownRef = useRef<HTMLDivElement>(null); |
| 27 | + |
| 28 | + // Close dropdown when clicking outside |
| 29 | + useEffect(() => { |
| 30 | + const handleClickOutside = (event: MouseEvent) => { |
| 31 | + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { |
| 32 | + setIsOpen(false); |
| 33 | + } |
| 34 | + }; |
| 35 | + |
| 36 | + document.addEventListener('mousedown', handleClickOutside); |
| 37 | + return () => document.removeEventListener('mousedown', handleClickOutside); |
| 38 | + }, []); |
| 39 | + |
| 40 | + const extractCleanMarkdown = (element: Element): string => { |
| 41 | + const lines: string[] = []; |
| 42 | + |
| 43 | + const processNode = (node: Node, depth: number = 0): void => { |
| 44 | + if (node.nodeType === Node.TEXT_NODE) { |
| 45 | + const text = node.textContent?.trim(); |
| 46 | + if (text) { |
| 47 | + lines.push(text); |
| 48 | + } |
| 49 | + return; |
| 50 | + } |
| 51 | + |
| 52 | + if (node.nodeType !== Node.ELEMENT_NODE) return; |
| 53 | + |
| 54 | + const el = node as Element; |
| 55 | + const tagName = el.tagName.toLowerCase(); |
| 56 | + |
| 57 | + // Skip unwanted elements |
| 58 | + if ( |
| 59 | + el.classList.contains('sl-banner') || |
| 60 | + el.classList.contains('copy-page-dropdown') || |
| 61 | + el.classList.contains('pagination-links') || |
| 62 | + el.classList.contains('edit-on-github') || |
| 63 | + el.closest('.copy-page-dropdown') || |
| 64 | + el.closest('astro-island') || |
| 65 | + tagName === 'button' || |
| 66 | + tagName === 'nav' || |
| 67 | + tagName === 'footer' || |
| 68 | + tagName === 'script' || |
| 69 | + tagName === 'style' || |
| 70 | + tagName === 'astro-island' || |
| 71 | + tagName === 'astro-slot' || |
| 72 | + tagName === 'astro-static-slot' || |
| 73 | + tagName === 'template' || |
| 74 | + tagName === 'noscript' || |
| 75 | + tagName === 'input' || |
| 76 | + tagName === 'textarea' || |
| 77 | + tagName === 'svg' || |
| 78 | + el.getAttribute('aria-hidden') === 'true' || |
| 79 | + // Skip "Section titled" links and "Edit page" links |
| 80 | + (tagName === 'a' && el.textContent?.includes('Section titled')) || |
| 81 | + (tagName === 'a' && el.textContent?.includes('Edit page')) |
| 82 | + ) { |
| 83 | + return; |
| 84 | + } |
| 85 | + |
| 86 | + // Handle headings |
| 87 | + if (/^h[1-6]$/.test(tagName)) { |
| 88 | + const level = parseInt(tagName[1]); |
| 89 | + const prefix = '#'.repeat(level); |
| 90 | + const text = el.textContent?.replace(/Section titled "[^"]*"/g, '').trim(); |
| 91 | + if (text) { |
| 92 | + lines.push(''); |
| 93 | + lines.push(`${prefix} ${text}`); |
| 94 | + lines.push(''); |
| 95 | + } |
| 96 | + return; |
| 97 | + } |
| 98 | + |
| 99 | + // Handle code blocks |
| 100 | + if (tagName === 'pre' || el.classList.contains('expressive-code')) { |
| 101 | + const codeEl = el.querySelector('code'); |
| 102 | + if (codeEl) { |
| 103 | + const code = codeEl.textContent?.trim() || ''; |
| 104 | + // Try to detect language from class |
| 105 | + const langClass = Array.from(codeEl.classList).find(c => c.startsWith('language-')); |
| 106 | + const lang = langClass ? langClass.replace('language-', '') : ''; |
| 107 | + lines.push(''); |
| 108 | + lines.push('```' + lang); |
| 109 | + lines.push(code); |
| 110 | + lines.push('```'); |
| 111 | + lines.push(''); |
| 112 | + } |
| 113 | + return; |
| 114 | + } |
| 115 | + |
| 116 | + // Handle inline code |
| 117 | + if (tagName === 'code' && !el.closest('pre')) { |
| 118 | + lines.push(`\`${el.textContent}\``); |
| 119 | + return; |
| 120 | + } |
| 121 | + |
| 122 | + // Handle links |
| 123 | + if (tagName === 'a' && !el.textContent?.includes('Section titled')) { |
| 124 | + const href = el.getAttribute('href'); |
| 125 | + const text = el.textContent?.trim(); |
| 126 | + if (text && href && !href.startsWith('#')) { |
| 127 | + // Make relative URLs absolute |
| 128 | + const fullUrl = href.startsWith('http') ? href : `https://docs.localstack.cloud${href}`; |
| 129 | + lines.push(`[${text}](${fullUrl})`); |
| 130 | + return; |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + // Handle lists |
| 135 | + if (tagName === 'ul' || tagName === 'ol') { |
| 136 | + lines.push(''); |
| 137 | + const items = el.querySelectorAll(':scope > li'); |
| 138 | + items.forEach((li, idx) => { |
| 139 | + const prefix = tagName === 'ol' ? `${idx + 1}.` : '-'; |
| 140 | + const text = li.textContent?.trim(); |
| 141 | + if (text) { |
| 142 | + lines.push(`${prefix} ${text}`); |
| 143 | + } |
| 144 | + }); |
| 145 | + lines.push(''); |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + // Handle paragraphs |
| 150 | + if (tagName === 'p') { |
| 151 | + const text = el.textContent?.trim(); |
| 152 | + if (text) { |
| 153 | + lines.push(''); |
| 154 | + lines.push(text); |
| 155 | + } |
| 156 | + return; |
| 157 | + } |
| 158 | + |
| 159 | + // Handle tables |
| 160 | + if (tagName === 'table') { |
| 161 | + lines.push(''); |
| 162 | + const rows = el.querySelectorAll('tr'); |
| 163 | + rows.forEach((row, rowIdx) => { |
| 164 | + const cells = row.querySelectorAll('th, td'); |
| 165 | + const cellTexts = Array.from(cells).map(cell => cell.textContent?.trim() || ''); |
| 166 | + lines.push('| ' + cellTexts.join(' | ') + ' |'); |
| 167 | + if (rowIdx === 0) { |
| 168 | + lines.push('| ' + cellTexts.map(() => '---').join(' | ') + ' |'); |
| 169 | + } |
| 170 | + }); |
| 171 | + lines.push(''); |
| 172 | + return; |
| 173 | + } |
| 174 | + |
| 175 | + // Recursively process children for other elements |
| 176 | + el.childNodes.forEach(child => processNode(child, depth + 1)); |
| 177 | + }; |
| 178 | + |
| 179 | + processNode(element); |
| 180 | + |
| 181 | + // Clean up the output |
| 182 | + return lines |
| 183 | + .join('\n') |
| 184 | + .replace(/\n{3,}/g, '\n\n') // Remove excessive newlines |
| 185 | + .replace(/^[\s\n]+/, '') // Trim start |
| 186 | + .replace(/[\s\n]+$/, ''); // Trim end |
| 187 | + }; |
| 188 | + |
| 189 | + const handleCopyPage = async () => { |
| 190 | + try { |
| 191 | + // Get the main content, specifically the markdown content area |
| 192 | + const mainContent = document.querySelector('.sl-markdown-content') || document.querySelector('main'); |
| 193 | + if (mainContent) { |
| 194 | + const markdown = extractCleanMarkdown(mainContent); |
| 195 | + const text = `# ${pageTitle}\n\nSource: ${pageUrl}\n\n${markdown}`; |
| 196 | + await navigator.clipboard.writeText(text); |
| 197 | + setCopied(true); |
| 198 | + setTimeout(() => { |
| 199 | + setCopied(false); |
| 200 | + setIsOpen(false); |
| 201 | + }, 2000); |
| 202 | + } |
| 203 | + } catch (err) { |
| 204 | + console.error('Failed to copy:', err); |
| 205 | + } |
| 206 | + }; |
| 207 | + |
| 208 | + const openInChatGPT = () => { |
| 209 | + const prompt = encodeURIComponent(`Read from ${pageUrl} so I can ask questions about it.`); |
| 210 | + window.open(`https://chatgpt.com/?prompt=${prompt}`, '_blank'); |
| 211 | + setIsOpen(false); |
| 212 | + }; |
| 213 | + |
| 214 | + const openInClaude = () => { |
| 215 | + const prompt = encodeURIComponent(`Read from ${pageUrl} so I can ask questions about it.`); |
| 216 | + window.open(`https://claude.ai/new?q=${prompt}`, '_blank'); |
| 217 | + setIsOpen(false); |
| 218 | + }; |
| 219 | + |
| 220 | + return ( |
| 221 | + <div className="copy-page-dropdown" ref={dropdownRef}> |
| 222 | + <button |
| 223 | + className="copy-page-button" |
| 224 | + onClick={() => setIsOpen(!isOpen)} |
| 225 | + aria-expanded={isOpen} |
| 226 | + aria-haspopup="true" |
| 227 | + > |
| 228 | + {copied ? <Check size={16} /> : <Copy size={16} />} |
| 229 | + <span>{copied ? 'Copied!' : 'Copy page'}</span> |
| 230 | + <ChevronDown size={14} className={`chevron ${isOpen ? 'rotated' : ''}`} /> |
| 231 | + </button> |
| 232 | + |
| 233 | + {isOpen && ( |
| 234 | + <div className="copy-page-menu"> |
| 235 | + <button className="menu-item" onClick={handleCopyPage}> |
| 236 | + <Copy size={16} /> |
| 237 | + <span>Copy page</span> |
| 238 | + <span className="menu-item-desc">Copy page as Markdown for LLMs</span> |
| 239 | + </button> |
| 240 | + |
| 241 | + <div className="menu-divider" /> |
| 242 | + |
| 243 | + <button className="menu-item" onClick={openInChatGPT}> |
| 244 | + <ChatGPTIcon /> |
| 245 | + <span>Open in ChatGPT</span> |
| 246 | + <ExternalLink size={12} className="external-icon" /> |
| 247 | + </button> |
| 248 | + |
| 249 | + <button className="menu-item" onClick={openInClaude}> |
| 250 | + <ClaudeIcon /> |
| 251 | + <span>Open in Claude</span> |
| 252 | + <ExternalLink size={12} className="external-icon" /> |
| 253 | + </button> |
| 254 | + </div> |
| 255 | + )} |
| 256 | + |
| 257 | + <style>{` |
| 258 | + .copy-page-dropdown { |
| 259 | + position: relative; |
| 260 | + display: inline-flex; |
| 261 | + } |
| 262 | +
|
| 263 | + .copy-page-button { |
| 264 | + display: inline-flex; |
| 265 | + align-items: center; |
| 266 | + gap: 0.375rem; |
| 267 | + padding: 0.375rem 0.75rem; |
| 268 | + background-color: var(--sl-color-gray-6); |
| 269 | + border: 1px solid var(--sl-color-gray-5); |
| 270 | + border-radius: 0.375rem; |
| 271 | + color: var(--sl-color-gray-2); |
| 272 | + font-size: 0.8125rem; |
| 273 | + font-weight: 500; |
| 274 | + cursor: pointer; |
| 275 | + transition: all 0.15s ease; |
| 276 | + white-space: nowrap; |
| 277 | + } |
| 278 | +
|
| 279 | + .copy-page-button:hover { |
| 280 | + background-color: var(--sl-color-gray-5); |
| 281 | + border-color: var(--sl-color-gray-4); |
| 282 | + } |
| 283 | +
|
| 284 | + .copy-page-button .chevron { |
| 285 | + transition: transform 0.2s ease; |
| 286 | + margin-left: 0.125rem; |
| 287 | + } |
| 288 | +
|
| 289 | + .copy-page-button .chevron.rotated { |
| 290 | + transform: rotate(180deg); |
| 291 | + } |
| 292 | +
|
| 293 | + .copy-page-menu { |
| 294 | + position: absolute; |
| 295 | + top: calc(100% + 0.375rem); |
| 296 | + right: 0; |
| 297 | + min-width: 240px; |
| 298 | + background-color: var(--sl-color-gray-6); |
| 299 | + border: 1px solid var(--sl-color-gray-5); |
| 300 | + border-radius: 0.5rem; |
| 301 | + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.15); |
| 302 | + z-index: 100; |
| 303 | + padding: 0.375rem; |
| 304 | + animation: fadeIn 0.15s ease; |
| 305 | + } |
| 306 | +
|
| 307 | + @keyframes fadeIn { |
| 308 | + from { |
| 309 | + opacity: 0; |
| 310 | + transform: translateY(-4px); |
| 311 | + } |
| 312 | + to { |
| 313 | + opacity: 1; |
| 314 | + transform: translateY(0); |
| 315 | + } |
| 316 | + } |
| 317 | +
|
| 318 | + .menu-item { |
| 319 | + display: flex; |
| 320 | + align-items: center; |
| 321 | + gap: 0.5rem; |
| 322 | + width: 100%; |
| 323 | + padding: 0.5rem 0.625rem; |
| 324 | + background: none; |
| 325 | + border: none; |
| 326 | + border-radius: 0.375rem; |
| 327 | + color: var(--sl-color-gray-2); |
| 328 | + font-size: 0.8125rem; |
| 329 | + text-align: left; |
| 330 | + cursor: pointer; |
| 331 | + transition: background-color 0.15s ease; |
| 332 | + position: relative; |
| 333 | + } |
| 334 | +
|
| 335 | + .menu-item:hover { |
| 336 | + background-color: var(--sl-color-gray-5); |
| 337 | + } |
| 338 | +
|
| 339 | + .menu-item-desc { |
| 340 | + display: block; |
| 341 | + font-size: 0.6875rem; |
| 342 | + color: var(--sl-color-gray-3); |
| 343 | + position: absolute; |
| 344 | + bottom: 0.25rem; |
| 345 | + left: 2rem; |
| 346 | + line-height: 1; |
| 347 | + } |
| 348 | +
|
| 349 | + .menu-item:has(.menu-item-desc) { |
| 350 | + padding-bottom: 1.25rem; |
| 351 | + flex-wrap: wrap; |
| 352 | + } |
| 353 | +
|
| 354 | + .external-icon { |
| 355 | + margin-left: auto; |
| 356 | + color: var(--sl-color-gray-4); |
| 357 | + } |
| 358 | +
|
| 359 | + .menu-divider { |
| 360 | + height: 1px; |
| 361 | + background-color: var(--sl-color-gray-5); |
| 362 | + margin: 0.375rem 0; |
| 363 | + } |
| 364 | +
|
| 365 | + @media (max-width: 640px) { |
| 366 | + .copy-page-button span:not(.chevron) { |
| 367 | + display: none; |
| 368 | + } |
| 369 | + |
| 370 | + .copy-page-button { |
| 371 | + padding: 0.375rem; |
| 372 | + } |
| 373 | + |
| 374 | + .copy-page-menu { |
| 375 | + right: -0.5rem; |
| 376 | + } |
| 377 | + } |
| 378 | + `}</style> |
| 379 | + </div> |
| 380 | + ); |
| 381 | +} |
0 commit comments