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}
)
)}
);
}