|
| 1 | +import { useState, useCallback, memo } from 'react'; |
| 2 | + |
| 3 | +/** |
| 4 | + * Generate a stable section ID from text using the same algorithm as TableOfContents. |
| 5 | + * This ensures IDs match between ChartHeader and ToC navigation. |
| 6 | + */ |
| 7 | +const generateSectionId = (text) => { |
| 8 | + if (!text) return ''; |
| 9 | + const cleanText = text.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); |
| 10 | + return `section-${cleanText}`; |
| 11 | +}; |
| 12 | + |
| 13 | +/** |
| 14 | + * Link icon SVG (GitHub octicon-link style) |
| 15 | + */ |
| 16 | +const LinkIcon = ({ className }) => ( |
| 17 | + <svg |
| 18 | + className={className} |
| 19 | + viewBox="0 0 16 16" |
| 20 | + version="1.1" |
| 21 | + width="16" |
| 22 | + height="16" |
| 23 | + aria-hidden="true" |
| 24 | + > |
| 25 | + <path |
| 26 | + fill="currentColor" |
| 27 | + d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z" |
| 28 | + /> |
| 29 | + </svg> |
| 30 | +); |
| 31 | + |
| 32 | +/** |
| 33 | + * ChartHeader component with GitHub-style anchor link. |
| 34 | + * |
| 35 | + * Features: |
| 36 | + * - Generates stable section IDs matching TableOfContents algorithm |
| 37 | + * - Shows anchor icon on hover (desktop) or always visible (mobile) |
| 38 | + * - Clicking anchor copies URL to clipboard and updates browser hash |
| 39 | + * - Brief visual feedback on copy |
| 40 | + */ |
| 41 | +const ChartHeader = ({ title, compact = false, className }) => { |
| 42 | + const [copied, setCopied] = useState(false); |
| 43 | + |
| 44 | + const sectionId = generateSectionId(title); |
| 45 | + |
| 46 | + const handleAnchorClick = useCallback((e) => { |
| 47 | + e.preventDefault(); |
| 48 | + |
| 49 | + if (!sectionId) return; |
| 50 | + |
| 51 | + // Update browser URL hash |
| 52 | + window.history.pushState(null, '', `#${sectionId}`); |
| 53 | + |
| 54 | + // Copy full URL to clipboard |
| 55 | + const fullUrl = `${window.location.origin}${window.location.pathname}#${sectionId}`; |
| 56 | + navigator.clipboard.writeText(fullUrl).then(() => { |
| 57 | + setCopied(true); |
| 58 | + setTimeout(() => setCopied(false), 1500); |
| 59 | + }).catch(() => { |
| 60 | + // Fallback: just update the hash without clipboard notification |
| 61 | + }); |
| 62 | + }, [sectionId]); |
| 63 | + |
| 64 | + // Determine header classes based on compact mode or custom className |
| 65 | + const headerClasses = className || (compact |
| 66 | + ? "text-sm font-medium text-nodered-gray-700" |
| 67 | + : "text-lg font-semibold text-nodered-gray-700" |
| 68 | + ); |
| 69 | + |
| 70 | + return ( |
| 71 | + <h3 |
| 72 | + id={sectionId} |
| 73 | + className={`group flex items-center gap-2 ${headerClasses}`} |
| 74 | + > |
| 75 | + {/* Title text */} |
| 76 | + <span>{title}</span> |
| 77 | + |
| 78 | + {/* Anchor link - positioned after the heading, visible on hover (desktop) or always (mobile) */} |
| 79 | + <a |
| 80 | + href={`#${sectionId}`} |
| 81 | + onClick={handleAnchorClick} |
| 82 | + className="opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-gray-400 hover:text-nodered-red" |
| 83 | + aria-label={`Link to ${title}`} |
| 84 | + title={copied ? "Copied!" : "Copy link"} |
| 85 | + > |
| 86 | + <LinkIcon className={`w-4 h-4 ${copied ? 'text-green-500' : ''}`} /> |
| 87 | + </a> |
| 88 | + </h3> |
| 89 | + ); |
| 90 | +}; |
| 91 | + |
| 92 | +export default memo(ChartHeader); |
0 commit comments