@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/CHANGELOG.md CHANGED
@@ -14,6 +14,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.10.0] - 2026-10-23
18
+
19
+ ### Added
20
+
21
+ - `Editor` accepts controlled `value`, native-operation `onOp` and `disabled`
22
+ props for server-owned shared documents. Local edits propose changes; only
23
+ the next host value changes the controlled document. Incoming values preserve
24
+ the selected handle and never echo through either callback. Local snapshot
25
+ Undo/Redo is disabled in controlled mode to protect other writers' work.
26
+ - Inspector fields keep a local draft, so typing survives server latency,
27
+ including while `disabled` pauses structural edits and submissions. Drafts
28
+ commit after 250 ms idle or on blur, one acknowledgement at a time, and
29
+ wait for IME composition to finish.
30
+ - When another writer changes the field being drafted, a save is rejected or
31
+ never acknowledged, or the node is removed, the draft is kept and the
32
+ inspector offers **Use server value** / **Keep draft**. Text is never
33
+ silently lost or overwritten.
34
+ - The host contract (compare-and-set, serialization, pending, unmount) is in
35
+ the README. Nothing changes for an uncontrolled `Editor`.
36
+
37
+ ### Fixed
38
+
39
+ - An uncontrolled `Editor` mounted under React StrictMode no longer emits an
40
+ initial `onChange` before any edit.
41
+
42
+ ### Security
43
+
44
+ - **vitest 4.1.12 in development** (was 4.1.11), clearing GHSA-qxg6-89mg-rqv8
45
+ and GHSA-xp26-3454-mjgp in `@vitest/mocker`. Test tooling only: nothing in
46
+ the published package changes, so **nothing to do** as a consumer.
47
+
48
+ ## [0.9.0] - 2026-10-23
49
+
50
+ Released together with `particle-academy/fancy-cms` 0.5.0, whose PHP renderer
51
+ emits the same markup and CSS: all five canonical documents are byte-identical
52
+ in both `parity/run.mjs` (CSS) and `parity/html.mjs` (HTML).
53
+
54
+ Found by rendering the lab's reference page server-side and capturing it at
55
+ phone and desktop widths: browser-default margins stacked on top of every
56
+ layout's `gap`, buttons were underlined links, and the JS and PHP renderers
57
+ produced different markup because the JS one used inline style objects.
58
+
59
+ ### Changed
60
+
61
+ - **BREAKING (markup + CSS): one fixed base stylesheet replaces inline styles.**
62
+ `emitDocCss` now emits `BASE_CSS` first: it resets the margins of headings,
63
+ paragraphs and lists, sets `box-sizing`, and styles buttons, callouts, code,
64
+ dividers and images. Every rule is wrapped in `:where()`, is never more
65
+ specific than a node's own `[data-cms="<id>"]` rule and comes before it, so
66
+ whatever a node sets wins. Elements carry data attributes instead:
67
+ - a `button` is ONE element, the `<a>` (or `<div>` without an href), with
68
+ `data-cms-button="primary|outline|ghost"`; there is no inner `<span>`;
69
+ - a `callout` is `data-cms-callout="info|success|warning|danger"` with its
70
+ text directly inside;
71
+ - an `image` keeps `<img>` and carries `data-cms-fit` when not `cover`;
72
+ - `divider` and `code` render bare `<hr>` and `<pre><code>`.
73
+ Buttons and callouts take `var(--cms-color-primary)` when the theme sets it.
74
+ **What to do:** nothing, unless your CSS targeted the old inner
75
+ `span[data-cms-button]` or your design relied on browser-default margins;
76
+ set the margin on the node instead. Unknown variants fall back to the
77
+ default rather than being written into the attribute.
78
+ - `emitDocCss` emits the base whenever a document has nodes or theme tokens,
79
+ even if no node sets a style; an empty document still emits nothing.
80
+
81
+ ### Fixed
82
+
83
+ - A node with no `props` or no `style` (a stored or legacy node) crashed both
84
+ the renderer and the emitter, taking the whole page with it. Both now read
85
+ nodes through `withNodeDefaults` (exported).
86
+ - A breakpoint named after an `Object.prototype` member (`constructor`, ...)
87
+ made the emitter print a function into a media query; breakpoint and
88
+ per-breakpoint lookups are now own-property only.
89
+ - An image with no `src` rendered `src=""`, which makes a browser request the
90
+ page again; the attribute is now omitted.
91
+
92
+ ### Added
93
+
94
+ - The parity runners call PHP without a shell (`FANCY_CMS_PHP` overrides the
95
+ binary), so a fixture's filename can never be read as shell text.
96
+ - `parity/html.mjs`: the markup half of the parity gate. It renders every
97
+ canonical document with `CmsPage` and with fancy-cms's PHP renderer and
98
+ requires byte-identical HTML. `canonical-page-ledgerline.json` is the lab's
99
+ premium reference page, so a real page is in the corpus.
100
+
17
101
  ## [0.8.0] - 2026-10-23
18
102
 
19
103
  What a professional landing page needs from the renderer, found by mapping
package/README.md CHANGED
@@ -72,6 +72,52 @@ Lean subpath entries also exist: `@particle-academy/fancy-cms-ui/react`
72
72
  the inline `EditablePage` — the one component that additionally requires the
73
73
  optional `@particle-academy/react-fancy` peer).
74
74
 
75
+ ### A server-owned document
76
+
77
+ Use `value` when a server or another writer owns the document. The canvas shows
78
+ accepted host state. Inspector fields keep local drafts so typing continues
79
+ without reverting while a save is pending. A field proposes a native `PageOp`
80
+ through `onOp` and a reduced document through `onChange` after 250 ms without
81
+ typing, or on blur. IME composition finishes before submission. Choose one
82
+ callback to submit writes; the other can observe proposals. Incoming values do
83
+ not echo themselves through these callbacks and preserve the selected handle.
84
+
85
+ ```tsx
86
+ import { Editor, type PageDoc, type PageOp } from "@particle-academy/fancy-cms-ui";
87
+
88
+ function SharedAuthor({ doc, pending, submit }: {
89
+ doc: PageDoc;
90
+ pending: boolean;
91
+ submit: (op: PageOp) => void;
92
+ }) {
93
+ return <Editor value={doc} onOp={submit} disabled={pending} />;
94
+ }
95
+ ```
96
+
97
+ The host validates and authorizes each operation, pins it to the current server
98
+ revision, and supplies accepted state through `value`. Keep `pending` true while
99
+ a write is being accepted, and clear it after publishing the accepted or
100
+ rejected server state. `disabled` blocks structural edits and draft submission;
101
+ controlled inspector fields remain editable. Additional typing stays buffered
102
+ until the prior field value is acknowledged. Inspector proposals are serialized
103
+ even if the host omits `disabled`; the host must still serialize and revision-pin
104
+ all writes. An operation-only host can omit `onChange`.
105
+
106
+ Incoming state updates other fields immediately. If it changes a drafted field,
107
+ the draft stays visible with a conflict. Choose **Use server value** to discard
108
+ it or **Keep draft** to submit it against the current state. The editor never
109
+ silently retries a conflict. Clearing `pending` without accepting the field,
110
+ or receiving no acknowledgement for 10 seconds, also retains the draft and
111
+ surfaces a decision. Node removal retains copyable draft text without recreating
112
+ the node. Keep this editor mounted while drafts or writes are outstanding;
113
+ navigation/unmount is owned by the host.
114
+
115
+ Controlled editors disable local snapshot Undo/Redo, since restoring an old
116
+ snapshot could overwrite another writer. The `defaultValue` mode retains local
117
+ history and reports document changes through `onChange`. Choose one ownership
118
+ mode for an editor's lifetime. In uncontrolled mode, `disabled` blocks property
119
+ edits as well.
120
+
75
121
  ### Styling
76
122
 
77
123
  There is **no stylesheet to import**. Authored page styles are compiled by the
@@ -1,5 +1,5 @@
1
- import { migrateDoc, reduce, CmsPage, rootIds, childrenOf, lastRootId, emptyDoc } from './chunk-SJYKJ6SG.js';
2
- import { useReducer, useCallback, useMemo, useRef, useState, useLayoutEffect, Fragment, useId, useEffect } from 'react';
1
+ import { migrateDoc, reduce, CmsPage, rootIds, childrenOf, lastRootId, emptyDoc } from './chunk-IRG5IKKO.js';
2
+ import { useReducer, useCallback, useMemo, useRef, useState, useLayoutEffect, Fragment, useId, useEffect, useSyncExternalStore } from 'react';
3
3
  import { jsxs, jsx, Fragment as Fragment$1 } from 'react/jsx-runtime';
4
4
  import { fractionalKey } from '@particle-academy/fancy-doc-commons';
5
5
 
@@ -243,16 +243,42 @@ var input = {
243
243
  font: "inherit",
244
244
  fontSize: 13
245
245
  };
246
- function Inspector({ doc, selection, apply }) {
246
+ function Inspector({ doc, selection, apply, drafts }) {
247
247
  const uid = useId();
248
248
  const fieldId = (key) => `${uid}-${key}`;
249
249
  const node = selection ? doc.nodes[selection] : null;
250
250
  if (!node) {
251
251
  return /* @__PURE__ */ jsx("div", { style: { padding: 16, color: "var(--fcms-muted)", fontFamily: "system-ui, sans-serif", fontSize: 13 }, children: "Select an element to edit." });
252
252
  }
253
- const s = node.style.base;
254
- const setStyle = (patch) => apply({ t: "set_style", id: node.id, breakpoint: "base", patch });
255
- const setProp = (key, value) => apply({ t: "set_node_props", id: node.id, patch: { [key]: value } });
253
+ const nodeId = node.id;
254
+ const bind = (name, read, op, numeric = false) => {
255
+ const key = JSON.stringify([nodeId, name]);
256
+ const field = {
257
+ key,
258
+ label: `${nodeId} / ${name}`,
259
+ op,
260
+ read: (next) => {
261
+ const item = next.nodes[nodeId];
262
+ return item ? read(item) : void 0;
263
+ },
264
+ canonical: numeric ? (raw) => String(Number(raw) || 0) : (raw) => raw
265
+ };
266
+ const accepted = read(node);
267
+ return {
268
+ value: drafts ? drafts.value(key, accepted) : accepted,
269
+ onChange: (event) => {
270
+ if (drafts) drafts.change(field, event.target.value);
271
+ else apply(op(event.target.value));
272
+ },
273
+ onFocus: () => drafts?.focus(key),
274
+ onBlur: () => drafts?.blur(key),
275
+ onCompositionStart: () => drafts?.composition(key, true),
276
+ onCompositionEnd: () => drafts?.composition(key, false)
277
+ };
278
+ };
279
+ const prop = (key) => bind(key, (item) => typeof item.props[key] === "string" ? item.props[key] : "", (raw) => ({ t: "set_node_props", id: nodeId, patch: { [key]: raw } }));
280
+ const style = (key) => bind(key, (item) => item.style.base[key] ?? "", (raw) => ({ t: "set_style", id: nodeId, breakpoint: "base", patch: { [key]: raw } }));
281
+ 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);
256
282
  return /* @__PURE__ */ jsxs("div", { style: { overflow: "auto", padding: 16, fontFamily: "system-ui, sans-serif", height: "100%", boxSizing: "border-box" }, children: [
257
283
  /* @__PURE__ */ jsx("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--fcms-fg)" }, children: node.type }),
258
284
  /* @__PURE__ */ jsx("div", { style: { fontSize: 11, color: "var(--fcms-muted)", marginBottom: 8 }, children: node.id }),
@@ -264,8 +290,7 @@ function Inspector({ doc, selection, apply }) {
264
290
  id: fieldId("content"),
265
291
  "data-cms-field": "content",
266
292
  style: { ...input, minHeight: 64, resize: "vertical" },
267
- value: typeof node.props.content === "string" ? node.props.content : "",
268
- onChange: (e) => setProp("content", e.target.value)
293
+ ...prop("content")
269
294
  }
270
295
  )
271
296
  ] }) : null,
@@ -277,8 +302,7 @@ function Inspector({ doc, selection, apply }) {
277
302
  id: fieldId("src"),
278
303
  "data-cms-field": "src",
279
304
  style: input,
280
- value: typeof node.props.src === "string" ? node.props.src : "",
281
- onChange: (e) => setProp("src", e.target.value)
305
+ ...prop("src")
282
306
  }
283
307
  )
284
308
  ] }) : null,
@@ -290,8 +314,7 @@ function Inspector({ doc, selection, apply }) {
290
314
  "data-cms-field": "background",
291
315
  style: input,
292
316
  placeholder: "#ffffff | gradient | url(\u2026)",
293
- value: s.background ?? "",
294
- onChange: (e) => setStyle({ background: e.target.value })
317
+ ...style("background")
295
318
  }
296
319
  ),
297
320
  /* @__PURE__ */ jsx("label", { style: label, htmlFor: fieldId("color"), children: "Text color" }),
@@ -302,8 +325,7 @@ function Inspector({ doc, selection, apply }) {
302
325
  "data-cms-field": "color",
303
326
  style: input,
304
327
  placeholder: "#0f172a",
305
- value: s.color ?? "",
306
- onChange: (e) => setStyle({ color: e.target.value })
328
+ ...style("color")
307
329
  }
308
330
  ),
309
331
  /* @__PURE__ */ jsx("label", { style: label, htmlFor: fieldId("fontSize"), children: "Font size (px)" }),
@@ -314,8 +336,7 @@ function Inspector({ doc, selection, apply }) {
314
336
  "data-cms-field": "fontSize",
315
337
  type: "number",
316
338
  style: input,
317
- value: s.fontSize?.value ?? "",
318
- onChange: (e) => setStyle({ fontSize: { value: Number(e.target.value) || 0, unit: "px" } })
339
+ ...length("fontSize")
319
340
  }
320
341
  ),
321
342
  /* @__PURE__ */ jsx("label", { style: label, htmlFor: fieldId("padding"), children: "Padding (px)" }),
@@ -326,8 +347,7 @@ function Inspector({ doc, selection, apply }) {
326
347
  "data-cms-field": "padding",
327
348
  type: "number",
328
349
  style: input,
329
- value: s.padding?.value ?? "",
330
- onChange: (e) => setStyle({ padding: { value: Number(e.target.value) || 0, unit: "px" } })
350
+ ...length("padding")
331
351
  }
332
352
  ),
333
353
  /* @__PURE__ */ jsx("label", { style: label, htmlFor: fieldId("opacity"), children: "Opacity" }),
@@ -341,8 +361,7 @@ function Inspector({ doc, selection, apply }) {
341
361
  min: "0",
342
362
  max: "1",
343
363
  style: input,
344
- value: s.opacity ?? "",
345
- onChange: (e) => setStyle({ opacity: Number(e.target.value) })
364
+ ...bind("opacity", (item) => item.style.base.opacity?.toString() ?? "", (raw) => ({ t: "set_style", id: nodeId, breakpoint: "base", patch: { opacity: Number(raw) } }), true)
346
365
  }
347
366
  )
348
367
  ] });
@@ -427,18 +446,219 @@ function useLatestRef(value) {
427
446
  });
428
447
  return ref;
429
448
  }
430
- function Editor({ defaultValue, onChange, registry, data }) {
449
+
450
+ // src/editor/InspectorDrafts.ts
451
+ var InspectorDrafts = class {
452
+ constructor(doc) {
453
+ this.doc = doc;
454
+ this.drafts = /* @__PURE__ */ new Map();
455
+ this.composing = /* @__PURE__ */ new Set();
456
+ this.focused = null;
457
+ this.flight = null;
458
+ this.listeners = /* @__PURE__ */ new Set();
459
+ this.version = 0;
460
+ this.disabled = false;
461
+ this.emit = () => false;
462
+ this.subscribe = (listener) => {
463
+ this.listeners.add(listener);
464
+ return () => {
465
+ this.listeners.delete(listener);
466
+ };
467
+ };
468
+ this.snapshot = () => this.version;
469
+ }
470
+ get busy() {
471
+ return this.flight !== null || [...this.drafts.values()].some((draft) => draft.dirty || draft.conflict !== null);
472
+ }
473
+ get conflicts() {
474
+ return [...this.drafts.values()].filter((draft) => draft.conflict !== null).map((draft) => ({
475
+ key: draft.field.key,
476
+ label: draft.field.label,
477
+ message: draft.conflict,
478
+ missing: draft.field.read(this.doc) === void 0
479
+ }));
480
+ }
481
+ value(key, accepted) {
482
+ return this.drafts.get(key)?.raw ?? accepted;
483
+ }
484
+ configure(doc, disabled, emit) {
485
+ const settled = this.disabled && !disabled;
486
+ this.doc = doc;
487
+ this.disabled = disabled;
488
+ this.emit = emit;
489
+ let changed = false;
490
+ for (const [key, draft] of this.drafts) {
491
+ const accepted = draft.field.read(doc);
492
+ if (this.flight?.draft === draft && accepted === this.flight.expected) {
493
+ this.clearFlight();
494
+ draft.base = accepted;
495
+ draft.dirty = draft.field.canonical(draft.raw) !== accepted;
496
+ if (!draft.dirty && !draft.focused) this.drafts.delete(key);
497
+ changed = true;
498
+ } else if (accepted !== draft.base) {
499
+ this.conflict(draft, accepted === void 0 ? "The edited element was removed. Your draft is retained." : "The server changed this field. Your draft is retained.");
500
+ draft.base = accepted;
501
+ changed = true;
502
+ } else if (settled && this.flight?.draft === draft) {
503
+ this.conflict(draft, "The save was not accepted. Your draft is retained.");
504
+ changed = true;
505
+ }
506
+ }
507
+ if (changed) this.notify();
508
+ this.arm();
509
+ }
510
+ focus(key) {
511
+ this.focused = key;
512
+ const draft = this.drafts.get(key);
513
+ if (draft) draft.focused = true;
514
+ }
515
+ change(field, raw) {
516
+ let draft = this.drafts.get(field.key);
517
+ if (!draft) {
518
+ draft = { field, raw, base: field.read(this.doc), focused: this.focused === field.key, dirty: true, conflict: null, readyAt: Date.now() + 250 };
519
+ this.drafts.set(field.key, draft);
520
+ } else {
521
+ draft.field = field;
522
+ draft.raw = raw;
523
+ draft.readyAt = Date.now() + 250;
524
+ }
525
+ draft.dirty = field.canonical(raw) !== draft.base;
526
+ this.notify();
527
+ this.arm();
528
+ }
529
+ blur(key) {
530
+ if (this.focused === key) this.focused = null;
531
+ const draft = this.drafts.get(key);
532
+ if (draft) {
533
+ draft.focused = false;
534
+ draft.readyAt = Date.now();
535
+ if (!draft.dirty && !draft.conflict && this.flight?.draft !== draft) this.drafts.delete(key);
536
+ this.notify();
537
+ this.pump();
538
+ }
539
+ }
540
+ composition(key, active) {
541
+ if (active) this.composing.add(key);
542
+ else this.composing.delete(key);
543
+ const draft = this.drafts.get(key);
544
+ if (draft) draft.readyAt = Date.now() + 250;
545
+ this.arm();
546
+ }
547
+ resolve(key, keep) {
548
+ if (this.disabled) return;
549
+ const draft = this.drafts.get(key);
550
+ if (!draft) return;
551
+ if (this.flight?.draft === draft) this.clearFlight();
552
+ if (!keep) this.drafts.delete(key);
553
+ else {
554
+ const accepted = draft.field.read(this.doc);
555
+ if (accepted === void 0) return;
556
+ draft.base = accepted;
557
+ draft.conflict = null;
558
+ draft.dirty = draft.field.canonical(draft.raw) !== accepted;
559
+ draft.readyAt = Date.now();
560
+ if (!draft.dirty && !draft.focused) this.drafts.delete(key);
561
+ }
562
+ this.notify();
563
+ this.pump();
564
+ }
565
+ dispose() {
566
+ clearTimeout(this.timer);
567
+ clearTimeout(this.ackTimer);
568
+ }
569
+ notify() {
570
+ this.version++;
571
+ for (const listener of this.listeners) listener();
572
+ }
573
+ clearFlight() {
574
+ this.flight = null;
575
+ clearTimeout(this.ackTimer);
576
+ this.ackTimer = void 0;
577
+ }
578
+ conflict(draft, message) {
579
+ draft.conflict = message;
580
+ if (this.flight?.draft === draft) this.clearFlight();
581
+ }
582
+ arm() {
583
+ clearTimeout(this.timer);
584
+ this.timer = void 0;
585
+ if (this.disabled || this.flight || this.conflicts.length > 0) return;
586
+ const ready = [...this.drafts.values()].filter((draft) => draft.dirty && !this.composing.has(draft.field.key));
587
+ if (ready.length) this.timer = setTimeout(() => this.pump(), Math.max(0, Math.min(...ready.map((draft) => draft.readyAt)) - Date.now()));
588
+ }
589
+ pump() {
590
+ if (this.disabled || this.flight || this.conflicts.length > 0) return;
591
+ const draft = [...this.drafts.values()].find((item) => item.dirty && item.readyAt <= Date.now() && !this.composing.has(item.field.key));
592
+ if (!draft) {
593
+ this.arm();
594
+ return;
595
+ }
596
+ this.flight = { draft, expected: draft.field.canonical(draft.raw) };
597
+ this.ackTimer = setTimeout(() => {
598
+ this.conflict(draft, "The save has not been acknowledged. Your draft is retained.");
599
+ this.notify();
600
+ }, 1e4);
601
+ try {
602
+ if (!this.emit(draft.field.op(draft.raw))) this.conflict(draft, "This edit could not be submitted. Your draft is retained.");
603
+ } catch {
604
+ this.conflict(draft, "This edit could not be submitted. Your draft is retained.");
605
+ }
606
+ this.notify();
607
+ }
608
+ };
609
+ function Editor({ value, defaultValue, onChange, onOp, disabled = false, registry, data }) {
431
610
  const initial = useMemo(() => defaultValue ?? emptyDoc("untitled"), [defaultValue]);
432
- const ed = useEditor(initial);
433
- const first = useRef(true);
611
+ const local = useEditor(initial);
612
+ const controlled = value !== void 0;
613
+ const localDoc = useLatestRef(local.state.doc);
614
+ const lastNotified = useRef(local.state.doc);
615
+ const onOpRef = useLatestRef(onOp);
616
+ const [drafts] = useState(() => new InspectorDrafts(value ?? initial));
617
+ useSyncExternalStore(drafts.subscribe, drafts.snapshot, drafts.snapshot);
434
618
  const onChangeRef = useLatestRef(onChange);
435
619
  useEffect(() => {
436
- if (first.current) {
437
- first.current = false;
438
- return;
620
+ if (controlled || lastNotified.current === local.state.doc) return;
621
+ lastNotified.current = local.state.doc;
622
+ onChangeRef.current?.(local.state.doc);
623
+ }, [controlled, local.state.doc, onChangeRef]);
624
+ const apply = (op) => {
625
+ if (disabled) return false;
626
+ const source = value ?? localDoc.current;
627
+ const next = reduce(source, op);
628
+ if (next === source) return false;
629
+ if (!controlled) {
630
+ localDoc.current = next;
631
+ local.apply(op);
439
632
  }
440
- onChangeRef.current?.(ed.state.doc);
441
- }, [ed.state.doc, onChangeRef]);
633
+ onOpRef.current?.(op);
634
+ if (controlled) onChangeRef.current?.(next);
635
+ return true;
636
+ };
637
+ useEffect(() => {
638
+ if (controlled) drafts.configure(value, disabled, apply);
639
+ });
640
+ useEffect(() => () => drafts.dispose(), [drafts]);
641
+ const structuralDisabled = disabled || controlled && drafts.busy;
642
+ const ed = {
643
+ ...local,
644
+ state: { ...local.state, doc: value ?? local.state.doc },
645
+ apply: (op) => {
646
+ if (!structuralDisabled) apply(op);
647
+ },
648
+ // Snapshot history belongs to the local document. Replaying it into a
649
+ // server-owned value could overwrite another writer's accepted work.
650
+ undo: () => {
651
+ if (!controlled && !disabled) local.undo();
652
+ },
653
+ redo: () => {
654
+ if (!controlled && !disabled) local.redo();
655
+ },
656
+ replace: (next) => {
657
+ if (!controlled && !disabled) local.replace(next);
658
+ },
659
+ canUndo: !controlled && !disabled && local.canUndo,
660
+ canRedo: !controlled && !disabled && local.canRedo
661
+ };
442
662
  const shell = {
443
663
  display: "grid",
444
664
  gridTemplateColumns: "220px 1fr 300px",
@@ -450,9 +670,9 @@ function Editor({ defaultValue, onChange, registry, data }) {
450
670
  borderRadius: 12,
451
671
  overflow: "hidden"
452
672
  };
453
- return /* @__PURE__ */ jsxs("div", { className: "fancy-cms-editor", style: shell, children: [
673
+ return /* @__PURE__ */ jsxs("div", { className: "fancy-cms-editor", style: shell, "aria-busy": structuralDisabled || void 0, children: [
454
674
  /* @__PURE__ */ jsx("style", { children: CHROME_CSS }),
455
- /* @__PURE__ */ jsxs("div", { style: { borderRight: "1px solid var(--fcms-border)", display: "flex", flexDirection: "column", minHeight: 0 }, children: [
675
+ /* @__PURE__ */ jsx("fieldset", { disabled: structuralDisabled, "aria-label": "Page structure controls", style: { display: "contents", border: 0, padding: 0, margin: 0 }, children: /* @__PURE__ */ jsxs("div", { style: { borderRight: "1px solid var(--fcms-border)", display: "flex", flexDirection: "column", minHeight: 0 }, children: [
456
676
  /* @__PURE__ */ jsx(Toolbar, { ed }),
457
677
  /* @__PURE__ */ jsx(
458
678
  LayersPanel,
@@ -463,9 +683,22 @@ function Editor({ defaultValue, onChange, registry, data }) {
463
683
  onMove: (id, parent, order) => ed.apply({ t: "move_node", id, parent, order })
464
684
  }
465
685
  )
466
- ] }),
686
+ ] }) }),
467
687
  /* @__PURE__ */ jsx(Canvas, { doc: ed.state.doc, selection: ed.state.selection, onSelect: ed.select, apply: ed.apply, registry, data }),
468
- /* @__PURE__ */ jsx("div", { style: { borderLeft: "1px solid var(--fcms-border)", minHeight: 0 }, children: /* @__PURE__ */ jsx(Inspector, { doc: ed.state.doc, selection: ed.state.selection, apply: ed.apply }) })
688
+ /* @__PURE__ */ jsxs("div", { style: { borderLeft: "1px solid var(--fcms-border)", minHeight: 0, overflow: "auto" }, children: [
689
+ controlled && drafts.conflicts.map((conflict) => /* @__PURE__ */ jsxs("div", { role: "alert", "data-cms-draft-conflict": conflict.key, style: { padding: 12, fontFamily: "system-ui,sans-serif", fontSize: 13 }, children: [
690
+ /* @__PURE__ */ jsxs("p", { children: [
691
+ conflict.label,
692
+ ": ",
693
+ conflict.message
694
+ ] }),
695
+ /* @__PURE__ */ jsx("pre", { "data-cms-retained-draft": "", style: { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }, children: drafts.value(conflict.key, "") }),
696
+ /* @__PURE__ */ jsx("button", { type: "button", disabled, onClick: () => drafts.resolve(conflict.key, false), children: "Use server value" }),
697
+ " ",
698
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: disabled || conflict.missing, onClick: () => drafts.resolve(conflict.key, true), children: "Keep draft" })
699
+ ] }, conflict.key)),
700
+ /* @__PURE__ */ jsx("fieldset", { disabled: !controlled && disabled, "aria-label": "Page property controls", style: { border: 0, padding: 0, margin: 0, minWidth: 0, height: "100%" }, children: /* @__PURE__ */ jsx(Inspector, { doc: ed.state.doc, selection: ed.state.selection, apply: ed.apply, drafts: controlled ? drafts : void 0 }) })
701
+ ] })
469
702
  ] });
470
703
  }
471
704
  function Toolbar({ ed }) {
@@ -576,5 +809,5 @@ var LIGHT_VARS = "color-scheme:light;--fcms-bg:#ffffff;--fcms-fg:#0f172a;--fcms-
576
809
  var CHROME_CSS = `.fancy-cms-editor{--fcms-accent:#8b5cf6;${LIGHT_VARS}}@media (prefers-color-scheme:dark){.fancy-cms-editor{${DARK_VARS}}}:where(.light,[data-theme="light"]) .fancy-cms-editor{${LIGHT_VARS}}:where(.dark,[data-theme="dark"]) .fancy-cms-editor{${DARK_VARS}}`;
577
810
 
578
811
  export { ADD_DEFAULTS, ADD_MENU, CONTAINER_TYPES, Canvas, Editor, Inspector, LayersPanel, buildInsertOp, editorReduce, initEditor, resolveInsertParent, useEditor, useLatestRef };
579
- //# sourceMappingURL=chunk-IOMIYI45.js.map
580
- //# sourceMappingURL=chunk-IOMIYI45.js.map
812
+ //# sourceMappingURL=chunk-G7ULXTKI.js.map
813
+ //# sourceMappingURL=chunk-G7ULXTKI.js.map