193 lines
7.9 KiB
TypeScript
193 lines
7.9 KiB
TypeScript
import { useMemo } from "react";
|
|
import { referenceOf, type ExactReference } from "@quixos/camino-react";
|
|
import { type Catalog, type DocNode, type FeatureNode, type ReaderNode } from "./model.js";
|
|
import { useCachedAnnotations } from "./cached-results.js";
|
|
import { cacheConfiguration, sameCacheConfiguration } from "./cache.js";
|
|
import { configDefinition, configValues } from "./annotator-config.js";
|
|
import { presentAnnotations } from "./annotation-display.js";
|
|
import { renderAnnotations } from "./render-hooks.js";
|
|
import { processorMatches, selectSuccessfulAnnotationJobs } from "./work.js";
|
|
import { Surface } from "./canvas/Surface.js";
|
|
import { wordAt } from "./canvas/text.js";
|
|
import { intersects, type Annotation, type Range } from "./canvas/types.js";
|
|
import { useVocabulary } from "./vocabulary.js";
|
|
|
|
const ignore = () => {};
|
|
|
|
/** A paragraph near the reading position, bounded without cutting a source word. */
|
|
export function previewRange(text: string, position: number): Range {
|
|
const cursor = Math.max(0, Math.min(text.length, position));
|
|
const paragraphStart = cursor > 0 ? text.lastIndexOf("\n", cursor - 1) + 1 : 0;
|
|
const newline = text.indexOf("\n", cursor);
|
|
const paragraphEnd = newline < 0 ? text.length : newline;
|
|
const start =
|
|
paragraphEnd - paragraphStart > 500
|
|
? Math.max(paragraphStart, wordAt(text, Math.max(paragraphStart, cursor - 120)).start)
|
|
: paragraphStart;
|
|
const end = Math.min(paragraphEnd, start + 500);
|
|
return { start, end: end < paragraphEnd ? wordAt(text, end - 1).end : paragraphEnd };
|
|
}
|
|
|
|
/** Settings observes existing results only. It deliberately has no processing
|
|
* hook, admission call, mutation, or sample-generation effect. */
|
|
export function ReaderPreview({
|
|
document,
|
|
cursor,
|
|
reader,
|
|
features,
|
|
selected,
|
|
catalog,
|
|
defaultVocabulary,
|
|
}: {
|
|
document: DocNode | null;
|
|
cursor: number;
|
|
reader: ReaderNode;
|
|
features: FeatureNode[];
|
|
selected?: FeatureNode;
|
|
catalog: Catalog;
|
|
defaultVocabulary?: ExactReference | null;
|
|
}) {
|
|
const cached = useCachedAnnotations(document ? referenceOf(document) : null);
|
|
const vocabulary = useVocabulary(reader.vocabulary ? referenceOf(reader.vocabulary) : defaultVocabulary);
|
|
const ready = cached.ready;
|
|
const jobs = useMemo(
|
|
() => (ready ? cached.entries.map(({ node }) => node) : (document?.jobs.entries.map(({ node }) => node) ?? [])),
|
|
[ready, cached.entries, document?.jobs],
|
|
);
|
|
const text = document?.data.text ?? "";
|
|
const excerpt = useMemo(() => previewRange(text, cursor), [text, cursor]);
|
|
const annotations = useMemo(() => {
|
|
const result: Annotation[] = [];
|
|
for (const [order, feature] of features.entries()) {
|
|
if (!feature.data.enabled) continue;
|
|
const entry = catalog.find((item) => item.key === feature.data.processor);
|
|
if (!entry) continue;
|
|
const definition = configDefinition(entry.config);
|
|
const options = {
|
|
...Object.fromEntries(definition.fields.map((field) => [field.key, field.default])),
|
|
...configValues(feature.options),
|
|
};
|
|
const configuration = cacheConfiguration(
|
|
definition.cache?.identity ?? feature.data.processor,
|
|
feature.data.scope,
|
|
feature.data.generation,
|
|
options,
|
|
definition.cache?.inputs ?? [],
|
|
);
|
|
const seen = new Set<string>();
|
|
for (const job of selectSuccessfulAnnotationJobs(
|
|
jobs,
|
|
(job) =>
|
|
job.data.sourceRevision === document?.data.sourceRevision &&
|
|
processorMatches(job.data, entry.processorRevision, definition.cache?.previousRevisions ?? []) &&
|
|
sameCacheConfiguration(job.configuration, configuration),
|
|
)) {
|
|
try {
|
|
const items = JSON.parse(job.data.output).items;
|
|
if (!Array.isArray(items)) continue;
|
|
for (const [index, item] of presentAnnotations(feature.data.processor, items, options, {
|
|
source: text,
|
|
vocabulary: vocabulary.known,
|
|
}).entries()) {
|
|
if (
|
|
!Number.isInteger(item.start) ||
|
|
!Number.isInteger(item.end) ||
|
|
item.start < 0 ||
|
|
item.end > text.length ||
|
|
item.end <= item.start ||
|
|
!intersects(item, excerpt)
|
|
)
|
|
continue;
|
|
const key = `${item.start}:${item.end}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(
|
|
...renderAnnotations(
|
|
{
|
|
...item,
|
|
id: `${referenceOf(feature).object}:${job.data.key}:${index}`,
|
|
feature: referenceOf(feature).object,
|
|
activation: { start: job.data.start, end: job.data.end },
|
|
priority:
|
|
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
|
|
order,
|
|
style: item.style ?? (feature.data.style as Annotation["style"]),
|
|
reveal: item.reveal ?? (feature.data.reveal as Annotation["reveal"]),
|
|
color: item.color ?? feature.data.color,
|
|
},
|
|
text,
|
|
item.style ? undefined : definition.renders,
|
|
),
|
|
);
|
|
}
|
|
} catch {
|
|
// An invalid or incomplete saved result cannot break the settings UI.
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}, [jobs, features, catalog, document?.data.sourceRevision, text, excerpt, vocabulary.words]);
|
|
const selectedId = selected ? referenceOf(selected).object : null;
|
|
const example = annotations.find((item) => item.feature === selectedId && item.reveal !== "always");
|
|
const visible = (item: Annotation) =>
|
|
item.reveal === "always" || (!!example && item.feature === selectedId && intersects(item, example));
|
|
const shown = annotations.filter(visible);
|
|
const canvasAnnotations = shown
|
|
.filter((item) => !["sidebar", "tooltip"].includes(item.style))
|
|
.map((item) => ({
|
|
...item,
|
|
start: Math.max(excerpt.start, item.start) - excerpt.start,
|
|
end: Math.min(excerpt.end, item.end) - excerpt.start,
|
|
}));
|
|
const notes = shown.filter((item) => ["sidebar", "tooltip"].includes(item.style)).slice(0, 3);
|
|
return (
|
|
<aside className="lr-reading-preview" aria-label="Reader preview">
|
|
{vocabulary.continuation}
|
|
{cached.continuation}
|
|
<header>
|
|
<h3>Preview</h3>
|
|
{document && <span title={document.data.title}>{document.data.title}</span>}
|
|
</header>
|
|
{document && text ? (
|
|
<>
|
|
<div className="lr-reading-preview-surface" style={{ background: reader.data.background }}>
|
|
<Surface
|
|
key={`${document._camino.ref.object}:${excerpt.start}`}
|
|
text={text.slice(excerpt.start, excerpt.end)}
|
|
annotations={canvasAnnotations}
|
|
fontSize={reader.data.size}
|
|
fontFamily={reader.data.font}
|
|
textColor={reader.palette.text}
|
|
accentColor={reader.palette.accent}
|
|
mode="scroll"
|
|
interactive={false}
|
|
selection={{ anchor: 0, focus: 0 }}
|
|
onSelection={ignore}
|
|
visible={() => true}
|
|
onFocus={ignore}
|
|
onHover={ignore}
|
|
onWindow={ignore}
|
|
onDismissTooltip={ignore}
|
|
/>
|
|
</div>
|
|
{notes.map((item) => (
|
|
<div className={`lr-reading-preview-note lr-reading-preview-note-${item.style}`} key={item.id}>
|
|
<strong>{text.slice(item.start, item.end)}</strong>
|
|
<p>{item.text}</p>
|
|
</div>
|
|
))}
|
|
<p className="lr-reading-preview-status">
|
|
{!ready
|
|
? "Loading saved annotations…"
|
|
: !annotations.length && selected
|
|
? "No saved results for these options."
|
|
: "Saved annotations"}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="lr-reading-preview-empty">Open a text to preview reader settings.</p>
|
|
)}
|
|
</aside>
|
|
);
|
|
}
|