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

1022 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Texts } from "./texts.js";
import { cacheConfiguration, sameCacheConfiguration } from "./cache.js";
import { configDefinition, configValues } from "./annotator-config.js";
import { presentAnnotations } from "./annotation-display.js";
import { Options } from "./controls.js";
import { Choice, SegmentedControl, useCommandControl } from "@quixos/camino-react/ui";
import { renderAnnotations, type AnnotationRender } from "./render-hooks.js";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
referenceOf,
useCaminoQuery,
useInterface,
useLiveControl,
useMutationFeedback,
type ExactReference,
} from "@quixos/camino-react";
import { interfaces, queries, type Modules } from "./gen/client.js";
import { useVocabulary, VocabularyLabel } from "./vocabulary.js";
import { useCachedAnnotations } from "./cached-results.js";
import { extensionActions, WordMenu, type ReaderContext } from "./extensions.js";
import { createReaderExtensions } from "./reader-extensions.js";
import type { CanvasContextTarget } from "./canvas/Surface.js";
import { Surface } from "./canvas/Surface.js";
import { copyText, selectionRange, type Selection } from "./canvas/selection.js";
import { intersects, type Annotation, type Range } from "./canvas/types.js";
import type { ReadingWindow } from "./canvas/pages.js";
import type { ProcessingRegion } from "./canvas/processing.js";
import { useProcessing } from "./processing.js";
import { HoverIntent, processorMatches, selectSuccessfulAnnotationJobs } from "./work.js";
import { ReaderSettings } from "./editor.js";
import { Importer, SpeechSettings, Playback, Rewrite } from "./sources.js";
import {
accepted,
asInterface,
errorMessage,
type Catalog,
type DocNode,
type FeatureNode,
type Root,
type ReaderNode,
} from "./model.js";
import { readingStyle } from "./style.js";
import { usePageViews } from "./page-views.js";
import type { NavigationValues } from "./model.js";
export const Reader: Modules["Reader"] = ({ subject, navigation, input }) => {
const app = useRef<HTMLElement>(null);
useLayoutEffect(() => {
const resize = () => {
if (app.current)
app.current.style.setProperty("--reader-top", Math.max(0, app.current.getBoundingClientRect().top) + "px");
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(document.documentElement);
window.addEventListener("resize", resize);
return () => {
observer.disconnect();
window.removeEventListener("resize", resize);
};
}, []);
const root = input.body.root,
library = subject;
const nav = useInterface(interfaces.ReadingNavigation, navigation.reference),
ops = useInterface(
{
catalog: interfaces.LanguageReader.catalog,
ensureReaders: interfaces.LanguageReader.ensureReaders,
ensureVocabulary: interfaces.LanguageReader.ensureVocabulary,
},
library,
);
const feedback = useMutationFeedback();
const ui = input.nav.root.ui;
const panel = ui.panel,
page = ui.page;
const setUI = (changes: Partial<typeof ui>) => {
if (nav.status === "ready") feedback.track(nav.value.ui.set({ ...ui, ...changes }));
};
const setPanel = (panel: string) => setUI({ panel });
const setPage = (page: string) => setUI({ page });
const [catalog, setCatalog] = useState<Catalog>([]),
[error, setError] = useState(""),
[pendingDocument, setPendingDocument] = useState<ExactReference | null>(null);
const chosen = input.nav.root.document ?? root.documents.entries.find((e) => !e.node.archived)?.node;
const renders = useMemo(
() => Object.fromEntries(catalog.map((item) => [item.key, configDefinition(item.config).renders])),
[catalog],
);
const revisions = useMemo(
() => Object.fromEntries(catalog.map((item) => [item.key, item.processorRevision])),
[catalog, root.inferenceProfile],
);
const revisionAliases = useMemo(
() =>
Object.fromEntries(
catalog.map((item) => [item.key, configDefinition(item.config).cache?.previousRevisions ?? []]),
),
[catalog],
);
const computations = useMemo(
() =>
Object.fromEntries(
root.features.entries.map(({ node: feature }) => {
const entry = catalog.find((item) => item.key === feature.data.processor);
const definition = configDefinition(entry?.config ?? "{}");
const options = {
...Object.fromEntries(definition.fields.map((field) => [field.key, field.default])),
...configValues(feature.options),
};
return [
referenceOf(feature).object,
cacheConfiguration(
definition.cache?.identity ?? feature.data.processor,
feature.data.scope,
feature.data.generation,
options,
definition.cache?.inputs ?? [],
),
];
}),
),
[catalog, root.features.entries],
);
const pendingAuthors = root.tasks.entries
.map((e) => e.node)
.filter(
(t) =>
t.data.kind === "customize" &&
["queued", "running", "waiting_input"].includes(
t.thread?.active?.progress.status ?? t.request?.progress.status ?? t.data.status,
),
);
const selected = pendingDocument
? asInterface(pendingDocument, "document")
: chosen
? asInterface(referenceOf(chosen), "document")
: undefined;
useEffect(() => {
if (pendingDocument?.object === input.nav.root.document?._camino.ref.object) setPendingDocument(null);
}, [pendingDocument?.object, input.nav.root.document?._camino.ref.object]);
const contentQuery = useCaminoQuery(queries.DocumentContentView, {
enabled: !!selected,
root: selected,
input: {},
admission: { query: queries.DocumentContentInputs, input: {} },
});
const contentReady =
contentQuery.status === "ready" &&
referenceOf(contentQuery.data.root).object === selected?.object &&
(!pendingDocument || input.nav.root.document?._camino.ref.object === pendingDocument.object);
useEffect(() => {
let closed = false;
if (ops.status === "ready")
void ops.value.catalog
.call(null)
.then((value) => {
if (!closed) setCatalog(value);
})
.catch((error) => {
if (!closed) setError(errorMessage(error));
});
return () => {
closed = true;
};
}, [ops.status, ops.status === "ready" ? ops.value : null]);
const open = (ref: ExactReference) => {
if (nav.status !== "ready") return;
setPendingDocument(ref);
const ticket = nav.value.open.call(asInterface(ref, "document"));
feedback.track(ticket);
void ticket.settled.then(
(outcome) => {
if (outcome.status !== "accepted")
setPendingDocument((current) => (current?.object === ref.object ? null : current));
},
() => setPendingDocument((current) => (current?.object === ref.object ? null : current)),
);
setUI({ panel: "", page: "reading" });
};
useEffect(() => {
if (ops.status === "ready" && !root.defaultReader)
void accepted(ops.value.ensureReaders.call(null)).catch((e) => setError(errorMessage(e)));
}, [ops.status, root.defaultReader]);
useEffect(() => {
if (ops.status === "ready" && !root.defaultVocabulary)
void accepted(ops.value.ensureVocabulary.call(null)).catch((e) => setError(errorMessage(e)));
}, [ops.status, root.defaultVocabulary]);
const selectedDoc = contentQuery.status === "ready" && contentReady ? contentQuery.data.root : null;
const activeReader = root.readers.entries
.map((e) => e.node)
.find(
(r) =>
referenceOf(r).object === (selectedDoc?.reader?._camino.ref.object ?? root.defaultReader?._camino.ref.object),
);
const openReaderSettings = () => {
if (nav.status !== "ready") return;
feedback.track(
nav.value.selectSettings.call({
reader: selectedDoc?.reader
? asInterface(referenceOf(selectedDoc.reader), "reader")
: root.defaultReader
? asInterface(referenceOf(root.defaultReader), "reader")
: null,
feature: null,
section: "general",
open: true,
document: null,
}),
);
};
return (
<section
ref={app}
className="lr-app"
aria-label="Text reader"
style={
{
background: activeReader?.data.background,
"--ink": activeReader?.palette.text,
"--green": activeReader?.palette.accent,
} as React.CSSProperties
}
>
<style>{readingStyle}</style>
<main className="lr-main">
{pendingAuthors.length > 0 && (
<button className="lr-building-status" onClick={openReaderSettings}>
<span className="lr-spinner" />
{pendingAuthors.length === 1 ? "Building annotator…" : `Building ${pendingAuthors.length} annotators…`}
</button>
)}
{page === "library" ? (
<Texts
root={root}
selected={selected?.object}
open={open}
add={() => setPanel("import")}
speech={() => setPanel("speech")}
/>
) : contentQuery.status === "ready" && contentReady ? (
<ReadingDocument
key={selected!.object}
document={{ ...contentQuery.data.root, jobs: { entries: [] } }}
library={library}
root={root}
revisions={revisions}
revisionAliases={revisionAliases}
computations={computations}
renders={renders}
reader={activeReader}
onOpen={open}
navigation={navigation.reference}
cursor={input.nav.root.cursor}
readingView={input.nav.root.reading}
annotationTarget={input.nav.root.annotationTarget}
viewports={input.nav.root.viewports}
panel={panel}
setPanel={setPanel}
onAnnotations={openReaderSettings}
onLibrary={() => setPage("library")}
/>
) : (
<div className="lr-empty">
<h2>{selected ? "Opening text…" : "No text selected"}</h2>
{!selected && <button onClick={() => setPanel("import")}>Add a text</button>}
</div>
)}
{(error || feedback.message || contentQuery.status === "error") && (
<p role="alert" className="lr-error">
{error || feedback.message || "Unable to open this document."}
</p>
)}
</main>
{panel === "import" && (
<Importer
library={library}
root={root}
initialReader={activeReader ? referenceOf(activeReader).object : undefined}
navigation={navigation.reference}
navigationValues={input.nav.root}
close={() => setPanel("")}
added={(ref) => {
setPendingDocument(ref);
setUI({ page: "reading", panel: "" });
}}
/>
)}
{panel === "speech" && <SpeechSettings library={library} close={() => setPanel("")} />}
{panel === "features" && (
<ReaderSettings
library={library}
root={root}
catalog={catalog}
navigationValues={input.nav.root}
document={selected}
previewDocument={selectedDoc ? { ...selectedDoc, jobs: { entries: [] } } : null}
previewCursor={input.nav.root.reading.position}
initialReader={activeReader ? referenceOf(activeReader).object : undefined}
navigation={navigation.reference}
close={() => setPanel("")}
/>
)}
</section>
);
};
function ReadingDocument({
document: contentDoc,
library,
root,
revisions,
revisionAliases,
computations,
renders,
reader,
onOpen,
navigation,
cursor,
readingView,
annotationTarget,
viewports,
onAnnotations,
onLibrary,
panel,
setPanel,
}: {
document: DocNode;
library: ExactReference;
root: Root;
revisions: Record<string, string>;
revisionAliases: Record<string, readonly string[]>;
computations: Record<string, string>;
renders: Record<string, AnnotationRender[] | undefined>;
reader?: ReaderNode;
onOpen: (ref: ExactReference) => void;
navigation: ExactReference;
cursor: { anchor: number; focus: number; size: number };
readingView: { displayMode: string; position: number };
annotationTarget: NavigationValues["annotationTarget"];
viewports: NavigationValues["viewports"];
panel: string;
setPanel: (panel: string) => void;
onAnnotations: () => void;
onLibrary: () => void;
}) {
const cachedAnnotations = useCachedAnnotations(referenceOf(contentDoc));
const annotationsReady = cachedAnnotations.ready;
const doc = useMemo(
() => ({
...contentDoc,
jobs: cachedAnnotations.ready ? { entries: cachedAnnotations.entries } : contentDoc.jobs,
}),
[contentDoc, cachedAnnotations.ready, cachedAnnotations.entries],
);
const ops = useInterface({ prepareVariant: interfaces.ReadingDocument.prepareVariant }, referenceOf(doc)),
nav = useInterface(interfaces.ReadingNavigation, navigation),
feedback = useMutationFeedback();
const [selection, setSelection] = useState<Selection>({ anchor: cursor.anchor, focus: cursor.focus }),
[hover, setHover] = useState<Range | null>(null),
[window, setWindow] = useState<ReadingWindow>({ visible: { start: 0, end: 0 }, nearby: { start: 0, end: 0 } }),
[message, setMessage] = useState(""),
[noteHover, setNoteHover] = useState<{ owner: string | null; range: Range } | null>(null),
[menu, setMenu] = useState<CanvasContextTarget | null>(null);
const focusControl = useLiveControl({
key: navigation.object + "/annotation-target",
operation: interfaces.ReadingNavigation.annotationTarget.set,
reference: navigation,
});
const [earlyFocus, setEarlyFocus] = useState<{ target: Range | null } | null>(null);
const focus = earlyFocus
? earlyFocus.target
: focusControl.value === undefined
? annotationTarget
: focusControl.value;
const setFocus = (target: Range | null) => {
if (focusControl.ready) focusControl.request(target);
else setEarlyFocus({ target });
};
useEffect(() => {
if (focusControl.ready && earlyFocus) {
focusControl.request(earlyFocus.target);
setEarlyFocus(null);
}
}, [focusControl.ready, earlyFocus]);
const pageViews = usePageViews(navigation, viewports, readingView.displayMode === "pages");
const rewrite = panel === "rewrite",
setRewrite = (open: boolean) => setPanel(open ? "rewrite" : "");
const cursorTimer = useRef<ReturnType<typeof setTimeout> | null>(null),
hoverIntent = useRef(new HoverIntent()),
viewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const viewRef = useRef(readingView);
viewRef.current = readingView;
const [surfacePosition, setSurfacePosition] = useState(readingView.position);
const viewEchoes = useRef(new Set<number>()),
previousMode = useRef(readingView.displayMode);
useLayoutEffect(() => {
// Delayed publication of our own save must not undo newer local movement.
// Positions written by another shared view still move this viewport.
const modeChanged = previousMode.current !== readingView.displayMode;
previousMode.current = readingView.displayMode;
if (!viewEchoes.current.delete(readingView.position) || modeChanged) setSurfacePosition(readingView.position);
}, [readingView.position, readingView.displayMode]);
const viewWrite = useRef<string | null>(null),
modeWrite = useRef<string | null>(null);
function saveReading(value: typeof readingView) {
if (nav.status !== "ready") throw Error("Reading navigation is not ready");
const ticket = nav.value.reading.set(value, { requires: viewWrite.current ? [viewWrite.current] : [] });
if (value.position !== viewRef.current.position) viewEchoes.current.add(value.position);
viewWrite.current = ticket.operationId;
void ticket.settled.then(
(result) => {
if (result.status !== "accepted") viewEchoes.current.delete(value.position);
if (result.status !== "accepted" && viewWrite.current === ticket.operationId) viewWrite.current = null;
if (modeWrite.current === ticket.operationId) modeWrite.current = null;
},
() => {
viewEchoes.current.delete(value.position);
if (viewWrite.current === ticket.operationId) viewWrite.current = null;
if (modeWrite.current === ticket.operationId) modeWrite.current = null;
},
);
return ticket;
}
const features = useMemo(() => {
const members = new Set(reader?.features.entries.map((e) => referenceOf(e.node).object) ?? []);
return root.features.entries.map((e) => e.node).filter((f) => members.has(referenceOf(f).object));
}, [root.features.entries, reader]),
jobs = useMemo(() => doc.jobs.entries.map(({ node }) => node), [doc.jobs.entries]);
const enabled = features.filter((feature) => feature.data.enabled);
const displays = enabled.flatMap((feature) =>
(renders[feature.data.processor] ?? [{ style: feature.data.style, reveal: feature.data.reveal }]).map((render) => ({
...feature,
data: { ...feature.data, style: render.style, reveal: render.reveal },
})),
);
const model = useProcessing({
document: doc,
annotationsReady,
features,
revisions,
revisionAliases,
computations,
renders,
window,
error: setMessage,
});
const currentSection = window.visible;
useEffect(() => {
if (focus) void model.focus(focus, "click");
}, [focus?.start, focus?.end, model.ready]);
const viewControl = useCommandControl({
key: navigation.object + "/mode",
value: readingView.displayMode,
submit:
nav.status === "ready"
? (mode: string) => {
if (viewTimer.current) clearTimeout(viewTimer.current);
setSurfacePosition(window.visible.start);
const ticket = saveReading({ displayMode: mode, position: window.visible.start });
modeWrite.current = ticket.operationId;
return ticket;
}
: null,
});
const onWindow = useCallback(
(next: ReadingWindow) => {
setWindow((previous) => (JSON.stringify(previous) === JSON.stringify(next) ? previous : next));
if (viewTimer.current) clearTimeout(viewTimer.current);
if (nav.status === "ready" && next.visible.start !== viewRef.current.position)
viewTimer.current = setTimeout(() => {
if (nav.status === "ready" && !modeWrite.current)
feedback.track(saveReading({ displayMode: viewRef.current.displayMode, position: next.visible.start }));
}, 250);
},
[nav.status, nav.status === "ready" ? nav.value : null],
);
const selectedRange = selectionRange(selection),
selectedText = doc.data.text.slice(selectedRange.start, selectedRange.end),
activeRange = displays.some((f) => f.data.style === "sidebar" && f.data.reveal === "hover")
? (hover ?? focus)
: focus;
useEffect(() => {
if (!cursorTimer.current) {
setSelection({ anchor: cursor.anchor, focus: cursor.focus });
}
}, [cursor.anchor, cursor.focus]);
useEffect(
() => () => {
if (cursorTimer.current) clearTimeout(cursorTimer.current);
hoverIntent.current.cancel();
if (viewTimer.current) clearTimeout(viewTimer.current);
},
[],
);
function updateSelection(value: Selection) {
setSelection(value);
if (cursorTimer.current) clearTimeout(cursorTimer.current);
cursorTimer.current = setTimeout(() => {
cursorTimer.current = null;
if (nav.status === "ready") feedback.track(nav.value.cursor.set({ ...value, size: cursor.size }));
}, 180);
}
const request = model.request;
const vocabularyRef = reader?.vocabulary
? referenceOf(reader.vocabulary)
: root.defaultVocabulary
? referenceOf(root.defaultVocabulary)
: null;
const vocabulary = useVocabulary(vocabularyRef);
const vocabularyAPI = useInterface(
{ remember: interfaces.ReadingVocabulary.remember },
vocabularyRef ? asInterface(vocabularyRef, "vocabulary") : undefined,
!!vocabularyRef,
);
const [savingWord, setSavingWord] = useState<string | null>(null);
const extensions = useMemo(
() =>
createReaderExtensions({
vocabulary: vocabularyRef,
name: vocabulary.node?.name ?? "Vocabulary",
known: vocabulary.known,
busy: savingWord,
ready: vocabularyAPI.status === "ready",
report: (error) => setMessage(errorMessage(error)),
async remember(word) {
if (vocabularyAPI.status !== "ready" || savingWord) return;
setSavingWord(word);
try {
await accepted(vocabularyAPI.value.remember.call(word));
} finally {
setSavingWord(null);
}
},
}),
[vocabularyRef?.object, vocabulary.node?.name, vocabulary.known, vocabularyAPI, savingWord],
);
const annotationMap = useMemo(() => {
const result: Annotation[] = [],
seen = new Set<string>();
for (const feature of enabled)
for (const job of selectSuccessfulAnnotationJobs(
jobs,
(job) =>
processorMatches(job.data, revisions[feature.data.processor], revisionAliases[feature.data.processor]) &&
job.data.sourceRevision === doc.data.sourceRevision &&
sameCacheConfiguration(computations[referenceOf(feature).object], job.configuration),
)) {
let items: {
start: number;
end: number;
text: string;
tag: string;
color?: string;
fontFamily?: string;
fontSize?: number;
}[];
try {
items = JSON.parse(job.data.output).items;
} catch {
continue;
}
if (!Array.isArray(items)) continue;
for (const [index, item] of presentAnnotations(feature.data.processor, items, configValues(feature.options), {
source: doc.data.text,
vocabulary: vocabulary.known,
}).entries()) {
const featureId = referenceOf(feature).object,
key = `${featureId}:${item.start}:${item.end}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(
...renderAnnotations(
{
...item,
activation: { start: job.data.start, end: job.data.end },
id: `${featureId}:${job.data.key}:${index}`,
feature: featureId,
// Broader context sits above word labels. Graph relation order
// does not express lane order and can differ between workspaces.
priority:
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
features.indexOf(feature),
style: item.style ?? (feature.data.style as Annotation["style"]),
reveal: item.reveal ?? (feature.data.reveal as Annotation["reveal"]),
color: item.color ?? feature.data.color,
},
doc.data.text,
item.style ? undefined : renders[feature.data.processor],
),
);
}
}
return result;
}, [jobs, features, revisions, revisionAliases, doc.data.sourceRevision, computations, renders, vocabulary.known]);
const visible = useCallback(
(id: string) => {
const annotation = annotationMap.find((item) => item.id === id),
feature = features.find((feature) => referenceOf(feature).object === annotation?.feature);
if (!annotation || !feature) return false;
const reveal = annotation.reveal ?? feature.data.reveal;
if (reveal === "click" && feature.data.generation === "focus" && feature.data.scope !== "word")
return (
!!focus &&
(model.clickScopes[referenceOf(feature).object] ?? []).some(
(scope) => annotation.activation?.start === scope.start && annotation.activation?.end === scope.end,
)
);
const range = reveal === "hover" ? hover : reveal === "click" ? focus : null;
return reveal === "always" || (!!range && intersects(annotation, range));
},
[annotationMap, features, focus, hover, model.clickScopes],
);
function choose(range: Range) {
setFocus(range);
setHover(null);
hoverIntent.current.cancel();
model.cancelHover();
}
function hoverWord(range: Range | null) {
if (hover?.start === range?.start && hover?.end === range?.end) return;
setHover(range);
hoverIntent.current.cancel();
model.cancelHover();
if (range)
hoverIntent.current.schedule(() => {
void model.focus(range, "hover");
});
}
const context: ReaderContext = {
library,
document: referenceOf(doc),
sourceRevision: doc.data.sourceRevision,
text: doc.data.text,
reader,
target: selectedRange.end > selectedRange.start ? selectedRange : focus,
targetKind: selectedRange.end > selectedRange.start ? "selection" : "word",
annotations: annotationMap,
highlight: useCallback((range: Range | null) => setNoteHover(range ? { owner: null, range } : null), []),
};
const wordActions = extensionActions(extensions, context);
const sidebarExtensions = extensions.filter(
(extension) => extension.Sidebar && (extension.sidebarVisible?.(context) ?? true),
);
const sidebar = annotationMap
.filter((item) => visible(item.id) && item.style === "sidebar" && intersects(item, activeRange ?? currentSection))
.slice(0, 12);
const sidebarJobs = displays
.filter((feature) => feature.data.style === "sidebar")
.flatMap((feature) =>
jobs
.filter(
(job) =>
job.data.kind === "annotation" &&
processorMatches(job.data, revisions[feature.data.processor], revisionAliases[feature.data.processor]) &&
sameCacheConfiguration(job.configuration, computations[referenceOf(feature).object]) &&
job.data.sourceRevision === doc.data.sourceRevision &&
intersects(
job.data,
(feature.data.reveal === "hover" ? hover : feature.data.reveal === "click" ? focus : currentSection) ?? {
start: 0,
end: 0,
},
),
)
.map((job) => ({ feature, job })),
);
const pendingSidebars = displays
.filter((feature) => feature.data.style === "sidebar")
.flatMap((feature) =>
model.pending
.filter(
(item) =>
computations[referenceOf(item.feature).object] === computations[referenceOf(feature).object] &&
intersects(
item.range,
(feature.data.reveal === "hover" ? hover : feature.data.reveal === "click" ? focus : currentSection) ?? {
start: 0,
end: 0,
},
),
)
.map((item) => ({ feature, range: item.range })),
);
const hoveredPanelPresent =
!noteHover?.owner ||
sidebar.some((item) => noteHover?.owner === "annotation:" + item.id) ||
pendingSidebars.some(
({ feature, range }) => noteHover?.owner === "pending:" + referenceOf(feature).object + ":" + range.start,
) ||
sidebarJobs.some(
({ feature, job }) =>
job.data.status !== "ready" &&
noteHover?.owner === "job:" + referenceOf(feature).object + ":" + referenceOf(job).object,
);
useLayoutEffect(() => {
if (!hoveredPanelPresent) setNoteHover(null);
}, [hoveredPanelPresent]);
const processing = useMemo(() => {
const scopes: ProcessingRegion[] = [];
for (const job of jobs) {
const feature = enabled.find(
(f) =>
sameCacheConfiguration(computations[referenceOf(f).object], job.configuration) &&
processorMatches(job.data, revisions[f.data.processor], revisionAliases[f.data.processor]),
);
if (
!feature ||
job.data.kind !== "annotation" ||
job.data.status !== "queued" ||
job.data.sourceRevision !== doc.data.sourceRevision ||
!processorMatches(job.data, revisions[feature.data.processor], revisionAliases[feature.data.processor])
)
continue;
scopes.push({
feature: referenceOf(feature).object,
range: { start: job.data.start, end: job.data.end },
label: `Preparing ${feature.data.name}`,
color: feature.data.color,
});
}
return scopes;
}, [jobs, features, revisions, root.inferenceProfile, doc.data.sourceRevision, computations]);
const previousLayoutAnnotations = useRef<Annotation[]>([]);
const layoutAnnotations = useMemo(() => {
const next = annotationMap.filter(
(a) => !["overhead", "replace", "bold", "italic"].includes(a.style) || visible(a.id),
);
const previous = previousLayoutAnnotations.current;
if (next.length === previous.length && next.every((annotation, index) => annotation === previous[index]))
return previous;
previousLayoutAnnotations.current = next;
return next;
}, [annotationMap, visible]);
const pendingTooltips = displays
.filter((feature) => feature.data.style === "tooltip")
.flatMap((feature) => {
const range = feature.data.reveal === "hover" ? hover : focus;
if (
!range ||
annotationMap.some((item) => item.feature === referenceOf(feature).object && intersects(item, range))
)
return [];
const waiting =
model.pending.some(
(item) => item.feature.data.processor === feature.data.processor && intersects(item.range, range),
) ||
jobs.some(
(job) =>
job.data.status === "queued" &&
job.data.sourceRevision === doc.data.sourceRevision &&
job.configuration === computations[referenceOf(feature).object] &&
processorMatches(job.data, revisions[feature.data.processor], revisionAliases[feature.data.processor]) &&
intersects(job.data, range),
);
return waiting
? [
{
...range,
id: "pending:" + referenceOf(feature).object,
feature: referenceOf(feature).object,
style: "tooltip" as const,
text: "Preparing…",
color: feature.data.color,
label: feature.data.name,
trigger: feature.data.reveal as "hover" | "click",
},
]
: [];
});
const variant = useMemo(() => {
try {
const value = JSON.parse(doc.adaptation);
return Array.isArray(value.sections)
? (value as { source: string; sections: { start: number; end: number; text?: string }[] })
: null;
} catch {
return null;
}
}, [doc.adaptation]);
const next = variant?.sections.find((s) => s.text === undefined),
variantJob = next
? [...jobs]
.sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))
.find((j) => j.data.kind === "variant" && j.data.start === next.start && j.data.end === next.end)
: undefined;
const preparingVariant = useRef(false);
const prepareVariant = useCallback(
(retry = false) => {
if (ops.status !== "ready" || preparingVariant.current) return;
preparingVariant.current = true;
void accepted(ops.value.prepareVariant.call({ createdAt: new Date().toISOString(), retry }))
.catch((e) => setMessage(errorMessage(e)))
.finally(() => {
preparingVariant.current = false;
});
},
[ops.status, ops.status === "ready" ? ops.value : null],
);
useEffect(() => {
if (next && !variantJob && (!doc.data.text || window.nearby.end >= doc.data.text.length - 1200)) prepareVariant();
}, [next?.start, variantJob, doc.data.text.length, window.nearby.end, prepareVariant]);
return (
<>
<header className="lr-document-head">
<div>
<button className="lr-library-link" onClick={onLibrary}>
‹ Texts
</button>
<h2>{doc.data.title}</h2>
{doc.data.url && (
<a
className="lr-source-link"
href={doc.data.url}
title={doc.data.attribution}
target="_blank"
rel="noreferrer"
>
Original source ↗
</a>
)}
</div>
<div className="lr-toolbar">
<button onClick={onAnnotations}>Reader settings</button>
<button onClick={() => setRewrite(true)}>Adapt text</button>
<Playback
library={library}
text={selectedText || doc.data.text.slice(currentSection.start, currentSection.end)}
/>
<div className="lr-view-mode">
<SegmentedControl
label="Reading layout"
value={readingView.displayMode}
disabled={!viewControl.ready || viewControl.pending}
options={[
{ value: "pages", label: "Pages", icon: "pages" },
{ value: "scroll", label: "Scroll", icon: "scroll" },
]}
onChange={(value) => {
void viewControl.request(value);
}}
/>
</div>
</div>
</header>
{vocabulary.continuation}
{cachedAnnotations.continuation}
<div className="lr-reading-layout">
<div className="lr-text-region">
<div
className="lr-page"
style={{ maxWidth: reader?.data.width || "none", background: reader?.data.background }}
>
<Surface
text={doc.data.text}
annotations={layoutAnnotations}
fontSize={reader?.data.size ?? 23}
fontFamily={reader?.data.font}
textColor={reader?.palette.text}
accentColor={reader?.palette.accent}
visible={visible}
selection={selection}
onSelection={updateSelection}
onFocus={choose}
onContextMenu={setMenu}
onHover={hoverWord}
onWindow={onWindow}
mode={readingView.displayMode}
position={surfacePosition}
pageSize={pageViews.pageSize}
onSize={pageViews.reportSize}
hover={hover}
emphasis={noteHover?.range ?? null}
focus={focus}
tooltips={[
...annotationMap
.filter((item) => item.style === "tooltip")
.map((item) => {
const feature = features.find((f) => referenceOf(f).object === item.feature)!;
return {
...item,
label: feature.data.name,
trigger: (item.reveal ?? feature.data.reveal) as "hover" | "click",
};
}),
...pendingTooltips,
]}
onDismissTooltip={() => {
setFocus(null);
hoverWord(null);
}}
processing={[
...processing,
...model.pending.map(({ feature, range }) => ({
feature: referenceOf(feature).object,
range,
label: `Preparing ${feature.data.name}`,
color: feature.data.color,
})),
]}
/>
{!annotationsReady && enabled.length > 0 && (
<div className="lr-variant-status" role="status">
Loading annotations…
</div>
)}
{variant && next && (
<div className="lr-variant-status" role="status">
<span>
{variantJob && ["failed", "skipped", "uncertain"].includes(variantJob.data.status)
? variantJob.data.error || "Adaptation paused"
: "Preparing more text…"}{" "}
· {variant.sections.filter((s) => s.text !== undefined).length} of {variant.sections.length} sections
</span>
{variantJob && ["failed", "skipped", "uncertain"].includes(variantJob.data.status) && (
<button onClick={() => prepareVariant(true)}>Try again</button>
)}
</div>
)}
{doc.canonical && (
<div className="lr-variant-status">
Adapted from {doc.canonical.data.title}. Sections are prepared as you read.
</div>
)}
</div>
</div>
{(displays.some((feature) => feature.data.style === "sidebar") || sidebarExtensions.length > 0) && (
<aside className="lr-margin" aria-label="Reading helpers">
<div className="lr-context">
<span className="lr-context-title">Notes</span>
{activeRange ? (
<h3 lang="zh">{doc.data.text.slice(activeRange.start, activeRange.end)}</h3>
) : (
<p className="lr-muted">Choose a word for its meaning, or a sentence for its translation.</p>
)}
{sidebar.map((item) => (
<article
className="lr-note"
key={item.id}
style={{ borderColor: item.color }}
onPointerEnter={() =>
setNoteHover({ owner: "annotation:" + item.id, range: { start: item.start, end: item.end } })
}
onPointerLeave={() => setNoteHover(null)}
>
<small>{features.find((f) => referenceOf(f).object === item.feature)?.data.name}</small>
<p>{item.text}</p>
</article>
))}
{pendingSidebars.map(({ feature, range }) => (
<div
className="lr-job"
key={referenceOf(feature).object + ":" + range.start}
onPointerEnter={() =>
setNoteHover({ owner: "pending:" + referenceOf(feature).object + ":" + range.start, range })
}
onPointerLeave={() => setNoteHover(null)}
>
<small>{feature.data.name}</small>
<p role="status">Preparing…</p>
</div>
))}
{sidebarJobs
.filter(({ job }) => job.data.status !== "ready")
.map(({ job, feature }) => (
<div
className="lr-job"
key={referenceOf(feature).object + ":" + referenceOf(job).object}
onPointerEnter={() =>
setNoteHover({
owner: "job:" + referenceOf(feature).object + ":" + referenceOf(job).object,
range: job.data,
})
}
onPointerLeave={() => setNoteHover(null)}
>
<small>{feature.data.name}</small>
<p role="status">
{job.data.status === "queued"
? job.request?.progress.status === "waiting_input"
? "Waiting for your answer"
: "Preparing…"
: job.data.error || `Request ${job.data.status}`}
</p>
{["failed", "skipped", "uncertain"].includes(job.data.status) && (
<button onClick={() => request(feature, job.data, true)}>Try again</button>
)}
</div>
))}
{sidebarExtensions.map(({ id, Sidebar }) =>
Sidebar ? <Sidebar key={id} context={context} actions={wordActions} /> : null,
)}
</div>
</aside>
)}
</div>
{processing.length > 0 && (
<p className="reading-sr" role="status">
{processing.map((item) => item.label).join(". ")}
</p>
)}
{(message || feedback.message) && (
<p className="lr-feedback" role="status">
{message || feedback.message}
</p>
)}
{menu && (
<WordMenu
target={menu}
actions={extensionActions(extensions, { ...context, target: menu.range, targetKind: menu.kind })}
close={() => setMenu(null)}
report={(error) => setMessage(errorMessage(error))}
/>
)}
{rewrite && (
<Rewrite document={referenceOf(doc)} library={library} created={onOpen} close={() => setRewrite(false)} />
)}
</>
);
}