522 lines
22 KiB
TypeScript
522 lines
22 KiB
TypeScript
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 `<h${depth} id="${id}" class="md-heading">${text}</h${depth}>`;
|
|
};
|
|
|
|
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
|
|
return `<div class="md-code-block"><div class="md-code-lang">${lang || ''}</div><pre><code class="language-${lang || ''}">${text.replace(/</g, '<').replace(/>/g, '>')}</code></pre></div>`;
|
|
};
|
|
|
|
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<HTMLDivElement>(null);
|
|
const codeRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(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<string>();
|
|
|
|
// 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 (
|
|
<div className="flex gap-4">
|
|
{/* Main content */}
|
|
<div className="flex-1 min-w-0">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1 bg-[var(--color-surface-3)] rounded-lg p-0.5">
|
|
<button
|
|
onClick={() => setMode('code')}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
mode === 'code'
|
|
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
|
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
|
}`}
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
|
|
</svg>
|
|
Code
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('preview')}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
mode === 'preview'
|
|
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)] shadow-sm'
|
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
|
}`}
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
Preview
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search toggle */}
|
|
<button
|
|
onClick={() => {
|
|
setSearchOpen(!searchOpen);
|
|
if (!searchOpen) setTimeout(() => searchInputRef.current?.focus(), 50);
|
|
}}
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors ${
|
|
searchOpen
|
|
? 'bg-accent-500/15 text-accent-400'
|
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-white/[0.04]'
|
|
}`}
|
|
title="Search in document (Cmd+F)"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
|
</svg>
|
|
<span className="hidden sm:inline">Search</span>
|
|
<kbd className="hidden sm:inline text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono ml-1">
|
|
{navigator.platform?.includes('Mac') ? '\u2318' : 'Ctrl'}F
|
|
</kbd>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleCopy}
|
|
className="text-[11px] px-2.5 py-1 rounded-md bg-white/[0.06] hover:bg-white/[0.12] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search bar */}
|
|
{searchOpen && (
|
|
<div className="mb-3 relative">
|
|
<div className="relative">
|
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
|
</svg>
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => 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
|
|
/>
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
|
{searchQuery.length >= 2 && (
|
|
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
|
{highlightCount > 0 ? `${highlightCount} match${highlightCount !== 1 ? 'es' : ''}` : 'No matches'}
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => { setSearchOpen(false); setSearchQuery(''); }}
|
|
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search results dropdown */}
|
|
{searchResults.length > 0 && (
|
|
<div className="absolute z-20 top-full left-0 right-0 mt-1 bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg shadow-xl overflow-hidden max-h-72 overflow-y-auto">
|
|
{searchResults.map((result, i) => (
|
|
<button
|
|
key={`${result.id}-${i}`}
|
|
onClick={() => navigateToResult(result)}
|
|
onMouseEnter={() => setSelectedIdx(i)}
|
|
className={`w-full text-left px-3 py-2.5 flex items-start gap-2.5 transition-colors ${
|
|
i === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.03]'
|
|
}`}
|
|
>
|
|
{result.type === 'heading' ? (
|
|
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5h13.5m-13.5 4.5h7.5m-7.5 4.5h13.5" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
</svg>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
{result.type === 'heading' ? (
|
|
<HighlightText text={result.text} query={searchQuery} className="text-sm text-[var(--color-text-primary)] font-medium" />
|
|
) : (
|
|
<>
|
|
<div className="text-[11px] text-[var(--color-text-tertiary)] mb-0.5">
|
|
{headings.find((h) => h.id === result.id)?.text ?? ''}
|
|
</div>
|
|
<HighlightText
|
|
text={result.context || ''}
|
|
query={searchQuery}
|
|
className="text-xs text-[var(--color-text-secondary)] leading-relaxed"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
{i === selectedIdx && (
|
|
<kbd className="text-[9px] px-1 py-0.5 rounded bg-white/[0.06] text-[var(--color-text-tertiary)] font-mono self-center shrink-0">
|
|
Enter
|
|
</kbd>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content area */}
|
|
<div className="relative">
|
|
{mode === 'code' ? (
|
|
<div
|
|
ref={codeRef}
|
|
className={`bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap font-mono overflow-hidden transition-[max-height] duration-300 ${
|
|
expanded ? '' : 'max-h-[32rem]'
|
|
}`}
|
|
>
|
|
{content || 'No content available'}
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={previewRef}
|
|
className={`md-preview bg-[var(--color-surface-2)] border border-[var(--color-border)] rounded-lg p-6 overflow-hidden transition-[max-height] duration-300 ${
|
|
expanded ? '' : 'max-h-[32rem]'
|
|
}`}
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
)}
|
|
|
|
{!expanded && (
|
|
<div className="mt-4 flex justify-center">
|
|
<button
|
|
onClick={() => setExpanded(true)}
|
|
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
Show full {mode === 'preview' ? 'document' : 'source'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{expanded && (
|
|
<div className="flex justify-center mt-4">
|
|
<button
|
|
onClick={() => setExpanded(false)}
|
|
className="text-[13px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-[var(--color-surface-3)] border border-[var(--color-border)] hover:border-[var(--color-primary-500)]/40 transition-all duration-150 active:scale-95"
|
|
>
|
|
<svg className="w-4 h-4 rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
Collapse
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table of Contents sidebar */}
|
|
{headings.length > 1 && mode === 'preview' && (
|
|
<nav className="hidden xl:block w-56 shrink-0 ml-6">
|
|
<div className="sticky top-28">
|
|
<h4 className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.08em] mb-3 px-3">
|
|
On this page
|
|
</h4>
|
|
<ul className="space-y-1 text-[12px] border-l-2 border-[var(--color-border)] max-h-[calc(100vh-12rem)] overflow-y-auto">
|
|
{headings.map((h) => (
|
|
<li key={h.id}>
|
|
<button
|
|
onClick={() => scrollToHeading(h.id)}
|
|
className={`block w-full text-left py-1.5 transition-all duration-150 border-l-2 -ml-0.5 ${
|
|
h.level === 1 ? 'pl-3' : h.level === 2 ? 'pl-5' : 'pl-7'
|
|
} ${
|
|
activeHeading === h.id
|
|
? 'border-[var(--color-primary-400)] text-[var(--color-primary-400)] font-medium'
|
|
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:border-[var(--color-border)]'
|
|
}`}
|
|
title={h.text}
|
|
>
|
|
<span className="line-clamp-2 leading-snug">{h.text}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</nav>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Highlights query matches within text */
|
|
function HighlightText({ text, query, className }: { text: string; query: string; className?: string }) {
|
|
if (!query.trim()) return <span className={className}>{text}</span>;
|
|
|
|
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 (
|
|
<span className={className}>
|
|
{parts.map((p, i) =>
|
|
p.match ? (
|
|
<mark key={i} className="bg-accent-400/25 text-accent-300 rounded-sm px-px">{p.text}</mark>
|
|
) : (
|
|
<span key={i}>{p.text}</span>
|
|
)
|
|
)}
|
|
</span>
|
|
);
|
|
}
|