import { configDefaults } from "./config-schema.js"; import { resolveRenders } from "./render-hooks.js"; import { readerExtensionSettings } from "./reader-extensions.js"; import { usePrompt } from "./drafts.js"; import { LocalInput, LocalSelect, LocalTextArea, Choice, LocalChoice, Toggle, useCommandControl, TextArea, TextInput, } from "@quixos/camino-react/ui"; import { useEffect, useRef, useState } from "react"; import { referenceOf, useCaminoMembers, useCrdtText, useInterface, useMutationFeedback, type ExactReference, } from "@quixos/camino-react"; import { ColorPicker, FontPicker, NumberControl, Options, Switch } from "./controls.js"; import { configDefinition } from "./annotator-config.js"; import { authoringTitle, FeatureConversation } from "./tasks.js"; import { ReaderPreview } from "./preview.js"; import { interfaces } from "./gen/client.js"; import { CustomConfig, fontFields, underlineFields, configFields, configValues, type ConfigValues, type ConfigValue, } from "./annotator-config.js"; import { accepted, errorMessage, type Catalog, type FeatureNode, type ReaderNode, type Root, type NavigationValues, type DocNode, } from "./model.js"; export const appearances = [ { key: "overhead", title: "Text labels", description: "Words or notes above or below the text." }, { key: "highlight", title: "Highlight", description: "Color behind the text." }, { key: "underline", title: "Underline", description: "A line beneath the text." }, { key: "color", title: "Text color", description: "Change the color of the text." }, { key: "tooltip", title: "Tooltip", description: "A floating note on hover or click." }, { key: "sidebar", title: "Sidebar", description: "Definitions, translations, or longer notes." }, { key: "replace", title: "Replace text", description: "Display different text in place of a source span." }, { key: "bold", title: "Bold", description: "Emphasize selected words." }, { key: "italic", title: "Italic", description: "Set selected words in italics." }, ]; export const revealOptions = (style: string) => [ ...(["tooltip", "sidebar"].includes(style) ? [] : [{ value: "always", label: "Permanent" }]), ...(["overhead", "replace", "bold", "italic"].includes(style) ? [] : [{ value: "hover", label: "On hover" }]), { value: "click", label: "On click" }, ]; export function Preview({ style, color = "#53796c", placement = "above", pattern = "solid", }: { style: string; color?: string; placement?: string; pattern?: string; }) { return ( ); } export function Modal({ title, children, close, wide = false, heading, }: { title: string; children: React.ReactNode; close: () => void; wide?: boolean; heading?: React.ReactNode; }) { const panel = useRef(null), previous = useRef(null); useEffect(() => { previous.current = (panel.current?.getRootNode() as Document | ShadowRoot)?.activeElement ?? document.activeElement; panel.current?.focus(); return () => { if (previous.current instanceof HTMLElement && previous.current.isConnected) previous.current.focus(); }; }, []); return (
{ if (event.target === event.currentTarget) close(); }} >
{ if ((event.target as Element).closest("dialog[open], [popover]:popover-open")) return; if (event.key === "Escape") close(); if (event.key === "Tab") { const elements = Array.from( panel.current?.querySelectorAll( "button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),a[href]", ) ?? [], ); const active = (panel.current?.getRootNode() as Document | ShadowRoot)?.activeElement ?? document.activeElement; const first = elements[0], last = elements[elements.length - 1]; if (event.shiftKey && (active === first || active === panel.current)) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); } } }} >
{heading ??

{title}

}
{children}
); } export function FeatureToggle({ feature, onIntent, }: { feature: FeatureNode; onIntent?: (enabled: boolean | null) => void; }) { const ops = useInterface(interfaces.ReadingFeature, referenceOf(feature)); const [intent, setIntent] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(""); const enabled = intent ?? feature.data.enabled; useEffect(() => { if (intent !== null && intent === feature.data.enabled) { setIntent(null); onIntent?.(null); } }, [intent, feature.data.enabled, onIntent]); return (
{ if (ops.status !== "ready") return; setIntent(value); onIntent?.(value); setBusy(true); setError(""); const ticket = ops.value.setEnabled.call(value); void ticket.local.catch((e) => { setIntent(null); onIntent?.(null); setError(errorMessage(e)); }); void ticket.settled.then( (result) => { setBusy(false); if (result.status !== "accepted") { setIntent(null); onIntent?.(null); setError("Could not change this annotator"); } }, (e) => { setBusy(false); setIntent(null); onIntent?.(null); setError(errorMessage(e)); }, ); }} /> {enabled ? "Enabled" : "Disabled"} {error && {error}}
); } /** Reader reassignment checks the complete inverse relation, not a paginated query window. */ function ReaderAssignmentInputs({ reader, ready, }: { reader: ReaderNode; ready: React.Dispatch>>; }) { const ref = referenceOf(reader); const inputs = useCaminoMembers({ subject: ref, interface: "interface:reading:reader@1", members: ["reading:reader:documents", "reading:reader:features"], complete: { "reading:reader:documents": 512, "reading:reader:features": 512 }, }); useEffect(() => { const available = inputs.status === "ready"; ready((current) => (current[ref.object] === available ? current : { ...current, [ref.object]: available })); }, [inputs.status, ref.object, ready]); return null; } export function ReaderSettings({ library, document, root, catalog, initialReader, navigation, navigationValues, previewDocument = null, previewCursor = 0, close, }: { library: ExactReference; document?: ExactReference; root: Root; catalog: Catalog; initialReader?: string; navigation: ExactReference; navigationValues: NavigationValues; previewDocument?: DocNode | null; previewCursor?: number; close: () => void; }) { const api = useInterface(interfaces.LanguageReader, library), nav = useInterface({ selectSettings: interfaces.ReadingNavigation.selectSettings }, navigation); const chosen = navigationValues.settingsReader?._camino.ref.object ?? initialReader ?? root.defaultReader?._camino.ref.object ?? ""; const settingsWrite = useRef(null); const section = navigationValues.ui.section === "feature" ? (navigationValues.settingsFeature?._camino.ref.object ?? "general") : navigationValues.ui.section; const setSection = (section: string) => { if (nav.status !== "ready") return; const feature = root.features.entries.find((e) => referenceOf(e.node).object === section)?.node; const ticket = nav.value.selectSettings.call( { reader: null, feature: feature ? referenceOf(feature) : null, section: feature ? "feature" : section, open: false, document: null, }, { requires: settingsWrite.current ? [settingsWrite.current] : [] }, ); settingsWrite.current = ticket.operationId; void accepted(ticket).catch((e) => { if (settingsWrite.current === ticket.operationId) settingsWrite.current = null; setError(errorMessage(e)); }); }; const selectReader = (ref: ExactReference) => { if (nav.status !== "ready") return; const ticket = nav.value.selectSettings.call( { reader: asReader(ref), feature: null, section: "general", open: false, document: document ? { ...document, expectation: { kind: "interface", id: "interface:reading:document@1" } } : null, }, { requires: settingsWrite.current ? [settingsWrite.current] : [] }, ); settingsWrite.current = ticket.operationId; void accepted(ticket).catch((e) => { if (settingsWrite.current === ticket.operationId) settingsWrite.current = null; setError(errorMessage(e)); }); }; const selectFeature = (ref: ExactReference) => { if (nav.status !== "ready") return; const ticket = nav.value.selectSettings.call( { reader: null, feature: { ...ref, expectation: { kind: "interface", id: "interface:reading:feature@1" } }, section: "feature", open: false, document: null, }, { requires: settingsWrite.current ? [settingsWrite.current] : [] }, ); settingsWrite.current = ticket.operationId; void accepted(ticket).catch((e) => { if (settingsWrite.current === ticket.operationId) settingsWrite.current = null; setError(errorMessage(e)); }); }; const setChosen = (object: string) => { const reader = readers.find((r) => referenceOf(r).object === object); if (reader) selectReader(referenceOf(reader)); }; const [error, setError] = useState(""), [creating, setCreating] = useState(false), [assignmentReady, setAssignmentReady] = useState>({}); const readers = root.readers.entries.map((e) => e.node); const retainedReader = navigationValues.settingsReader; if (retainedReader && !readers.some((r) => referenceOf(r).object === referenceOf(retainedReader).object)) readers.push(retainedReader); const reader = readers.find((r) => referenceOf(r).object === chosen); const members = new Set(reader?.features.entries.map((e) => referenceOf(e.node).object) ?? []), features = root.features.entries.map((e) => e.node).filter((f) => members.has(referenceOf(f).object)); const selected = features.find((f) => referenceOf(f).object === section); const settingsExtension = readerExtensionSettings.find((extension) => section === `extension:${extension.id}`); const ExtensionSettings = settingsExtension?.Settings; const [previewReader, setPreviewReader] = useState(null), [previewFeature, setPreviewFeature] = useState(null); const shownReader = previewReader && reader && referenceOf(previewReader).object === referenceOf(reader).object ? previewReader : reader; const shownFeatures = features.map((feature) => previewFeature && referenceOf(previewFeature).object === referenceOf(feature).object ? previewFeature : feature, ); async function add(copy: boolean) { if (api.status !== "ready" || !reader || creating) return; setCreating(true); try { const ref = await accepted( api.value.addReader.call({ name: copy ? reader.data.name + " copy" : "New reader", copy: copy ? { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } } : null, }), ); selectReader(ref); } catch (e) { setError(errorMessage(e)); } finally { setCreating(false); } } return (
({ value: referenceOf(r).object, label: r.data.name, disabled: !assignmentReady[referenceOf(r).object], }))} />
New reader
{readers.map((r) => ( ))}
{reader && (selected ? ( ) : ExtensionSettings ? ( ) : section === "new" || section === "templates" ? ( setSection("general")} /> ) : ( ))} {reader && selected && !selected.authoring && ( )} {reader && ( e.node) .filter( (t) => t.data.kind === "customize" && t.reader?._camino.ref.object === referenceOf(reader).object && (selected ? selected.authoring?._camino.ref.object === referenceOf(t).object : section === "general"), )} /> )}
{shownReader && ( )}
{error &&

{error}

}
); } const asReader = (ref: ExactReference) => ({ ...ref, expectation: { kind: "interface" as const, id: "interface:reading:reader@1" as const }, }); function GeneralReader({ reader, onPreviewChange, }: { reader: ReaderNode; onPreviewChange?: (reader: ReaderNode | null) => void; }) { const api = useInterface(interfaces.ReadingReader, referenceOf(reader)), feedback = useMutationFeedback(); const [intent, setIntent] = useState(null), [paletteIntent, setPaletteIntent] = useState(null), [saveError, setSaveError] = useState(""); const saved = useRef(reader.data), queued = useRef>({}), running = useRef(false); saved.current = reader.data.configRevision >= saved.current.configRevision ? reader.data : saved.current; const d = intent ?? reader.data, [name, setName] = useState(d.name), [width, setWidth] = useState(d.width); useEffect(() => { onPreviewChange?.({ ...reader, data: d, palette: paletteIntent ?? reader.palette }); }, [reader, d, paletteIntent, onPreviewChange]); useEffect(() => () => onPreviewChange?.(null), [onPreviewChange]); useEffect(() => { if (paletteIntent && paletteIntent.text === reader.palette.text && paletteIntent.accent === reader.palette.accent) setPaletteIntent(null); }, [reader.palette, paletteIntent]); useEffect(() => { setName(d.name); setWidth(d.width); }, [d.name, d.width]); useEffect(() => { if ( !running.current && !Object.keys(queued.current).length && intent && reader.data.configRevision >= intent.configRevision ) setIntent(null); }, [reader.data, intent]); function update(changes: Partial) { if (api.status !== "ready") return; setIntent((previous) => ({ ...saved.current, ...previous, ...changes })); Object.assign(queued.current, changes); if (running.current) return; running.current = true; setSaveError(""); void (async () => { try { while (Object.keys(queued.current).length) { const next = queued.current; queued.current = {}; const { configRevision, ...settings } = saved.current; const value = { ...settings, ...next }; await accepted(api.value.configure.call({ ...value, expectedRevision: configRevision })); saved.current = { ...value, configRevision: configRevision + 1 }; } setIntent(saved.current); } catch (error) { queued.current = {}; setIntent(null); saved.current = reader.data; setSaveError(errorMessage(error)); } finally { running.current = false; } })(); } const widths = [640, 800, 1000, 1200, 1600, 0]; return (

General

{saveError &&

{saveError}

}

Applies to all texts using this reader.

Text size
update({ size })} />
Text width Text stays centered beside your notes.
setWidth(widths[Number(e.target.value)])} onPointerUp={() => width !== d.width && update({ width })} onKeyUp={() => width !== d.width && update({ width })} /> {width ? width + " px" : "Full width"}

Reading fonts

update({ font })} />

Text color

{ if (api.status !== "ready") return; const palette = { ...reader.palette, ...paletteIntent, text }; setPaletteIntent(palette); feedback.track(api.value.palette.set(palette)); }} />

Accent color

{ if (api.status !== "ready") return; const palette = { ...reader.palette, ...paletteIntent, accent }; setPaletteIntent(palette); feedback.track(api.value.palette.set(palette)); }} />

Background color

update({ background })} />
{feedback.message &&

{feedback.message}

}
); } function AnnotatorLibrary({ library, reader, catalog, archivedTypes, added, initiallyOpen, }: { library: ExactReference; reader: ReaderNode; catalog: Catalog; archivedTypes: readonly string[]; added: (ref: ExactReference) => void; initiallyOpen: boolean; }) { const api = useInterface(interfaces.LanguageReader, library); const [selected, setSelected] = useState(null), [values, setValues] = useState({}), [title, setTitle] = useState(""), [archiveIntent, setArchiveIntent] = useState>({}), [busy, setBusy] = useState(false), [error, setError] = useState(""); useEffect(() => { setArchiveIntent((previous) => Object.fromEntries( Object.entries(previous).filter(([key, archived]) => archivedTypes.includes(key) !== archived), ), ); }, [archivedTypes]); const isArchived = (key: string) => archiveIntent[key] ?? archivedTypes.includes(key); const item = catalog.find((candidate) => candidate.key === selected); const definition = configDefinition(item?.config ?? "{}"); const channels = resolveRenders(definition.renders, values); const style = channels[0]?.style ?? "color"; const choose = (candidate: Catalog[number]) => { const fields = configDefinition(candidate.config).fields; setValues(configDefaults(fields)); setTitle(candidate.label); setSelected(candidate.key); setError(""); }; async function archive(candidate: Catalog[number], archived: boolean) { if (api.status !== "ready") return; setArchiveIntent((previous) => ({ ...previous, [candidate.key]: archived })); setError(""); try { await accepted(api.value.archiveType.call({ processor: candidate.key, archived })); if (selected === candidate.key && archived) setSelected(null); } catch (error) { setArchiveIntent((previous) => { const next = { ...previous }; delete next[candidate.key]; return next; }); setError(errorMessage(error)); } } async function add() { if (api.status !== "ready" || !item) return; setBusy(true); setError(""); try { added( await accepted( api.value.addFeature.call({ authoring: null, reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } }, name: title.trim() || item.label, processor: item.key, options: JSON.stringify(values), }), ), ); } catch (error) { setError(errorMessage(error)); } finally { setBusy(false); } } return (
Use an existing type Add another annotator with its own settings.
{item ? (

{item.label}

{definition.description}

setValues((previous) => ({ ...previous, [key]: value }))} />
{style === "overhead" ? "Text labels" : (appearances.find((appearance) => appearance.key === style)?.title ?? style)}{" "} ·{" "} {channels[0]?.reveal === "click" ? "On click" : channels[0]?.reveal === "hover" ? "On hover" : "Always"}
) : ( <>
{catalog .filter((candidate) => !isArchived(candidate.key)) .map((candidate) => { const config = configDefinition(candidate.config); return (
); })} {!catalog.some((candidate) => !isArchived(candidate.key)) && (

No types in the library. Restore an archived type or create one below.

)}
{catalog.some((candidate) => isArchived(candidate.key)) && (
Archived types ({catalog.filter((candidate) => isArchived(candidate.key)).length})

Existing annotators are unaffected.

{catalog .filter((candidate) => isArchived(candidate.key)) .map((candidate) => (
{candidate.label}
))}
)} )} {error &&

{error}

}
); } function LabelPosition({ value, change, disabled = false, }: { value: string; change: (value: string) => void; disabled?: boolean; }) { return (
Label position note mot ), }, { value: "below", label: "Below", preview: ( note mot ), }, ]} change={change} />
); } function NewAnnotator({ library, reader, navigation, navigationValues, catalog, archivedTypes, added, libraryOpen, created, }: { library: ExactReference; reader: ReaderNode; navigation: ExactReference; navigationValues: NavigationValues; catalog: Catalog; archivedTypes: readonly string[]; added: (ref: ExactReference) => void; libraryOpen: boolean; created: () => void; }) { const api = useInterface(interfaces.LanguageReader, library), nav = useInterface(interfaces.ReadingNavigation, navigation); const title = usePrompt(navigation, "name"); const instructions = useCrdtText({ key: navigation.object + "/annotator-instructions", reference: navigation, operation: interfaces.ReadingNavigation.draft.edit, path: ["custom"], }); const name = title.text, text = instructions.text; const { style, reveal, color } = navigationValues.builder, options = configValues(navigationValues.builder.options); const updateBuilder = (changes: Partial) => { if (nav.status === "ready") void accepted(nav.value.builder.set({ ...navigationValues.builder, ...changes })).catch((e) => setError(errorMessage(e)), ); }; const setStyle = (style: string) => updateBuilder({ style, reveal: revealOptions(style).some((o) => o.value === reveal) ? reveal : revealOptions(style)[0].value, }); const setReveal = (reveal: string) => updateBuilder({ reveal }); const setColor = (color: string) => updateBuilder({ color }); const [busy, setBusy] = useState(false), [error, setError] = useState(""); async function build() { await Promise.all([title.flush(), instructions.flush()]); if (api.status !== "ready") return; setBusy(true); setError(""); try { await accepted( api.value.customize.call({ feature: null, reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } }, configuration: JSON.stringify({ name, style, reveal, color, ...options, instructions: text }), text: text.trim(), createdAt: new Date().toISOString(), }), ); created(); } catch (e) { setError(errorMessage(e)); } finally { setBusy(false); } } return (
Create a new type

How should it appear?

{appearances .filter((a) => !["underline", "color", "bold", "italic"].includes(a.key)) .map((a) => ( ))}
{["highlight", "underline", "color", "bold", "italic"].includes(style) && (
Format ["highlight", "underline", "color", "bold", "italic"].includes(a.key)) .map((a) => ({ value: a.key, label: a.title, preview: ( mot ), }))} change={setStyle} />
)}
{["overhead", "highlight", "underline", "color"].includes(style) && ( )}
{style === "underline" && ( updateBuilder({ options: JSON.stringify({ ...options, [key]: value }) })} /> )} {style === "overhead" && (
updateBuilder({ options: JSON.stringify({ ...options, placement }) })} />
Label font and size updateBuilder({ options: JSON.stringify({ ...options, [key]: value }) })} />
)}