421 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|