Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fdc5891c6 | ||
|
|
8f327c9ac8 |
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"monorepoCommit": "488f7e229afed8f03fc3bfa4abe707da0c613460",
|
||||
"monorepoCommit": "e33714a5ca38429d1bdb77a961a14461697944f5",
|
||||
"sourcePath": "examples/local-first/packages/language-reader-presentation"
|
||||
}
|
||||
|
||||
+20
-20
@@ -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"; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user