From 19666efd5be3433e198e715ea5bdd21e3a2eb7ce Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 19 Feb 2026 17:48:45 +0100 Subject: [PATCH] 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 --- web/src/index.css | 8 ++ web/src/pages/RulesPage.tsx | 154 ++++++++++++++++++++++++++++++------ 2 files changed, 136 insertions(+), 26 deletions(-) diff --git a/web/src/index.css b/web/src/index.css index 00f691e..93ce214 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; +} + diff --git a/web/src/pages/RulesPage.tsx b/web/src/pages/RulesPage.tsx index 3ffacf8..48b3cbc 100644 --- a/web/src/pages/RulesPage.tsx +++ b/web/src/pages/RulesPage.tsx @@ -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 }) => ( +
{children}
+ ), +}; + +// Memoised so Markdown only renders once per section, never re-parses on search +const SectionContent = memo(function SectionContent({ content }: { content: string }) { + return ( +
+ + {content} + +
+ ); +}); + +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({text.slice(idx, idx + q.length)}); + 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>(new Set()); const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const sectionsRef = useRef(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(); + 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 ( <> @@ -56,30 +160,28 @@ export default function RulesPage() { - {search.trim() && ( - {filtered.length} result{filtered.length !== 1 ? 's' : ''} + {debouncedSearch.trim() && ( + + {matchCount} result{matchCount !== 1 ? 's' : ''} + )} -
- {filtered.map((section) => ( -
- { e.preventDefault(); toggle(section.originalIndex); }}> - {section.title} +
+ {battleReferenceSections.map((section, i) => ( +
+ { e.preventDefault(); toggle(i); }}> + -
- ( -
{children}
- ), - }} - >{section.content}
-
+
))} - {filtered.length === 0 && ( + {matchSet !== null && matchSet.size === 0 && (

No sections match "{search}".