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 (
Un{" "}
{style === "overhead" && coffee}
{style === "tooltip" && coffee}
{style === "replace" ? "coffee" : "café"}
{style === "sidebar" && (
café
coffee
)}
);
}
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();
}
}
}}
>
{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 }))}
/>
) : (
<>
{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 }) })}
/>
)}
{["Translate to English", "Define unfamiliar words", "Transliterate"].map((intent) => (
))}
{error && {error}
}
);
}
export function ExistingFeature({
feature,
catalog,
onPreviewChange,
}: {
feature: FeatureNode;
catalog: Catalog;
onPreviewChange?: (feature: FeatureNode | null) => void;
}) {
const ops = useInterface(interfaces.ReadingFeature, referenceOf(feature)),
feedback = useMutationFeedback();
const d = feature.data;
const [title, setTitle] = useState(d.name),
[enabledIntent, setEnabledIntent] = useState(null),
[appearanceIntent, setAppearanceIntent] = useState | null>(null),
[draft, setDraft] = useState<{ values: ConfigValues; revision: number } | null>(null),
[saving, setSaving] = useState(false),
[error, setError] = useState("");
useEffect(() => setTitle(d.name), [d.name]);
function update(changes: Partial) {
if (ops.status !== "ready") return;
const { processor: _, configRevision, ...settings } = d;
setAppearanceIntent((previous) => ({ ...previous, ...changes }));
feedback.track(ops.value.configure.call({ ...settings, ...changes, expectedRevision: configRevision }));
}
const definition = configDefinition(catalog.find((item) => item.key === d.processor)?.config ?? "{}"),
persisted = configValues(feature.options),
values = draft?.values ?? persisted;
useEffect(() => {
onPreviewChange?.({
...feature,
data: { ...d, ...appearanceIntent, ...(enabledIntent === null ? {} : { enabled: enabledIntent }) },
options: JSON.stringify(values),
});
}, [feature, appearanceIntent, enabledIntent, draft, onPreviewChange]);
useEffect(() => () => onPreviewChange?.(null), [onPreviewChange]);
useEffect(() => {
if (
appearanceIntent &&
Object.entries(appearanceIntent).every(([key, value]) => d[key as keyof typeof d] === value)
)
setAppearanceIntent(null);
}, [d, appearanceIntent]);
const change = (key: string, value: ConfigValue) =>
setDraft((previous) => ({
revision: previous?.revision ?? d.configRevision,
values: { ...persisted, ...previous?.values, [key]: value },
}));
async function save() {
if (ops.status !== "ready" || !draft) return;
setSaving(true);
setError("");
try {
await accepted(
ops.value.configureOptions.call({ expectedRevision: draft.revision, options: JSON.stringify(draft.values) }),
);
setDraft(null);
} catch (e) {
setError(errorMessage(e));
} finally {
setSaving(false);
}
}
return (
{d.name}
{definition.description}
{resolveRenders(definition.renders, values)
.map(
(render) =>
`${appearances.find((a) => a.key === render.style)?.title ?? render.style} · ${render.reveal === "always" ? "Always" : render.reveal === "hover" ? "On hover" : "On click"}`,
)
.join("; ")}
{draft && (
)}
{(error || feedback.message) && {error || feedback.message}
}
To change what this annotator does, ask its authoring agent.
);
}
function AskForChange({
library,
reader,
feature,
navigation,
}: {
library: ExactReference;
reader: ReaderNode;
feature: FeatureNode;
navigation: ExactReference;
}) {
const prompt = usePrompt(navigation, "change");
const text = prompt.text;
const api = useInterface(interfaces.LanguageReader, library),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
return (
Change this annotator
Describe a change for its agent to build
);
}