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:
@@ -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
128
src/SearchModal.tsx
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 “{q}”</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user