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