textmachine/frontend/src/showcase/Goto.tsx

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>
</>
),
});