@particle-academy/fancy-cms-ui 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/editor.cjs CHANGED
@@ -16,6 +16,12 @@ function hasNodeMap(value) {
16
16
  const nodes = value.nodes;
17
17
  return typeof nodes === "object" && nodes !== null && !Array.isArray(nodes);
18
18
  }
19
+ function withNodeDefaults(node) {
20
+ const props = typeof node.props === "object" && node.props !== null ? node.props : {};
21
+ const style = typeof node.style === "object" && node.style !== null ? node.style : { base: {} };
22
+ const base = typeof style.base === "object" && style.base !== null ? style.base : {};
23
+ return props === node.props && style === node.style && base === style.base ? node : { ...node, props, style: { ...style, base } };
24
+ }
19
25
  var DEFAULT_BREAKPOINTS = {
20
26
  base: 0,
21
27
  md: 768,
@@ -41,51 +47,7 @@ function setOwn(map, key, value) {
41
47
  Object.defineProperty(map, key, { value, writable: true, enumerable: true, configurable: true });
42
48
  }
43
49
 
44
- // src/document/migrate.ts
45
- function normalizeDoc(input3) {
46
- if (hasNodeMap(input3)) return input3;
47
- const id = typeof input3 === "object" && input3 !== null && !Array.isArray(input3) && typeof input3.id === "string" ? input3.id : "malformed";
48
- return emptyDoc(id);
49
- }
50
- function migrateDocVerbose(input3) {
51
- const normalized = normalizeDoc(input3);
52
- const legacy = normalized;
53
- const sections = legacy.sections;
54
- if (!Array.isArray(sections)) {
55
- return { doc: normalized, migrated: false, danglingIds: [], unlistedRootIds: [] };
56
- }
57
- const danglingIds = [];
58
- const ordered = [];
59
- const seen = /* @__PURE__ */ new Set();
60
- for (const id of sections) {
61
- if (!own(normalized.nodes, id)) {
62
- danglingIds.push(id);
63
- continue;
64
- }
65
- if (seen.has(id)) continue;
66
- seen.add(id);
67
- ordered.push(id);
68
- }
69
- const unlistedRootIds = Object.values(normalized.nodes).filter((n) => n.parent === null && !seen.has(n.id)).sort((a, b) => a.order < b.order ? -1 : a.order > b.order ? 1 : 0).map((n) => n.id);
70
- const finalOrder = [...ordered, ...unlistedRootIds];
71
- const nodes = { ...normalized.nodes };
72
- let prev = null;
73
- for (const id of finalOrder) {
74
- const key = fancyDocCommons.fractionalKey(prev, null);
75
- setOwn(nodes, id, { ...own(nodes, id), order: key });
76
- prev = key;
77
- }
78
- const { sections: _dropped, ...rest } = legacy;
79
- return {
80
- doc: { ...rest, nodes },
81
- migrated: true,
82
- danglingIds,
83
- unlistedRootIds
84
- };
85
- }
86
- function migrateDoc(input3) {
87
- return migrateDocVerbose(input3).doc;
88
- }
50
+ // src/document/reduce.ts
89
51
  function childrenOf(doc, parent) {
90
52
  return fancyDocCommons.childrenOf(doc, parent);
91
53
  }
@@ -225,6 +187,50 @@ function reduce(doc, op, opts = {}) {
225
187
  return invalid(`unknown op "${op.t}"`);
226
188
  }
227
189
  }
190
+ function normalizeDoc(input3) {
191
+ if (hasNodeMap(input3)) return input3;
192
+ const id = typeof input3 === "object" && input3 !== null && !Array.isArray(input3) && typeof input3.id === "string" ? input3.id : "malformed";
193
+ return emptyDoc(id);
194
+ }
195
+ function migrateDocVerbose(input3) {
196
+ const normalized = normalizeDoc(input3);
197
+ const legacy = normalized;
198
+ const sections = legacy.sections;
199
+ if (!Array.isArray(sections)) {
200
+ return { doc: normalized, migrated: false, danglingIds: [], unlistedRootIds: [] };
201
+ }
202
+ const danglingIds = [];
203
+ const ordered = [];
204
+ const seen = /* @__PURE__ */ new Set();
205
+ for (const id of sections) {
206
+ if (!own(normalized.nodes, id)) {
207
+ danglingIds.push(id);
208
+ continue;
209
+ }
210
+ if (seen.has(id)) continue;
211
+ seen.add(id);
212
+ ordered.push(id);
213
+ }
214
+ const unlistedRootIds = Object.values(normalized.nodes).filter((n) => n.parent === null && !seen.has(n.id)).sort((a, b) => a.order < b.order ? -1 : a.order > b.order ? 1 : 0).map((n) => n.id);
215
+ const finalOrder = [...ordered, ...unlistedRootIds];
216
+ const nodes = { ...normalized.nodes };
217
+ let prev = null;
218
+ for (const id of finalOrder) {
219
+ const key = fancyDocCommons.fractionalKey(prev, null);
220
+ setOwn(nodes, id, { ...own(nodes, id), order: key });
221
+ prev = key;
222
+ }
223
+ const { sections: _dropped, ...rest } = legacy;
224
+ return {
225
+ doc: { ...rest, nodes },
226
+ migrated: true,
227
+ danglingIds,
228
+ unlistedRootIds
229
+ };
230
+ }
231
+ function migrateDoc(input3) {
232
+ return migrateDocVerbose(input3).doc;
233
+ }
228
234
  function initEditor(doc) {
229
235
  return { doc: migrateDoc(doc), past: [], future: [], selection: null };
230
236
  }
@@ -311,6 +317,28 @@ function cssString(value) {
311
317
  return out;
312
318
  }
313
319
  var selectorFor = (id) => `[data-cms="${cssString(id)}"]`;
320
+ var BASE_CSS = [
321
+ "/* fancy-cms base */",
322
+ ":where([data-cms]) {\n box-sizing: border-box;\n}",
323
+ ":where(h1, h2, h3, h4, h5, h6, p, ul, ol)[data-cms] {\n margin: 0;\n}",
324
+ ":where(ul, ol)[data-cms] {\n padding-left: 1.25em;\n}",
325
+ ":where(a[data-cms]) {\n color: inherit;\n}",
326
+ ":where([data-cms] > img) {\n display: block;\n height: 100%;\n object-fit: cover;\n width: 100%;\n}",
327
+ ':where([data-cms] > img[data-cms-fit="contain"]) {\n object-fit: contain;\n}',
328
+ ':where([data-cms] > img[data-cms-fit="fill"]) {\n object-fit: fill;\n}',
329
+ ':where([data-cms] > img[data-cms-fit="none"]) {\n object-fit: none;\n}',
330
+ ':where([data-cms] > img[data-cms-fit="scale-down"]) {\n object-fit: scale-down;\n}',
331
+ ":where([data-cms-button]) {\n align-items: center;\n border: 1px solid var(--cms-color-primary, #7c3aed);\n border-radius: 8px;\n cursor: pointer;\n display: inline-flex;\n font-weight: 600;\n gap: 6px;\n justify-content: center;\n line-height: 1.2;\n padding: 10px 18px;\n text-decoration: none;\n}",
332
+ ':where([data-cms-button="primary"]) {\n background: var(--cms-color-primary, #7c3aed);\n color: #ffffff;\n}',
333
+ ':where([data-cms-button="outline"]) {\n background: transparent;\n color: var(--cms-color-primary, #7c3aed);\n}',
334
+ ':where([data-cms-button="ghost"]) {\n background: transparent;\n border-color: currentColor;\n color: inherit;\n}',
335
+ ":where([data-cms-callout]) {\n background: #3b82f614;\n border-left: 3px solid #3b82f6;\n border-radius: 10px;\n padding: 12px 14px;\n}",
336
+ ':where([data-cms-callout="success"]) {\n background: #10b98114;\n border-left-color: #10b981;\n}',
337
+ ':where([data-cms-callout="warning"]) {\n background: #f59e0b14;\n border-left-color: #f59e0b;\n}',
338
+ ':where([data-cms-callout="danger"]) {\n background: #ef444414;\n border-left-color: #ef4444;\n}',
339
+ ":where([data-cms] > hr) {\n border: none;\n border-top: 1px solid #e2e8f0;\n margin: 0;\n}",
340
+ ":where([data-cms] > pre) {\n background: #0b1220;\n border-radius: 10px;\n color: #e2e8f0;\n font-family: ui-monospace, monospace;\n font-size: 13px;\n margin: 0;\n overflow-x: auto;\n padding: 12px 14px;\n}"
341
+ ].join("\n\n");
314
342
  var UNITS = /* @__PURE__ */ new Set(["px", "%", "vw", "vh", "rem", "fr"]);
315
343
  function lengthToCss(len2) {
316
344
  if (typeof len2 !== "object" || len2 === null) return null;
@@ -523,7 +551,7 @@ function emitDocCss(doc) {
523
551
  const themeRule = serializeRule(":root", themeDecls(doc.theme), "");
524
552
  if (themeRule) blocks.push(themeRule);
525
553
  for (const id of Object.keys(doc.nodes).sort()) {
526
- const node = doc.nodes[id];
554
+ const node = withNodeDefaults(doc.nodes[id]);
527
555
  const selector = selectorFor(id);
528
556
  const parentFree = isParentFree(doc, node);
529
557
  const base = {
@@ -536,12 +564,13 @@ function emitDocCss(doc) {
536
564
  if (baseRule) blocks.push(baseRule);
537
565
  for (const bp of doc.breakpoints) {
538
566
  if (bp === "base") continue;
539
- const px = breakpointPx[bp];
540
- if (px === void 0) continue;
541
- const bpStyle = node.style[bp];
567
+ const px = own(breakpointPx, bp);
568
+ if (typeof px !== "number") continue;
569
+ const bpStyle = own(node.style, bp);
570
+ const bpConstraints = node.constraints ? own(node.constraints, bp) : void 0;
542
571
  const decls = {
543
572
  ...bpStyle ? containerDecls(node, bpStyle, false) : {},
544
- ...node.constraints?.[bp] ? constraintDecls(node.constraints[bp], parentFree) : {},
573
+ ...bpConstraints ? constraintDecls(bpConstraints, parentFree) : {},
545
574
  ...bpStyle ? styleDecls(bpStyle) : {},
546
575
  ...bpStyle ? visibilityDecls(node, bpStyle, false) : {}
547
576
  };
@@ -551,7 +580,8 @@ ${inner}
551
580
  }`);
552
581
  }
553
582
  }
554
- return blocks.join("\n\n") + (blocks.length ? "\n" : "");
583
+ if (blocks.length === 0 && Object.keys(doc.nodes).length === 0) return "";
584
+ return [BASE_CSS, ...blocks].join("\n\n") + "\n";
555
585
  }
556
586
  function getPath(data, path) {
557
587
  return path.split(".").reduce((o, k) => o == null ? void 0 : o[k], data);
@@ -594,10 +624,17 @@ function elementFor(node, text) {
594
624
  case "button":
595
625
  case "link": {
596
626
  const href = safeHref(text(node.props.href));
597
- if (href === null) return { tag: node.type === "link" ? "span" : "div" };
598
- const attrs = { href };
599
- if (/^https?:\/\//i.test(href)) attrs.rel = "noopener noreferrer";
600
- return { tag: "a", attrs };
627
+ const attrs = {};
628
+ if (href !== null) {
629
+ attrs.href = href;
630
+ if (/^https?:\/\//i.test(href)) attrs.rel = "noopener noreferrer";
631
+ }
632
+ if (node.type === "button") attrs["data-cms-button"] = BUTTON_VARIANTS.has(text(node.props.variant)) ? text(node.props.variant) : "primary";
633
+ return { tag: href !== null ? "a" : node.type === "link" ? "span" : "div", attrs };
634
+ }
635
+ case "callout": {
636
+ const variant = text(node.props.variant);
637
+ return { tag: "div", attrs: { "data-cms-callout": CALLOUT_VARIANTS.has(variant) ? variant : "info" } };
601
638
  }
602
639
  default:
603
640
  return { tag: CONTAINERS.has(node.type) && LANDMARKS.has(as) ? as : "div" };
@@ -621,51 +658,32 @@ var defaultRegistry = {
621
658
  const items = resolve(node.props.items);
622
659
  return Array.isArray(items) ? items.map((item, i) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: item === null || item === void 0 ? "" : String(item) }, i)) : null;
623
660
  },
624
- divider: () => /* @__PURE__ */ jsxRuntime.jsx("hr", { style: { border: "none", borderTop: "1px solid #e2e8f0", margin: 0 } }),
625
- callout: ({ node, text }) => {
626
- const tone = { info: "#3b82f6", success: "#10b981", warning: "#f59e0b", danger: "#ef4444" }[text(node.props.variant)] ?? "#3b82f6";
627
- return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { display: "flex", gap: 10, padding: "12px 14px", borderRadius: 10, background: `${tone}14`, borderLeft: `3px solid ${tone}` }, children: /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 13 }, children: text(node.props.content) || "Callout" }) });
628
- },
629
- code: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("pre", { style: { margin: 0, padding: "12px 14px", borderRadius: 10, background: "#0b1220", color: "#e2e8f0", fontFamily: "ui-monospace, monospace", fontSize: 13, overflowX: "auto" }, children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: text(node.props.content) }) }),
630
- button: ({ node, text }) => {
631
- const label2 = text(node.props.label) || text(node.props.content) || "Button";
632
- const variant = text(node.props.variant) || "primary";
633
- return /* @__PURE__ */ jsxRuntime.jsx("span", { "data-cms-button": variant, style: buttonStyle(variant), children: label2 });
634
- },
661
+ // Looks come from the base stylesheet (render/css.ts BASE_CSS), never from
662
+ // inline style objects, so the PHP renderer emits the same markup.
663
+ divider: () => /* @__PURE__ */ jsxRuntime.jsx("hr", {}),
664
+ callout: ({ node, text }) => text(node.props.content) || "Callout",
665
+ code: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("pre", { children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: text(node.props.content) }) }),
666
+ button: ({ node, text }) => text(node.props.label) || text(node.props.content) || "Button",
635
667
  image: ({ node, text }) => {
636
668
  const fit = text(node.props.fit);
637
669
  return /* @__PURE__ */ jsxRuntime.jsx(
638
670
  "img",
639
671
  {
640
- src: text(node.props.src),
672
+ src: text(node.props.src) || void 0,
641
673
  alt: text(node.props.alt),
642
674
  loading: text(node.props.loading) === "eager" ? "eager" : "lazy",
643
- style: { width: "100%", height: "100%", objectFit: IMAGE_FITS.has(fit) ? fit : "cover", display: "block" }
675
+ "data-cms-fit": IMAGE_FITS.has(fit) && fit !== "cover" ? fit : void 0
644
676
  }
645
677
  );
646
678
  }
647
679
  };
648
680
  var IMAGE_FITS = /* @__PURE__ */ new Set(["cover", "contain", "fill", "none", "scale-down"]);
649
- function buttonStyle(variant) {
650
- const base = {
651
- display: "inline-flex",
652
- alignItems: "center",
653
- gap: 6,
654
- padding: "9px 16px",
655
- borderRadius: 8,
656
- fontWeight: 600,
657
- fontSize: 14,
658
- lineHeight: 1,
659
- cursor: "pointer",
660
- userSelect: "none"
661
- };
662
- if (variant === "ghost") return { ...base, background: "transparent", color: "inherit", border: "1px solid currentColor" };
663
- if (variant === "outline") return { ...base, background: "transparent", color: "#7c3aed", border: "1px solid #7c3aed" };
664
- return { ...base, background: "#7c3aed", color: "#fff", border: "1px solid #7c3aed" };
665
- }
681
+ var BUTTON_VARIANTS = /* @__PURE__ */ new Set(["primary", "outline", "ghost"]);
682
+ var CALLOUT_VARIANTS = /* @__PURE__ */ new Set(["info", "success", "warning", "danger"]);
666
683
  function RenderNode({ doc, id, registry = defaultRegistry, data }) {
667
- const node = own(doc.nodes, id);
668
- if (!node) return null;
684
+ const stored = own(doc.nodes, id);
685
+ if (!stored) return null;
686
+ const node = withNodeDefaults(stored);
669
687
  if (node.type === "repeater") {
670
688
  const arr = resolveValue(node.props.items, data);
671
689
  const items = Array.isArray(arr) ? arr : [];
@@ -882,16 +900,42 @@ var input = {
882
900
  font: "inherit",
883
901
  fontSize: 13
884
902
  };
885
- function Inspector({ doc, selection, apply }) {
903
+ function Inspector({ doc, selection, apply, drafts }) {
886
904
  const uid = react.useId();
887
905
  const fieldId = (key) => `${uid}-${key}`;
888
906
  const node = selection ? doc.nodes[selection] : null;
889
907
  if (!node) {
890
908
  return /* @__PURE__ */ jsxRuntime.jsx("div", { style: { padding: 16, color: "var(--fcms-muted)", fontFamily: "system-ui, sans-serif", fontSize: 13 }, children: "Select an element to edit." });
891
909
  }
892
- const s = node.style.base;
893
- const setStyle = (patch) => apply({ t: "set_style", id: node.id, breakpoint: "base", patch });
894
- const setProp = (key, value) => apply({ t: "set_node_props", id: node.id, patch: { [key]: value } });
910
+ const nodeId = node.id;
911
+ const bind = (name, read, op, numeric = false) => {
912
+ const key = JSON.stringify([nodeId, name]);
913
+ const field2 = {
914
+ key,
915
+ label: `${nodeId} / ${name}`,
916
+ op,
917
+ read: (next) => {
918
+ const item = next.nodes[nodeId];
919
+ return item ? read(item) : void 0;
920
+ },
921
+ canonical: numeric ? (raw) => String(Number(raw) || 0) : (raw) => raw
922
+ };
923
+ const accepted = read(node);
924
+ return {
925
+ value: drafts ? drafts.value(key, accepted) : accepted,
926
+ onChange: (event) => {
927
+ if (drafts) drafts.change(field2, event.target.value);
928
+ else apply(op(event.target.value));
929
+ },
930
+ onFocus: () => drafts?.focus(key),
931
+ onBlur: () => drafts?.blur(key),
932
+ onCompositionStart: () => drafts?.composition(key, true),
933
+ onCompositionEnd: () => drafts?.composition(key, false)
934
+ };
935
+ };
936
+ const prop = (key) => bind(key, (item) => typeof item.props[key] === "string" ? item.props[key] : "", (raw) => ({ t: "set_node_props", id: nodeId, patch: { [key]: raw } }));
937
+ const style = (key) => bind(key, (item) => item.style.base[key] ?? "", (raw) => ({ t: "set_style", id: nodeId, breakpoint: "base", patch: { [key]: raw } }));
938
+ const length = (key) => bind(key, (item) => item.style.base[key]?.value?.toString() ?? "", (raw) => ({ t: "set_style", id: nodeId, breakpoint: "base", patch: { [key]: { value: Number(raw) || 0, unit: "px" } } }), true);
895
939
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { overflow: "auto", padding: 16, fontFamily: "system-ui, sans-serif", height: "100%", boxSizing: "border-box" }, children: [
896
940
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--fcms-fg)" }, children: node.type }),
897
941
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, color: "var(--fcms-muted)", marginBottom: 8 }, children: node.id }),
@@ -903,8 +947,7 @@ function Inspector({ doc, selection, apply }) {
903
947
  id: fieldId("content"),
904
948
  "data-cms-field": "content",
905
949
  style: { ...input, minHeight: 64, resize: "vertical" },
906
- value: typeof node.props.content === "string" ? node.props.content : "",
907
- onChange: (e) => setProp("content", e.target.value)
950
+ ...prop("content")
908
951
  }
909
952
  )
910
953
  ] }) : null,
@@ -916,8 +959,7 @@ function Inspector({ doc, selection, apply }) {
916
959
  id: fieldId("src"),
917
960
  "data-cms-field": "src",
918
961
  style: input,
919
- value: typeof node.props.src === "string" ? node.props.src : "",
920
- onChange: (e) => setProp("src", e.target.value)
962
+ ...prop("src")
921
963
  }
922
964
  )
923
965
  ] }) : null,
@@ -929,8 +971,7 @@ function Inspector({ doc, selection, apply }) {
929
971
  "data-cms-field": "background",
930
972
  style: input,
931
973
  placeholder: "#ffffff | gradient | url(\u2026)",
932
- value: s.background ?? "",
933
- onChange: (e) => setStyle({ background: e.target.value })
974
+ ...style("background")
934
975
  }
935
976
  ),
936
977
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("color"), children: "Text color" }),
@@ -941,8 +982,7 @@ function Inspector({ doc, selection, apply }) {
941
982
  "data-cms-field": "color",
942
983
  style: input,
943
984
  placeholder: "#0f172a",
944
- value: s.color ?? "",
945
- onChange: (e) => setStyle({ color: e.target.value })
985
+ ...style("color")
946
986
  }
947
987
  ),
948
988
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("fontSize"), children: "Font size (px)" }),
@@ -953,8 +993,7 @@ function Inspector({ doc, selection, apply }) {
953
993
  "data-cms-field": "fontSize",
954
994
  type: "number",
955
995
  style: input,
956
- value: s.fontSize?.value ?? "",
957
- onChange: (e) => setStyle({ fontSize: { value: Number(e.target.value) || 0, unit: "px" } })
996
+ ...length("fontSize")
958
997
  }
959
998
  ),
960
999
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("padding"), children: "Padding (px)" }),
@@ -965,8 +1004,7 @@ function Inspector({ doc, selection, apply }) {
965
1004
  "data-cms-field": "padding",
966
1005
  type: "number",
967
1006
  style: input,
968
- value: s.padding?.value ?? "",
969
- onChange: (e) => setStyle({ padding: { value: Number(e.target.value) || 0, unit: "px" } })
1007
+ ...length("padding")
970
1008
  }
971
1009
  ),
972
1010
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("opacity"), children: "Opacity" }),
@@ -980,12 +1018,171 @@ function Inspector({ doc, selection, apply }) {
980
1018
  min: "0",
981
1019
  max: "1",
982
1020
  style: input,
983
- value: s.opacity ?? "",
984
- onChange: (e) => setStyle({ opacity: Number(e.target.value) })
1021
+ ...bind("opacity", (item) => item.style.base.opacity?.toString() ?? "", (raw) => ({ t: "set_style", id: nodeId, breakpoint: "base", patch: { opacity: Number(raw) } }), true)
985
1022
  }
986
1023
  )
987
1024
  ] });
988
1025
  }
1026
+
1027
+ // src/editor/InspectorDrafts.ts
1028
+ var InspectorDrafts = class {
1029
+ constructor(doc) {
1030
+ this.doc = doc;
1031
+ this.drafts = /* @__PURE__ */ new Map();
1032
+ this.composing = /* @__PURE__ */ new Set();
1033
+ this.focused = null;
1034
+ this.flight = null;
1035
+ this.listeners = /* @__PURE__ */ new Set();
1036
+ this.version = 0;
1037
+ this.disabled = false;
1038
+ this.emit = () => false;
1039
+ this.subscribe = (listener) => {
1040
+ this.listeners.add(listener);
1041
+ return () => {
1042
+ this.listeners.delete(listener);
1043
+ };
1044
+ };
1045
+ this.snapshot = () => this.version;
1046
+ }
1047
+ get busy() {
1048
+ return this.flight !== null || [...this.drafts.values()].some((draft) => draft.dirty || draft.conflict !== null);
1049
+ }
1050
+ get conflicts() {
1051
+ return [...this.drafts.values()].filter((draft) => draft.conflict !== null).map((draft) => ({
1052
+ key: draft.field.key,
1053
+ label: draft.field.label,
1054
+ message: draft.conflict,
1055
+ missing: draft.field.read(this.doc) === void 0
1056
+ }));
1057
+ }
1058
+ value(key, accepted) {
1059
+ return this.drafts.get(key)?.raw ?? accepted;
1060
+ }
1061
+ configure(doc, disabled, emit) {
1062
+ const settled = this.disabled && !disabled;
1063
+ this.doc = doc;
1064
+ this.disabled = disabled;
1065
+ this.emit = emit;
1066
+ let changed = false;
1067
+ for (const [key, draft] of this.drafts) {
1068
+ const accepted = draft.field.read(doc);
1069
+ if (this.flight?.draft === draft && accepted === this.flight.expected) {
1070
+ this.clearFlight();
1071
+ draft.base = accepted;
1072
+ draft.dirty = draft.field.canonical(draft.raw) !== accepted;
1073
+ if (!draft.dirty && !draft.focused) this.drafts.delete(key);
1074
+ changed = true;
1075
+ } else if (accepted !== draft.base) {
1076
+ this.conflict(draft, accepted === void 0 ? "The edited element was removed. Your draft is retained." : "The server changed this field. Your draft is retained.");
1077
+ draft.base = accepted;
1078
+ changed = true;
1079
+ } else if (settled && this.flight?.draft === draft) {
1080
+ this.conflict(draft, "The save was not accepted. Your draft is retained.");
1081
+ changed = true;
1082
+ }
1083
+ }
1084
+ if (changed) this.notify();
1085
+ this.arm();
1086
+ }
1087
+ focus(key) {
1088
+ this.focused = key;
1089
+ const draft = this.drafts.get(key);
1090
+ if (draft) draft.focused = true;
1091
+ }
1092
+ change(field2, raw) {
1093
+ let draft = this.drafts.get(field2.key);
1094
+ if (!draft) {
1095
+ draft = { field: field2, raw, base: field2.read(this.doc), focused: this.focused === field2.key, dirty: true, conflict: null, readyAt: Date.now() + 250 };
1096
+ this.drafts.set(field2.key, draft);
1097
+ } else {
1098
+ draft.field = field2;
1099
+ draft.raw = raw;
1100
+ draft.readyAt = Date.now() + 250;
1101
+ }
1102
+ draft.dirty = field2.canonical(raw) !== draft.base;
1103
+ this.notify();
1104
+ this.arm();
1105
+ }
1106
+ blur(key) {
1107
+ if (this.focused === key) this.focused = null;
1108
+ const draft = this.drafts.get(key);
1109
+ if (draft) {
1110
+ draft.focused = false;
1111
+ draft.readyAt = Date.now();
1112
+ if (!draft.dirty && !draft.conflict && this.flight?.draft !== draft) this.drafts.delete(key);
1113
+ this.notify();
1114
+ this.pump();
1115
+ }
1116
+ }
1117
+ composition(key, active) {
1118
+ if (active) this.composing.add(key);
1119
+ else this.composing.delete(key);
1120
+ const draft = this.drafts.get(key);
1121
+ if (draft) draft.readyAt = Date.now() + 250;
1122
+ this.arm();
1123
+ }
1124
+ resolve(key, keep) {
1125
+ if (this.disabled) return;
1126
+ const draft = this.drafts.get(key);
1127
+ if (!draft) return;
1128
+ if (this.flight?.draft === draft) this.clearFlight();
1129
+ if (!keep) this.drafts.delete(key);
1130
+ else {
1131
+ const accepted = draft.field.read(this.doc);
1132
+ if (accepted === void 0) return;
1133
+ draft.base = accepted;
1134
+ draft.conflict = null;
1135
+ draft.dirty = draft.field.canonical(draft.raw) !== accepted;
1136
+ draft.readyAt = Date.now();
1137
+ if (!draft.dirty && !draft.focused) this.drafts.delete(key);
1138
+ }
1139
+ this.notify();
1140
+ this.pump();
1141
+ }
1142
+ dispose() {
1143
+ clearTimeout(this.timer);
1144
+ clearTimeout(this.ackTimer);
1145
+ }
1146
+ notify() {
1147
+ this.version++;
1148
+ for (const listener of this.listeners) listener();
1149
+ }
1150
+ clearFlight() {
1151
+ this.flight = null;
1152
+ clearTimeout(this.ackTimer);
1153
+ this.ackTimer = void 0;
1154
+ }
1155
+ conflict(draft, message) {
1156
+ draft.conflict = message;
1157
+ if (this.flight?.draft === draft) this.clearFlight();
1158
+ }
1159
+ arm() {
1160
+ clearTimeout(this.timer);
1161
+ this.timer = void 0;
1162
+ if (this.disabled || this.flight || this.conflicts.length > 0) return;
1163
+ const ready = [...this.drafts.values()].filter((draft) => draft.dirty && !this.composing.has(draft.field.key));
1164
+ if (ready.length) this.timer = setTimeout(() => this.pump(), Math.max(0, Math.min(...ready.map((draft) => draft.readyAt)) - Date.now()));
1165
+ }
1166
+ pump() {
1167
+ if (this.disabled || this.flight || this.conflicts.length > 0) return;
1168
+ const draft = [...this.drafts.values()].find((item) => item.dirty && item.readyAt <= Date.now() && !this.composing.has(item.field.key));
1169
+ if (!draft) {
1170
+ this.arm();
1171
+ return;
1172
+ }
1173
+ this.flight = { draft, expected: draft.field.canonical(draft.raw) };
1174
+ this.ackTimer = setTimeout(() => {
1175
+ this.conflict(draft, "The save has not been acknowledged. Your draft is retained.");
1176
+ this.notify();
1177
+ }, 1e4);
1178
+ try {
1179
+ if (!this.emit(draft.field.op(draft.raw))) this.conflict(draft, "This edit could not be submitted. Your draft is retained.");
1180
+ } catch {
1181
+ this.conflict(draft, "This edit could not be submitted. Your draft is retained.");
1182
+ }
1183
+ this.notify();
1184
+ }
1185
+ };
989
1186
  var CONTAINER_TYPES = /* @__PURE__ */ new Set([
990
1187
  "section",
991
1188
  "frame",
@@ -1059,18 +1256,59 @@ function buildInsertOp(doc, kind, selectedId, targetId) {
1059
1256
  }
1060
1257
  };
1061
1258
  }
1062
- function Editor({ defaultValue, onChange, registry, data }) {
1259
+ function Editor({ value, defaultValue, onChange, onOp, disabled = false, registry, data }) {
1063
1260
  const initial = react.useMemo(() => defaultValue ?? emptyDoc("untitled"), [defaultValue]);
1064
- const ed = useEditor(initial);
1065
- const first = react.useRef(true);
1261
+ const local = useEditor(initial);
1262
+ const controlled = value !== void 0;
1263
+ const localDoc = useLatestRef(local.state.doc);
1264
+ const lastNotified = react.useRef(local.state.doc);
1265
+ const onOpRef = useLatestRef(onOp);
1266
+ const [drafts] = react.useState(() => new InspectorDrafts(value ?? initial));
1267
+ react.useSyncExternalStore(drafts.subscribe, drafts.snapshot, drafts.snapshot);
1066
1268
  const onChangeRef = useLatestRef(onChange);
1067
1269
  react.useEffect(() => {
1068
- if (first.current) {
1069
- first.current = false;
1070
- return;
1270
+ if (controlled || lastNotified.current === local.state.doc) return;
1271
+ lastNotified.current = local.state.doc;
1272
+ onChangeRef.current?.(local.state.doc);
1273
+ }, [controlled, local.state.doc, onChangeRef]);
1274
+ const apply = (op) => {
1275
+ if (disabled) return false;
1276
+ const source = value ?? localDoc.current;
1277
+ const next = reduce(source, op);
1278
+ if (next === source) return false;
1279
+ if (!controlled) {
1280
+ localDoc.current = next;
1281
+ local.apply(op);
1071
1282
  }
1072
- onChangeRef.current?.(ed.state.doc);
1073
- }, [ed.state.doc, onChangeRef]);
1283
+ onOpRef.current?.(op);
1284
+ if (controlled) onChangeRef.current?.(next);
1285
+ return true;
1286
+ };
1287
+ react.useEffect(() => {
1288
+ if (controlled) drafts.configure(value, disabled, apply);
1289
+ });
1290
+ react.useEffect(() => () => drafts.dispose(), [drafts]);
1291
+ const structuralDisabled = disabled || controlled && drafts.busy;
1292
+ const ed = {
1293
+ ...local,
1294
+ state: { ...local.state, doc: value ?? local.state.doc },
1295
+ apply: (op) => {
1296
+ if (!structuralDisabled) apply(op);
1297
+ },
1298
+ // Snapshot history belongs to the local document. Replaying it into a
1299
+ // server-owned value could overwrite another writer's accepted work.
1300
+ undo: () => {
1301
+ if (!controlled && !disabled) local.undo();
1302
+ },
1303
+ redo: () => {
1304
+ if (!controlled && !disabled) local.redo();
1305
+ },
1306
+ replace: (next) => {
1307
+ if (!controlled && !disabled) local.replace(next);
1308
+ },
1309
+ canUndo: !controlled && !disabled && local.canUndo,
1310
+ canRedo: !controlled && !disabled && local.canRedo
1311
+ };
1074
1312
  const shell = {
1075
1313
  display: "grid",
1076
1314
  gridTemplateColumns: "220px 1fr 300px",
@@ -1082,9 +1320,9 @@ function Editor({ defaultValue, onChange, registry, data }) {
1082
1320
  borderRadius: 12,
1083
1321
  overflow: "hidden"
1084
1322
  };
1085
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fancy-cms-editor", style: shell, children: [
1323
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fancy-cms-editor", style: shell, "aria-busy": structuralDisabled || void 0, children: [
1086
1324
  /* @__PURE__ */ jsxRuntime.jsx("style", { children: CHROME_CSS }),
1087
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { borderRight: "1px solid var(--fcms-border)", display: "flex", flexDirection: "column", minHeight: 0 }, children: [
1325
+ /* @__PURE__ */ jsxRuntime.jsx("fieldset", { disabled: structuralDisabled, "aria-label": "Page structure controls", style: { display: "contents", border: 0, padding: 0, margin: 0 }, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { borderRight: "1px solid var(--fcms-border)", display: "flex", flexDirection: "column", minHeight: 0 }, children: [
1088
1326
  /* @__PURE__ */ jsxRuntime.jsx(Toolbar, { ed }),
1089
1327
  /* @__PURE__ */ jsxRuntime.jsx(
1090
1328
  LayersPanel,
@@ -1095,9 +1333,22 @@ function Editor({ defaultValue, onChange, registry, data }) {
1095
1333
  onMove: (id, parent, order) => ed.apply({ t: "move_node", id, parent, order })
1096
1334
  }
1097
1335
  )
1098
- ] }),
1336
+ ] }) }),
1099
1337
  /* @__PURE__ */ jsxRuntime.jsx(Canvas, { doc: ed.state.doc, selection: ed.state.selection, onSelect: ed.select, apply: ed.apply, registry, data }),
1100
- /* @__PURE__ */ jsxRuntime.jsx("div", { style: { borderLeft: "1px solid var(--fcms-border)", minHeight: 0 }, children: /* @__PURE__ */ jsxRuntime.jsx(Inspector, { doc: ed.state.doc, selection: ed.state.selection, apply: ed.apply }) })
1338
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { borderLeft: "1px solid var(--fcms-border)", minHeight: 0, overflow: "auto" }, children: [
1339
+ controlled && drafts.conflicts.map((conflict) => /* @__PURE__ */ jsxRuntime.jsxs("div", { role: "alert", "data-cms-draft-conflict": conflict.key, style: { padding: 12, fontFamily: "system-ui,sans-serif", fontSize: 13 }, children: [
1340
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
1341
+ conflict.label,
1342
+ ": ",
1343
+ conflict.message
1344
+ ] }),
1345
+ /* @__PURE__ */ jsxRuntime.jsx("pre", { "data-cms-retained-draft": "", style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }, children: drafts.value(conflict.key, "") }),
1346
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled, onClick: () => drafts.resolve(conflict.key, false), children: "Use server value" }),
1347
+ " ",
1348
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled: disabled || conflict.missing, onClick: () => drafts.resolve(conflict.key, true), children: "Keep draft" })
1349
+ ] }, conflict.key)),
1350
+ /* @__PURE__ */ jsxRuntime.jsx("fieldset", { disabled: !controlled && disabled, "aria-label": "Page property controls", style: { border: 0, padding: 0, margin: 0, minWidth: 0, height: "100%" }, children: /* @__PURE__ */ jsxRuntime.jsx(Inspector, { doc: ed.state.doc, selection: ed.state.selection, apply: ed.apply, drafts: controlled ? drafts : void 0 }) })
1351
+ ] })
1101
1352
  ] });
1102
1353
  }
1103
1354
  function Toolbar({ ed }) {