import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { marked } from 'marked'; interface Heading { level: number; text: string; id: string; } interface SearchMatch { type: 'heading' | 'text'; text: string; id: string; // heading id to scroll to context?: string; // surrounding text for text matches } interface MarkdownViewerProps { content: string; headings: Heading[]; } function renderMarkdown(content: string): string { const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ''); marked.setOptions({ gfm: true, breaks: false }); const renderer = new marked.Renderer(); renderer.heading = ({ text, depth }: { text: string; depth: number }) => { const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, ''); return `${text}`; }; renderer.code = ({ text, lang }: { text: string; lang?: string }) => { return `
${lang || ''}
${text.replace(//g, '>')}
`; }; return marked.parse(stripped, { renderer }) as string; } /** Build a searchable index: map each paragraph/line to its nearest preceding heading */ function buildSearchIndex(content: string, headings: Heading[]): { text: string; headingId: string }[] { const stripped = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ''); const lines = stripped.split('\n'); const entries: { text: string; headingId: string }[] = []; let currentHeadingId = headings[0]?.id ?? ''; let paragraph = ''; for (const line of lines) { const headingMatch = line.match(/^(#{1,4})\s+(.+)$/); if (headingMatch) { if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; } const text = headingMatch[2].replace(/[*_`\[\]]/g, '').trim(); currentHeadingId = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, ''); continue; } if (line.trim() === '') { if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); paragraph = ''; } } else { paragraph += (paragraph ? ' ' : '') + line.trim(); } } if (paragraph.trim()) { entries.push({ text: paragraph.trim(), headingId: currentHeadingId }); } return entries; } export default function MarkdownViewer({ content, headings }: MarkdownViewerProps) { const [mode, setMode] = useState<'code' | 'preview'>('preview'); const [expanded, setExpanded] = useState(false); const [activeHeading, setActiveHeading] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [searchOpen, setSearchOpen] = useState(false); const [selectedIdx, setSelectedIdx] = useState(0); const [highlightCount, setHighlightCount] = useState(0); const previewRef = useRef(null); const codeRef = useRef(null); const searchInputRef = useRef(null); const html = useMemo(() => renderMarkdown(content), [content]); const searchIndex = useMemo(() => buildSearchIndex(content, headings), [content, headings]); // Search results const searchResults = useMemo((): SearchMatch[] => { if (!searchQuery.trim() || searchQuery.length < 2) return []; const q = searchQuery.toLowerCase(); const results: SearchMatch[] = []; const seen = new Set(); // Search headings first for (const h of headings) { if (h.text.toLowerCase().includes(q)) { results.push({ type: 'heading', text: h.text, id: h.id }); seen.add(h.id); } } // Search content paragraphs for (const entry of searchIndex) { if (entry.text.toLowerCase().includes(q) && !seen.has(entry.headingId)) { const idx = entry.text.toLowerCase().indexOf(q); const start = Math.max(0, idx - 40); const end = Math.min(entry.text.length, idx + q.length + 40); const context = (start > 0 ? '...' : '') + entry.text.slice(start, end) + (end < entry.text.length ? '...' : ''); results.push({ type: 'text', text: entry.text.slice(idx, idx + q.length), id: entry.headingId, context }); seen.add(entry.headingId); } } return results.slice(0, 15); }, [searchQuery, headings, searchIndex]); // Highlight matches inside preview useEffect(() => { if (!previewRef.current || mode !== 'preview') return; // Remove old highlights previewRef.current.querySelectorAll('mark.md-search-hl').forEach((m) => { const parent = m.parentNode; if (parent) { parent.replaceChild(document.createTextNode(m.textContent || ''), m); parent.normalize(); } }); if (!searchQuery.trim() || searchQuery.length < 2) { setHighlightCount(0); return; } const walker = document.createTreeWalker(previewRef.current, NodeFilter.SHOW_TEXT); const q = searchQuery.toLowerCase(); const nodesToProcess: { node: Text; indices: number[] }[] = []; let node: Text | null; while ((node = walker.nextNode() as Text | null)) { const text = node.textContent || ''; const lower = text.toLowerCase(); const indices: number[] = []; let pos = 0; while ((pos = lower.indexOf(q, pos)) !== -1) { indices.push(pos); pos += q.length; } if (indices.length > 0) { nodesToProcess.push({ node, indices }); } } let count = 0; for (const { node: textNode, indices } of nodesToProcess) { const text = textNode.textContent || ''; const parent = textNode.parentNode; if (!parent || parent.nodeName === 'CODE' || parent.nodeName === 'PRE') continue; const frag = document.createDocumentFragment(); let lastIdx = 0; for (const idx of indices) { if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx))); const mark = document.createElement('mark'); mark.className = 'md-search-hl'; mark.textContent = text.slice(idx, idx + q.length); frag.appendChild(mark); lastIdx = idx + q.length; count++; } if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx))); parent.replaceChild(frag, textNode); } setHighlightCount(count); }, [searchQuery, mode, html]); // Track active heading on scroll useEffect(() => { if (mode !== 'preview' || !previewRef.current) return; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) setActiveHeading(entry.target.id); } }, { rootMargin: '-80px 0px -70% 0px', threshold: 0 } ); const headingEls = previewRef.current.querySelectorAll('.md-heading'); headingEls.forEach((el) => observer.observe(el)); return () => observer.disconnect(); }, [mode, html]); // Keyboard shortcut: Cmd/Ctrl+F to open search useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'f') { // Only intercept if the viewer is visible if (previewRef.current || codeRef.current) { e.preventDefault(); setSearchOpen(true); setTimeout(() => searchInputRef.current?.focus(), 50); } } if (e.key === 'Escape' && searchOpen) { setSearchOpen(false); setSearchQuery(''); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [searchOpen]); // Reset selection when results change useEffect(() => { setSelectedIdx(0); }, [searchResults]); const handleCopy = () => { navigator.clipboard.writeText(content); }; const scrollToHeading = useCallback((id: string) => { if (!expanded) setExpanded(true); // Small delay to allow expand animation setTimeout(() => { const container = previewRef.current || codeRef.current; const el = container?.querySelector(`#${CSS.escape(id)}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); setActiveHeading(id); } }, expanded ? 0 : 100); }, [expanded]); const navigateToResult = useCallback((result: SearchMatch) => { if (mode !== 'preview') setMode('preview'); scrollToHeading(result.id); setSearchOpen(false); }, [mode, scrollToHeading]); const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIdx((i) => Math.min(i + 1, searchResults.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIdx((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter' && searchResults[selectedIdx]) { e.preventDefault(); navigateToResult(searchResults[selectedIdx]); } }; return (
{/* Main content */}
{/* Toolbar */}
{/* Search toggle */}
{/* Search bar */} {searchOpen && (
setSearchQuery(e.target.value)} onKeyDown={handleSearchKeyDown} placeholder="Search sections and content..." className="w-full bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg pl-9 pr-20 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-accent-400/50 focus:ring-1 focus:ring-accent-400/20 font-sans" autoFocus />
{searchQuery.length >= 2 && ( {highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'} )}
{/* Search results dropdown */} {searchResults.length > 0 && (
{searchResults.map((result, i) => ( ))}
)}
)} {/* Content area */}
{mode === 'code' ? (
{content || 'No content available'}
) : (
)} {!expanded && (
)} {expanded && (
)}
{/* Table of Contents sidebar */} {headings.length > 1 && mode === 'preview' && ( )}
); } /** Highlights query matches within text */ function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) { if (!query.trim()) return {text}; const parts: { text: string; match: boolean }[] = []; const lower = text.toLowerCase(); const q = query.toLowerCase(); let lastIdx = 0; let pos = 0; while ((pos = lower.indexOf(q, lastIdx)) !== -1) { if (pos > lastIdx) parts.push({ text: text.slice(lastIdx, pos), match: false }); parts.push({ text: text.slice(pos, pos + q.length), match: true }); lastIdx = pos + q.length; } if (lastIdx < text.length) parts.push({ text: text.slice(lastIdx), match: false }); return ( {parts.map((p, i) => p.match ? ( {p.text} ) : ( {p.text} ) )} ); }