1383 lines
48 KiB
TypeScript
1383 lines
48 KiB
TypeScript
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>
|
||
);
|
||
}
|