133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
|
|
import type { BankTerm, Chapter } from '../api';
|
|
import { useText } from '../i18n/text';
|
|
import { FilterField } from '../ui/FilterField';
|
|
import { List, type ListRow } from '../ui/List';
|
|
import { Modal } from '../ui/Modal';
|
|
import { chapterLabel } from './format';
|
|
import { matchesTerm } from './terms';
|
|
import styles from './Goto.module.css';
|
|
|
|
interface Props {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
chapters: Chapter[];
|
|
terms: BankTerm[];
|
|
onOpenChapter: (chapterId: string) => void;
|
|
/** A term leads into the bank: the panel switches to it and the filter lands on that term. */
|
|
onOpenTerm: (src: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Past the first fifty matches the list is not read — refining the query is faster than scrolling.
|
|
*/
|
|
const LIMIT = 50;
|
|
|
|
/**
|
|
* A quick jump to a chapter or a term (remark 4). This is NOT a search through the text of the
|
|
* book: the contract has no search handle at all, which is why the placeholder "Search" tab was
|
|
* removed rather than left standing as a promise (Ф-33).
|
|
*/
|
|
export function Goto({ isOpen, onClose, chapters, terms, onOpenChapter, onOpenTerm }: Props) {
|
|
const text = useText();
|
|
const [query, setQuery] = useState('');
|
|
// Closed — the query is forgotten: the next call of the palette starts from a blank sheet, as the
|
|
// add-book modal does. Otherwise it opens with the previous query but without a selection — and
|
|
// the very first letter is appended to it.
|
|
const close = () => {
|
|
setQuery('');
|
|
onClose();
|
|
};
|
|
|
|
const { rows, truncated } = useMemo(() => {
|
|
const needle = query.trim().toLowerCase();
|
|
// The word for the kind of row is looked up ONCE, not per row: the loop below walks the whole
|
|
// book, and on 2284 chapters that is 2284 lookups for one and the same word.
|
|
const chapterWhat = text('goto.chapter');
|
|
const termWhat = text('goto.term');
|
|
const chapterHits: ListRow[] = [];
|
|
chapters.forEach((chapter, index) => {
|
|
const label = chapterLabel(chapter, index + 1);
|
|
if (needle === '' || label.toLowerCase().includes(needle)) {
|
|
chapterHits.push(row(`chapter:${chapter.id}`, label, chapterWhat));
|
|
}
|
|
});
|
|
// The key is the opaque id of the term: one hanzi legally arrives as several rows (`sense` and
|
|
// the applicability window are part of the key), and a key made of the visible side collapsed
|
|
// them together.
|
|
const termHits =
|
|
needle === ''
|
|
? []
|
|
: terms.filter(matchesTerm(needle)).map((term) =>
|
|
row(
|
|
`term:${term.id}`,
|
|
// The applicability window is named here too: the contract calls `since_chapter` a
|
|
// SPOILER boundary, and a term from the three-hundredth chapter must not look like an
|
|
// ordinary one.
|
|
term.since_chapter > 0
|
|
? text('goto.termWithWindow', {
|
|
src: term.src,
|
|
dst: term.dst,
|
|
chapter: term.since_chapter,
|
|
})
|
|
: text('goto.termRow', { src: term.src, dst: term.dst }),
|
|
termWhat,
|
|
),
|
|
);
|
|
|
|
// Chapters and terms are given slots of THEIR OWN: on a book of 2284 chapters the query "1"
|
|
// produced half a hundred chapters, and not one term reached the list — however much the query
|
|
// was refined.
|
|
const half = Math.floor(LIMIT / 2);
|
|
const shownChapters = chapterHits.slice(0, Math.max(half, LIMIT - termHits.length));
|
|
const shownTerms = termHits.slice(0, LIMIT - shownChapters.length);
|
|
return {
|
|
rows: [...shownChapters, ...shownTerms],
|
|
truncated: chapterHits.length + termHits.length > LIMIT,
|
|
};
|
|
}, [chapters, terms, query, text]);
|
|
|
|
return (
|
|
<Modal title={text('goto.title')} isOpen={isOpen} onClose={close} size="wide">
|
|
{/* Escape is intercepted BEFORE the field: the search primitive clears its own line on the
|
|
first press and does not let it out, which is why the window closed only on the second. */}
|
|
<div
|
|
className={styles.palette}
|
|
onKeyDownCapture={(event) => {
|
|
if (event.key === 'Escape') close();
|
|
}}
|
|
>
|
|
<FilterField label={text('goto.field')} value={query} onChange={setQuery} autoFocus />
|
|
<div className={styles.results}>
|
|
<List
|
|
label={text('goto.results')}
|
|
items={rows}
|
|
empty={text('goto.empty')}
|
|
onSelect={(id) => {
|
|
const [what, ...rest] = id.split(':');
|
|
const value = rest.join(':');
|
|
if (what === 'chapter') onOpenChapter(value);
|
|
else onOpenTerm(terms.find((term) => term.id === value)?.src ?? '');
|
|
close();
|
|
}}
|
|
/>
|
|
</div>
|
|
{/* The cut of the list is named out loud: a silent "not all of them are shown" reads as
|
|
"this is all there is". */}
|
|
{truncated && <p className={styles.more}>{text('goto.truncated', { count: LIMIT })}</p>}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const row = (id: string, label: string, what: string): ListRow => ({
|
|
id,
|
|
text: label,
|
|
content: (
|
|
<>
|
|
<span className={styles.label}>{label}</span>
|
|
<span className={styles.what}>{what}</span>
|
|
</>
|
|
),
|
|
});
|