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

1022 lines
40 KiB
TypeScript
Raw Normal View History

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