Compare commits

..
7 changed files with 134 additions and 56 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{
"monorepoCommit": "488f7e229afed8f03fc3bfa4abe707da0c613460",
"monorepoCommit": "e33714a5ca38429d1bdb77a961a14461697944f5",
"sourcePath": "examples/local-first/packages/language-reader-presentation"
}
+20 -20
View File
@@ -1,22 +1,22 @@
quixos-lock version 1 {
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 "8de22a0d48b96a31651eb15c4fb4c8a7e244f9e6"; }
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 "7f3c95fc9a4b7bb3ad3c695bb58a74a10db290d3"; }
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 "71deaa6f479bf226a55ecc2c8b15130f855c6972"; }
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 "95f608afeeb255e2eb6c00d22ccc2d8efa13742d"; }
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 "bd77d8dfbc8ec72902249a28912fb749a49d943a"; }
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 "18ed4818742b6ab013b1dd50ede348c597fd2037"; }
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 "d6ce4d674379ee93645dee5df4b43c1304d44b68"; }
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 "ac0194a32b7773d6f738c94371c5b610d1946521"; }
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 "8f90502dcf3626bca59044cf68709c4db0a3cfac"; }
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 "6a7d7881ab0656ea4209934e5e81de1759951e10"; }
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "e33714a5ca38429d1bdb77a961a14461697944f5"; }
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "03ab458b996852f1998abfd999e699527ad3553c"; }
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "a0e458c19bbf61cc7afa40112e4a8eb30ce4c3c6"; }
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "f44ef80714db6fbc81df64369e1c3b1ba8177f48"; }
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "4414205afe1bc9e959083638b565d46dab09ec17"; }
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "0829b97660a2ed788ac0d29acb21502feea3e139"; }
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "0b147f9e0b2926687a2a12bfad627ee9bf502a0d"; }
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "9124b19926a5201bb380058d1dba08a208175177"; }
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "6f3f42f1cba542823f925fd94deca3808f2d9763"; }
interface AgentThread source { repository "https://repos.quixos.org/quixos-templates/interface-agent-thread.git"; commit "574f603ba3536dd9583e2a9472dcefc8ba34fd20"; }
interface AgentItem source { repository "https://repos.quixos.org/quixos-templates/interface-agent-item.git"; commit "631c09b123eab90f97ae025ba177d906224c0c8e"; }
interface AgentQuestion source { repository "https://repos.quixos.org/quixos-templates/interface-agent-question.git"; commit "0ca9d75c16db688521c1c4548bd26b883b952e42"; }
interface StandaloneView source { repository "https://repos.quixos.org/quixos-templates/interface-react-standalone-view.git"; commit "3b0be4ad469b41bb83a5f08493024ff2cbe1b569"; }
interface ReactComponent source { repository "https://repos.quixos.org/quixos-templates/interface-react-component.git"; commit "6418a415e8599f0689b09beb16751e6de79e8ce3"; }
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "a5360f6213e11937e14a534091748b86ee9dc3f1"; }
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "cf0984feed030ecb0e91e4ca88b1bdfb76526505"; }
interface Presentation source { repository "https://repos.quixos.org/quixos-templates/interface-presentation.git"; commit "c97b9060c982963446ffb1732486c067bab63518"; }
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "f001eb3813c5e263651474994de7aecf736388c6"; }
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "187bcfabc2d221f56b0405fd466e7a0ffb53d6ac"; }
interface GutenbergCatalog source { repository "https://repos.quixos.org/quixos-templates/interface-gutenberg-catalog.git"; commit "fe89f54e3970449dc3339d3b85b46a4b55b90a30"; }
}
+26
View File
@@ -0,0 +1,26 @@
import type { ConfigValue, ConfigValues } from "./config-schema.js";
export type OptionEdit = { path: string[]; value: ConfigValue };
/** A group component returns its struct; save only the leaves it actually changed. */
export function optionEdits(before: ConfigValue | undefined, after: ConfigValue, path: string[]): OptionEdit[] {
if (typeof after === "object") {
const previous = typeof before === "object" ? before : {};
return Object.entries(after).flatMap(([key, value]) => optionEdits(previous[key], value, [...path, key]));
}
return before === after ? [] : [{ path, value: after }];
}
/** Merge into the newest local snapshot, preserving edits to other controls. */
export function applyOptionEdits(values: ConfigValues, edits: OptionEdit[]): ConfigValues {
const result = structuredClone(values);
for (const { path, value } of edits) {
let group = result;
for (const key of path.slice(0, -1)) {
if (typeof group[key] !== "object") group[key] = {};
group = group[key] as ConfigValues;
}
group[path.at(-1)!] = value;
}
return result;
}
+10
View File
@@ -18,6 +18,16 @@ export function configDefaults(fields: ConfigNode[]): ConfigValues {
fields.map((field) => [field.key, field.kind === "group" ? configDefaults(field.fields) : field.default]),
);
}
/** Include declared defaults inside partially saved groups, keeping other saved values. */
export function configWithDefaults(fields: ConfigNode[], saved: ConfigValues): ConfigValues {
const values = { ...configDefaults(fields), ...saved };
for (const field of fields) {
if (field.kind !== "group") continue;
const group = saved[field.key];
values[field.key] = configWithDefaults(field.fields, group && typeof group === "object" ? group : {});
}
return values;
}
export function configAt(values: ConfigValues, path: string): ConfigValues {
const result = path
? path
+30 -34
View File
@@ -1,4 +1,5 @@
import { configDefaults } from "./config-schema.js";
import { applyOptionEdits, optionEdits } from "./config-edits.js";
import { configDefaults, configWithDefaults } from "./config-schema.js";
import { resolveRenders } from "./render-hooks.js";
import { readerExtensionSettings } from "./reader-extensions.js";
import { usePrompt } from "./drafts.js";
@@ -1208,9 +1209,10 @@ export function ExistingFeature({
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("");
// Serialize only local projections; remote acknowledgements never block controls.
// Tickets belong to the replica and remain submitted when this editor unmounts.
const edits = useRef(Promise.resolve());
useEffect(() => setTitle(d.name), [d.name]);
function update(changes: Partial<typeof d>) {
if (ops.status !== "ready") return;
@@ -1219,15 +1221,14 @@ export function ExistingFeature({
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;
values = configWithDefaults(definition.fields, configValues(feature.options));
useEffect(() => {
onPreviewChange?.({
...feature,
data: { ...d, ...appearanceIntent, ...(enabledIntent === null ? {} : { enabled: enabledIntent }) },
options: JSON.stringify(values),
});
}, [feature, appearanceIntent, enabledIntent, draft, onPreviewChange]);
}, [feature, appearanceIntent, enabledIntent, onPreviewChange]);
useEffect(() => () => onPreviewChange?.(null), [onPreviewChange]);
useEffect(() => {
if (
@@ -1236,25 +1237,25 @@ export function ExistingFeature({
)
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);
function change(key: string, value: ConfigValue) {
if (ops.status !== "ready") return;
const patch = optionEdits(values[key], value, [key]);
if (!patch.length) return;
const binding = ops.value;
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);
}
edits.current = edits.current
.then(async () => {
const current = await binding.data.get(null);
const options = applyOptionEdits(configValues(await binding.options.get(null)), patch);
const ticket = feedback.track(
binding.configureOptions.call({
expectedRevision: current.configRevision,
options: JSON.stringify(options),
}),
);
await ticket.local;
})
.catch((error) => setError(errorMessage(error)));
}
return (
<section className="lr-existing-row lr-settings-section">
@@ -1287,18 +1288,13 @@ export function ExistingFeature({
processor={d.processor}
fields={definition.fields}
values={values}
disabled={saving}
disabled={ops.status !== "ready"}
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>
{definition.fields.length > 0 && (
<small className="lr-config-status" role="status" aria-live="polite">
{feedback.pending > 0 ? "Saving…" : "Changes save automatically."}
</small>
)}
{(error || feedback.message) && <p role="alert">{error || feedback.message}</p>}
<div className="lr-annotator-footer">
+1 -1
View File
@@ -133,7 +133,7 @@ export const readingStyle = `
.lr-preview-color b{color:var(--annotation)}
.lr-processor-settings{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:0 16px}
.lr-processor-settings input[type=checkbox]{width:auto;align-self:flex-start}
.lr-config-save{display:flex;gap:8px;margin:12px 0}
.lr-config-status{display:block;margin:12px 0;color:var(--muted)}
.lr-page{max-width:none}
.reading-pagination{display:flex;align-items:center;justify-content:center;gap:18px;padding:10px 24px;border-top:1px solid var(--line);font-size:12px}.reading-pagination button{font-size:11px}.lr-view-mode{width:100px}
+46
View File
@@ -1,3 +1,5 @@
import { configWithDefaults, type ConfigNode, type ConfigValues } from "../src/config-schema.js";
import { applyOptionEdits, optionEdits } from "../src/config-edits.js";
import assert from "node:assert/strict";
import {
HoverIntent,
@@ -473,3 +475,47 @@ assert.deepEqual(
{ underlines: { pattern: "dashed" } },
);
console.log("PASS code-owned settings bind independent render channels without replacing semantic data");
// Two group controls may fire from the same render before either query update.
const shown = { label: { fontSize: 13, fontFamily: "serif", color: "#112233" } };
const sizeEdit = optionEdits(shown.label, { ...shown.label, fontSize: 20 }, ["label"]);
const fontEdit = optionEdits(shown.label, { ...shown.label, fontFamily: "monospace" }, ["label"]);
const concurrent = { ...shown, label: { ...shown.label, color: "#334455" } };
assert.deepEqual(
applyOptionEdits(applyOptionEdits(concurrent, sizeEdit), fontEdit),
{
label: { fontSize: 20, fontFamily: "monospace", color: "#334455" },
},
"Queued group edits preserve each other and a concurrent change to another leaf",
);
assert.equal(shown.label.fontSize, 13, "Applying an edit never mutates the displayed snapshot");
// New controls can have defaults without their values being stored yet.
const labelFields: ConfigNode[] = [
{
kind: "group",
key: "label",
label: "Label",
fields: [
{ kind: "number", key: "fontSize", label: "Size", default: 13 },
{ kind: "text", key: "fontFamily", label: "Font", default: "serif" },
{ kind: "color", key: "color", label: "Color", default: "#112233" },
],
},
];
const sparseOptions: ConfigValues[] = [{}, { label: { color: "#556677" } }];
for (const persisted of sparseOptions) {
const effective = configWithDefaults(labelFields, persisted);
const label = effective.label as ConfigValues;
const size = optionEdits(label, { ...label, fontSize: 20 }, ["label"]);
const font = optionEdits(label, { ...label, fontFamily: "monospace" }, ["label"]);
const latest = { label: { color: "#abcdef" }, unrelated: "preserved" };
assert.deepEqual(
applyOptionEdits(applyOptionEdits(latest, size), font),
{
label: { color: "#abcdef", fontSize: 20, fontFamily: "monospace" },
unrelated: "preserved",
},
"Defaults in sparse groups must not undo queued edits or a concurrent color change",
);
}