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 }) => (
+
+ ),
+};
+
+// 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); }}>
+
-
-
(
-
- ),
- }}
- >{section.content}
-
+
))}
- {filtered.length === 0 && (
+ {matchSet !== null && matchSet.size === 0 && (
No sections match "{search}".