1022 lines
40 KiB
TypeScript
1022 lines
40 KiB
TypeScript
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)} />
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|