Files
package-language-reader-pre…/src/vocabulary.tsx
T

421 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import {
referenceOf,
useCaminoQuery,
useInterface,
useCrdtText,
useLiveField,
objectPresentationURL,
type ExactReference,
} from "@quixos/camino-react";
import {
Button,
Icon,
ObjectLink,
ObjectPicker,
LocalInput,
TextInput,
useCommandControl,
type ObjectPickerRequest,
} from "@quixos/camino-react/ui";
import { interfaces, queries, type Modules } from "./gen/client.js";
import { accepted, asInterface, errorMessage, type ReaderNode } from "./model.js";
import { readingStyle } from "./style.js";
type Word = { _camino: { ref: ExactReference }; text: string };
export function useVocabulary(reference?: ExactReference | null) {
const [pages, setPages] = useState<Record<string, readonly Word[]>>({});
const result = useCaminoQuery(queries.VocabularyView, {
enabled: !!reference,
root: reference ? asInterface(reference, "vocabulary") : undefined,
input: { search: "", after: null },
admission: { query: queries.VocabularyInputs, input: { search: "", after: null } },
});
useEffect(() => {
setPages({});
}, [reference?.object]);
const report = useCallback((cursor: string, words: readonly Word[] | null) => {
setPages((old) => {
const next = { ...old };
if (words) next[cursor] = words;
else delete next[cursor];
return next;
});
}, []);
const node =
result.status === "ready" && referenceOf(result.data.root).object === reference?.object ? result.data.root : null;
const words = useMemo(
() => [
...new Map(
[...(node?.words.entries.map((e) => e.node) ?? []), ...Object.values(pages).flat()].map((word) => [
referenceOf(word).object,
word,
]),
).values(),
],
[node, pages],
);
const known = useMemo(() => new Set(words.map((word) => word.text)), [words]);
return {
node,
words,
known,
ready: !!node,
continuation:
node?.words.pageInfo.hasNextPage && node.words.pageInfo.endCursor ? (
<VocabularyTail
key={reference!.object}
reference={reference!}
cursor={node.words.pageInfo.endCursor}
report={report}
/>
) : null,
};
}
function VocabularyTail({
reference,
cursor,
report,
}: {
reference: ExactReference;
cursor: string;
report(cursor: string, words: readonly Word[] | null): void;
}) {
const result = useCaminoQuery(queries.VocabularyView, {
root: asInterface(reference, "vocabulary"),
input: { search: "", after: cursor },
admission: { query: queries.VocabularyInputs, input: { search: "", after: cursor } },
});
const words = result.status === "ready" ? result.data.root.words : null;
useEffect(() => {
if (words)
report(
cursor,
words.entries.map((e) => e.node),
);
return () => report(cursor, null);
}, [words, cursor, report]);
return words?.pageInfo.hasNextPage && words.pageInfo.endCursor ? (
<VocabularyTail reference={reference} cursor={words.pageInfo.endCursor} report={report} />
) : null;
}
export const Vocabulary: Modules["Vocabulary"] = ({ subject }) => (
<section className="lr-app lr-vocabulary-app">
<style>{readingStyle}</style>
<VocabularyEditor reference={subject} />
</section>
);
export function VocabularyEditor({ reference }: { reference: ExactReference }) {
const vocabulary = useVocabulary(reference);
return vocabulary.ready ? (
<ReadyVocabularyEditor key={reference.object} reference={reference} vocabulary={vocabulary} />
) : (
<p role="status">Opening vocabulary…</p>
);
}
function ReadyVocabularyEditor({
reference,
vocabulary,
}: {
reference: ExactReference;
vocabulary: ReturnType<typeof useVocabulary>;
}) {
const api = useInterface(
{ remember: interfaces.ReadingVocabulary.remember, rename: interfaces.ReadingVocabulary.name.set },
asInterface(reference, "vocabulary"),
);
const name = useCommandControl({
key: reference.object + "/name",
value: vocabulary.node?.name ?? "",
submit: api.status === "ready" ? (value: string) => api.value.rename(value) : null,
});
const draftControl = useCrdtText({
key: reference.object + "/word-draft",
operation: interfaces.ReadingVocabulary.draft.edit,
reference: asInterface(reference, "vocabulary"),
path: ["word"],
});
const draftField = useLiveField({
key: reference.object + "/draft-clear",
operation: interfaces.ReadingVocabulary.draft.edit,
reference: asInterface(reference, "vocabulary"),
});
const draft = draftControl.text;
const [nameDraft, setNameDraft] = useState<string | null>(null);
useEffect(() => {
if (nameDraft === vocabulary.node?.name) setNameDraft(null);
}, [vocabulary.node?.name, nameDraft]);
const [search, setSearch] = useState(""),
[pending, setPending] = useState(false),
[error, setError] = useState("");
const words = vocabulary.words.filter((word) => word.text.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
return (
<div className="lr-vocabulary-editor">
{vocabulary.continuation}
<header>
<div>
<span className="lr-nav-label">Vocabulary list</span>
<h2>{vocabulary.node?.name ?? "Vocabulary"}</h2>
</div>
<span className="lr-vocab-count">
{vocabulary.words.length} {vocabulary.words.length === 1 ? "word" : "words"}
</span>
</header>
<label className="lr-field">
List name
<LocalInput
aria-label="Vocabulary list name"
value={nameDraft ?? name.value ?? vocabulary.node?.name ?? ""}
disabled={!name.ready}
maxLength={100}
onBlur={() => {
if (nameDraft?.trim() && nameDraft !== vocabulary.node?.name) name.request(nameDraft);
else setNameDraft(null);
}}
onChange={(event) => {
setNameDraft(event.target.value);
}}
/>
</label>
<div className="lr-vocab-tools">
<label className="lr-vocab-search">
<Icon name="search" />
<LocalInput
aria-label="Find saved words"
placeholder="Find a word"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<form
onSubmit={(event) => {
event.preventDefault();
if (api.status !== "ready" || !draft.trim() || pending) return;
setPending(true);
void accepted(api.value.remember.call(draft))
.then(
() => {
const current = draftField.current();
if (current.status === "ready") {
const snapshot = current.binding.getSnapshot();
if (snapshot.value.word === draft)
current.binding.edit((editor) => editor.spliceText(["word"], 0, draft.length, ""), snapshot);
}
},
(e) => setError(errorMessage(e)),
)
.finally(() => setPending(false));
}}
>
<TextInput binding={draftControl} aria-label="New vocabulary word" placeholder="Add a word" maxLength={100} />
<Button type="submit" disabled={pending || api.status !== "ready" || !draft.trim()}>
{pending ? "Adding…" : "Add"}
</Button>
</form>
</div>
{!!draftControl.error && <p role="alert">{errorMessage(draftControl.error)}</p>}
{!vocabulary.ready ? (
<p role="status">Opening vocabulary…</p>
) : words.length ? (
<ul className="lr-vocab-rows">
{words.map((word) => (
<VocabularyWord key={referenceOf(word).object} word={word} report={setError} />
))}
</ul>
) : (
<div className="lr-vocab-empty">
<Icon name="book" size={28} />
<h3>{search ? "No matching words" : "No words saved yet"}</h3>
<p>{search ? "Try another search." : "Save a word from the text or add one above."}</p>
</div>
)}
{(error || name.error) && <p role="alert">{error || name.error}</p>}
</div>
);
}
function VocabularyWord({ word, report }: { word: Word; report(message: string): void }) {
const api = useInterface({ forget: interfaces.ReadingWord.forget }, asInterface(referenceOf(word), "word"));
const [pending, setPending] = useState(false);
return (
<li>
<span>{word.text}</span>
<Button
size="compact"
tone="quiet"
disabled={pending || api.status !== "ready"}
aria-label={"Remove " + word.text}
onClick={() => {
if (api.status !== "ready") return;
setPending(true);
void accepted(api.value.forget.call(null))
.catch((e) => report(errorMessage(e)))
.finally(() => setPending(false));
}}
>
{pending ? "Removing…" : "Remove"}
</Button>
</li>
);
}
export function VocabularySettings({
library,
reader,
defaultVocabulary,
}: {
library: ExactReference;
reader: ReaderNode;
defaultVocabulary?: ExactReference | null;
}) {
const attached = reader.vocabulary ? referenceOf(reader.vocabulary) : defaultVocabulary;
const [picking, setPicking] = useState(false),
[editing, setEditing] = useState(false),
[error, setError] = useState("");
const api = useInterface(
{ attach: interfaces.ReadingReader.vocabulary.connect },
asInterface(referenceOf(reader), "reader"),
);
return (
<section className="lr-vocabulary-settings">
<h3>Vocabulary</h3>
<p>This reader uses a shared list. Saving a word updates that list for every reader attached to it.</p>
<div className="lr-vocab-attachment">
<Icon name="book" />
<div>{attached ? <VocabularyLabel reference={attached} /> : <span>No list selected</span>}</div>
<Button onClick={() => setPicking(true)}>Choose list</Button>
{attached && (
<Button tone="quiet" onClick={() => setEditing(!editing)}>
{editing ? "Close editor" : "Edit list"}
</Button>
)}
</div>
{editing && attached && <VocabularyEditor key={attached.object} reference={attached} />}
{picking && (
<VocabularyPicker
library={library}
value={attached}
close={() => setPicking(false)}
selected={(ref) => {
if (api.status !== "ready") return;
void accepted(api.value.attach(asInterface(ref, "vocabulary"))).then(
() => setPicking(false),
(e) => setError(errorMessage(e)),
);
}}
/>
)}
{error && <p role="alert">{error}</p>}
</section>
);
}
export function VocabularyLabel({ reference }: { reference: ExactReference }) {
const result = useCaminoQuery(queries.VocabularyView, {
root: asInterface(reference, "vocabulary"),
input: { search: "", after: null },
admission: { query: queries.VocabularyInputs, input: { search: "", after: null } },
});
const node = result.status === "ready" ? result.data.root : null;
return (
<ObjectLink reference={reference} href={objectPresentationURL(reference)}>
{node?.name ?? "Vocabulary"}
</ObjectLink>
);
}
function VocabularyPicker({
library,
value,
selected,
close,
}: {
library: ExactReference;
value?: ExactReference | null;
selected(ref: ExactReference): void;
close(): void;
}) {
const [request, setRequest] = useState<ObjectPickerRequest>({ location: "lists", search: "", cursor: null }),
[creating, setCreating] = useState(false),
[error, setError] = useState("");
const input = { search: request.search, after: request.cursor };
const result = useCaminoQuery(queries.VocabularyListsView, {
root: library,
input,
admission: { query: queries.VocabularyListsInputs, input },
});
const api = useInterface({ create: interfaces.LanguageReader.addVocabulary }, library);
const rows = result.status === "ready" ? result.data.root.vocabularies : null;
return (
<ObjectPicker
title="Choose a vocabulary list"
acceptedInterfaces={["interface:reading:vocabulary@1"]}
value={value}
locations={[{ id: "lists", title: "Vocabulary lists", icon: <Icon name="book" /> }]}
request={request}
onBrowse={setRequest}
page={
error
? { status: "error", error }
: rows
? {
status: "ready",
items: rows.entries.map(({ node }) => ({
reference: referenceOf(node),
title: node.name,
kind: "Vocabulary list",
description: "Share this list between readers",
open: { href: objectPresentationURL(referenceOf(node)) },
})),
nextCursor: rows.pageInfo.hasNextPage ? rows.pageInfo.endCursor : null,
}
: result.status === "error"
? { status: "error", error: errorMessage(result.error) }
: { status: "loading" }
}
onSelect={selected}
onCancel={close}
renderPreview={(item) => (
<div className="lr-vocab-picker-preview">
<Icon name="book" size={32} />
<h3>{item.title}</h3>
<p>Attach this list to the reader. Other readers can continue to use it.</p>
<VocabularyPreview reference={item.reference} />
</div>
)}
create={{
label: creating ? "Creating…" : "New vocabulary list",
disabled: creating || api.status !== "ready",
action: () => {
if (api.status !== "ready") return;
setCreating(true);
void accepted(api.value.create.call("New vocabulary list"))
.then(selected, (e) => setError(errorMessage(e)))
.finally(() => setCreating(false));
},
}}
/>
);
}
function VocabularyPreview({ reference }: { reference: ExactReference }) {
const result = useCaminoQuery(queries.VocabularyView, {
root: asInterface(reference, "vocabulary"),
input: { search: "", after: null },
admission: { query: queries.VocabularyInputs, input: { search: "", after: null } },
});
if (result.status !== "ready") return <p role="status">Loading preview…</p>;
const words = result.data.root.words;
return (
<div className="lr-vocab-preview">
<p>
{words.entries.length}
{words.pageInfo.hasNextPage ? "+" : ""} saved words
</p>
<div>
{words.entries.slice(0, 24).map(({ node }) => (
<span key={referenceOf(node).object}>{node.text}</span>
))}
</div>
</div>
);
}