@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.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- export { C as Canvas, a as CanvasProps, E as Editor, b as EditorAction, c as EditorApi, d as EditorProps, e as EditorState, I as Inspector, f as InspectorProps, L as LayersPanel, g as LayersPanelProps, i as editorReduce, j as initEditor, u as useEditor } from './useEditor-CIRm2oi-.cjs';
1
+ export { C as Canvas, a as CanvasProps, E as Editor, b as EditorAction, c as EditorApi, d as EditorProps, e as EditorState, I as Inspector, f as InspectorProps, L as LayersPanel, g as LayersPanelProps, i as editorReduce, j as initEditor, u as useEditor } from './useEditor-fIQEaKzZ.cjs';
2
2
  import { ReactNode, ReactElement } from 'react';
3
- import { P as PageDoc, h as ElementRegistry, g as DataContext, N as Node, q as StyleProps, L as LayoutMode } from './registry-B4FaQDip.cjs';
3
+ import { P as PageDoc, h as ElementRegistry, g as DataContext, N as Node, q as StyleProps, L as LayoutMode } from './registry-F2TAMjhx.cjs';
4
4
  import '@particle-academy/fancy-doc-commons';
5
5
 
6
6
  /** Animatable per-node transform (mirrors fancy-motion's NodeState, kept local to avoid a hard dep). */
package/dist/editor.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { C as Canvas, a as CanvasProps, E as Editor, b as EditorAction, c as EditorApi, d as EditorProps, e as EditorState, I as Inspector, f as InspectorProps, L as LayersPanel, g as LayersPanelProps, i as editorReduce, j as initEditor, u as useEditor } from './useEditor-DZDD9qpS.js';
1
+ export { C as Canvas, a as CanvasProps, E as Editor, b as EditorAction, c as EditorApi, d as EditorProps, e as EditorState, I as Inspector, f as InspectorProps, L as LayersPanel, g as LayersPanelProps, i as editorReduce, j as initEditor, u as useEditor } from './useEditor-wGEqxpJT.js';
2
2
  import { ReactNode, ReactElement } from 'react';
3
- import { P as PageDoc, h as ElementRegistry, g as DataContext, N as Node, q as StyleProps, L as LayoutMode } from './registry-B4FaQDip.js';
3
+ import { P as PageDoc, h as ElementRegistry, g as DataContext, N as Node, q as StyleProps, L as LayoutMode } from './registry-F2TAMjhx.js';
4
4
  import '@particle-academy/fancy-doc-commons';
5
5
 
6
6
  /** Animatable per-node transform (mirrors fancy-motion's NodeState, kept local to avoid a hard dep). */
package/dist/editor.js CHANGED
@@ -1,6 +1,6 @@
1
- import { useEditor, useLatestRef, CONTAINER_TYPES, ADD_DEFAULTS, ADD_MENU } from './chunk-IOMIYI45.js';
2
- export { Canvas, Editor, Inspector, LayersPanel, editorReduce, initEditor, useEditor } from './chunk-IOMIYI45.js';
3
- import { isBinding, lastRootId, childrenOf, CmsPage, defaultRegistry } from './chunk-SJYKJ6SG.js';
1
+ import { useEditor, useLatestRef, CONTAINER_TYPES, ADD_DEFAULTS, ADD_MENU } from './chunk-G7ULXTKI.js';
2
+ export { Canvas, Editor, Inspector, LayersPanel, editorReduce, initEditor, useEditor } from './chunk-G7ULXTKI.js';
3
+ import { isBinding, lastRootId, childrenOf, CmsPage, defaultRegistry } from './chunk-IRG5IKKO.js';
4
4
  import { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
5
5
  import { ContextMenu } from '@particle-academy/react-fancy';
6
6
  import { fractionalKey } from '@particle-academy/fancy-doc-commons';
package/dist/index.cjs CHANGED
@@ -13,6 +13,12 @@ function hasNodeMap(value) {
13
13
  const nodes = value.nodes;
14
14
  return typeof nodes === "object" && nodes !== null && !Array.isArray(nodes);
15
15
  }
16
+ function withNodeDefaults(node) {
17
+ const props = typeof node.props === "object" && node.props !== null ? node.props : {};
18
+ const style = typeof node.style === "object" && node.style !== null ? node.style : { base: {} };
19
+ const base = typeof style.base === "object" && style.base !== null ? style.base : {};
20
+ return props === node.props && style === node.style && base === style.base ? node : { ...node, props, style: { ...style, base } };
21
+ }
16
22
  var DEFAULT_BREAKPOINTS = {
17
23
  base: 0,
18
24
  md: 768,
@@ -332,6 +338,28 @@ function cssString(value) {
332
338
  return out;
333
339
  }
334
340
  var selectorFor = (id) => `[data-cms="${cssString(id)}"]`;
341
+ var BASE_CSS = [
342
+ "/* fancy-cms base */",
343
+ ":where([data-cms]) {\n box-sizing: border-box;\n}",
344
+ ":where(h1, h2, h3, h4, h5, h6, p, ul, ol)[data-cms] {\n margin: 0;\n}",
345
+ ":where(ul, ol)[data-cms] {\n padding-left: 1.25em;\n}",
346
+ ":where(a[data-cms]) {\n color: inherit;\n}",
347
+ ":where([data-cms] > img) {\n display: block;\n height: 100%;\n object-fit: cover;\n width: 100%;\n}",
348
+ ':where([data-cms] > img[data-cms-fit="contain"]) {\n object-fit: contain;\n}',
349
+ ':where([data-cms] > img[data-cms-fit="fill"]) {\n object-fit: fill;\n}',
350
+ ':where([data-cms] > img[data-cms-fit="none"]) {\n object-fit: none;\n}',
351
+ ':where([data-cms] > img[data-cms-fit="scale-down"]) {\n object-fit: scale-down;\n}',
352
+ ":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}",
353
+ ':where([data-cms-button="primary"]) {\n background: var(--cms-color-primary, #7c3aed);\n color: #ffffff;\n}',
354
+ ':where([data-cms-button="outline"]) {\n background: transparent;\n color: var(--cms-color-primary, #7c3aed);\n}',
355
+ ':where([data-cms-button="ghost"]) {\n background: transparent;\n border-color: currentColor;\n color: inherit;\n}',
356
+ ":where([data-cms-callout]) {\n background: #3b82f614;\n border-left: 3px solid #3b82f6;\n border-radius: 10px;\n padding: 12px 14px;\n}",
357
+ ':where([data-cms-callout="success"]) {\n background: #10b98114;\n border-left-color: #10b981;\n}',
358
+ ':where([data-cms-callout="warning"]) {\n background: #f59e0b14;\n border-left-color: #f59e0b;\n}',
359
+ ':where([data-cms-callout="danger"]) {\n background: #ef444414;\n border-left-color: #ef4444;\n}',
360
+ ":where([data-cms] > hr) {\n border: none;\n border-top: 1px solid #e2e8f0;\n margin: 0;\n}",
361
+ ":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}"
362
+ ].join("\n\n");
335
363
  var UNITS = /* @__PURE__ */ new Set(["px", "%", "vw", "vh", "rem", "fr"]);
336
364
  function lengthToCss(len) {
337
365
  if (typeof len !== "object" || len === null) return null;
@@ -544,7 +572,7 @@ function emitDocCss(doc) {
544
572
  const themeRule = serializeRule(":root", themeDecls(doc.theme), "");
545
573
  if (themeRule) blocks.push(themeRule);
546
574
  for (const id of Object.keys(doc.nodes).sort()) {
547
- const node = doc.nodes[id];
575
+ const node = withNodeDefaults(doc.nodes[id]);
548
576
  const selector = selectorFor(id);
549
577
  const parentFree = isParentFree(doc, node);
550
578
  const base = {
@@ -557,12 +585,13 @@ function emitDocCss(doc) {
557
585
  if (baseRule) blocks.push(baseRule);
558
586
  for (const bp of doc.breakpoints) {
559
587
  if (bp === "base") continue;
560
- const px = breakpointPx[bp];
561
- if (px === void 0) continue;
562
- const bpStyle = node.style[bp];
588
+ const px = own(breakpointPx, bp);
589
+ if (typeof px !== "number") continue;
590
+ const bpStyle = own(node.style, bp);
591
+ const bpConstraints = node.constraints ? own(node.constraints, bp) : void 0;
563
592
  const decls = {
564
593
  ...bpStyle ? containerDecls(node, bpStyle, false) : {},
565
- ...node.constraints?.[bp] ? constraintDecls(node.constraints[bp], parentFree) : {},
594
+ ...bpConstraints ? constraintDecls(bpConstraints, parentFree) : {},
566
595
  ...bpStyle ? styleDecls(bpStyle) : {},
567
596
  ...bpStyle ? visibilityDecls(node, bpStyle, false) : {}
568
597
  };
@@ -572,7 +601,8 @@ ${inner}
572
601
  }`);
573
602
  }
574
603
  }
575
- return blocks.join("\n\n") + (blocks.length ? "\n" : "");
604
+ if (blocks.length === 0 && Object.keys(doc.nodes).length === 0) return "";
605
+ return [BASE_CSS, ...blocks].join("\n\n") + "\n";
576
606
  }
577
607
  function getPath(data, path) {
578
608
  return path.split(".").reduce((o, k) => o == null ? void 0 : o[k], data);
@@ -615,10 +645,17 @@ function elementFor(node, text) {
615
645
  case "button":
616
646
  case "link": {
617
647
  const href = safeHref(text(node.props.href));
618
- if (href === null) return { tag: node.type === "link" ? "span" : "div" };
619
- const attrs = { href };
620
- if (/^https?:\/\//i.test(href)) attrs.rel = "noopener noreferrer";
621
- return { tag: "a", attrs };
648
+ const attrs = {};
649
+ if (href !== null) {
650
+ attrs.href = href;
651
+ if (/^https?:\/\//i.test(href)) attrs.rel = "noopener noreferrer";
652
+ }
653
+ if (node.type === "button") attrs["data-cms-button"] = BUTTON_VARIANTS.has(text(node.props.variant)) ? text(node.props.variant) : "primary";
654
+ return { tag: href !== null ? "a" : node.type === "link" ? "span" : "div", attrs };
655
+ }
656
+ case "callout": {
657
+ const variant = text(node.props.variant);
658
+ return { tag: "div", attrs: { "data-cms-callout": CALLOUT_VARIANTS.has(variant) ? variant : "info" } };
622
659
  }
623
660
  default:
624
661
  return { tag: CONTAINERS.has(node.type) && LANDMARKS.has(as) ? as : "div" };
@@ -642,51 +679,32 @@ var defaultRegistry = {
642
679
  const items = resolve(node.props.items);
643
680
  return Array.isArray(items) ? items.map((item, i) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: item === null || item === void 0 ? "" : String(item) }, i)) : null;
644
681
  },
645
- divider: () => /* @__PURE__ */ jsxRuntime.jsx("hr", { style: { border: "none", borderTop: "1px solid #e2e8f0", margin: 0 } }),
646
- callout: ({ node, text }) => {
647
- const tone = { info: "#3b82f6", success: "#10b981", warning: "#f59e0b", danger: "#ef4444" }[text(node.props.variant)] ?? "#3b82f6";
648
- 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" }) });
649
- },
650
- 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) }) }),
651
- button: ({ node, text }) => {
652
- const label2 = text(node.props.label) || text(node.props.content) || "Button";
653
- const variant = text(node.props.variant) || "primary";
654
- return /* @__PURE__ */ jsxRuntime.jsx("span", { "data-cms-button": variant, style: buttonStyle(variant), children: label2 });
655
- },
682
+ // Looks come from the base stylesheet (render/css.ts BASE_CSS), never from
683
+ // inline style objects, so the PHP renderer emits the same markup.
684
+ divider: () => /* @__PURE__ */ jsxRuntime.jsx("hr", {}),
685
+ callout: ({ node, text }) => text(node.props.content) || "Callout",
686
+ code: ({ node, text }) => /* @__PURE__ */ jsxRuntime.jsx("pre", { children: /* @__PURE__ */ jsxRuntime.jsx("code", { children: text(node.props.content) }) }),
687
+ button: ({ node, text }) => text(node.props.label) || text(node.props.content) || "Button",
656
688
  image: ({ node, text }) => {
657
689
  const fit = text(node.props.fit);
658
690
  return /* @__PURE__ */ jsxRuntime.jsx(
659
691
  "img",
660
692
  {
661
- src: text(node.props.src),
693
+ src: text(node.props.src) || void 0,
662
694
  alt: text(node.props.alt),
663
695
  loading: text(node.props.loading) === "eager" ? "eager" : "lazy",
664
- style: { width: "100%", height: "100%", objectFit: IMAGE_FITS.has(fit) ? fit : "cover", display: "block" }
696
+ "data-cms-fit": IMAGE_FITS.has(fit) && fit !== "cover" ? fit : void 0
665
697
  }
666
698
  );
667
699
  }
668
700
  };
669
701
  var IMAGE_FITS = /* @__PURE__ */ new Set(["cover", "contain", "fill", "none", "scale-down"]);
670
- function buttonStyle(variant) {
671
- const base = {
672
- display: "inline-flex",
673
- alignItems: "center",
674
- gap: 6,
675
- padding: "9px 16px",
676
- borderRadius: 8,
677
- fontWeight: 600,
678
- fontSize: 14,
679
- lineHeight: 1,
680
- cursor: "pointer",
681
- userSelect: "none"
682
- };
683
- if (variant === "ghost") return { ...base, background: "transparent", color: "inherit", border: "1px solid currentColor" };
684
- if (variant === "outline") return { ...base, background: "transparent", color: "#7c3aed", border: "1px solid #7c3aed" };
685
- return { ...base, background: "#7c3aed", color: "#fff", border: "1px solid #7c3aed" };
686
- }
702
+ var BUTTON_VARIANTS = /* @__PURE__ */ new Set(["primary", "outline", "ghost"]);
703
+ var CALLOUT_VARIANTS = /* @__PURE__ */ new Set(["info", "success", "warning", "danger"]);
687
704
  function RenderNode({ doc, id, registry = defaultRegistry, data }) {
688
- const node = own(doc.nodes, id);
689
- if (!node) return null;
705
+ const stored = own(doc.nodes, id);
706
+ if (!stored) return null;
707
+ const node = withNodeDefaults(stored);
690
708
  if (node.type === "repeater") {
691
709
  const arr = resolveValue(node.props.items, data);
692
710
  const items = Array.isArray(arr) ? arr : [];
@@ -987,16 +1005,42 @@ var input = {
987
1005
  font: "inherit",
988
1006
  fontSize: 13
989
1007
  };
990
- function Inspector({ doc, selection, apply }) {
1008
+ function Inspector({ doc, selection, apply, drafts }) {
991
1009
  const uid = react.useId();
992
1010
  const fieldId = (key) => `${uid}-${key}`;
993
1011
  const node = selection ? doc.nodes[selection] : null;
994
1012
  if (!node) {
995
1013
  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." });
996
1014
  }
997
- const s = node.style.base;
998
- const setStyle = (patch) => apply({ t: "set_style", id: node.id, breakpoint: "base", patch });
999
- const setProp = (key, value) => apply({ t: "set_node_props", id: node.id, patch: { [key]: value } });
1015
+ const nodeId = node.id;
1016
+ const bind = (name, read, op, numeric = false) => {
1017
+ const key = JSON.stringify([nodeId, name]);
1018
+ const field = {
1019
+ key,
1020
+ label: `${nodeId} / ${name}`,
1021
+ op,
1022
+ read: (next) => {
1023
+ const item = next.nodes[nodeId];
1024
+ return item ? read(item) : void 0;
1025
+ },
1026
+ canonical: numeric ? (raw) => String(Number(raw) || 0) : (raw) => raw
1027
+ };
1028
+ const accepted = read(node);
1029
+ return {
1030
+ value: drafts ? drafts.value(key, accepted) : accepted,
1031
+ onChange: (event) => {
1032
+ if (drafts) drafts.change(field, event.target.value);
1033
+ else apply(op(event.target.value));
1034
+ },
1035
+ onFocus: () => drafts?.focus(key),
1036
+ onBlur: () => drafts?.blur(key),
1037
+ onCompositionStart: () => drafts?.composition(key, true),
1038
+ onCompositionEnd: () => drafts?.composition(key, false)
1039
+ };
1040
+ };
1041
+ const prop = (key) => bind(key, (item) => typeof item.props[key] === "string" ? item.props[key] : "", (raw) => ({ t: "set_node_props", id: nodeId, patch: { [key]: raw } }));
1042
+ const style = (key) => bind(key, (item) => item.style.base[key] ?? "", (raw) => ({ t: "set_style", id: nodeId, breakpoint: "base", patch: { [key]: raw } }));
1043
+ 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);
1000
1044
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { overflow: "auto", padding: 16, fontFamily: "system-ui, sans-serif", height: "100%", boxSizing: "border-box" }, children: [
1001
1045
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--fcms-fg)" }, children: node.type }),
1002
1046
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 11, color: "var(--fcms-muted)", marginBottom: 8 }, children: node.id }),
@@ -1008,8 +1052,7 @@ function Inspector({ doc, selection, apply }) {
1008
1052
  id: fieldId("content"),
1009
1053
  "data-cms-field": "content",
1010
1054
  style: { ...input, minHeight: 64, resize: "vertical" },
1011
- value: typeof node.props.content === "string" ? node.props.content : "",
1012
- onChange: (e) => setProp("content", e.target.value)
1055
+ ...prop("content")
1013
1056
  }
1014
1057
  )
1015
1058
  ] }) : null,
@@ -1021,8 +1064,7 @@ function Inspector({ doc, selection, apply }) {
1021
1064
  id: fieldId("src"),
1022
1065
  "data-cms-field": "src",
1023
1066
  style: input,
1024
- value: typeof node.props.src === "string" ? node.props.src : "",
1025
- onChange: (e) => setProp("src", e.target.value)
1067
+ ...prop("src")
1026
1068
  }
1027
1069
  )
1028
1070
  ] }) : null,
@@ -1034,8 +1076,7 @@ function Inspector({ doc, selection, apply }) {
1034
1076
  "data-cms-field": "background",
1035
1077
  style: input,
1036
1078
  placeholder: "#ffffff | gradient | url(\u2026)",
1037
- value: s.background ?? "",
1038
- onChange: (e) => setStyle({ background: e.target.value })
1079
+ ...style("background")
1039
1080
  }
1040
1081
  ),
1041
1082
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("color"), children: "Text color" }),
@@ -1046,8 +1087,7 @@ function Inspector({ doc, selection, apply }) {
1046
1087
  "data-cms-field": "color",
1047
1088
  style: input,
1048
1089
  placeholder: "#0f172a",
1049
- value: s.color ?? "",
1050
- onChange: (e) => setStyle({ color: e.target.value })
1090
+ ...style("color")
1051
1091
  }
1052
1092
  ),
1053
1093
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("fontSize"), children: "Font size (px)" }),
@@ -1058,8 +1098,7 @@ function Inspector({ doc, selection, apply }) {
1058
1098
  "data-cms-field": "fontSize",
1059
1099
  type: "number",
1060
1100
  style: input,
1061
- value: s.fontSize?.value ?? "",
1062
- onChange: (e) => setStyle({ fontSize: { value: Number(e.target.value) || 0, unit: "px" } })
1101
+ ...length("fontSize")
1063
1102
  }
1064
1103
  ),
1065
1104
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("padding"), children: "Padding (px)" }),
@@ -1070,8 +1109,7 @@ function Inspector({ doc, selection, apply }) {
1070
1109
  "data-cms-field": "padding",
1071
1110
  type: "number",
1072
1111
  style: input,
1073
- value: s.padding?.value ?? "",
1074
- onChange: (e) => setStyle({ padding: { value: Number(e.target.value) || 0, unit: "px" } })
1112
+ ...length("padding")
1075
1113
  }
1076
1114
  ),
1077
1115
  /* @__PURE__ */ jsxRuntime.jsx("label", { style: label, htmlFor: fieldId("opacity"), children: "Opacity" }),
@@ -1085,12 +1123,171 @@ function Inspector({ doc, selection, apply }) {
1085
1123
  min: "0",
1086
1124
  max: "1",
1087
1125
  style: input,
1088
- value: s.opacity ?? "",
1089
- onChange: (e) => setStyle({ opacity: Number(e.target.value) })
1126
+ ...bind("opacity", (item) => item.style.base.opacity?.toString() ?? "", (raw) => ({ t: "set_style", id: nodeId, breakpoint: "base", patch: { opacity: Number(raw) } }), true)
1090
1127
  }
1091
1128
  )
1092
1129
  ] });
1093
1130
  }
1131
+
1132
+ // src/editor/InspectorDrafts.ts
1133
+ var InspectorDrafts = class {
1134
+ constructor(doc) {
1135
+ this.doc = doc;
1136
+ this.drafts = /* @__PURE__ */ new Map();
1137
+ this.composing = /* @__PURE__ */ new Set();
1138
+ this.focused = null;
1139
+ this.flight = null;
1140
+ this.listeners = /* @__PURE__ */ new Set();
1141
+ this.version = 0;
1142
+ this.disabled = false;
1143
+ this.emit = () => false;
1144
+ this.subscribe = (listener) => {
1145
+ this.listeners.add(listener);
1146
+ return () => {
1147
+ this.listeners.delete(listener);
1148
+ };
1149
+ };
1150
+ this.snapshot = () => this.version;
1151
+ }
1152
+ get busy() {
1153
+ return this.flight !== null || [...this.drafts.values()].some((draft) => draft.dirty || draft.conflict !== null);
1154
+ }
1155
+ get conflicts() {
1156
+ return [...this.drafts.values()].filter((draft) => draft.conflict !== null).map((draft) => ({
1157
+ key: draft.field.key,
1158
+ label: draft.field.label,
1159
+ message: draft.conflict,
1160
+ missing: draft.field.read(this.doc) === void 0
1161
+ }));
1162
+ }
1163
+ value(key, accepted) {
1164
+ return this.drafts.get(key)?.raw ?? accepted;
1165
+ }
1166
+ configure(doc, disabled, emit) {
1167
+ const settled = this.disabled && !disabled;
1168
+ this.doc = doc;
1169
+ this.disabled = disabled;
1170
+ this.emit = emit;
1171
+ let changed = false;
1172
+ for (const [key, draft] of this.drafts) {
1173
+ const accepted = draft.field.read(doc);
1174
+ if (this.flight?.draft === draft && accepted === this.flight.expected) {
1175
+ this.clearFlight();
1176
+ draft.base = accepted;
1177
+ draft.dirty = draft.field.canonical(draft.raw) !== accepted;
1178
+ if (!draft.dirty && !draft.focused) this.drafts.delete(key);
1179
+ changed = true;
1180
+ } else if (accepted !== draft.base) {
1181
+ this.conflict(draft, accepted === void 0 ? "The edited element was removed. Your draft is retained." : "The server changed this field. Your draft is retained.");
1182
+ draft.base = accepted;
1183
+ changed = true;
1184
+ } else if (settled && this.flight?.draft === draft) {
1185
+ this.conflict(draft, "The save was not accepted. Your draft is retained.");
1186
+ changed = true;
1187
+ }
1188
+ }
1189
+ if (changed) this.notify();
1190
+ this.arm();
1191
+ }
1192
+ focus(key) {
1193
+ this.focused = key;
1194
+ const draft = this.drafts.get(key);
1195
+ if (draft) draft.focused = true;
1196
+ }
1197
+ change(field, raw) {
1198
+ let draft = this.drafts.get(field.key);
1199
+ if (!draft) {
1200
+ draft = { field, raw, base: field.read(this.doc), focused: this.focused === field.key, dirty: true, conflict: null, readyAt: Date.now() + 250 };
1201
+ this.drafts.set(field.key, draft);
1202
+ } else {
1203
+ draft.field = field;
1204
+ draft.raw = raw;
1205
+ draft.readyAt = Date.now() + 250;
1206
+ }
1207
+ draft.dirty = field.canonical(raw) !== draft.base;
1208
+ this.notify();
1209
+ this.arm();
1210
+ }
1211
+ blur(key) {
1212
+ if (this.focused === key) this.focused = null;
1213
+ const draft = this.drafts.get(key);
1214
+ if (draft) {
1215
+ draft.focused = false;
1216
+ draft.readyAt = Date.now();
1217
+ if (!draft.dirty && !draft.conflict && this.flight?.draft !== draft) this.drafts.delete(key);
1218
+ this.notify();
1219
+ this.pump();
1220
+ }
1221
+ }
1222
+ composition(key, active) {
1223
+ if (active) this.composing.add(key);
1224
+ else this.composing.delete(key);
1225
+ const draft = this.drafts.get(key);
1226
+ if (draft) draft.readyAt = Date.now() + 250;
1227
+ this.arm();
1228
+ }
1229
+ resolve(key, keep) {
1230
+ if (this.disabled) return;
1231
+ const draft = this.drafts.get(key);
1232
+ if (!draft) return;
1233
+ if (this.flight?.draft === draft) this.clearFlight();
1234
+ if (!keep) this.drafts.delete(key);
1235
+ else {
1236
+ const accepted = draft.field.read(this.doc);
1237
+ if (accepted === void 0) return;
1238
+ draft.base = accepted;
1239
+ draft.conflict = null;
1240
+ draft.dirty = draft.field.canonical(draft.raw) !== accepted;
1241
+ draft.readyAt = Date.now();
1242
+ if (!draft.dirty && !draft.focused) this.drafts.delete(key);
1243
+ }
1244
+ this.notify();
1245
+ this.pump();
1246
+ }
1247
+ dispose() {
1248
+ clearTimeout(this.timer);
1249
+ clearTimeout(this.ackTimer);
1250
+ }
1251
+ notify() {
1252
+ this.version++;
1253
+ for (const listener of this.listeners) listener();
1254
+ }
1255
+ clearFlight() {
1256
+ this.flight = null;
1257
+ clearTimeout(this.ackTimer);
1258
+ this.ackTimer = void 0;
1259
+ }
1260
+ conflict(draft, message) {
1261
+ draft.conflict = message;
1262
+ if (this.flight?.draft === draft) this.clearFlight();
1263
+ }
1264
+ arm() {
1265
+ clearTimeout(this.timer);
1266
+ this.timer = void 0;
1267
+ if (this.disabled || this.flight || this.conflicts.length > 0) return;
1268
+ const ready = [...this.drafts.values()].filter((draft) => draft.dirty && !this.composing.has(draft.field.key));
1269
+ if (ready.length) this.timer = setTimeout(() => this.pump(), Math.max(0, Math.min(...ready.map((draft) => draft.readyAt)) - Date.now()));
1270
+ }
1271
+ pump() {
1272
+ if (this.disabled || this.flight || this.conflicts.length > 0) return;
1273
+ const draft = [...this.drafts.values()].find((item) => item.dirty && item.readyAt <= Date.now() && !this.composing.has(item.field.key));
1274
+ if (!draft) {
1275
+ this.arm();
1276
+ return;
1277
+ }
1278
+ this.flight = { draft, expected: draft.field.canonical(draft.raw) };
1279
+ this.ackTimer = setTimeout(() => {
1280
+ this.conflict(draft, "The save has not been acknowledged. Your draft is retained.");
1281
+ this.notify();
1282
+ }, 1e4);
1283
+ try {
1284
+ if (!this.emit(draft.field.op(draft.raw))) this.conflict(draft, "This edit could not be submitted. Your draft is retained.");
1285
+ } catch {
1286
+ this.conflict(draft, "This edit could not be submitted. Your draft is retained.");
1287
+ }
1288
+ this.notify();
1289
+ }
1290
+ };
1094
1291
  var CONTAINER_TYPES = /* @__PURE__ */ new Set([
1095
1292
  "section",
1096
1293
  "frame",
@@ -1164,18 +1361,59 @@ function buildInsertOp(doc, kind, selectedId, targetId) {
1164
1361
  }
1165
1362
  };
1166
1363
  }
1167
- function Editor({ defaultValue, onChange, registry, data }) {
1364
+ function Editor({ value, defaultValue, onChange, onOp, disabled = false, registry, data }) {
1168
1365
  const initial = react.useMemo(() => defaultValue ?? emptyDoc("untitled"), [defaultValue]);
1169
- const ed = useEditor(initial);
1170
- const first = react.useRef(true);
1366
+ const local = useEditor(initial);
1367
+ const controlled = value !== void 0;
1368
+ const localDoc = useLatestRef(local.state.doc);
1369
+ const lastNotified = react.useRef(local.state.doc);
1370
+ const onOpRef = useLatestRef(onOp);
1371
+ const [drafts] = react.useState(() => new InspectorDrafts(value ?? initial));
1372
+ react.useSyncExternalStore(drafts.subscribe, drafts.snapshot, drafts.snapshot);
1171
1373
  const onChangeRef = useLatestRef(onChange);
1172
1374
  react.useEffect(() => {
1173
- if (first.current) {
1174
- first.current = false;
1175
- return;
1375
+ if (controlled || lastNotified.current === local.state.doc) return;
1376
+ lastNotified.current = local.state.doc;
1377
+ onChangeRef.current?.(local.state.doc);
1378
+ }, [controlled, local.state.doc, onChangeRef]);
1379
+ const apply = (op) => {
1380
+ if (disabled) return false;
1381
+ const source = value ?? localDoc.current;
1382
+ const next = reduce(source, op);
1383
+ if (next === source) return false;
1384
+ if (!controlled) {
1385
+ localDoc.current = next;
1386
+ local.apply(op);
1176
1387
  }
1177
- onChangeRef.current?.(ed.state.doc);
1178
- }, [ed.state.doc, onChangeRef]);
1388
+ onOpRef.current?.(op);
1389
+ if (controlled) onChangeRef.current?.(next);
1390
+ return true;
1391
+ };
1392
+ react.useEffect(() => {
1393
+ if (controlled) drafts.configure(value, disabled, apply);
1394
+ });
1395
+ react.useEffect(() => () => drafts.dispose(), [drafts]);
1396
+ const structuralDisabled = disabled || controlled && drafts.busy;
1397
+ const ed = {
1398
+ ...local,
1399
+ state: { ...local.state, doc: value ?? local.state.doc },
1400
+ apply: (op) => {
1401
+ if (!structuralDisabled) apply(op);
1402
+ },
1403
+ // Snapshot history belongs to the local document. Replaying it into a
1404
+ // server-owned value could overwrite another writer's accepted work.
1405
+ undo: () => {
1406
+ if (!controlled && !disabled) local.undo();
1407
+ },
1408
+ redo: () => {
1409
+ if (!controlled && !disabled) local.redo();
1410
+ },
1411
+ replace: (next) => {
1412
+ if (!controlled && !disabled) local.replace(next);
1413
+ },
1414
+ canUndo: !controlled && !disabled && local.canUndo,
1415
+ canRedo: !controlled && !disabled && local.canRedo
1416
+ };
1179
1417
  const shell = {
1180
1418
  display: "grid",
1181
1419
  gridTemplateColumns: "220px 1fr 300px",
@@ -1187,9 +1425,9 @@ function Editor({ defaultValue, onChange, registry, data }) {
1187
1425
  borderRadius: 12,
1188
1426
  overflow: "hidden"
1189
1427
  };
1190
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fancy-cms-editor", style: shell, children: [
1428
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "fancy-cms-editor", style: shell, "aria-busy": structuralDisabled || void 0, children: [
1191
1429
  /* @__PURE__ */ jsxRuntime.jsx("style", { children: CHROME_CSS }),
1192
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { borderRight: "1px solid var(--fcms-border)", display: "flex", flexDirection: "column", minHeight: 0 }, children: [
1430
+ /* @__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: [
1193
1431
  /* @__PURE__ */ jsxRuntime.jsx(Toolbar, { ed }),
1194
1432
  /* @__PURE__ */ jsxRuntime.jsx(
1195
1433
  LayersPanel,
@@ -1200,9 +1438,22 @@ function Editor({ defaultValue, onChange, registry, data }) {
1200
1438
  onMove: (id, parent, order) => ed.apply({ t: "move_node", id, parent, order })
1201
1439
  }
1202
1440
  )
1203
- ] }),
1441
+ ] }) }),
1204
1442
  /* @__PURE__ */ jsxRuntime.jsx(Canvas, { doc: ed.state.doc, selection: ed.state.selection, onSelect: ed.select, apply: ed.apply, registry, data }),
1205
- /* @__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 }) })
1443
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { borderLeft: "1px solid var(--fcms-border)", minHeight: 0, overflow: "auto" }, children: [
1444
+ 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: [
1445
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
1446
+ conflict.label,
1447
+ ": ",
1448
+ conflict.message
1449
+ ] }),
1450
+ /* @__PURE__ */ jsxRuntime.jsx("pre", { "data-cms-retained-draft": "", style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }, children: drafts.value(conflict.key, "") }),
1451
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled, onClick: () => drafts.resolve(conflict.key, false), children: "Use server value" }),
1452
+ " ",
1453
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled: disabled || conflict.missing, onClick: () => drafts.resolve(conflict.key, true), children: "Keep draft" })
1454
+ ] }, conflict.key)),
1455
+ /* @__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 }) })
1456
+ ] })
1206
1457
  ] });
1207
1458
  }
1208
1459
  function Toolbar({ ed }) {
@@ -1355,5 +1606,6 @@ exports.resolveInsertParent = resolveInsertParent;
1355
1606
  exports.resolveValue = resolveValue;
1356
1607
  exports.rootIds = rootIds;
1357
1608
  exports.useEditor = useEditor;
1609
+ exports.withNodeDefaults = withNodeDefaults;
1358
1610
  //# sourceMappingURL=index.cjs.map
1359
1611
  //# sourceMappingURL=index.cjs.map