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