Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9cf0e9a0 | ||
|
|
390d953b6f |
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"monorepoCommit": "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9",
|
"monorepoCommit": "488f7e229afed8f03fc3bfa4abe707da0c613460",
|
||||||
"sourcePath": "examples/local-first/packages/language-reader-presentation"
|
"sourcePath": "examples/local-first/packages/language-reader-presentation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ query Reader {
|
|||||||
defaultReader {_camino {ref}}
|
defaultReader {_camino {ref}}
|
||||||
inferenceProfile
|
inferenceProfile
|
||||||
documents(first: 100) { entries { node { archived data { title source url attribution } } } }
|
documents(first: 100) { entries { node { archived data { title source url attribution } } } }
|
||||||
features(first: 200) { entries { node { authoring {_camino {ref}} options data { name processor style reveal generation scope color enabled configRevision } } } }
|
features(first: 200) { entries { node { authoring {_camino {ref}} options data { name processor enabled configRevision } } } }
|
||||||
tasks(first: 20, where: {data: {kind: {in: ["customize", "text"]}}}, orderBy: {data: {createdAt: DESC}}) { entries { node {
|
tasks(first: 20, where: {data: {kind: {in: ["customize", "text"]}}}, orderBy: {data: {createdAt: DESC}}) { entries { node {
|
||||||
reader {_camino {ref}}
|
reader {_camino {ref}}
|
||||||
document {_camino {ref}}
|
document {_camino {ref}}
|
||||||
|
|||||||
+20
-20
@@ -1,22 +1,22 @@
|
|||||||
quixos-lock version 1 {
|
quixos-lock version 1 {
|
||||||
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9"; }
|
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "488f7e229afed8f03fc3bfa4abe707da0c613460"; }
|
||||||
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "ad65d325d071443ba72edf5a99a5aeaa59f3e7e0"; }
|
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "8de22a0d48b96a31651eb15c4fb4c8a7e244f9e6"; }
|
||||||
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "42b3565df44e6297ef88476fa44fa37e38a70d25"; }
|
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "f00914748db8594df1a9916d933e7ed801e9beef"; }
|
||||||
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "6ac56d55ee1dd79992cf28c3d760e1cf190225f1"; }
|
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "7f3c95fc9a4b7bb3ad3c695bb58a74a10db290d3"; }
|
||||||
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "2e18f362ce34884e15771b4068197f1cd97c12d4"; }
|
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "3533a0f2cedd3b5993ae08986d7f6a4030c28445"; }
|
||||||
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "3bc1b603b65c981e08c8521d111c9dfb983f3d83"; }
|
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "71deaa6f479bf226a55ecc2c8b15130f855c6972"; }
|
||||||
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "9b302fb44e96671832de4aef4e4563dba5af6f2d"; }
|
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "2623679d1259ec5646a66cae94c78b886416fd36"; }
|
||||||
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "4c8dc969136d0add25c87c8f6cacb00f7dde97c0"; }
|
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "95f608afeeb255e2eb6c00d22ccc2d8efa13742d"; }
|
||||||
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "7f1e9d6278366329fd76f2698cd5a53c136f9914"; }
|
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "e5143427c37b6dc8bbf1b542f95bf08473905f9d"; }
|
||||||
interface AgentThread source { repository "https://repos.quixos.org/quixos-templates/interface-agent-thread.git"; commit "6ace62f773c230c468efeabe9b5aac35115b7900"; }
|
interface AgentThread source { repository "https://repos.quixos.org/quixos-templates/interface-agent-thread.git"; commit "bd77d8dfbc8ec72902249a28912fb749a49d943a"; }
|
||||||
interface AgentItem source { repository "https://repos.quixos.org/quixos-templates/interface-agent-item.git"; commit "6e702e5cab24634d306c8f657f3a0b4873469060"; }
|
interface AgentItem source { repository "https://repos.quixos.org/quixos-templates/interface-agent-item.git"; commit "ecd25e19dc169403d66e41dcab660f3d4219b0e2"; }
|
||||||
interface AgentQuestion source { repository "https://repos.quixos.org/quixos-templates/interface-agent-question.git"; commit "7d69058c2c12b4ca1800d8ff8218caffa45e5d13"; }
|
interface AgentQuestion source { repository "https://repos.quixos.org/quixos-templates/interface-agent-question.git"; commit "18ed4818742b6ab013b1dd50ede348c597fd2037"; }
|
||||||
interface StandaloneView source { repository "https://repos.quixos.org/quixos-templates/interface-react-standalone-view.git"; commit "29a974e9db15387c9efc0f0fe64fec348db10344"; }
|
interface StandaloneView source { repository "https://repos.quixos.org/quixos-templates/interface-react-standalone-view.git"; commit "fb382e3ca97dfd99b35ed6403198e23a8654c624"; }
|
||||||
interface ReactComponent source { repository "https://repos.quixos.org/quixos-templates/interface-react-component.git"; commit "59a8927fdeaba29c51ce5de8fa2cc0ee10a6a134"; }
|
interface ReactComponent source { repository "https://repos.quixos.org/quixos-templates/interface-react-component.git"; commit "d6ce4d674379ee93645dee5df4b43c1304d44b68"; }
|
||||||
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "4a322809adfbb50494c9d5dd9889b56c907e737d"; }
|
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "52d9ef58de0aa8b1c1ba7d38c2606ea51511b6c2"; }
|
||||||
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "b8f27e813b05231c9d3b4d3692e91b8fc2936dec"; }
|
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "ac0194a32b7773d6f738c94371c5b610d1946521"; }
|
||||||
interface Presentation source { repository "https://repos.quixos.org/quixos-templates/interface-presentation.git"; commit "e11912f9e11a2014a489919294424c00a98f7cf3"; }
|
interface Presentation source { repository "https://repos.quixos.org/quixos-templates/interface-presentation.git"; commit "4d63aaa32d91524f4f96b7364180b1a62b3eeea2"; }
|
||||||
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "710cab97959f633a0ffd9a757f2dd2cf9b06f77f"; }
|
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "8f90502dcf3626bca59044cf68709c4db0a3cfac"; }
|
||||||
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "ea1ffb89efbc7bdd9c20794cc63dc112055536c8"; }
|
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "02d0b41c6f1b25439318e2764f6499c2bd6d04ba"; }
|
||||||
interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-templates/interface-gutenberg-catalog.git"; commit "8f5661caa8b6a4abb5bc85c88f1c3843a4c92f38"; }
|
interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-templates/interface-gutenberg-catalog.git"; commit "6a7d7881ab0656ea4209934e5e81de1759951e10"; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const annotationDisplays: Record<
|
|||||||
string,
|
string,
|
||||||
(items: Item[], options: ConfigValues, context?: { source: string; vocabulary: ReadonlySet<string> }) => Item[]
|
(items: Item[], options: ConfigValues, context?: { source: string; vocabulary: ReadonlySet<string> }) => Item[]
|
||||||
> = {
|
> = {
|
||||||
|
names: (items, options) =>
|
||||||
|
items.map((item) => ({ ...item, color: String(options[item.tag + "Color"] ?? "#66866b") })),
|
||||||
|
grammar: (items, options) =>
|
||||||
|
items.map((item) => ({ ...item, color: String(options[item.tag + "Color"] ?? options.otherColor ?? "#66866b") })),
|
||||||
frenchEasyVocabulary: (items, options) =>
|
frenchEasyVocabulary: (items, options) =>
|
||||||
items.filter((item) => {
|
items.filter((item) => {
|
||||||
const match = /^cefr:(A1|A2):(noun|adjective|verb)$/.exec(item.tag);
|
const match = /^cefr:(A1|A2):(noun|adjective|verb)$/.exec(item.tag);
|
||||||
@@ -52,26 +56,13 @@ export function presentAnnotations(
|
|||||||
options: ConfigValues,
|
options: ConfigValues,
|
||||||
context?: { source: string; vocabulary: ReadonlySet<string> },
|
context?: { source: string; vocabulary: ReadonlySet<string> },
|
||||||
) {
|
) {
|
||||||
const decorated = items.map((item) => ({
|
return (annotationDisplays[processor]?.(items, options, context) ?? items).map((item) => {
|
||||||
...item,
|
|
||||||
color:
|
|
||||||
typeof (options[item.tag + "Color"] ?? options.otherColor) === "string"
|
|
||||||
? String(options[item.tag + "Color"] ?? options.otherColor)
|
|
||||||
: undefined,
|
|
||||||
fontFamily: String(options.fontFamily ?? ""),
|
|
||||||
fontSize: Number(options.fontSize ?? 13),
|
|
||||||
underlinePattern: (options.underlinePattern ?? item.underlinePattern ?? "solid") as UnderlinePattern,
|
|
||||||
placement: (options.placement ?? item.placement ?? "above") as "above" | "below",
|
|
||||||
}));
|
|
||||||
return (annotationDisplays[processor]?.(decorated, options, context) ?? decorated).map((item) => {
|
|
||||||
if (
|
if (
|
||||||
(item.underlinePattern && !["solid", "dashed", "squiggly"].includes(item.underlinePattern)) ||
|
(item.underlinePattern && !["solid", "dashed", "squiggly"].includes(item.underlinePattern)) ||
|
||||||
(item.placement !== "above" && item.placement !== "below") ||
|
(item.placement && item.placement !== "above" && item.placement !== "below") ||
|
||||||
(item.color && !/^#[0-9a-f]{6}$/i.test(item.color)) ||
|
(item.color && !/^#[0-9a-f]{6}$/i.test(item.color)) ||
|
||||||
(item.fontFamily && (item.fontFamily.length > 160 || /[\r\n\x00]/.test(item.fontFamily))) ||
|
(item.fontFamily && (item.fontFamily.length > 160 || /[\r\n\x00]/.test(item.fontFamily))) ||
|
||||||
!Number.isFinite(item.fontSize) ||
|
(item.fontSize !== undefined && (!Number.isFinite(item.fontSize) || item.fontSize < 6 || item.fontSize > 72))
|
||||||
item.fontSize! < 6 ||
|
|
||||||
item.fontSize! > 72
|
|
||||||
)
|
)
|
||||||
throw Error("Invalid annotator appearance");
|
throw Error("Invalid annotator appearance");
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
+108
-103
@@ -3,29 +3,19 @@ import { ColorPicker, FontPicker, NumberControl, Options, Switch, UnderlinePrevi
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { AnnotationRender } from "./render-hooks.js";
|
import type { AnnotationRender } from "./render-hooks.js";
|
||||||
|
|
||||||
export type ConfigValue = string | number | boolean;
|
import { configDefaults, type ConfigValue, type ConfigValues, type ConfigNode } from "./config-schema.js";
|
||||||
export type ConfigValues = Record<string, ConfigValue>;
|
export type { ConfigValue, ConfigValues, ConfigField, ConfigNode } from "./config-schema.js";
|
||||||
export type ConfigField = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
kind: "color" | "number" | "select" | "toggle" | "text";
|
|
||||||
default: ConfigValue;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
computation?: boolean;
|
|
||||||
options?: { value: string; label: string }[];
|
|
||||||
};
|
|
||||||
export type AnnotatorConfigProps = {
|
export type AnnotatorConfigProps = {
|
||||||
fields: ConfigField[];
|
fields: ConfigNode[];
|
||||||
values: ConfigValues;
|
values: ConfigValues;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
change: (key: string, value: ConfigValue) => void;
|
change: (key: string, value: ConfigValue) => void;
|
||||||
};
|
};
|
||||||
// Authors may install a purpose-built inline component here. The component edits
|
// Authors may install a purpose-built inline component here. The component edits
|
||||||
// the same validated processor options as the generated controls; it does not
|
// the same validated processor options as the generated controls; it does not
|
||||||
// own persistence, model calls, or the generic appearance/reveal preferences.
|
// own persistence or model calls. Every control, including appearance, is opt-in.
|
||||||
export const annotatorConfigViews: Record<string, ComponentType<AnnotatorConfigProps>> = {
|
export const annotatorConfigViews: Record<string, ComponentType<AnnotatorConfigProps>> = {
|
||||||
pinyin: ({ values, disabled, change }) => (
|
pinyin: ({ fields, values, disabled, change }) => (
|
||||||
<div className="lr-hsk-control">
|
<div className="lr-hsk-control">
|
||||||
<label htmlFor="lr-hsk-level">Words above your HSK level</label>
|
<label htmlFor="lr-hsk-level">Words above your HSK level</label>
|
||||||
<LocalInput
|
<LocalInput
|
||||||
@@ -52,6 +42,13 @@ export const annotatorConfigViews: Record<string, ComponentType<AnnotatorConfigP
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<strong>{Number(values.minimumLevel ?? 2) ? `Above HSK ${values.minimumLevel ?? 2}` : "All words"}</strong>
|
<strong>{Number(values.minimumLevel ?? 2) ? `Above HSK ${values.minimumLevel ?? 2}` : "All words"}</strong>
|
||||||
|
<CustomConfig
|
||||||
|
processor="pinyin-appearance"
|
||||||
|
fields={fields.filter((field) => field.key !== "minimumLevel")}
|
||||||
|
values={values}
|
||||||
|
disabled={disabled}
|
||||||
|
change={change}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -64,27 +61,21 @@ export function configValues(raw: string): ConfigValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function configDefinition(raw: string): {
|
export function configDefinition(raw: string): {
|
||||||
fields: ConfigField[];
|
fields: ConfigNode[];
|
||||||
settings: { color?: boolean; font?: boolean; when?: string[] };
|
|
||||||
description: string;
|
description: string;
|
||||||
preset?: { style: string; reveal: string };
|
|
||||||
cache?: { identity: string; inputs: string[]; previousRevisions?: string[] };
|
cache?: { identity: string; inputs: string[]; previousRevisions?: string[] };
|
||||||
renders?: AnnotationRender[];
|
renders?: AnnotationRender[];
|
||||||
} {
|
} {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(raw);
|
const value = JSON.parse(raw);
|
||||||
return Array.isArray(value)
|
return {
|
||||||
? { fields: value, settings: {}, description: "" }
|
fields: value.fields ?? [],
|
||||||
: {
|
description: value.description ?? "",
|
||||||
fields: value.fields ?? [],
|
cache: value.cache,
|
||||||
settings: value.settings ?? {},
|
renders: value.renders,
|
||||||
description: value.description ?? "",
|
};
|
||||||
preset: value.preset,
|
|
||||||
cache: value.cache,
|
|
||||||
renders: value.renders,
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return { fields: [], settings: {}, description: "" };
|
return { fields: [], description: "" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export const configFields = (raw: string) => configDefinition(raw).fields;
|
export const configFields = (raw: string) => configDefinition(raw).fields;
|
||||||
@@ -99,91 +90,105 @@ export function CustomConfig({
|
|||||||
if (Custom) return <Custom fields={fields} values={values} change={change} disabled={disabled} />;
|
if (Custom) return <Custom fields={fields} values={values} change={change} disabled={disabled} />;
|
||||||
return (
|
return (
|
||||||
<div className="lr-processor-settings">
|
<div className="lr-processor-settings">
|
||||||
{fields.map((field) => (
|
{fields.map((field) =>
|
||||||
<div key={field.key} className="lr-field">
|
field.kind === "group" ? (
|
||||||
<span>{field.label}</span>
|
<fieldset key={field.key} className="lr-config-group">
|
||||||
{field.key === "fontFamily" ? (
|
<legend>{field.label}</legend>
|
||||||
<FontPicker
|
<CustomConfig
|
||||||
label={field.label}
|
processor={processor + "/" + field.key}
|
||||||
value={String(values[field.key] ?? field.default)}
|
fields={field.fields}
|
||||||
|
values={
|
||||||
|
typeof values[field.key] === "object"
|
||||||
|
? (values[field.key] as ConfigValues)
|
||||||
|
: configDefaults(field.fields)
|
||||||
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inherit
|
change={(key, value) =>
|
||||||
change={(value) => change(field.key, value)}
|
change(field.key, {
|
||||||
|
...configDefaults(field.fields),
|
||||||
|
...(typeof values[field.key] === "object" ? (values[field.key] as ConfigValues) : {}),
|
||||||
|
[key]: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : field.kind === "select" ? (
|
</fieldset>
|
||||||
<Options
|
) : (
|
||||||
label={field.label}
|
<div key={field.key} className="lr-field">
|
||||||
value={String(values[field.key] ?? field.default)}
|
<span>{field.label}</span>
|
||||||
options={(field.options ?? []).map((option) => ({
|
{field.presentation === "font" ? (
|
||||||
...option,
|
<FontPicker
|
||||||
...(field.key === "underlinePattern" ? { preview: <UnderlinePreview pattern={option.value} /> } : {}),
|
label={field.label}
|
||||||
}))}
|
value={String(values[field.key] ?? field.default)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
change={(value) => change(field.key, value)}
|
inherit
|
||||||
/>
|
change={(value) => change(field.key, value)}
|
||||||
) : field.kind === "toggle" ? (
|
/>
|
||||||
<Switch
|
) : field.kind === "select" ? (
|
||||||
label={field.label}
|
<Options
|
||||||
value={Boolean(values[field.key] ?? field.default)}
|
label={field.label}
|
||||||
disabled={disabled}
|
value={String(values[field.key] ?? field.default)}
|
||||||
change={(value) => change(field.key, value)}
|
options={(field.options ?? []).map((option) => ({
|
||||||
/>
|
...option,
|
||||||
) : field.kind === "color" ? (
|
...(field.presentation === "underline"
|
||||||
<ColorPicker
|
? { preview: <UnderlinePreview pattern={option.value} /> }
|
||||||
label={field.label}
|
: {}),
|
||||||
value={String(values[field.key] ?? field.default)}
|
}))}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
change={(value) => change(field.key, value)}
|
change={(value) => change(field.key, value)}
|
||||||
/>
|
/>
|
||||||
) : field.kind === "number" ? (
|
) : field.kind === "toggle" ? (
|
||||||
<NumberControl
|
<Switch
|
||||||
label={field.label}
|
label={field.label}
|
||||||
value={Number(values[field.key] ?? field.default)}
|
value={Boolean(values[field.key] ?? field.default)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={field.min}
|
change={(value) => change(field.key, value)}
|
||||||
max={field.max}
|
/>
|
||||||
change={(value) => change(field.key, value)}
|
) : field.kind === "color" ? (
|
||||||
/>
|
<ColorPicker
|
||||||
) : (
|
label={field.label}
|
||||||
<LocalInput
|
value={String(values[field.key] ?? field.default)}
|
||||||
aria-label={field.label}
|
disabled={disabled}
|
||||||
value={String(values[field.key] ?? field.default)}
|
change={(value) => change(field.key, value)}
|
||||||
disabled={disabled}
|
/>
|
||||||
onChange={(e) => change(field.key, e.target.value)}
|
) : field.kind === "number" ? (
|
||||||
/>
|
<NumberControl
|
||||||
)}
|
label={field.label}
|
||||||
</div>
|
value={Number(values[field.key] ?? field.default)}
|
||||||
))}
|
disabled={disabled}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
change={(value) => change(field.key, value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LocalInput
|
||||||
|
aria-label={field.label}
|
||||||
|
value={String(values[field.key] ?? field.default)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => change(field.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export const fontFields: ConfigField[] = [
|
|
||||||
{
|
// Creation suggestions only: these are never injected into a built annotator.
|
||||||
key: "fontFamily",
|
export const fontFields: ConfigNode[] = [
|
||||||
label: "Annotation font",
|
{ key: "fontFamily", label: "Annotation font", kind: "text", presentation: "font", default: "" },
|
||||||
kind: "select",
|
|
||||||
default: "",
|
|
||||||
options: [
|
|
||||||
{ value: "", label: "Match the reading text" },
|
|
||||||
{ value: '"Noto Serif", Georgia, serif', label: "Serif" },
|
|
||||||
{ value: "system-ui, sans-serif", label: "Sans serif" },
|
|
||||||
{ value: "ui-monospace, monospace", label: "Monospace" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ key: "fontSize", label: "Annotation font size", kind: "number", default: 13, min: 6, max: 72 },
|
{ key: "fontSize", label: "Annotation font size", kind: "number", default: 13, min: 6, max: 72 },
|
||||||
];
|
];
|
||||||
|
export const underlineFields: ConfigNode[] = [
|
||||||
export const underlineFields: ConfigField[] = [
|
|
||||||
{
|
{
|
||||||
key: "underlinePattern",
|
key: "underlinePattern",
|
||||||
label: "Underline pattern",
|
label: "Underline pattern",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
|
presentation: "underline",
|
||||||
default: "solid",
|
default: "solid",
|
||||||
computation: false,
|
options: ["solid", "dashed", "squiggly"].map((value) => ({
|
||||||
options: [
|
value,
|
||||||
{ value: "solid", label: "Solid" },
|
label: value[0].toUpperCase() + value.slice(1),
|
||||||
{ value: "dashed", label: "Dashed" },
|
})),
|
||||||
{ value: "squiggly", label: "Squiggly" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+14
-1
@@ -11,7 +11,20 @@ export function cacheConfiguration(
|
|||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
processor: identity,
|
processor: identity,
|
||||||
scope,
|
scope,
|
||||||
options: Object.fromEntries([...inputs].sort().map((key) => [key, options[key]])),
|
options: Object.fromEntries(
|
||||||
|
[...inputs]
|
||||||
|
.sort()
|
||||||
|
.map((key) => [
|
||||||
|
key,
|
||||||
|
key
|
||||||
|
.split(".")
|
||||||
|
.reduce<unknown>(
|
||||||
|
(value, part) =>
|
||||||
|
value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined,
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export type ConfigValue = string | number | boolean | ConfigValues;
|
||||||
|
export type ConfigValues = { [key: string]: ConfigValue };
|
||||||
|
export type ConfigField = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: "color" | "number" | "select" | "toggle" | "text";
|
||||||
|
default: string | number | boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
computation?: boolean;
|
||||||
|
presentation?: "font" | "underline" | "position";
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
};
|
||||||
|
export type ConfigGroup = { key: string; label: string; kind: "group"; fields: ConfigNode[] };
|
||||||
|
export type ConfigNode = ConfigField | ConfigGroup;
|
||||||
|
export function configDefaults(fields: ConfigNode[]): ConfigValues {
|
||||||
|
return Object.fromEntries(
|
||||||
|
fields.map((field) => [field.key, field.kind === "group" ? configDefaults(field.fields) : field.default]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function configAt(values: ConfigValues, path: string): ConfigValues {
|
||||||
|
const result = path
|
||||||
|
? path
|
||||||
|
.split(".")
|
||||||
|
.reduce<
|
||||||
|
ConfigValue | undefined
|
||||||
|
>((value, key) => (value && typeof value === "object" ? value[key] : undefined), values)
|
||||||
|
: values;
|
||||||
|
return result && typeof result === "object" ? result : {};
|
||||||
|
}
|
||||||
+23
-78
@@ -1,3 +1,5 @@
|
|||||||
|
import { configDefaults } from "./config-schema.js";
|
||||||
|
import { resolveRenders } from "./render-hooks.js";
|
||||||
import { readerExtensionSettings } from "./reader-extensions.js";
|
import { readerExtensionSettings } from "./reader-extensions.js";
|
||||||
import { usePrompt } from "./drafts.js";
|
import { usePrompt } from "./drafts.js";
|
||||||
import {
|
import {
|
||||||
@@ -27,10 +29,10 @@ import { ReaderPreview } from "./preview.js";
|
|||||||
import { interfaces } from "./gen/client.js";
|
import { interfaces } from "./gen/client.js";
|
||||||
import {
|
import {
|
||||||
CustomConfig,
|
CustomConfig,
|
||||||
configFields,
|
|
||||||
configValues,
|
|
||||||
fontFields,
|
fontFields,
|
||||||
underlineFields,
|
underlineFields,
|
||||||
|
configFields,
|
||||||
|
configValues,
|
||||||
type ConfigValues,
|
type ConfigValues,
|
||||||
type ConfigValue,
|
type ConfigValue,
|
||||||
} from "./annotator-config.js";
|
} from "./annotator-config.js";
|
||||||
@@ -462,7 +464,7 @@ export function ReaderSettings({
|
|||||||
aria-current={section === referenceOf(f).object ? "page" : undefined}
|
aria-current={section === referenceOf(f).object ? "page" : undefined}
|
||||||
onClick={() => setSection(referenceOf(f).object)}
|
onClick={() => setSection(referenceOf(f).object)}
|
||||||
>
|
>
|
||||||
<i style={{ background: f.data.color }} />
|
<i className="lr-annotator-dot" />
|
||||||
{f.data.name}
|
{f.data.name}
|
||||||
{!f.data.enabled && <small>Off</small>}
|
{!f.data.enabled && <small>Off</small>}
|
||||||
</button>
|
</button>
|
||||||
@@ -764,10 +766,11 @@ function AnnotatorLibrary({
|
|||||||
const isArchived = (key: string) => archiveIntent[key] ?? archivedTypes.includes(key);
|
const isArchived = (key: string) => archiveIntent[key] ?? archivedTypes.includes(key);
|
||||||
const item = catalog.find((candidate) => candidate.key === selected);
|
const item = catalog.find((candidate) => candidate.key === selected);
|
||||||
const definition = configDefinition(item?.config ?? "{}");
|
const definition = configDefinition(item?.config ?? "{}");
|
||||||
const style = definition.preset?.style ?? "highlight";
|
const channels = resolveRenders(definition.renders, values);
|
||||||
|
const style = channels[0]?.style ?? "color";
|
||||||
const choose = (candidate: Catalog[number]) => {
|
const choose = (candidate: Catalog[number]) => {
|
||||||
const fields = configDefinition(candidate.config).fields;
|
const fields = configDefinition(candidate.config).fields;
|
||||||
setValues(Object.fromEntries(fields.map((field) => [field.key, field.default])));
|
setValues(configDefaults(fields));
|
||||||
setTitle(candidate.label);
|
setTitle(candidate.label);
|
||||||
setSelected(candidate.key);
|
setSelected(candidate.key);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -800,11 +803,6 @@ function AnnotatorLibrary({
|
|||||||
reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } },
|
reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } },
|
||||||
name: title.trim() || item.label,
|
name: title.trim() || item.label,
|
||||||
processor: item.key,
|
processor: item.key,
|
||||||
style,
|
|
||||||
reveal: definition.preset?.reveal ?? "always",
|
|
||||||
generation: item.strategy,
|
|
||||||
scope: item.scope,
|
|
||||||
color: "#53796c",
|
|
||||||
options: JSON.stringify(values),
|
options: JSON.stringify(values),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -855,24 +853,13 @@ function AnnotatorLibrary({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
change={(key, value) => setValues((previous) => ({ ...previous, [key]: value }))}
|
change={(key, value) => setValues((previous) => ({ ...previous, [key]: value }))}
|
||||||
/>
|
/>
|
||||||
{style === "overhead" && (
|
|
||||||
<LabelPosition
|
|
||||||
value={String(values.placement ?? "above")}
|
|
||||||
disabled={busy}
|
|
||||||
change={(placement) => setValues((previous) => ({ ...previous, placement }))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>
|
<span>
|
||||||
{style === "overhead"
|
{style === "overhead"
|
||||||
? "Text labels"
|
? "Text labels"
|
||||||
: (appearances.find((appearance) => appearance.key === style)?.title ?? style)}{" "}
|
: (appearances.find((appearance) => appearance.key === style)?.title ?? style)}{" "}
|
||||||
·{" "}
|
·{" "}
|
||||||
{definition.preset?.reveal === "click"
|
{channels[0]?.reveal === "click" ? "On click" : channels[0]?.reveal === "hover" ? "On hover" : "Always"}
|
||||||
? "On click"
|
|
||||||
: definition.preset?.reveal === "hover"
|
|
||||||
? "On hover"
|
|
||||||
: "Always"}
|
|
||||||
</span>
|
</span>
|
||||||
<button className="lr-primary" disabled={busy || api.status !== "ready"} onClick={() => void add()}>
|
<button className="lr-primary" disabled={busy || api.status !== "ready"} onClick={() => void add()}>
|
||||||
{busy ? "Adding…" : "Add to reader"}
|
{busy ? "Adding…" : "Add to reader"}
|
||||||
@@ -890,11 +877,11 @@ function AnnotatorLibrary({
|
|||||||
<article className="lr-type-card" key={candidate.key}>
|
<article className="lr-type-card" key={candidate.key}>
|
||||||
<button className="lr-type-card-select" onClick={() => choose(candidate)}>
|
<button className="lr-type-card-select" onClick={() => choose(candidate)}>
|
||||||
<span className="lr-type-mark" aria-hidden="true">
|
<span className="lr-type-mark" aria-hidden="true">
|
||||||
{config.preset?.style === "overhead"
|
{config.renders?.[0]?.style === "overhead"
|
||||||
? "Ā"
|
? "Ā"
|
||||||
: config.preset?.style === "sidebar"
|
: config.renders?.[0]?.style === "sidebar"
|
||||||
? "☷"
|
? "☷"
|
||||||
: config.preset?.style === "tooltip"
|
: config.renders?.[0]?.style === "tooltip"
|
||||||
? "◌"
|
? "◌"
|
||||||
: "Aa"}
|
: "Aa"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1194,7 +1181,11 @@ function NewAnnotator({
|
|||||||
</div>
|
</div>
|
||||||
<footer className="lr-create-footer">
|
<footer className="lr-create-footer">
|
||||||
<small>Built and checked by the agent. A title is generated if left empty.</small>
|
<small>Built and checked by the agent. A title is generated if left empty.</small>
|
||||||
<button className="lr-primary" disabled={busy || !text.trim()} onClick={() => void build()}>
|
<button
|
||||||
|
className="lr-primary"
|
||||||
|
disabled={busy || api.status !== "ready" || !text.trim()}
|
||||||
|
onClick={() => void build()}
|
||||||
|
>
|
||||||
{busy ? "Starting…" : "Create annotator"}
|
{busy ? "Starting…" : "Create annotator"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -1228,7 +1219,6 @@ export function ExistingFeature({
|
|||||||
feedback.track(ops.value.configure.call({ ...settings, ...changes, expectedRevision: configRevision }));
|
feedback.track(ops.value.configure.call({ ...settings, ...changes, expectedRevision: configRevision }));
|
||||||
}
|
}
|
||||||
const definition = configDefinition(catalog.find((item) => item.key === d.processor)?.config ?? "{}"),
|
const definition = configDefinition(catalog.find((item) => item.key === d.processor)?.config ?? "{}"),
|
||||||
showOptions = revealOptions(d.style).filter((option) => definition.settings.when?.includes(option.value)),
|
|
||||||
persisted = configValues(feature.options),
|
persisted = configValues(feature.options),
|
||||||
values = draft?.values ?? persisted;
|
values = draft?.values ?? persisted;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1286,29 +1276,13 @@ export function ExistingFeature({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="lr-output-summary">
|
<p className="lr-output-summary">
|
||||||
{appearances.find((a) => a.key === d.style)?.title ?? d.style} ·{" "}
|
{resolveRenders(definition.renders, values)
|
||||||
{d.reveal === "always" ? "Always shown" : d.reveal === "hover" ? "Shown when hovering" : "Shown when clicked"}
|
.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>
|
</p>
|
||||||
{showOptions.length > 0 && (
|
|
||||||
<div className="lr-field">
|
|
||||||
Show annotations
|
|
||||||
<Options
|
|
||||||
label={d.name + " show annotations"}
|
|
||||||
value={d.reveal}
|
|
||||||
options={showOptions.map((o) => ({
|
|
||||||
...o,
|
|
||||||
label: o.value === "always" ? "Always" : o.value === "hover" ? "When hovering" : "When clicked",
|
|
||||||
}))}
|
|
||||||
change={(reveal) => update({ reveal })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{definition.settings.color && (
|
|
||||||
<div className="lr-field">
|
|
||||||
Annotation color
|
|
||||||
<ColorPicker label={d.name + " color"} value={d.color} change={(color) => update({ color })} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<CustomConfig
|
<CustomConfig
|
||||||
processor={d.processor}
|
processor={d.processor}
|
||||||
fields={definition.fields}
|
fields={definition.fields}
|
||||||
@@ -1316,33 +1290,6 @@ export function ExistingFeature({
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
change={change}
|
change={change}
|
||||||
/>
|
/>
|
||||||
{(d.style === "underline" || definition.renders?.some((render) => render.style === "underline")) && (
|
|
||||||
<CustomConfig
|
|
||||||
processor="underline-pattern"
|
|
||||||
fields={underlineFields}
|
|
||||||
values={values}
|
|
||||||
disabled={saving}
|
|
||||||
change={change}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{definition.settings.font && d.style === "overhead" && (
|
|
||||||
<>
|
|
||||||
{!definition.fields.some((field) => field.key === "placement") && (
|
|
||||||
<LabelPosition
|
|
||||||
value={String(values.placement ?? "above")}
|
|
||||||
disabled={saving}
|
|
||||||
change={(placement) => change("placement", placement)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CustomConfig
|
|
||||||
processor="overhead-font"
|
|
||||||
fields={fontFields}
|
|
||||||
values={values}
|
|
||||||
disabled={saving}
|
|
||||||
change={change}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{draft && (
|
{draft && (
|
||||||
<div className="lr-config-save">
|
<div className="lr-config-save">
|
||||||
<button className="lr-primary" disabled={saving} onClick={() => void save()}>
|
<button className="lr-primary" disabled={saving} onClick={() => void save()}>
|
||||||
@@ -1401,8 +1348,6 @@ function AskForChange({
|
|||||||
reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } },
|
reader: { ...referenceOf(reader), expectation: { kind: "atom", id: "atom:reading:reader" } },
|
||||||
configuration: JSON.stringify({
|
configuration: JSON.stringify({
|
||||||
name: feature.data.name,
|
name: feature.data.name,
|
||||||
style: feature.data.style,
|
|
||||||
reveal: feature.data.reveal,
|
|
||||||
instructions: text,
|
instructions: text,
|
||||||
feature: referenceOf(feature),
|
feature: referenceOf(feature),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -58,3 +58,7 @@ export async function accepted<T>(ticket: { settled: Promise<{ status: string; v
|
|||||||
if (outcome.status !== "accepted") throw Error(JSON.stringify(outcome));
|
if (outcome.status !== "accepted") throw Error(JSON.stringify(outcome));
|
||||||
return outcome.value as T;
|
return outcome.value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProcessingFeature = FeatureNode & {
|
||||||
|
data: FeatureNode["data"] & { generation: string; scope: string; color: string };
|
||||||
|
};
|
||||||
|
|||||||
+10
-10
@@ -1,3 +1,4 @@
|
|||||||
|
import { configDefaults } from "./config-schema.js";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { referenceOf, type ExactReference } from "@quixos/camino-react";
|
import { referenceOf, type ExactReference } from "@quixos/camino-react";
|
||||||
import { type Catalog, type DocNode, type FeatureNode, type ReaderNode } from "./model.js";
|
import { type Catalog, type DocNode, type FeatureNode, type ReaderNode } from "./model.js";
|
||||||
@@ -64,13 +65,13 @@ export function ReaderPreview({
|
|||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
const definition = configDefinition(entry.config);
|
const definition = configDefinition(entry.config);
|
||||||
const options = {
|
const options = {
|
||||||
...Object.fromEntries(definition.fields.map((field) => [field.key, field.default])),
|
...configDefaults(definition.fields),
|
||||||
...configValues(feature.options),
|
...configValues(feature.options),
|
||||||
};
|
};
|
||||||
const configuration = cacheConfiguration(
|
const configuration = cacheConfiguration(
|
||||||
definition.cache?.identity ?? feature.data.processor,
|
definition.cache?.identity ?? feature.data.processor,
|
||||||
feature.data.scope,
|
entry.scope,
|
||||||
feature.data.generation,
|
entry.strategy,
|
||||||
options,
|
options,
|
||||||
definition.cache?.inputs ?? [],
|
definition.cache?.inputs ?? [],
|
||||||
);
|
);
|
||||||
@@ -108,15 +109,14 @@ export function ReaderPreview({
|
|||||||
id: `${referenceOf(feature).object}:${job.data.key}:${index}`,
|
id: `${referenceOf(feature).object}:${job.data.key}:${index}`,
|
||||||
feature: referenceOf(feature).object,
|
feature: referenceOf(feature).object,
|
||||||
activation: { start: job.data.start, end: job.data.end },
|
activation: { start: job.data.start, end: job.data.end },
|
||||||
priority:
|
priority: (entry.scope === "word" ? 0 : entry.scope === "sentence" ? 1 : 2) * features.length + order,
|
||||||
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
|
style: item.style ?? "color",
|
||||||
order,
|
reveal: item.reveal ?? "always",
|
||||||
style: item.style ?? (feature.data.style as Annotation["style"]),
|
color: item.color ?? "",
|
||||||
reveal: item.reveal ?? (feature.data.reveal as Annotation["reveal"]),
|
|
||||||
color: item.color ?? feature.data.color,
|
|
||||||
},
|
},
|
||||||
text,
|
text,
|
||||||
item.style ? undefined : definition.renders,
|
item.style ? [{ style: item.style, reveal: item.reveal ?? "always" }] : definition.renders,
|
||||||
|
options,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-10
@@ -2,11 +2,12 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { referenceOf, useInterface } from "@quixos/camino-react";
|
import { referenceOf, useInterface } from "@quixos/camino-react";
|
||||||
import { interfaces } from "./gen/client.js";
|
import { interfaces } from "./gen/client.js";
|
||||||
import { sameCacheConfiguration } from "./cache.js";
|
import { sameCacheConfiguration } from "./cache.js";
|
||||||
import { accepted, errorMessage, type DocNode, type FeatureNode } from "./model.js";
|
import { accepted, errorMessage, type DocNode, type ProcessingFeature as FeatureNode } from "./model.js";
|
||||||
import { preparationQueue, processorMatches, sameScope } from "./work.js";
|
import { preparationQueue, processorMatches, sameScope } from "./work.js";
|
||||||
import type { Range } from "./canvas/types.js";
|
import type { Range } from "./canvas/types.js";
|
||||||
import type { ReadingWindow } from "./canvas/pages.js";
|
import type { ReadingWindow } from "./canvas/pages.js";
|
||||||
import type { AnnotationRender } from "./render-hooks.js";
|
import { resolveRenders, type AnnotationRender } from "./render-hooks.js";
|
||||||
|
import { configValues } from "./annotator-config.js";
|
||||||
|
|
||||||
type Planned = { feature: FeatureNode; range: Range; context: string; planning?: "click" | "hover" };
|
type Planned = { feature: FeatureNode; range: Range; context: string; planning?: "click" | "hover" };
|
||||||
/** One admission/cache path for proximity preparation, clicks, hover and retry.
|
/** One admission/cache path for proximity preparation, clicks, hover and retry.
|
||||||
@@ -78,7 +79,7 @@ export function useProcessing({
|
|||||||
range.start,
|
range.start,
|
||||||
range.end,
|
range.end,
|
||||||
].join(":");
|
].join(":");
|
||||||
function request(feature: FeatureNode, range: Range, retry = false): boolean {
|
function request(feature: FeatureNode, range: Range, retry = false, settled?: () => void): boolean {
|
||||||
if (!annotationsReady || ops.status !== "ready" || range.end <= range.start || !revisions[feature.data.processor])
|
if (!annotationsReady || ops.status !== "ready" || range.end <= range.start || !revisions[feature.data.processor])
|
||||||
return false;
|
return false;
|
||||||
const item = { feature, range, context },
|
const item = { feature, range, context },
|
||||||
@@ -94,12 +95,14 @@ export function useProcessing({
|
|||||||
retry,
|
retry,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
).catch((cause) => {
|
)
|
||||||
admitting.current.delete(key);
|
.catch((cause) => {
|
||||||
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
|
admitting.current.delete(key);
|
||||||
error(errorMessage(cause));
|
setIntent((previous) => previous.filter((item) => item.context !== context || keyFor(item) !== key));
|
||||||
setTick((value) => value + 1);
|
error(errorMessage(cause));
|
||||||
});
|
setTick((value) => value + 1);
|
||||||
|
})
|
||||||
|
.finally(settled);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,7 +192,7 @@ export function useProcessing({
|
|||||||
(f) =>
|
(f) =>
|
||||||
f.data.enabled &&
|
f.data.enabled &&
|
||||||
f.data.generation === "focus" &&
|
f.data.generation === "focus" &&
|
||||||
(renders[f.data.processor] ?? [{ reveal: f.data.reveal }]).some((render) =>
|
resolveRenders(renders[f.data.processor], configValues(f.options)).some((render) =>
|
||||||
kind === "hover" ? render.reveal === "hover" : render.reveal !== "hover",
|
kind === "hover" ? render.reveal === "hover" : render.reveal !== "hover",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+38
-7
@@ -1,21 +1,52 @@
|
|||||||
import type { Annotation } from "./canvas/types.js";
|
import type { Annotation } from "./canvas/types.js";
|
||||||
|
import { configAt, type ConfigValues } from "./config-schema.js";
|
||||||
/** Several views of one cached semantic item. These are authored presentation
|
/** Each channel is code-owned. Options explicitly binds an appearance struct;
|
||||||
* choices, not separate computations or independently generated annotations. */
|
* several channels can use different structs from the same annotator config. */
|
||||||
export type AnnotationRender = {
|
export type AnnotationRender = {
|
||||||
style: Annotation["style"];
|
style: Annotation["style"];
|
||||||
underlinePattern?: Annotation["underlinePattern"];
|
underlinePattern?: Annotation["underlinePattern"];
|
||||||
placement?: Annotation["placement"];
|
placement?: Annotation["placement"];
|
||||||
|
color?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
options?: string;
|
||||||
reveal: "always" | "click" | "hover";
|
reveal: "always" | "click" | "hover";
|
||||||
content?: "result" | "source";
|
content?: "result" | "source";
|
||||||
};
|
};
|
||||||
export function renderAnnotations(item: Annotation, source: string, renders: readonly AnnotationRender[] | undefined) {
|
export function resolveRenders(renders: readonly AnnotationRender[] | undefined, values: ConfigValues = {}) {
|
||||||
if (!renders?.length) return [item];
|
return (renders ?? []).map((render) => {
|
||||||
return renders.map(
|
const bound = render.options === undefined ? {} : configAt(values, render.options);
|
||||||
|
const output = { ...render };
|
||||||
|
for (const key of ["color", "fontFamily", "fontSize", "placement", "underlinePattern", "reveal"] as const)
|
||||||
|
if (bound[key] !== undefined) Object.assign(output, { [key]: bound[key] });
|
||||||
|
if (
|
||||||
|
!["always", "click", "hover"].includes(output.reveal) ||
|
||||||
|
(output.reveal === "hover" && ["overhead", "replace", "bold", "italic"].includes(output.style)) ||
|
||||||
|
(output.style === "tooltip" && output.reveal === "always") ||
|
||||||
|
(output.underlinePattern && !["solid", "dashed", "squiggly"].includes(output.underlinePattern)) ||
|
||||||
|
(output.placement && !["above", "below"].includes(output.placement)) ||
|
||||||
|
(output.color && !/^#[0-9a-f]{6}$/i.test(output.color)) ||
|
||||||
|
(output.fontFamily && (output.fontFamily.length > 160 || /[\r\n\x00]/.test(output.fontFamily))) ||
|
||||||
|
(output.fontSize !== undefined &&
|
||||||
|
(!Number.isFinite(output.fontSize) || output.fontSize < 6 || output.fontSize > 72))
|
||||||
|
)
|
||||||
|
throw Error("Invalid annotator render channel");
|
||||||
|
return output;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function renderAnnotations(
|
||||||
|
item: Annotation,
|
||||||
|
source: string,
|
||||||
|
renders: readonly AnnotationRender[] | undefined,
|
||||||
|
values: ConfigValues = {},
|
||||||
|
) {
|
||||||
|
if (!renders?.length) return [];
|
||||||
|
return resolveRenders(renders, values).map(
|
||||||
(render, index): Annotation => ({
|
(render, index): Annotation => ({
|
||||||
...item,
|
...item,
|
||||||
id: `${item.id}:render:${index}`,
|
|
||||||
...render,
|
...render,
|
||||||
|
id: `${item.id}:render:${index}`,
|
||||||
|
color: item.color || render.color || "#66866b",
|
||||||
text: render.content === "source" ? source.slice(item.start, item.end) : item.text,
|
text: render.content === "source" ? source.slice(item.start, item.end) : item.text,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const readingStyle = `
|
export const readingStyle = `
|
||||||
|
.lr-config-group{border:1px solid #d9dfd6;border-radius:12px;padding:18px;margin:10px 0 20px;min-width:0}.lr-config-group legend{padding:0 8px;font-weight:600;color:#354b3f}.lr-config-group .lr-processor-settings{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:18px}.lr-annotator-dot{background:#66866b}
|
||||||
.lr-app{--ink:#303a36;--muted:#7c827b;--line:#e6e6de;--green:#3e6957;position:relative;display:flex;flex:0 1 auto;height:calc(100dvh - var(--reader-top,120px));max-height:100%;min-height:0;background:#fafaf6;color:var(--ink);font:14px/1.5 system-ui,sans-serif;overflow:hidden}
|
.lr-app{--ink:#303a36;--muted:#7c827b;--line:#e6e6de;--green:#3e6957;position:relative;display:flex;flex:0 1 auto;height:calc(100dvh - var(--reader-top,120px));max-height:100%;min-height:0;background:#fafaf6;color:var(--ink);font:14px/1.5 system-ui,sans-serif;overflow:hidden}
|
||||||
|
|
||||||
.lr-app *{box-sizing:border-box}
|
.lr-app *{box-sizing:border-box}
|
||||||
@@ -216,6 +217,9 @@ export const readingStyle = `
|
|||||||
.lr-request-config dt{color:var(--muted)}.lr-request-config dd{margin:0}
|
.lr-request-config dt{color:var(--muted)}.lr-request-config dd{margin:0}
|
||||||
.lr-request-config p{font-size:13px;white-space:pre-wrap}
|
.lr-request-config p{font-size:13px;white-space:pre-wrap}
|
||||||
.lr-variant-status{padding:12px 24px;font-size:12px;border-top:1px solid var(--line);background:#f5f7f0;display:flex;align-items:center;gap:12px}
|
.lr-variant-status{padding:12px 24px;font-size:12px;border-top:1px solid var(--line);background:#f5f7f0;display:flex;align-items:center;gap:12px}
|
||||||
|
.lr-annotation-retries{padding:8px 24px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
|
||||||
|
.lr-annotation-retry{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:4px 0}
|
||||||
|
.lr-annotation-retry button{font-size:12px;white-space:nowrap}
|
||||||
@media(max-width:650px){.lr-settings-nav{width:145px;padding:12px 6px}.lr-settings-content{padding:18px 14px}.lr-settings-top{padding:8px 14px}.lr-settings-nav button{font-size:12px}.lr-settings-content .lr-fields{flex-direction:column;gap:0}}
|
@media(max-width:650px){.lr-settings-nav{width:145px;padding:12px 6px}.lr-settings-content{padding:18px 14px}.lr-settings-top{padding:8px 14px}.lr-settings-nav button{font-size:12px}.lr-settings-content .lr-fields{flex-direction:column;gap:0}}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+92
-16
@@ -1,10 +1,11 @@
|
|||||||
|
import { configDefaults } from "./config-schema.js";
|
||||||
import { Texts } from "./texts.js";
|
import { Texts } from "./texts.js";
|
||||||
import { cacheConfiguration, sameCacheConfiguration } from "./cache.js";
|
import { cacheConfiguration, sameCacheConfiguration } from "./cache.js";
|
||||||
import { configDefinition, configValues } from "./annotator-config.js";
|
import { configDefinition, configValues } from "./annotator-config.js";
|
||||||
import { presentAnnotations } from "./annotation-display.js";
|
import { presentAnnotations } from "./annotation-display.js";
|
||||||
import { Options } from "./controls.js";
|
import { Options } from "./controls.js";
|
||||||
import { Choice, SegmentedControl, useCommandControl } from "@quixos/camino-react/ui";
|
import { Button, Choice, SegmentedControl, useCommandControl } from "@quixos/camino-react/ui";
|
||||||
import { renderAnnotations, type AnnotationRender } from "./render-hooks.js";
|
import { renderAnnotations, resolveRenders, type AnnotationRender } from "./render-hooks.js";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
referenceOf,
|
referenceOf,
|
||||||
@@ -26,7 +27,14 @@ import { intersects, type Annotation, type Range } from "./canvas/types.js";
|
|||||||
import type { ReadingWindow } from "./canvas/pages.js";
|
import type { ReadingWindow } from "./canvas/pages.js";
|
||||||
import type { ProcessingRegion } from "./canvas/processing.js";
|
import type { ProcessingRegion } from "./canvas/processing.js";
|
||||||
import { useProcessing } from "./processing.js";
|
import { useProcessing } from "./processing.js";
|
||||||
import { HoverIntent, processorMatches, selectSuccessfulAnnotationJobs } from "./work.js";
|
import {
|
||||||
|
HoverIntent,
|
||||||
|
processorMatches,
|
||||||
|
selectSuccessfulAnnotationJobs,
|
||||||
|
selectAnnotationRetryJobs,
|
||||||
|
annotationRetryStatus,
|
||||||
|
explicitAnnotationRetry,
|
||||||
|
} from "./work.js";
|
||||||
import { ReaderSettings } from "./editor.js";
|
import { ReaderSettings } from "./editor.js";
|
||||||
import { Importer, SpeechSettings, Playback, Rewrite } from "./sources.js";
|
import { Importer, SpeechSettings, Playback, Rewrite } from "./sources.js";
|
||||||
import {
|
import {
|
||||||
@@ -105,15 +113,15 @@ export const Reader: Modules["Reader"] = ({ subject, navigation, input }) => {
|
|||||||
const entry = catalog.find((item) => item.key === feature.data.processor);
|
const entry = catalog.find((item) => item.key === feature.data.processor);
|
||||||
const definition = configDefinition(entry?.config ?? "{}");
|
const definition = configDefinition(entry?.config ?? "{}");
|
||||||
const options = {
|
const options = {
|
||||||
...Object.fromEntries(definition.fields.map((field) => [field.key, field.default])),
|
...configDefaults(definition.fields),
|
||||||
...configValues(feature.options),
|
...configValues(feature.options),
|
||||||
};
|
};
|
||||||
return [
|
return [
|
||||||
referenceOf(feature).object,
|
referenceOf(feature).object,
|
||||||
cacheConfiguration(
|
cacheConfiguration(
|
||||||
definition.cache?.identity ?? feature.data.processor,
|
definition.cache?.identity ?? feature.data.processor,
|
||||||
feature.data.scope,
|
entry?.scope ?? "word",
|
||||||
feature.data.generation,
|
entry?.strategy ?? "section",
|
||||||
options,
|
options,
|
||||||
definition.cache?.inputs ?? [],
|
definition.cache?.inputs ?? [],
|
||||||
),
|
),
|
||||||
@@ -248,6 +256,7 @@ export const Reader: Modules["Reader"] = ({ subject, navigation, input }) => {
|
|||||||
revisionAliases={revisionAliases}
|
revisionAliases={revisionAliases}
|
||||||
computations={computations}
|
computations={computations}
|
||||||
renders={renders}
|
renders={renders}
|
||||||
|
catalog={catalog}
|
||||||
reader={activeReader}
|
reader={activeReader}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
navigation={navigation.reference}
|
navigation={navigation.reference}
|
||||||
@@ -312,6 +321,7 @@ function ReadingDocument({
|
|||||||
revisionAliases,
|
revisionAliases,
|
||||||
computations,
|
computations,
|
||||||
renders,
|
renders,
|
||||||
|
catalog,
|
||||||
reader,
|
reader,
|
||||||
onOpen,
|
onOpen,
|
||||||
navigation,
|
navigation,
|
||||||
@@ -331,6 +341,7 @@ function ReadingDocument({
|
|||||||
revisionAliases: Record<string, readonly string[]>;
|
revisionAliases: Record<string, readonly string[]>;
|
||||||
computations: Record<string, string>;
|
computations: Record<string, string>;
|
||||||
renders: Record<string, AnnotationRender[] | undefined>;
|
renders: Record<string, AnnotationRender[] | undefined>;
|
||||||
|
catalog: Catalog;
|
||||||
reader?: ReaderNode;
|
reader?: ReaderNode;
|
||||||
onOpen: (ref: ExactReference) => void;
|
onOpen: (ref: ExactReference) => void;
|
||||||
navigation: ExactReference;
|
navigation: ExactReference;
|
||||||
@@ -423,14 +434,32 @@ function ReadingDocument({
|
|||||||
}
|
}
|
||||||
const features = useMemo(() => {
|
const features = useMemo(() => {
|
||||||
const members = new Set(reader?.features.entries.map((e) => referenceOf(e.node).object) ?? []);
|
const members = new Set(reader?.features.entries.map((e) => referenceOf(e.node).object) ?? []);
|
||||||
return root.features.entries.map((e) => e.node).filter((f) => members.has(referenceOf(f).object));
|
return root.features.entries
|
||||||
}, [root.features.entries, reader]),
|
.map((e) => e.node)
|
||||||
|
.filter((f) => members.has(referenceOf(f).object))
|
||||||
|
.flatMap((feature) => {
|
||||||
|
const entry = catalog.find((item) => item.key === feature.data.processor);
|
||||||
|
if (!entry) return [];
|
||||||
|
const channels = resolveRenders(renders[feature.data.processor], configValues(feature.options));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...feature,
|
||||||
|
data: {
|
||||||
|
...feature.data,
|
||||||
|
generation: entry.strategy,
|
||||||
|
scope: entry.scope,
|
||||||
|
color: channels[0]?.color ?? "#66866b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}, [root.features.entries, reader, catalog, renders]),
|
||||||
jobs = useMemo(() => doc.jobs.entries.map(({ node }) => node), [doc.jobs.entries]);
|
jobs = useMemo(() => doc.jobs.entries.map(({ node }) => node), [doc.jobs.entries]);
|
||||||
const enabled = features.filter((feature) => feature.data.enabled);
|
const enabled = features.filter((feature) => feature.data.enabled);
|
||||||
const displays = enabled.flatMap((feature) =>
|
const displays = enabled.flatMap((feature) =>
|
||||||
(renders[feature.data.processor] ?? [{ style: feature.data.style, reveal: feature.data.reveal }]).map((render) => ({
|
resolveRenders(renders[feature.data.processor], configValues(feature.options)).map((render) => ({
|
||||||
...feature,
|
...feature,
|
||||||
data: { ...feature.data, style: render.style, reveal: render.reveal },
|
data: { ...feature.data, style: render.style, reveal: render.reveal, color: render.color ?? feature.data.color },
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const model = useProcessing({
|
const model = useProcessing({
|
||||||
@@ -580,12 +609,13 @@ function ReadingDocument({
|
|||||||
priority:
|
priority:
|
||||||
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
|
(feature.data.scope === "word" ? 0 : feature.data.scope === "sentence" ? 1 : 2) * features.length +
|
||||||
features.indexOf(feature),
|
features.indexOf(feature),
|
||||||
style: item.style ?? (feature.data.style as Annotation["style"]),
|
style: item.style ?? "color",
|
||||||
reveal: item.reveal ?? (feature.data.reveal as Annotation["reveal"]),
|
reveal: item.reveal ?? "always",
|
||||||
color: item.color ?? feature.data.color,
|
color: item.color ?? "",
|
||||||
},
|
},
|
||||||
doc.data.text,
|
doc.data.text,
|
||||||
item.style ? undefined : renders[feature.data.processor],
|
item.style ? [{ style: item.style, reveal: item.reveal ?? "always" }] : renders[feature.data.processor],
|
||||||
|
configValues(feature.options),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -597,7 +627,7 @@ function ReadingDocument({
|
|||||||
const annotation = annotationMap.find((item) => item.id === id),
|
const annotation = annotationMap.find((item) => item.id === id),
|
||||||
feature = features.find((feature) => referenceOf(feature).object === annotation?.feature);
|
feature = features.find((feature) => referenceOf(feature).object === annotation?.feature);
|
||||||
if (!annotation || !feature) return false;
|
if (!annotation || !feature) return false;
|
||||||
const reveal = annotation.reveal ?? feature.data.reveal;
|
const reveal = annotation.reveal ?? "always";
|
||||||
if (reveal === "click" && feature.data.generation === "focus" && feature.data.scope !== "word")
|
if (reveal === "click" && feature.data.generation === "focus" && feature.data.scope !== "word")
|
||||||
return (
|
return (
|
||||||
!!focus &&
|
!!focus &&
|
||||||
@@ -665,6 +695,22 @@ function ReadingDocument({
|
|||||||
)
|
)
|
||||||
.map((job) => ({ feature, job })),
|
.map((job) => ({ feature, job })),
|
||||||
);
|
);
|
||||||
|
const retryJobs = selectAnnotationRetryJobs(
|
||||||
|
jobs,
|
||||||
|
enabled.map((feature) => ({
|
||||||
|
feature,
|
||||||
|
configuration: computations[referenceOf(feature).object],
|
||||||
|
revision: revisions[feature.data.processor],
|
||||||
|
aliases: revisionAliases[feature.data.processor],
|
||||||
|
styles: resolveRenders(renders[feature.data.processor], configValues(feature.options)).map(
|
||||||
|
(render) => render.style,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
doc.data.sourceRevision,
|
||||||
|
window.visible,
|
||||||
|
);
|
||||||
|
const retryInFlight = useRef(new Set<string>());
|
||||||
|
const [, updateRetryState] = useState(0);
|
||||||
const pendingSidebars = displays
|
const pendingSidebars = displays
|
||||||
.filter((feature) => feature.data.style === "sidebar")
|
.filter((feature) => feature.data.style === "sidebar")
|
||||||
.flatMap((feature) =>
|
.flatMap((feature) =>
|
||||||
@@ -881,7 +927,7 @@ function ReadingDocument({
|
|||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
label: feature.data.name,
|
label: feature.data.name,
|
||||||
trigger: (item.reveal ?? feature.data.reveal) as "hover" | "click",
|
trigger: item.reveal as "hover" | "click",
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
...pendingTooltips,
|
...pendingTooltips,
|
||||||
@@ -905,6 +951,36 @@ function ReadingDocument({
|
|||||||
Loading annotations…
|
Loading annotations…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{retryJobs.length > 0 && (
|
||||||
|
<div className="lr-annotation-retries" aria-label="Annotation preparation">
|
||||||
|
{retryJobs.map(({ feature, job }) => {
|
||||||
|
const key = referenceOf(job).object;
|
||||||
|
return (
|
||||||
|
<div className="lr-annotation-retry" key={key}>
|
||||||
|
<span role="status">
|
||||||
|
<strong>{feature.data.name}</strong>: {annotationRetryStatus(job.data.status, job.data.error)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
tone="quiet"
|
||||||
|
disabled={!annotationsReady || retryInFlight.current.has(key)}
|
||||||
|
aria-label={`Try again: ${feature.data.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
explicitAnnotationRetry(
|
||||||
|
key,
|
||||||
|
retryInFlight.current,
|
||||||
|
(settled) => request(feature, { start: job.data.start, end: job.data.end }, true, settled),
|
||||||
|
() => updateRetryState((value) => value + 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{retryInFlight.current.has(key) ? "Trying again…" : "Try again"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{variant && next && (
|
{variant && next && (
|
||||||
<div className="lr-variant-status" role="status">
|
<div className="lr-variant-status" role="status">
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
+81
-1
@@ -1,5 +1,5 @@
|
|||||||
import { sameCacheConfiguration } from "./cache.js";
|
import { sameCacheConfiguration } from "./cache.js";
|
||||||
import type { Range } from "./canvas/types.js";
|
import { intersects, type Range } from "./canvas/types.js";
|
||||||
export type Feature = {
|
export type Feature = {
|
||||||
processor: string;
|
processor: string;
|
||||||
generation: string;
|
generation: string;
|
||||||
@@ -37,6 +37,86 @@ type SemanticJob = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
export type AnnotationRetryFeature<F> = {
|
||||||
|
feature: F;
|
||||||
|
configuration: string;
|
||||||
|
revision: string;
|
||||||
|
aliases?: readonly string[];
|
||||||
|
styles: readonly string[];
|
||||||
|
};
|
||||||
|
/** Nearby controls describe the newest attempt for a semantic scope, not its
|
||||||
|
* history. Shared renderer instances and verified processor aliases must not
|
||||||
|
* multiply controls. Sidebar computations already have their own controls. */
|
||||||
|
export function selectAnnotationRetryJobs<F, J extends SemanticJob>(
|
||||||
|
jobs: readonly J[],
|
||||||
|
features: readonly AnnotationRetryFeature<F>[],
|
||||||
|
sourceRevision: string,
|
||||||
|
visible: Range,
|
||||||
|
) {
|
||||||
|
const scopes: { configuration: string; start: number; end: number; revision: string }[] = [];
|
||||||
|
const result: { feature: F; job: J }[] = [];
|
||||||
|
if (visible.end <= visible.start) return result;
|
||||||
|
for (const job of [...jobs].sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))) {
|
||||||
|
if (job.data.kind !== "annotation" || job.data.sourceRevision !== sourceRevision || !intersects(job.data, visible))
|
||||||
|
continue;
|
||||||
|
const matching = features.filter(
|
||||||
|
(feature) =>
|
||||||
|
feature.revision &&
|
||||||
|
processorMatches(job.data, feature.revision, feature.aliases) &&
|
||||||
|
sameCacheConfiguration(feature.configuration, job.configuration),
|
||||||
|
);
|
||||||
|
const feature = matching.find((feature) => feature.styles.some((style) => style !== "sidebar"));
|
||||||
|
if (!feature || matching.some((feature) => feature.styles.includes("sidebar"))) continue;
|
||||||
|
if (
|
||||||
|
scopes.some(
|
||||||
|
(scope) =>
|
||||||
|
scope.revision === feature.revision &&
|
||||||
|
scope.start === job.data.start &&
|
||||||
|
scope.end === job.data.end &&
|
||||||
|
sameCacheConfiguration(scope.configuration, job.configuration),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
scopes.push({
|
||||||
|
configuration: job.configuration,
|
||||||
|
start: job.data.start,
|
||||||
|
end: job.data.end,
|
||||||
|
revision: feature.revision,
|
||||||
|
});
|
||||||
|
if (["failed", "skipped", "uncertain"].includes(job.data.status)) result.push({ feature: feature.feature, job });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export function annotationRetryStatus(status: string, error: string) {
|
||||||
|
if (error.includes("AGENT_SETUP_UNAVAILABLE")) return "Setup was unavailable. Try again when connected.";
|
||||||
|
if (status === "skipped") return "Not prepared. Try again when ready.";
|
||||||
|
if (status === "uncertain") return "Preparation could not be confirmed.";
|
||||||
|
return "Could not prepare annotations.";
|
||||||
|
}
|
||||||
|
/** Only an explicit control invokes this dispatcher. Guard double clicks until
|
||||||
|
* the public operation settles; neither selection nor a status update retries. */
|
||||||
|
export function explicitAnnotationRetry(
|
||||||
|
key: string,
|
||||||
|
inFlight: Set<string>,
|
||||||
|
dispatch: (settled: () => void) => boolean,
|
||||||
|
changed: () => void,
|
||||||
|
) {
|
||||||
|
if (inFlight.has(key)) return false;
|
||||||
|
inFlight.add(key);
|
||||||
|
changed();
|
||||||
|
const settled = () => {
|
||||||
|
inFlight.delete(key);
|
||||||
|
changed();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (dispatch(settled)) return true;
|
||||||
|
settled();
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
settled();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
/** A successful task replaces its whole semantic scope, including omitted
|
/** A successful task replaces its whole semantic scope, including omitted
|
||||||
* annotations. Deduplicating individual items resurrects removed old output. */
|
* annotations. Deduplicating individual items resurrects removed old output. */
|
||||||
export function selectSuccessfulAnnotationJobs<T extends SemanticJob>(
|
export function selectSuccessfulAnnotationJobs<T extends SemanticJob>(
|
||||||
|
|||||||
+218
-1
@@ -6,6 +6,9 @@ import {
|
|||||||
preparationQueue,
|
preparationQueue,
|
||||||
selectSuccessfulAnnotationJobs,
|
selectSuccessfulAnnotationJobs,
|
||||||
mergeCachedAnnotationEntries,
|
mergeCachedAnnotationEntries,
|
||||||
|
selectAnnotationRetryJobs,
|
||||||
|
annotationRetryStatus,
|
||||||
|
explicitAnnotationRetry,
|
||||||
} from "../src/work.js";
|
} from "../src/work.js";
|
||||||
const oldProcessor = { key: "processor-v1:scope" };
|
const oldProcessor = { key: "processor-v1:scope" };
|
||||||
assert(processorMatches(oldProcessor, "processor-v1"));
|
assert(processorMatches(oldProcessor, "processor-v1"));
|
||||||
@@ -146,7 +149,7 @@ assert.deepEqual(
|
|||||||
assert.equal(new Set(outputs.map((output) => output.id)).size, 3);
|
assert.equal(new Set(outputs.map((output) => output.id)).size, 3);
|
||||||
assert(outputs.every((output) => output.feature === semantic.feature && output.start === 6 && output.end === 9));
|
assert(outputs.every((output) => output.feature === semantic.feature && output.start === 6 && output.end === 9));
|
||||||
assert.equal(semantic.text, "word", "Rendering never mutates cached semantic data");
|
assert.equal(semantic.text, "word", "Rendering never mutates cached semantic data");
|
||||||
assert.deepEqual(renderAnnotations(semantic, source, undefined), [semantic]);
|
assert.deepEqual(renderAnnotations(semantic, source, undefined), [], "Missing channels never invent an appearance");
|
||||||
console.log("PASS one source rendered as replacement, color and original-text hover tooltip");
|
console.log("PASS one source rendered as replacement, color and original-text hover tooltip");
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
@@ -256,3 +259,217 @@ assert.deepEqual(
|
|||||||
console.log(
|
console.log(
|
||||||
"PASS canonical cache pointers and whole-scope replacement, including empty/narrowed successes, aliases and failed retries",
|
"PASS canonical cache pointers and whole-scope replacement, including empty/narrowed successes, aliases and failed retries",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const retryFeature = {
|
||||||
|
feature: "pinyin",
|
||||||
|
configuration: scopeConfiguration,
|
||||||
|
revision: "current",
|
||||||
|
aliases: ["verified-previous"],
|
||||||
|
styles: ["overhead"],
|
||||||
|
};
|
||||||
|
const retryAttempt = (object: string, status: string, start = 0, createdAt = "2026-10-04") => ({
|
||||||
|
...savedJob(object, createdAt, []),
|
||||||
|
data: { ...savedJob(object, createdAt, []).data, status, start, end: start + 6 },
|
||||||
|
});
|
||||||
|
const skipped = retryAttempt("skipped", "skipped"),
|
||||||
|
failed = retryAttempt("failed", "failed", 6),
|
||||||
|
uncertain = retryAttempt("uncertain", "uncertain", 12),
|
||||||
|
offPage = retryAttempt("off-page", "skipped", 30),
|
||||||
|
ready = retryAttempt("ready", "ready", 18),
|
||||||
|
queued = retryAttempt("queued", "queued", 24);
|
||||||
|
const retryHistory = [skipped, failed, uncertain, offPage, ready, queued];
|
||||||
|
const retryHistorySnapshot = structuredClone(retryHistory);
|
||||||
|
const visiblePage = { start: 0, end: 30 };
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs(
|
||||||
|
retryHistory,
|
||||||
|
[retryFeature, { ...retryFeature, feature: "hover pinyin", styles: ["tooltip"] }],
|
||||||
|
"doc@1",
|
||||||
|
visiblePage,
|
||||||
|
).map(({ feature, job }) => [feature, job._camino.ref.object]),
|
||||||
|
[
|
||||||
|
["pinyin", "skipped"],
|
||||||
|
["pinyin", "failed"],
|
||||||
|
["pinyin", "uncertain"],
|
||||||
|
],
|
||||||
|
"Failed/skipped/uncertain visible scopes appear once across shared annotators; queued/ready/off-page scopes do not",
|
||||||
|
);
|
||||||
|
const legacySkipped = {
|
||||||
|
...skipped,
|
||||||
|
configuration: oldAlias.configuration,
|
||||||
|
data: { ...skipped.data, key: "verified-previous:legacy", createdAt: "2026-10-03" },
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
selectAnnotationRetryJobs([legacySkipped, skipped], [retryFeature], "doc@1", visiblePage).length,
|
||||||
|
1,
|
||||||
|
"Verified aliases and legacy scheduling configuration deduplicate the same scope",
|
||||||
|
);
|
||||||
|
for (const status of ["ready", "queued"]) {
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs(
|
||||||
|
[skipped, legacySkipped, retryAttempt("replacement", status, 0, "2026-10-05")],
|
||||||
|
[retryFeature],
|
||||||
|
"doc@1",
|
||||||
|
visiblePage,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
"Newer queued or successful work supersedes old errors",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["sidebar"] }], "doc@1", visiblePage),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs([skipped], [{ ...retryFeature, styles: ["underline", "sidebar"] }], "doc@1", visiblePage),
|
||||||
|
[],
|
||||||
|
"Multi-hook sidebar processors already have controls",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs(
|
||||||
|
[skipped],
|
||||||
|
[retryFeature, { ...retryFeature, feature: "notes", styles: ["sidebar"] }],
|
||||||
|
"doc@1",
|
||||||
|
visiblePage,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
"A shared sidebar renderer does not get duplicate controls",
|
||||||
|
);
|
||||||
|
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@2", visiblePage), []);
|
||||||
|
assert.deepEqual(selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", { start: 0, end: 0 }), []);
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs(
|
||||||
|
[{ ...skipped, data: { ...skipped.data, kind: "variant" } }],
|
||||||
|
[retryFeature],
|
||||||
|
"doc@1",
|
||||||
|
visiblePage,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs(
|
||||||
|
[{ ...unknown, data: { ...unknown.data, status: "failed" } }],
|
||||||
|
[retryFeature],
|
||||||
|
"doc@1",
|
||||||
|
visiblePage,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
selectAnnotationRetryJobs(
|
||||||
|
[{ ...anotherInput, data: { ...anotherInput.data, status: "failed" } }],
|
||||||
|
[retryFeature],
|
||||||
|
"doc@1",
|
||||||
|
visiblePage,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
retryHistory,
|
||||||
|
retryHistorySnapshot,
|
||||||
|
"Status selection preserves all cached values and attempt history",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
annotationRetryStatus("skipped", "AGENT_SETUP_UNAVAILABLE: Authentication required"),
|
||||||
|
"Setup was unavailable. Try again when connected.",
|
||||||
|
);
|
||||||
|
assert.equal(annotationRetryStatus("uncertain", "raw transport error"), "Preparation could not be confirmed.");
|
||||||
|
const retrying = new Set<string>();
|
||||||
|
let explicitCalls = 0,
|
||||||
|
finishRetry: () => void = () => {},
|
||||||
|
rerenders = 0;
|
||||||
|
const dispatch = (settled: () => void) => {
|
||||||
|
explicitCalls++;
|
||||||
|
// The view delegates the original scope with retry:true, without replanning.
|
||||||
|
const { feature, job } = selectAnnotationRetryJobs([skipped], [retryFeature], "doc@1", visiblePage)[0];
|
||||||
|
assert.deepEqual(
|
||||||
|
{ feature, start: job.data.start, end: job.data.end, retry: true },
|
||||||
|
{ feature: "pinyin", start: 0, end: 6, retry: true },
|
||||||
|
);
|
||||||
|
finishRetry = settled;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
assert.equal(explicitCalls, 0, "Selecting failed statuses never dispatches inference");
|
||||||
|
assert(explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
|
||||||
|
assert(!explicitAnnotationRetry("skipped", retrying, dispatch, () => rerenders++));
|
||||||
|
assert.equal(explicitCalls, 1, "Double clicks cannot repeat an in-flight retry");
|
||||||
|
finishRetry();
|
||||||
|
assert.equal(explicitCalls, 1, "Settlement never automatically retries");
|
||||||
|
assert.equal(retrying.size, 0);
|
||||||
|
assert.equal(rerenders, 2);
|
||||||
|
assert(
|
||||||
|
!explicitAnnotationRetry(
|
||||||
|
"not-ready",
|
||||||
|
retrying,
|
||||||
|
() => false,
|
||||||
|
() => {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(retrying.size, 0, "An unavailable operation releases the button");
|
||||||
|
assert.throws(() =>
|
||||||
|
explicitAnnotationRetry(
|
||||||
|
"rejected",
|
||||||
|
retrying,
|
||||||
|
() => {
|
||||||
|
throw Error("rejected");
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(retrying.size, 0);
|
||||||
|
console.log(
|
||||||
|
"PASS explicit nearby annotator retries, shared identities/aliases, sidebar/off-page exclusion and superseded attempts",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two channels use independently declared structs, not a single feature type.
|
||||||
|
import { configDefaults } from "../src/config-schema.js";
|
||||||
|
const raw = { ...semantic, color: "" };
|
||||||
|
const multi = renderAnnotations(
|
||||||
|
raw,
|
||||||
|
source,
|
||||||
|
[
|
||||||
|
{ style: "underline", reveal: "always", options: "primary" },
|
||||||
|
{ style: "underline", reveal: "always", options: "secondary" },
|
||||||
|
{ style: "replace", reveal: "click" },
|
||||||
|
{ style: "tooltip", reveal: "hover", content: "source" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
primary: { color: "#112233", underlinePattern: "squiggly" },
|
||||||
|
secondary: { color: "#445566", underlinePattern: "dashed" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
multi.slice(0, 2).map((a) => [a.color, a.underlinePattern]),
|
||||||
|
[
|
||||||
|
["#112233", "squiggly"],
|
||||||
|
["#445566", "dashed"],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const changed = renderAnnotations(raw, source, [{ style: "underline", reveal: "always", options: "primary" }], {
|
||||||
|
primary: { color: "#112233", underlinePattern: "solid" },
|
||||||
|
});
|
||||||
|
assert.equal(changed[0].underlinePattern, "solid");
|
||||||
|
assert.equal(raw.text, "word");
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
renderAnnotations(raw, source, [{ style: "overhead", reveal: "always", options: "label" }], {
|
||||||
|
label: { fontSize: 500 },
|
||||||
|
}),
|
||||||
|
/Invalid annotator render channel/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => renderAnnotations(raw, source, [{ style: "overhead", reveal: "hover" }]),
|
||||||
|
/Invalid annotator render channel/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
configDefaults([
|
||||||
|
{
|
||||||
|
kind: "group",
|
||||||
|
key: "underlines",
|
||||||
|
label: "Underlines",
|
||||||
|
fields: [{ kind: "select", key: "pattern", label: "Pattern", default: "dashed" }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{ underlines: { pattern: "dashed" } },
|
||||||
|
);
|
||||||
|
console.log("PASS code-owned settings bind independent render channels without replacing semantic data");
|
||||||
|
|||||||
Reference in New Issue
Block a user