feat: Multiple book improvements

- Format spell tables like they are in the book
- Add permalinks to headers
- Formatting
- Book-wide search
This commit is contained in:
2026-06-26 20:50:14 -04:00
parent 7f81f85735
commit 5327b524d2
30 changed files with 25674 additions and 21123 deletions

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import SearchModal, { SearchEntry } from './SearchModal';
interface BookIndexProps {
title: string;
@@ -6,10 +7,63 @@ interface BookIndexProps {
pages: BookPage[];
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
const pageNums = useMemo(() => pages.map(p => p.n), [pages]);
const total = pageNums.length;
// Single DOMParser pass per page: build search index then inject permalink buttons.
// Both outputs are stable memos so React's dangerouslySetInnerHTML diffing never
// strips the injected buttons on re-render.
const { processedPages, searchIndex } = useMemo(() => {
const parser = new DOMParser();
const slugCount: Record<string, number> = {};
const searchIndex: SearchEntry[] = [];
const processedPages = pages.map(({ n, content }) => {
const doc = parser.parseFromString(content, 'text/html');
// 1. Index text blocks before mutation so heading textContent has no '#' noise
let currentHeading = '';
doc.body.querySelectorAll('h1, h2, h3, p, li, blockquote, td').forEach(el => {
if (/^H[1-3]$/.test(el.tagName)) {
currentHeading = el.textContent?.trim() || '';
} else {
const text = el.textContent?.trim() || '';
if (text.length > 20) {
searchIndex.push({ pageNum: n, headingContext: currentHeading, text });
}
}
});
// 2. Add IDs and permalink buttons to headings
doc.querySelectorAll('h1, h2, h3').forEach(heading => {
const baseSlug = slugify(heading.textContent || '');
if (!baseSlug) return;
slugCount[baseSlug] = (slugCount[baseSlug] || 0) + 1;
const id = slugCount[baseSlug] === 1 ? baseSlug : `${baseSlug}-${slugCount[baseSlug] - 1}`;
heading.id = id;
const btn = doc.createElement('button');
btn.className = 'heading-link';
btn.setAttribute('aria-label', 'Copy permalink');
btn.textContent = '#';
heading.insertBefore(btn, heading.firstChild);
});
return { n, content: doc.body.innerHTML };
});
return { processedPages, searchIndex };
}, [pages]);
const [currentIdx, setCurrentIdx] = useState(() => {
const m = window.location.hash.match(/^#page-(\d+)$/);
if (m) {
@@ -19,6 +73,8 @@ export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
return 0;
});
const [isSearchOpen, setIsSearchOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const goTo = useCallback((n: number, smooth: boolean, push: boolean) => {
@@ -66,9 +122,15 @@ export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
return () => container.removeEventListener('scroll', handleScroll);
}, [pageNums]);
// Keyboard navigation
// Keyboard navigation (page-level) and search shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+K / Ctrl+F → open search
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'f')) {
e.preventDefault();
setIsSearchOpen(true);
return;
}
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
setCurrentIdx(prev => {
@@ -88,16 +150,61 @@ export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
// Browser back/forward
useEffect(() => {
const handlePopState = () => {
const m = location.hash.match(/^#page-(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
const hash = location.hash.slice(1);
const pageM = hash.match(/^page-(\d+)$/);
if (pageM) {
const n = parseInt(pageM[1], 10);
if (pageNums.includes(n)) goTo(n, false, false);
} else if (hash) {
const target = document.getElementById(hash);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [goTo, pageNums]);
// Event delegation for heading permalink buttons
useEffect(() => {
const container = contentRef.current;
if (!container) return;
const handleClick = (e: MouseEvent) => {
const btn = (e.target as Element).closest('.heading-link') as HTMLButtonElement | null;
if (!btn) return;
const id = btn.closest('h1, h2, h3')?.id;
if (!id) return;
e.preventDefault();
const url = `${window.location.origin}${window.location.pathname}#${id}`;
history.pushState(null, '', `#${id}`);
navigator.clipboard.writeText(url).catch(() => {
const ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
});
const prev = btn.textContent!;
btn.textContent = '✓';
btn.classList.add('heading-link--copied');
setTimeout(() => {
btn.textContent = prev;
btn.classList.remove('heading-link--copied');
}, 1500);
};
container.addEventListener('click', handleClick);
return () => container.removeEventListener('click', handleClick);
}, []);
// Scroll to heading hash on initial load
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash && !/^page-\d+$/.test(hash)) {
const target = document.getElementById(hash);
if (target) target.scrollIntoView({ behavior: 'instant', block: 'start' });
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const currentPage = pageNums[currentIdx];
return (
@@ -105,6 +212,9 @@ export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
<header>
<div className="logo">{logoText}</div>
<div className="toolbar">
<button className="tab" onClick={() => setIsSearchOpen(true)} title="Search (Ctrl+K)">
Search
</button>
<span id="page-indicator">{currentIdx + 1} / {total}</span>
</div>
</header>
@@ -153,7 +263,7 @@ export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
</button>
</div>
<div id="content" ref={contentRef}>
{pages.map(({ n, content }) => (
{processedPages.map(({ n, content }) => (
<section
key={n}
id={`page-${n}`}
@@ -164,6 +274,14 @@ export default function BookIndex({ title, logoText, pages }: BookIndexProps) {
</div>
</div>
</div>
{isSearchOpen && (
<SearchModal
searchIndex={searchIndex}
onNavigate={n => goTo(n, false, true)}
onClose={() => setIsSearchOpen(false)}
/>
)}
</>
);
}

128
src/SearchModal.tsx Normal file
View File

@@ -0,0 +1,128 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
export interface SearchEntry {
pageNum: number;
headingContext: string;
text: string;
}
interface Props {
searchIndex: SearchEntry[];
onNavigate: (pageNum: number) => void;
onClose: () => void;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function buildSnippetHtml(text: string, q: string): string {
const lo = text.toLowerCase();
const idx = lo.indexOf(q);
if (idx === -1) return escapeHtml(text.slice(0, 140)) + (text.length > 140 ? '…' : '');
const r = 65;
const s = Math.max(0, idx - r);
const e = Math.min(text.length, idx + q.length + r);
return (
(s > 0 ? '…' : '') +
escapeHtml(text.slice(s, idx)) +
`<mark>${escapeHtml(text.slice(idx, idx + q.length))}</mark>` +
escapeHtml(text.slice(idx + q.length, e)) +
(e < text.length ? '…' : '')
);
}
export default function SearchModal({ searchIndex, onNavigate, onClose }: Props) {
const [query, setQuery] = useState('');
const [activeIdx, setActiveIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const seen: Record<number, number> = {};
const out: { pageNum: number; headingContext: string; snippetHtml: string }[] = [];
for (const entry of searchIndex) {
if (out.length >= 40) break;
const hitText = entry.text.toLowerCase().includes(q);
const hitHeading = entry.headingContext.toLowerCase().includes(q);
if (!hitText && !hitHeading) continue;
const count = seen[entry.pageNum] ?? 0;
if (count >= 2) continue;
seen[entry.pageNum] = count + 1;
out.push({
pageNum: entry.pageNum,
headingContext: entry.headingContext,
snippetHtml: buildSnippetHtml(hitText ? entry.text : entry.headingContext, q),
});
}
return out;
}, [query, searchIndex]);
useEffect(() => { setActiveIdx(0); }, [results]);
useEffect(() => {
const el = listRef.current?.children[activeIdx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [activeIdx]);
const go = useCallback((pageNum: number) => {
onNavigate(pageNum);
onClose();
}, [onNavigate, onClose]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)); }
if (e.key === 'Enter' && results[activeIdx]) go(results[activeIdx].pageNum);
};
const q = query.trim();
return (
<div className="search-overlay" onClick={onClose}>
<div className="search-modal" onClick={e => e.stopPropagation()}>
<input
ref={inputRef}
className="search-input"
type="text"
placeholder="Search the rulebook…"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
/>
{q.length < 2 ? (
<div className="search-empty">Type at least 2 characters</div>
) : results.length === 0 ? (
<div className="search-empty">No results for &ldquo;{q}&rdquo;</div>
) : (
<ul className="search-results" ref={listRef}>
{results.map((r, i) => (
<li
key={i}
className={`search-result${i === activeIdx ? ' active' : ''}`}
onClick={() => go(r.pageNum)}
onMouseEnter={() => setActiveIdx(i)}
>
<div className="search-result-meta">
<span className="search-result-heading">{r.headingContext || '—'}</span>
<span className="search-result-page">p.{r.pageNum}</span>
</div>
<div className="search-result-snippet" dangerouslySetInnerHTML={{ __html: r.snippetHtml }} />
</li>
))}
</ul>
)}
<div className="search-footer">
<span> navigate</span>
<span> go to page</span>
<span>esc close</span>
</div>
</div>
</div>
);
}