perf(web): fast rules search with memoized sections and highlighting
Memoize Markdown rendering (React.memo) so sections parse once on mount. Search filters by toggling visibility (no re-render), with debounced input and DOM-based text highlighting for matches in both titles and content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -671,3 +671,11 @@ details[open] > .rules-summary::before {
|
||||
.rules-content p {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
mark.search-hl {
|
||||
background: rgba(255, 170, 0, 0.35);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,124 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect, useRef, memo } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import TopBar from '../components/layout/TopBar';
|
||||
import PageContainer from '../components/layout/PageContainer';
|
||||
import { battleReferenceSections } from '../rules/battle-reference';
|
||||
|
||||
const plugins = [remarkGfm];
|
||||
const mdComponents = {
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="table-wrap"><table>{children}</table></div>
|
||||
),
|
||||
};
|
||||
|
||||
// Memoised so Markdown only renders once per section, never re-parses on search
|
||||
const SectionContent = memo(function SectionContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="rules-content">
|
||||
<Markdown remarkPlugins={plugins} components={mdComponents}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function HighlightText({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <>{text}</>;
|
||||
const lower = text.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = lower.indexOf(q);
|
||||
let key = 0;
|
||||
|
||||
while (idx !== -1) {
|
||||
if (idx > lastIdx) parts.push(text.slice(lastIdx, idx));
|
||||
parts.push(<mark key={key++} className="search-hl">{text.slice(idx, idx + q.length)}</mark>);
|
||||
lastIdx = idx + q.length;
|
||||
idx = lower.indexOf(q, lastIdx);
|
||||
}
|
||||
|
||||
if (lastIdx < text.length) parts.push(text.slice(lastIdx));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
export default function RulesPage() {
|
||||
const [openSections, setOpenSections] = useState<Set<number>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const sectionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return battleReferenceSections.map((s, i) => ({ ...s, originalIndex: i }));
|
||||
const q = search.toLowerCase();
|
||||
return battleReferenceSections
|
||||
.map((s, i) => ({ ...s, originalIndex: i }))
|
||||
.filter((s) => s.title.toLowerCase().includes(q) || s.content.toLowerCase().includes(q));
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Which sections match (null = show all)
|
||||
const matchSet = useMemo(() => {
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
const set = new Set<number>();
|
||||
battleReferenceSections.forEach((s, i) => {
|
||||
if (s.title.toLowerCase().includes(q) || s.content.toLowerCase().includes(q))
|
||||
set.add(i);
|
||||
});
|
||||
return set;
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Auto-expand matching sections when searching
|
||||
useEffect(() => {
|
||||
if (matchSet) setOpenSections(new Set(matchSet));
|
||||
}, [matchSet]);
|
||||
|
||||
// Highlight matching text inside rendered markdown (DOM-based, no React re-render)
|
||||
useEffect(() => {
|
||||
const container = sectionsRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Clear old highlights
|
||||
container.querySelectorAll('mark.search-hl').forEach((mark) => {
|
||||
const parent = mark.parentNode;
|
||||
if (parent) {
|
||||
parent.replaceChild(document.createTextNode(mark.textContent || ''), mark);
|
||||
parent.normalize();
|
||||
}
|
||||
});
|
||||
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
if (!q) return;
|
||||
|
||||
// Walk text nodes inside visible .rules-content and wrap matches
|
||||
container.querySelectorAll('.rules-content').forEach((content) => {
|
||||
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||
const textNodes: Text[] = [];
|
||||
while (walker.nextNode()) textNodes.push(walker.currentNode as Text);
|
||||
|
||||
textNodes.forEach((node) => {
|
||||
const text = node.textContent || '';
|
||||
const lower = text.toLowerCase();
|
||||
if (!lower.includes(q)) return;
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIdx = 0;
|
||||
let idx = lower.indexOf(q);
|
||||
|
||||
while (idx !== -1) {
|
||||
if (idx > lastIdx) frag.appendChild(document.createTextNode(text.slice(lastIdx, idx)));
|
||||
const mark = document.createElement('mark');
|
||||
mark.className = 'search-hl';
|
||||
mark.textContent = text.slice(idx, idx + q.length);
|
||||
frag.appendChild(mark);
|
||||
lastIdx = idx + q.length;
|
||||
idx = lower.indexOf(q, lastIdx);
|
||||
}
|
||||
|
||||
if (lastIdx < text.length) frag.appendChild(document.createTextNode(text.slice(lastIdx)));
|
||||
node.parentNode?.replaceChild(frag, node);
|
||||
});
|
||||
});
|
||||
}, [debouncedSearch, openSections]);
|
||||
|
||||
const toggle = (index: number) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -30,13 +132,15 @@ export default function RulesPage() {
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setOpenSections(new Set(filtered.map((s) => s.originalIndex)));
|
||||
setOpenSections(new Set(battleReferenceSections.map((_, i) => i)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setOpenSections(new Set());
|
||||
};
|
||||
|
||||
const matchCount = matchSet?.size ?? battleReferenceSections.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Ship Reference" />
|
||||
@@ -56,30 +160,28 @@ export default function RulesPage() {
|
||||
<button className="btn-secondary btn-sm" onClick={collapseAll}>
|
||||
Collapse All
|
||||
</button>
|
||||
{search.trim() && (
|
||||
<span className="rules-count">{filtered.length} result{filtered.length !== 1 ? 's' : ''}</span>
|
||||
{debouncedSearch.trim() && (
|
||||
<span className="rules-count">
|
||||
{matchCount} result{matchCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rules-sections">
|
||||
{filtered.map((section) => (
|
||||
<details key={section.originalIndex} className="rules-section" open={openSections.has(section.originalIndex)}>
|
||||
<summary className="rules-summary" onClick={(e) => { e.preventDefault(); toggle(section.originalIndex); }}>
|
||||
{section.title}
|
||||
<div className="rules-sections" ref={sectionsRef}>
|
||||
{battleReferenceSections.map((section, i) => (
|
||||
<details
|
||||
key={i}
|
||||
className="rules-section"
|
||||
open={openSections.has(i)}
|
||||
style={matchSet !== null && !matchSet.has(i) ? { display: 'none' } : undefined}
|
||||
>
|
||||
<summary className="rules-summary" onClick={(e) => { e.preventDefault(); toggle(i); }}>
|
||||
<HighlightText text={section.title} query={debouncedSearch} />
|
||||
</summary>
|
||||
<div className="rules-content">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
table: ({ children }) => (
|
||||
<div className="table-wrap"><table>{children}</table></div>
|
||||
),
|
||||
}}
|
||||
>{section.content}</Markdown>
|
||||
</div>
|
||||
<SectionContent content={section.content} />
|
||||
</details>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
{matchSet !== null && matchSet.size === 0 && (
|
||||
<p className="empty-text" style={{ padding: 'var(--spacing-md) 0' }}>
|
||||
No sections match "{search}".
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user