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

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