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

1383 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div
className={"lr-preview lr-preview-" + style + " lr-preview-" + placement + " lr-preview-line-" + pattern}
style={{ "--annotation": color } as React.CSSProperties}
aria-hidden="true"
>
<span className="lr-preview-source">
Un{" "}
<b>
{style === "overhead" && <small className="lr-preview-label">coffee</small>}
{style === "tooltip" && <small className="lr-preview-tip-label">coffee</small>}
{style === "replace" ? "coffee" : "café"}
</b>
</span>
{style === "sidebar" && (
<small className="lr-preview-margin">
café
<br />
coffee
</small>
)}
</div>
);
}
export function Modal({
title,
children,
close,
wide = false,
heading,
}: {
title: string;
children: React.ReactNode;
close: () => void;
wide?: boolean;
heading?: React.ReactNode;
}) {
const panel = useRef<HTMLDivElement>(null),
previous = useRef<Element | null>(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 (
<div
className="lr-backdrop"
onPointerDown={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<div
className={wide ? "lr-modal lr-settings-modal" : "lr-modal"}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
ref={panel}
onKeyDown={(event) => {
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<HTMLElement>(
"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();
}
}
}}
>
<header>
<div>{heading ?? <h2>{title}</h2>}</div>
<button className="lr-icon" onClick={close} aria-label="Close dialog">
×
</button>
</header>
{children}
</div>
</div>
);
}
export function FeatureToggle({
feature,
onIntent,
}: {
feature: FeatureNode;
onIntent?: (enabled: boolean | null) => void;
}) {
const ops = useInterface(interfaces.ReadingFeature, referenceOf(feature));
const [intent, setIntent] = useState<boolean | null>(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 (
<div className="lr-feature-toggle">
<Switch
label={feature.data.name + " enabled"}
value={enabled}
disabled={busy || ops.status !== "ready"}
change={(value) => {
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));
},
);
}}
/>
<span>{enabled ? "Enabled" : "Disabled"}</span>
{error && <small role="alert">{error}</small>}
</div>
);
}
/** Reader reassignment checks the complete inverse relation, not a paginated query window. */
function ReaderAssignmentInputs({
reader,
ready,
}: {
reader: ReaderNode;
ready: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
}) {
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<string | null>(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<Record<string, boolean>>({});
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<ReaderNode | null>(null),
[previewFeature, setPreviewFeature] = useState<FeatureNode | null>(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 (
<Modal wide title="Reader settings" close={close}>
<div className="lr-settings-top">
<LocalChoice
aria-label="Configure reader"
className="lr-reader-choice"
value={reader ? referenceOf(reader).object : ""}
onValueChange={setChosen}
disabled={nav.status !== "ready"}
options={readers.map((r) => ({
value: referenceOf(r).object,
label: r.data.name,
disabled: !assignmentReady[referenceOf(r).object],
}))}
/>
<div className="lr-reader-actions">
<button disabled={!reader || creating} onClick={() => void add(true)}>
Clone reader
</button>
<details className="lr-new-reader">
<summary>New reader</summary>
<div>
<button disabled={creating} onClick={() => void add(false)}>
Blank reader
</button>
<button
disabled={creating || api.status !== "ready"}
onClick={(event) => {
if (api.status !== "ready") return;
event.currentTarget.closest("details")?.removeAttribute("open");
setCreating(true);
void accepted(api.value.addFrenchReader.call(null))
.then(
(ref) => {
selectReader(ref);
},
(e) => setError(errorMessage(e)),
)
.finally(() => setCreating(false));
}}
>
French · click glosses
</button>
</div>
</details>
</div>
{readers.map((r) => (
<ReaderAssignmentInputs key={referenceOf(r).object} reader={r} ready={setAssignmentReady} />
))}
</div>
<div className="lr-settings-body lr-settings-with-preview">
<nav className="lr-settings-nav" aria-label="Reader settings sections">
<button
disabled={nav.status !== "ready"}
aria-current={section === "general" ? "page" : undefined}
onClick={() => setSection("general")}
>
General
</button>
{readerExtensionSettings
.filter((extension) => extension.Settings)
.map((extension) => (
<button
key={extension.id}
disabled={nav.status !== "ready"}
aria-current={section === `extension:${extension.id}` ? "page" : undefined}
onClick={() => setSection(`extension:${extension.id}`)}
>
{extension.title}
</button>
))}
<div className="lr-nav-label">Annotators</div>
{features.map((f) => (
<button
key={referenceOf(f).object}
disabled={nav.status !== "ready"}
aria-current={section === referenceOf(f).object ? "page" : undefined}
onClick={() => setSection(referenceOf(f).object)}
>
<i className="lr-annotator-dot" />
{f.data.name}
{!f.data.enabled && <small>Off</small>}
</button>
))}
{root.tasks.entries
.map((e) => e.node)
.filter(
(t) =>
t.data.kind === "customize" &&
t.reader?._camino.ref.object === chosen &&
!features.some((f) => f.authoring?._camino.ref.object === referenceOf(t).object) &&
["queued", "running", "waiting_input"].includes(
t.thread?.active?.progress.status ?? t.request?.progress.status ?? t.data.status,
),
)
.map((t) => (
<div className="lr-building-annotator" role="status" key={referenceOf(t).object}>
<span className="lr-spinner" />
<span>
{authoringTitle(t)}
<small>Building…</small>
</span>
</div>
))}
<button disabled={nav.status !== "ready"} onClick={() => setSection("new")}>
+ New annotator
</button>
</nav>
<div className="lr-settings-content" key={`${reader ? referenceOf(reader).object : ""}:${section}`}>
<nav className="lr-breadcrumb" aria-label="Settings path">
<button onClick={() => setSection("general")}>{reader?.data.name ?? "Reader"}</button>
<span>
{" "}
/{" "}
{selected?.data.name ??
(settingsExtension
? settingsExtension.title
: section === "new"
? "New annotator"
: section === "templates"
? "New annotator"
: "General")}
</span>
</nav>
{reader &&
(selected ? (
<ExistingFeature
key={referenceOf(selected).object}
feature={selected}
catalog={catalog}
onPreviewChange={setPreviewFeature}
/>
) : ExtensionSettings ? (
<ExtensionSettings
library={library}
reader={reader}
defaultVocabulary={root.defaultVocabulary ? referenceOf(root.defaultVocabulary) : null}
/>
) : section === "new" || section === "templates" ? (
<NewAnnotator
key={referenceOf(reader).object}
library={library}
reader={reader}
navigation={navigation}
navigationValues={navigationValues}
catalog={catalog}
archivedTypes={root.archivedTypes}
added={selectFeature}
libraryOpen={section === "templates"}
created={() => setSection("general")}
/>
) : (
<GeneralReader key={referenceOf(reader).object} reader={reader} onPreviewChange={setPreviewReader} />
))}
{reader && selected && !selected.authoring && (
<AskForChange library={library} reader={reader} feature={selected} navigation={navigation} />
)}
{reader && (
<FeatureConversation
library={library}
reader={referenceOf(reader)}
navigation={navigation}
tasks={root.tasks.entries
.map((e) => 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"),
)}
/>
)}
</div>
{shownReader && (
<ReaderPreview
defaultVocabulary={root.defaultVocabulary ? referenceOf(root.defaultVocabulary) : null}
document={previewDocument}
cursor={previewCursor}
reader={shownReader}
features={shownFeatures}
selected={selected}
catalog={catalog}
/>
)}
</div>
{error && <p role="alert">{error}</p>}
</Modal>
);
}
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<ReaderNode["data"] | null>(null),
[paletteIntent, setPaletteIntent] = useState<ReaderNode["palette"] | null>(null),
[saveError, setSaveError] = useState("");
const saved = useRef(reader.data),
queued = useRef<Partial<ReaderNode["data"]>>({}),
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<typeof d>) {
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 (
<section className="lr-settings-section">
<h3>General</h3>
{saveError && <p role="alert">{saveError}</p>}
<p className="lr-muted">Applies to all texts using this reader.</p>
<label className="lr-field lr-name-field">
Reader name
<LocalInput
aria-label="Reader name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => name !== d.name && update({ name })}
maxLength={100}
/>
</label>
<div className="lr-setting-row">
<div>
<strong>Text size</strong>
</div>
<NumberControl label="Reader font size" value={d.size} min={12} max={64} change={(size) => update({ size })} />
</div>
<div className="lr-setting-row">
<div>
<strong>Text width</strong>
<small>Text stays centered beside your notes.</small>
</div>
<div className="lr-width-control">
<LocalInput
aria-label="Maximum reading width"
type="range"
min={0}
max={5}
step={1}
value={Math.max(0, widths.indexOf(width))}
onChange={(e) => setWidth(widths[Number(e.target.value)])}
onPointerUp={() => width !== d.width && update({ width })}
onKeyUp={() => width !== d.width && update({ width })}
/>
<small>{width ? width + " px" : "Full width"}</small>
</div>
</div>
<div className="lr-setting-block">
<h4>Reading fonts</h4>
<FontPicker label="Reader font and fallbacks" value={d.font} change={(font) => update({ font })} />
</div>
<div className="lr-setting-block">
<h4>Text color</h4>
<ColorPicker
label="Reader text color"
value={reader.palette.text}
change={(text) => {
if (api.status !== "ready") return;
const palette = { ...reader.palette, ...paletteIntent, text };
setPaletteIntent(palette);
feedback.track(api.value.palette.set(palette));
}}
/>
</div>
<div className="lr-setting-block">
<h4>Accent color</h4>
<ColorPicker
label="Reader accent color"
value={reader.palette.accent}
change={(accent) => {
if (api.status !== "ready") return;
const palette = { ...reader.palette, ...paletteIntent, accent };
setPaletteIntent(palette);
feedback.track(api.value.palette.set(palette));
}}
/>
</div>
<div className="lr-setting-block">
<h4>Background color</h4>
<ColorPicker
label="Reader background"
background
value={d.background}
change={(background) => update({ background })}
/>
</div>
{feedback.message && <p role="alert">{feedback.message}</p>}
</section>
);
}
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<string | null>(null),
[values, setValues] = useState<ConfigValues>({}),
[title, setTitle] = useState(""),
[archiveIntent, setArchiveIntent] = useState<Record<string, boolean>>({}),
[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 (
<details className="lr-type-library" open={initiallyOpen || undefined}>
<summary>
<span className="lr-library-symbol" aria-hidden="true">
▦
</span>
<span>
<strong>Use an existing type</strong>
<small>Add another annotator with its own settings.</small>
</span>
<span className="lr-library-chevron" aria-hidden="true">
⌄
</span>
</summary>
<div className="lr-type-library-content">
{item ? (
<div className="lr-type-instance">
<button className="lr-type-back" onClick={() => setSelected(null)}>
← All types
</button>
<header>
<h4>{item.label}</h4>
<p>{definition.description}</p>
</header>
<label className="lr-field">
Name
<LocalInput
aria-label="Existing type annotator name"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={100}
/>
</label>
<CustomConfig
processor={item.key}
fields={definition.fields}
values={values}
disabled={busy}
change={(key, value) => setValues((previous) => ({ ...previous, [key]: value }))}
/>
<footer>
<span>
{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"}
</span>
<button className="lr-primary" disabled={busy || api.status !== "ready"} onClick={() => void add()}>
{busy ? "Adding…" : "Add to reader"}
</button>
</footer>
</div>
) : (
<>
<div className="lr-type-list">
{catalog
.filter((candidate) => !isArchived(candidate.key))
.map((candidate) => {
const config = configDefinition(candidate.config);
return (
<article className="lr-type-card" key={candidate.key}>
<button className="lr-type-card-select" onClick={() => choose(candidate)}>
<span className="lr-type-mark" aria-hidden="true">
{config.renders?.[0]?.style === "overhead"
? "Ā"
: config.renders?.[0]?.style === "sidebar"
? "☷"
: config.renders?.[0]?.style === "tooltip"
? "◌"
: "Aa"}
</span>
<span>
<strong>{candidate.label}</strong>
<small>{config.description}</small>
</span>
<span className="lr-type-card-arrow" aria-hidden="true">
›
</span>
</button>
<button
className="lr-type-archive"
aria-label={"Archive type: " + candidate.label}
title="Hide this type from the library; existing annotators stay active."
disabled={api.status !== "ready"}
onClick={() => void archive(candidate, true)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M3 3h14v4H3zM4 7v10h12V7M8 10h4" />
</svg>
</button>
</article>
);
})}
{!catalog.some((candidate) => !isArchived(candidate.key)) && (
<p className="lr-muted">No types in the library. Restore an archived type or create one below.</p>
)}
</div>
{catalog.some((candidate) => isArchived(candidate.key)) && (
<details className="lr-archived-types">
<summary>Archived types ({catalog.filter((candidate) => isArchived(candidate.key)).length})</summary>
<p>Existing annotators are unaffected.</p>
{catalog
.filter((candidate) => isArchived(candidate.key))
.map((candidate) => (
<div key={candidate.key}>
<span>{candidate.label}</span>
<button
disabled={api.status !== "ready"}
onClick={() => void archive(candidate, false)}
aria-label={"Restore type: " + candidate.label}
>
Restore
</button>
</div>
))}
</details>
)}
</>
)}
{error && <p role="alert">{error}</p>}
</div>
</details>
);
}
function LabelPosition({
value,
change,
disabled = false,
}: {
value: string;
change: (value: string) => void;
disabled?: boolean;
}) {
return (
<div className="lr-field lr-label-position">
<span>Label position</span>
<Options
label="Label position"
value={value}
disabled={disabled}
options={[
{
value: "above",
label: "Above",
preview: (
<span className="lr-position-preview lr-position-above">
<small>note</small>
<span>mot</span>
</span>
),
},
{
value: "below",
label: "Below",
preview: (
<span className="lr-position-preview lr-position-below">
<small>note</small>
<span>mot</span>
</span>
),
},
]}
change={change}
/>
</div>
);
}
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<typeof navigationValues.builder>) => {
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 (
<section className="lr-new-annotator">
<AnnotatorLibrary
library={library}
reader={reader}
catalog={catalog}
archivedTypes={archivedTypes}
added={added}
initiallyOpen={libraryOpen}
/>
<div className="lr-create-divider">
<span>Create a new type</span>
</div>
<label className="lr-field lr-new-title">
Title
<TextInput aria-label="Annotator title" binding={title} placeholder="New Annotator" maxLength={100} />
</label>
<h3 className="lr-create-heading">How should it appear?</h3>
<div className="lr-appearance-grid">
{appearances
.filter((a) => !["underline", "color", "bold", "italic"].includes(a.key))
.map((a) => (
<button
key={a.key}
className="lr-appearance"
aria-pressed={
a.key === "highlight"
? ["highlight", "underline", "color", "bold", "italic"].includes(style)
: style === a.key
}
onClick={() => setStyle(a.key)}
>
<Preview style={a.key} color={color} placement={String(options.placement ?? "above")} />
<strong>{a.key === "highlight" ? "Formatting" : a.title}</strong>
<small>{a.key === "highlight" ? "Highlight, underline, color, bold, or italic." : a.description}</small>
</button>
))}
</div>
{["highlight", "underline", "color", "bold", "italic"].includes(style) && (
<div className="lr-field lr-format-picker">
Format
<Options
label="Formatting style"
value={style}
options={appearances
.filter((a) => ["highlight", "underline", "color", "bold", "italic"].includes(a.key))
.map((a) => ({
value: a.key,
label: a.title,
preview: (
<span
className={"lr-format-sample lr-format-sample-" + a.key}
style={{ "--annotation": color } as React.CSSProperties}
>
mot
</span>
),
}))}
change={setStyle}
/>
</div>
)}
<div className="lr-creation-options">
<label className="lr-field">
Show it
<Options
label="When to show annotations"
value={reveal}
options={revealOptions(style).map((o) => ({
...o,
label: o.value === "always" ? "Always" : o.value === "hover" ? "When hovering" : "When clicked",
}))}
change={setReveal}
/>
</label>
{["overhead", "highlight", "underline", "color"].includes(style) && (
<label className="lr-field">
Suggested color
<ColorPicker label="Suggested annotation color" value={color} change={setColor} />
</label>
)}
</div>
{style === "underline" && (
<CustomConfig
processor="underline-pattern"
fields={underlineFields}
values={options}
disabled={busy}
change={(key, value) => updateBuilder({ options: JSON.stringify({ ...options, [key]: value }) })}
/>
)}
{style === "overhead" && (
<div className="lr-label-options">
<LabelPosition
value={String(options.placement ?? "above")}
disabled={busy}
change={(placement) => updateBuilder({ options: JSON.stringify({ ...options, placement }) })}
/>
<details className="lr-label-font-options">
<summary>Label font and size</summary>
<CustomConfig
processor="overhead-font"
fields={fontFields}
values={options}
disabled={busy}
change={(key, value) => updateBuilder({ options: JSON.stringify({ ...options, [key]: value }) })}
/>
</details>
</div>
)}
<label className="lr-field lr-build-instructions">
What should it do?
<TextArea
binding={instructions}
aria-label="Annotator instructions"
rows={3}
placeholder="Describe the words to annotate and the information to show."
maxLength={12000}
/>
</label>
<div className="lr-intents">
{["Translate to English", "Define unfamiliar words", "Transliterate"].map((intent) => (
<button key={intent} onClick={() => instructions.appendText((instructions.text ? "\n" : "") + intent)}>
{intent}
</button>
))}
</div>
<footer className="lr-create-footer">
<small>Built and checked by the agent. A title is generated if left empty.</small>
<button
className="lr-primary"
disabled={busy || api.status !== "ready" || !text.trim()}
onClick={() => void build()}
>
{busy ? "Starting…" : "Create annotator"}
</button>
</footer>
{error && <p role="alert">{error}</p>}
</section>
);
}
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<boolean | null>(null),
[appearanceIntent, setAppearanceIntent] = useState<Partial<FeatureNode["data"]> | 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<typeof d>) {
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 (
<section className="lr-existing-row lr-settings-section">
<header className="lr-annotator-head">
<div>
<h3>{d.name}</h3>
<p className="lr-muted">{definition.description}</p>
</div>
<FeatureToggle feature={feature} onIntent={setEnabledIntent} />
</header>
<label className="lr-field lr-name-field">
Title
<LocalInput
aria-label="Annotator title"
value={title}
maxLength={100}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => title !== d.name && update({ name: title })}
/>
</label>
<p className="lr-output-summary">
{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("; ")}
</p>
<CustomConfig
processor={d.processor}
fields={definition.fields}
values={values}
disabled={saving}
change={change}
/>
{draft && (
<div className="lr-config-save">
<button className="lr-primary" disabled={saving} onClick={() => void save()}>
{saving ? "Saving…" : "Save annotation options"}
</button>
<button disabled={saving} onClick={() => setDraft(null)}>
Cancel
</button>
</div>
)}
{(error || feedback.message) && <p role="alert">{error || feedback.message}</p>}
<div className="lr-annotator-footer">
<small>To change what this annotator does, ask its authoring agent.</small>
<button
className="lr-danger"
disabled={feedback.pending > 0}
onClick={() => ops.status === "ready" && feedback.track(ops.value.remove.call(null))}
>
Remove annotator
</button>
</div>
</section>
);
}
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 (
<details className="lr-ask-change">
<summary>
<span>Change this annotator</span>
<small>Describe a change for its agent to build</small>
</summary>
<form
onSubmit={(event) => {
event.preventDefault();
if (api.status !== "ready") return;
setBusy(true);
setError("");
void accepted(
api.value.customize.call({
feature: { ...referenceOf(feature), expectation: { kind: "atom", id: "atom:reading:feature" } },
reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } },
configuration: JSON.stringify({
name: feature.data.name,
instructions: text,
feature: referenceOf(feature),
}),
text: `Modify the existing annotator ${feature.data.name} (${referenceOf(feature).object}), preserving its other settings. ${text}`,
createdAt: new Date().toISOString(),
}),
).then(
() => {
prompt.clearSubmitted(text);
setBusy(false);
},
(e) => {
setError(errorMessage(e));
setBusy(false);
},
);
}}
>
<TextArea
binding={prompt}
aria-label="Change this annotator"
rows={3}
placeholder="Describe what you want to change"
/>
<button disabled={busy || !text.trim()} className="lr-primary">
{busy ? "Starting…" : "Send change"}
</button>
{error && <p role="alert">{error}</p>}
</form>
</details>
);
}