mnfst 0.5.231 → 0.5.232

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.
@@ -2,149 +2,446 @@
2
2
 
3
3
  (function () {
4
4
 
5
- /* Manifest Edit — SPIKE.
6
-
7
- Compiled from src/scripts/edit/*.js into src/scripts/manifest.edit.js by build.mjs
8
- (order matters). These subscripts are FRAGMENTS of a single IIFE: core.js opens it,
9
- main.js closes it. Everything stays in one private closure — no globals, no E.* churn.
10
- Each fragment is grouped by concern; references resolve at runtime once all parts load.
11
-
12
- x-edit marks an editable AREA (or single target). Always-on by default; .gated requires
13
- $edit.on(). Capabilities via modifiers: .sort .text .style .size (default sort+text+style);
14
- .lock opts a subtree out. Fine config (size axes/snap/collapse) reads --edit-* CSS vars.
15
- Three regimes: static HTML · data mutation · component override. Append-only typed-delta
16
- log is the spine; A overlay + B source patch are projections. */
5
+ /* Manifest Edit */
17
6
 
18
7
  (function () {
19
- const LS_KEY = 'mnfst-edit-log';
20
- const SCHEMA = 7; // overlay schema version — bump when the delta shape changes
21
- const HISTORY_CAP = 400; // cap the append-only log so long sessions don't grow unbounded
22
- const ALL_CAPS = ['sort', 'text', 'style', 'size', 'data']; // 'data' = edit $x field values (opt-in)
8
+ const lsKey = (path) => 'mnfst-edit-log:' + (path || location.pathname);
9
+ let lsPath = location.pathname;
10
+ const SCHEMA = 8;
11
+ const HISTORY_CAP = 400;
12
+ const ALL_CAPS = ['text', 'move', 'size', 'data'];
13
+ const DEFAULT_CAPS = ['text', 'move'];
14
+ const LOCK_ACTIONS = ['move', 'remove', 'duplicate', 'text', 'attrs', 'size'];
15
+ const RETIRED = { sort: '.move', authoring: 'nothing (visuals are on by default; use .bare to hide them, .persist to keep edits)', style: '$edit.classes / $edit.attr', none: '.lock' };
16
+ const COALESCE_MS = 1000;
23
17
  let dragged = null, autoN = 0;
24
18
 
25
19
  let log = [], cursor = 0;
26
- const lastSnap = {}, lastOrder = {};
27
- // Content-derived key for a static sortable child — stable across reorder AND reload
28
- // (same content → same key), so reorder can be stored as a tiny key permutation.
29
- const staticKey = (el) => el.tagName + ':' + (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
30
- // The SAME identity markStatic assigned, not a fresh derivation. Recomputing from
31
- // current content would drift the moment text is edited, and the child order
32
- // recorded before that edit would then fail to find the element it named.
33
- // Two identical siblings are told apart by the ordinal markStatic gave them.
20
+ const lastSnap = {};
21
+ const persisted = new WeakSet();
22
+
23
+ // Text an element holds in source: Alpine-rendered and generated content is left out.
24
+ // Plugin-rendered hosts: their children are produced at runtime, not authored.
25
+ const RENDERED = ['x-html', 'x-text', 'x-markdown', 'x-icon', 'x-svg', 'x-code', 'x-code-group', 'x-chart', 'x-anchors', 'x-carousel', 'x-color', 'x-colorpicker', 'x-combobox', 'x-date', 'x-defer', 'x-virtual', 'x-import', 'x-files', 'x-data-files'];
26
+ const isRendered = (el) => [...el.attributes].some(a => { const n = a.name.toLowerCase(); return RENDERED.some(r => n === r || n.startsWith(r + '.')); });
27
+ // Elements whose content is not markup: never text-edited.
28
+ const RAW_EDIT = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE', 'NOSCRIPT', 'TEMPLATE', 'XMP', 'IFRAME', 'NOEMBED', 'NOFRAMES']);
29
+ const listTpl = (area) => [...area.children].find(c => c.tagName === 'TEMPLATE' && c.hasAttribute('x-for')) || null;
30
+ function sourceText(el) {
31
+ if (isRendered(el)) return '';
32
+ let t = '';
33
+ for (const n of el.childNodes) {
34
+ if (n.nodeType === 3) t += n.textContent;
35
+ else if (n.nodeType === 1 && !pluginNode(n) && n.tagName !== 'TEMPLATE' && !isGenerated(n) && !n._manifestComponent) t += sourceText(n);
36
+ }
37
+ return t;
38
+ }
39
+ const staticKey = (el) => el.tagName.toUpperCase() + ':' + sourceText(el).replace(/\s+/g, ' ').trim().slice(0, 24);
40
+ // Identity: "@region@KEY" for source elements (origin area + content key),
41
+ // "~id~@region@KEY" for a copy of one, "!id" for content that is not in source.
42
+ const canonKey = (region, k) => '@' + encodeURIComponent(region) + '@' + k;
43
+ const originOf = (k) => k && k[0] === '~' ? k.slice(k.indexOf('~', 1) + 1) : k;
44
+ let mintN = 0;
45
+ const mint = () => Date.now().toString(36) + (++mintN).toString(36) + Math.random().toString(36).slice(2, 6);
46
+ // Elements Alpine renders from a template (x-if / x-for clones) are not source.
47
+ function isGenerated(el) {
48
+ for (let t = el.previousElementSibling; t; t = t.previousElementSibling) {
49
+ if (t.tagName !== 'TEMPLATE') continue;
50
+ if (t._x_currentIfEl === el) return true;
51
+ const l = t._x_lookup;
52
+ if (l && (l instanceof Map ? [...l.values()] : Object.values(l)).includes(el)) return true;
53
+ }
54
+ return false;
55
+ }
34
56
  function staticKeys(container) {
35
57
  const seen = Object.create(null);
36
58
  return sortableChildren(container).map(el => {
37
- const assigned = el.getAttribute('data-edit-key');
59
+ const assigned = el.getAttribute('data-edit-host') || el.getAttribute('data-edit-key');
38
60
  if (assigned) return assigned;
39
61
  const base = staticKey(el);
40
62
  const n = seen[base] = (seen[base] || 0) + 1;
41
63
  return n > 1 ? base + '#' + n : base;
42
64
  });
43
65
  }
44
- const loadState = () => { try { const s = JSON.parse(localStorage.getItem(LS_KEY)); if (s && s.v === SCHEMA) { log = s.log || []; cursor = s.cursor ?? log.length; } else if (s) localStorage.removeItem(LS_KEY); } catch {} };
45
- // Only an .authoring area is a page being edited. Everywhere else the plugin is
46
- // behaviour — a sortable list, a resizable panel — and the app owns the state, so
47
- // those deltas neither travel to the source nor survive in the overlay. They stay
48
- // in the in-memory log, so undo still works for the session.
49
- const authoringRegion = (r) => { const a = r != null && areaByKey(r); return !!(a && a._edit.authoring); };
50
- const persistable = (d) => d.kind !== 'data-splice' && (d.kind === 'st-move'
51
- ? authoringRegion(d.from) && authoringRegion(d.to)
52
- : (d.region == null || authoringRegion(d.region)));
53
-
54
- const saveState = () => { if (log.length > HISTORY_CAP) { const n = log.length - HISTORY_CAP; log.splice(0, n); cursor = Math.max(0, cursor - n); } const keep = log.filter(persistable); localStorage.setItem(LS_KEY, JSON.stringify({ v: SCHEMA, log: keep, cursor: Math.min(cursor, keep.length) })); };
66
+ // Surfaced conflicts ($edit.conflicts); those of .persist regions are stored with the log until cleared
67
+ let conflicts = [];
68
+ const keptConflicts = new WeakSet();
69
+ const loadState = () => {
70
+ try {
71
+ lsPath = location.pathname;
72
+ const s = JSON.parse(localStorage.getItem(lsKey(lsPath)));
73
+ if (s && s.v === SCHEMA) {
74
+ log = s.log || []; cursor = s.cursor ?? log.length; log.forEach(d => persisted.add(d));
75
+ conflicts = Array.isArray(s.conflicts) ? s.conflicts : []; conflicts.forEach(c => keptConflicts.add(c));
76
+ if (estore) estore.conflicts = conflicts.slice();
77
+ }
78
+ else if (s) localStorage.removeItem(lsKey(lsPath));
79
+ } catch { }
80
+ };
81
+ // One document, one log: an SPA's routes share it, so every path this page has been at
82
+ // holds the same state (a publish from one route leaves no published deltas under another).
83
+ const sessionPaths = new Set([location.pathname]);
84
+ const saveState = (path) => {
85
+ if (log.length > HISTORY_CAP) { const n = log.length - HISTORY_CAP; log.splice(0, n); cursor = Math.max(0, cursor - n); }
86
+ const done = log.slice(0, cursor).filter(d => persisted.has(d)), ahead = log.slice(cursor).filter(d => persisted.has(d));
87
+ sessionPaths.add(path || location.pathname);
88
+ const kept = conflicts.filter(c => keptConflicts.has(c));
89
+ (path ? [path] : [...sessionPaths]).forEach(p => {
90
+ try {
91
+ if (!done.length && !ahead.length && !kept.length) localStorage.removeItem(lsKey(p));
92
+ else localStorage.setItem(lsKey(p), JSON.stringify(Object.assign({ v: SCHEMA, log: [...done, ...ahead], cursor: done.length }, kept.length ? { conflicts: kept } : {})));
93
+ } catch { }
94
+ });
95
+ };
55
96
  const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
56
- const cssVar = (el, name, fallback) => { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; };
57
-
58
- /* ---- Safety helpers ---- */
59
- // Structural signature for a node at a path — guards against the source structure
60
- // drifting under a stored delta (apply only if the node still matches).
97
+ // Nearest value of a custom property, `none` included ('' when unset).
98
+ function rawVar(el, name) {
99
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
100
+ const v = getComputedStyle(n).getPropertyValue(name).trim();
101
+ if (v) return v;
102
+ }
103
+ return '';
104
+ }
105
+ // Inherited custom properties; `none` cancels an inherited value.
106
+ function cssVar(el, name, fallback) {
107
+ const v = rawVar(el, name);
108
+ if (!v) return fallback ?? '';
109
+ return v === 'none' && name !== '--edit-size' ? (fallback ?? '') : v;
110
+ }
111
+ // Inline style minus declarations the plugin applied itself.
112
+ // Inline style minus declarations the plugins applied: Edit's position, Rich Text's --rich-text-* state.
113
+ function stripRuntime(s, pos) {
114
+ if (!pos && !/--rich-text-/.test(s)) return s;
115
+ const d = document.createElement('div');
116
+ d.setAttribute('style', s);
117
+ if (pos) d.style.removeProperty('position');
118
+ const names = []; for (let i = 0; i < d.style.length; i++) names.push(d.style.item(i));
119
+ names.filter(p => p.startsWith('--rich-text-')).forEach(p => d.style.removeProperty(p));
120
+ return d.getAttribute('style') || '';
121
+ }
122
+ const ownStyle = (el) => stripRuntime(el.getAttribute('style') || '', !!el._editPos);
123
+ function writeStyle(el, v) {
124
+ if (v == null || v === '') el.removeAttribute('style'); else if (el.getAttribute('style') !== v) el.setAttribute('style', v);
125
+ if (el._editPos && !el.style.position) el.style.position = 'relative';
126
+ }
127
+
128
+ /* ---- Safety ---- */
61
129
  const nodeSig = (el) => el ? el.tagName.toLowerCase() : '';
62
- // Sanitize contentEditable / replayed HTML: allowlist inline tags, strip everything
63
- // else (attributes, scripts, comments, javascript: hrefs). Critical once overlays can
64
- // come from other users (cloud). Unknown tags are unwrapped to their text.
65
130
  const SAFE_TAGS = new Set(['B', 'I', 'EM', 'STRONG', 'U', 'S', 'SMALL', 'CODE', 'MARK', 'SUB', 'SUP', 'BR', 'SPAN', 'A']);
66
- // An element owned by the rich editor carries block markup this plugin's own
67
- // allowlist would flatten, so let that plugin vet its own content. Everything
68
- // else keeps the conservative inline-only policy.
69
- const sanitizeFor = (el, html) => el && el.hasAttribute && el.hasAttribute('data-text-edit') && window.ManifestTextEdit
70
- ? window.ManifestTextEdit.sanitize(html, true, true)
71
- : sanitizeHTML(html);
131
+ // x-rich-text fields keep blocks; every other text leaf is inline. Rich Text's allowlist when loaded.
132
+ const authoredRich = (el) => !!el && el.nodeType === 1 && el.hasAttribute('data-rich-text') && !el._editRt;
133
+ const LEAF_STYLE = true; // inline style survives Rich Text's sanitizer, so it is plain formatting too
134
+ const inlineClean = (html) => window.ManifestRichText.sanitize(html, false, LEAF_STYLE);
135
+ // Leaf text: Rich Text's allowlist; no interactive element inside another (or inside an
136
+ // interactive leaf), no empty link; a trailing <br> shows nothing, so none is kept.
137
+ // Comments the writer keeps verbatim: nothing that could close one early or open another.
138
+ // Comment text the HTML spec allows: written back verbatim, it parses to the same comment.
139
+ const keptComment = (d) => !/^-?>|-->|--!>|<!-$/.test(d);
140
+ let warnedComment = false;
141
+ const commentsIn = (root) => { const w = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT), out = []; for (let n = w.nextNode(); n; n = w.nextNode()) out.push(n); return out; };
142
+ const attrKept = (n, a) => !attrRefusal(a.name, a.value, n.tagName) && !(a.name === 'style' && /expression|url\s*\(|javascript:|@import/i.test(a.value));
143
+ // The end of the text, along the last-descendant chain: <br>s, blank text and the inline elements they leave empty go.
144
+ // Comments are kept and passed over.
145
+ function trimTail(root) {
146
+ const last = (p) => { let n = p.lastChild; while (n && n.nodeType === 8) n = n.previousSibling; return n; };
147
+ for (let guard = 0; guard < 1000; guard++) {
148
+ let n = last(root);
149
+ while (n && n.nodeType === 1 && n.tagName !== 'BR' && last(n)) n = last(n);
150
+ if (!n || n === root) return;
151
+ const gone = n.nodeType === 3 ? !n.nodeValue.trim() : n.tagName === 'BR' || (!last(n) && !/^(IMG|WBR)$/.test(n.tagName));
152
+ if (!gone) return;
153
+ n.remove();
154
+ }
155
+ }
156
+ function leafClean(html, host) {
157
+ const t = document.createElement('template');
158
+ // Authored comments ride through the sanitizer as placeholders
159
+ t.innerHTML = String(html == null ? '' : html);
160
+ const kept = [];
161
+ const nonce = 'mnfst' + Math.random().toString(36).slice(2) + Date.now().toString(36), mark = new RegExp('(' + nonce + '\\d+_)');
162
+ commentsIn(t.content).forEach(c => {
163
+ if (keptComment(c.data)) { c.replaceWith(nonce + kept.length + '_'); kept.push(c.data); return; }
164
+ c.remove();
165
+ if (!warnedComment) { warnedComment = true; console.warn('[edit] a comment that cannot be written back as it is was left out of edited text'); }
166
+ });
167
+ t.innerHTML = inlineClean(t.innerHTML);
168
+ if (kept.length) {
169
+ const w = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT), texts = [];
170
+ for (let n = w.nextNode(); n; n = w.nextNode()) if (n.nodeValue.includes(nonce)) texts.push(n);
171
+ texts.forEach(n => n.replaceWith(...n.nodeValue.split(mark).filter(Boolean).map(p => mark.test(p) && p.startsWith(nonce) ? document.createComment(kept[parseInt(p.slice(nonce.length), 10)]) : document.createTextNode(p))));
172
+ }
173
+ trimTail(t.content);
174
+ // The source writer's attribute rules, so what is shown is what publishes
175
+ t.content.querySelectorAll('*').forEach(n => [...n.attributes].forEach(a => { if (!attrKept(n, a)) n.removeAttribute(a.name); }));
176
+ const sel = CONTENT_RULES.interactive.join(','), hosted = !!(host && host.closest && host.closest('a, button'));
177
+ [...t.content.querySelectorAll(sel)].forEach(n => { if (hosted || (n.parentElement && n.parentElement.closest(sel))) n.replaceWith(...n.childNodes); });
178
+ t.content.querySelectorAll('a').forEach(a => { if (!a.textContent.trim() && !a.querySelector('img')) a.replaceWith(...a.childNodes); });
179
+ return t.innerHTML.trim();
180
+ }
181
+ // A live leaf's markup; links nested by DOM edits are unwrapped first (a parser would split them).
182
+ function leafHTML(el) {
183
+ const sel = CONTENT_RULES.interactive.join(','), hosted = !!el.closest('a, button');
184
+ [...el.querySelectorAll(sel)].forEach(n => { const up = n.parentElement && n.parentElement.closest(sel); if (hosted || (up && el.contains(up))) n.replaceWith(...n.childNodes); });
185
+ return el.innerHTML.trim();
186
+ }
187
+ const sanitizeFor = (el, html) => !window.ManifestRichText ? sanitizeHTML(html)
188
+ : authoredRich(el) ? window.ManifestRichText.sanitize(html, true, true) : leafClean(html, el);
72
189
 
73
190
  function sanitizeHTML(html) {
74
191
  const t = document.createElement('template'); t.innerHTML = String(html == null ? '' : html);
75
192
  const walk = (node) => [...node.childNodes].forEach(c => {
76
- if (c.nodeType === 8) return c.remove(); // comments
193
+ if (c.nodeType === 8) return c.remove();
77
194
  if (c.nodeType !== 1) return;
78
- if (!SAFE_TAGS.has(c.tagName)) { c.replaceWith(document.createTextNode(c.textContent)); return; } // unwrap unknown tag
79
- [...c.attributes].forEach(a => { const n = a.name.toLowerCase(); if (!(c.tagName === 'A' && n === 'href' && !/^\s*javascript:/i.test(a.value))) c.removeAttribute(a.name); });
195
+ if (!SAFE_TAGS.has(c.tagName)) { c.replaceWith(document.createTextNode(c.textContent)); return; }
196
+ [...c.attributes].forEach(a => { const n = a.name.toLowerCase(); if (!(c.tagName === 'A' && n === 'href' && !attrRefusal('href', a.value, 'A'))) c.removeAttribute(a.name); });
80
197
  walk(c);
81
198
  });
82
199
  walk(t.content);
83
200
  return t.innerHTML;
84
201
  }
85
202
 
203
+ // Attribute guardrails. Returns a reason, or null when safe. A script scheme is
204
+ // refused in any value; data: only as a raster image on img/source src/srcset.
205
+ const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', tab: '\t', newline: '\n', colon: ':' };
206
+ const decodeEntities = (s) => String(s).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);?/gi, (m, e) => {
207
+ if (e[0] === '#') { const c = /^#x/i.test(e) ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return c >= 0 && c < 0x110000 ? String.fromCodePoint(c) : m; }
208
+ const v = NAMED[e.toLowerCase()]; return v === undefined ? m : v;
209
+ });
210
+ const LOADS = { script: ['src', 'type'], iframe: ['src', 'srcdoc'], embed: ['src', 'srcdoc'], object: ['src', 'srcdoc', 'data'], frame: ['src', 'srcdoc'], link: ['href', 'rel'], base: ['href', 'rel'], meta: ['http-equiv', 'content'] };
211
+ const SVG_ANIM = new Set(['set', 'animate', 'animatetransform', 'animatemotion']);
212
+ const DATA_IMAGE = /^data:image\/(png|jpeg|gif|webp|avif)[;,]/;
213
+ function attrRefusal(name, value, tag) {
214
+ const n = String(name || '').trim().toLowerCase(), t = String(tag || '').toLowerCase();
215
+ if (!n || !/^[a-z_][\w:.-]*$/i.test(n)) return /^[@:]/.test(n) ? 'Alpine directive' : 'invalid name';
216
+ if (n === 'text') return 'reserved name';
217
+ if (n === 'action' || n === 'formaction') return 'form target';
218
+ if (n.startsWith('on')) return 'event handler attribute';
219
+ if (n.startsWith('x-')) return 'Alpine directive';
220
+ if (n.startsWith('data-edit-')) return 'plugin attribute';
221
+ if (n === 'srcdoc') return 'inline document';
222
+ if (SVG_ANIM.has(t) && ['attributename', 'to', 'values', 'from', 'by'].includes(n)) return 'SVG animation target';
223
+ if (LOADS[t] && LOADS[t].includes(n)) return 'loads or runs content';
224
+ if (value == null) return null;
225
+ for (const raw of [String(value), decodeEntities(value)]) {
226
+ const v = raw.replace(/[\u0000-\u0020\u007f]/g, '').toLowerCase();
227
+ if (/(^|[,(;'"=])(javascript|vbscript):/.test(v)) return 'script URL';
228
+ const datas = v.match(/(^|[,(;'"=])data:[^,)]*/g) || [];
229
+ if (datas.length) {
230
+ const imageOk = (t === 'img' || t === 'source') && (n === 'src' || n === 'srcset');
231
+ if (!imageOk || !datas.every(d => DATA_IMAGE.test(d.replace(/^[,(;'"=]/, '')))) return 'data URL';
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
86
237
  /* ---- x-edit registry ---- */
87
238
  const editEls = new Set();
88
- const themeScopes = {}; // scope key → element (CSS-var cascade target for x-edit.cssvar)
239
+ const themeScopes = {};
240
+ const warnedRetired = new Set();
241
+ // Within an area, a rendered host's content is not editable (the leaf exclusion).
242
+ const insideRendered = (el) => { const a = regionOf(el); for (let n = el && el.parentElement; n; n = n.parentElement) { if (isRendered(n)) return true; if (n === a) break; } return false; };
243
+ // Hosts whose content is generated output, not authored markup: an area inside one edits
244
+ // in the page, but its markup is in no HTML file mnfst-run can write (x-code-group,
245
+ // x-carousel and x-defer keep their authored children, so those can be written).
246
+ const GENERATED = RENDERED.filter(r => r !== 'x-code-group' && r !== 'x-carousel' && r !== 'x-defer');
247
+ function generatedBy(el) {
248
+ for (let n = el && el.parentElement; n; n = n.parentElement) {
249
+ if (n.nodeType !== 1) continue;
250
+ const a = [...n.attributes].map(x => x.name.toLowerCase()).find(x => GENERATED.some(r => x === r || x.startsWith(r + '.')));
251
+ if (a) return a.split('.')[0];
252
+ }
253
+ return null;
254
+ }
89
255
  function registerEdit(el, modifiers, expression) {
90
- if (modifiers.includes('cssvar')) return registerCssVar(el, expression); // theme control, not an area
256
+ if (modifiers.includes('cssvar')) return registerCssVar(el, expression, modifiers);
257
+ modifiers.forEach(m => { if (RETIRED[m] && !warnedRetired.has(m)) { warnedRetired.add(m); console.warn(`[edit] x-edit.${m} was removed — use ${RETIRED[m]}`); } });
258
+ if (modifiers.includes('lock')) {
259
+ const actions = modifiers.filter(m => LOCK_ACTIONS.includes(m));
260
+ el._editLock = new Set(actions.length ? actions : ['*']);
261
+ return;
262
+ }
91
263
  const caps = new Set(ALL_CAPS.filter(c => modifiers.includes(c)));
92
- const lock = modifiers.includes('lock') || modifiers.includes('none');
93
- const theme = modifiers.includes('theme'); // theme scope: vars set here cascade to this subtree only
264
+ const theme = modifiers.includes('theme');
94
265
  const k = (expression || '').trim() || `area-${++autoN}`;
95
266
  if (theme) themeScopes[k] = el;
96
- if (theme && !caps.size && !lock) return; // pure theme scope — a cascade target, NOT an editable area (so it doesn't swallow nested areas)
97
- if (!caps.size && !lock) ['sort', 'text', 'style'].forEach(c => caps.add(c)); // size is opt-in
98
- if ((expression || '').trim() && [...editEls].some(e => e !== el && e._edit && e._edit.key === k)) console.warn('[edit] duplicate x-edit key (deltas will mis-route):', k); // re-init of the same element is not a duplicate
99
- el._edit = { key: k, caps, lock, gated: modifiers.includes('gated'), theme, authoring: modifiers.includes('authoring') };
100
- el.setAttribute('data-edit-area', '');
101
- editEls.add(el);
102
- }
103
- const areas = () => [...editEls].filter(el => { for (let n = el.parentElement; n; n = n.parentElement) if (n._edit) return false; return true; });
267
+ if (theme && !caps.size) { if (modifiers.includes('persist')) el._editPersist = true; return; }
268
+ if (!caps.size) DEFAULT_CAPS.forEach(c => caps.add(c));
269
+ if ((expression || '').trim() && [...editEls].some(e => e !== el && e._edit && e._edit.key === k)) console.warn('[edit] duplicate x-edit key (deltas will mis-route):', k);
270
+ const bare = modifiers.includes('bare');
271
+ el._edit = { key: k, caps, gated: modifiers.includes('gated'), theme, persist: modifiers.includes('persist'), bare, rendered: generatedBy(el) };
272
+ el.setAttribute('data-edit-area', bare ? 'bare' : '');
273
+ editEls.add(el); resetAreas();
274
+ }
275
+ let areasCache = null;
276
+ // Top-level areas: no x-edit above them in the same file (component markup is another file's).
277
+ const areas = () => areasCache || (areasCache = [...editEls].filter(el => { for (let n = el; n; n = n.parentElement) { if (n !== el && n._edit) return false; if (n._manifestComponent || (n !== el && isRendered(n))) return true; } return true; }));
278
+ const resetAreas = () => { areasCache = null; };
104
279
  const areaByKey = (k) => areas().find(a => a._edit.key === k);
105
- const topArea = (a) => !!a && areas().includes(a); // only top-level regions can be move endpoints (nested ones can't persist)
106
- // The top-level region an element belongs to NOW — resolved at commit time, not
107
- // arm time, so a block moved to another region commits against its new home.
108
- const regionOf = (el) => areas().find(a => a === el || a.contains(el));
280
+ // A key mounted more than once (a component used several times) is not editable.
281
+ const sharedKey = (a) => areas().filter(b => b._edit.key === a._edit.key).length > 1;
282
+ // The component file an area is authored in: its nearest component root, when the area
283
+ // sits inside it or that file itself carries this x-edit (else it came from the placeholder).
284
+ function sourceComponent(area) {
285
+ if (area._editSrc !== undefined) return area._editSrc;
286
+ let name = null;
287
+ for (let n = area; n; n = n.parentElement) {
288
+ if (!n._manifestComponent) continue;
289
+ const src = n === area && window.ManifestComponentsLoader && window.ManifestComponentsLoader.cache[n._manifestComponent];
290
+ const own = new RegExp('\\sx-edit(?:\\.[\\w-]+)*\\s*=\\s*(["\'])' + area._edit.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\1');
291
+ if (n !== area || (src && own.test(src))) name = n._manifestComponent;
292
+ break;
293
+ }
294
+ return (area._editSrc = name);
295
+ }
296
+ // Its project path, as the components loader resolves it.
297
+ function componentFile(name) {
298
+ const mf = (window.ManifestComponentsRegistry && window.ManifestComponentsRegistry.manifest) || {};
299
+ const p = [...(mf.preloadedComponents || []), ...(mf.components || [])].find(x => String(x).split('/').pop().replace('.html', '') === name);
300
+ return String(p || 'components/' + name + '.html').replace(/^\/+/, '');
301
+ }
302
+ const sourceFile = (area) => { const c = sourceComponent(area); return c ? componentFile(c) : 'index.html'; };
303
+ const topArea = (a) => !!a && areas().includes(a);
304
+ const regionOf = (el) => el && areas().reduce((best, a) => (a === el || a.contains(el)) && (!best || best.contains(a)) ? a : best, null);
109
305
  const key = (area) => area._edit.key;
110
306
  function editInfo(node) { for (let n = node; n; n = n.parentElement) if (n._edit) return n._edit; return null; }
111
- const capOf = (node, cap) => { const i = editInfo(node); return !!i && !i.lock && i.caps.has(cap); };
112
- const locked = (node) => { const i = editInfo(node); return !!i && i.lock; };
113
- const ownsCap = (el, cap) => !!el._edit && !el._edit.lock && el._edit.caps.has(cap);
307
+ const capOf = (node, cap) => { const i = editInfo(node); return !!i && i.caps.has(cap) && !lockedFor(node, cap); };
308
+ const ownsCap = (el, cap) => !!el._edit && el._edit.caps.has(cap) && !lockedFor(el, cap);
309
+ const persistOf = (region) => { const a = region != null && areaByKey(region); return !!(a && a._edit.persist); };
114
310
 
115
311
  /* ---- Regime classification ---- */
116
312
  function classify(area) {
117
- if (area.querySelector('template[x-for]')) return 'data';
118
- if (area.matches('[data-component]') || area.querySelector('[data-component]')) return 'component';
313
+ if (listTpl(area)) return 'data';
314
+ if ((area.matches('[data-component]') && !sourceComponent(area)) || cmpRoot(area)) return 'component';
119
315
  return 'static';
120
316
  }
121
- function dataSourceExpr(area) { const t = area.querySelector('template[x-for]'); const m = t && t.getAttribute('x-for').match(/\bin\s+(.+)$/); return m ? m[1].trim() : null; }
317
+ // An instance the area overrides by path: the one component the area wraps, with no
318
+ // x-edit region of its own. Among other content, a component is a host piece instead.
319
+ const cmpRoot = (area) => {
320
+ const kids = realChildren(area).filter(k => k.tagName !== 'TEMPLATE'), r = kids[0];
321
+ return kids.length === 1 && r.matches('[data-component]') && !r._edit && !r.querySelector('[data-edit-area]') ? r : null;
322
+ };
323
+ function dataSourceExpr(area) { const t = listTpl(area); const m = t && t.getAttribute('x-for').match(/\bin\s+(.+)$/); return m ? m[1].trim() : null; }
122
324
  const dataSourceName = (area) => { const e = dataSourceExpr(area); const m = e && e.match(/\$x\.(\w+)/); return m ? m[1] : (e || 'source'); };
123
- // Plugin-injected nodes (size handles, drag ghosts) are not authored children —
124
- // keys and paths skip them on client AND server (serve.mjs childAt mirrors this).
125
- const pluginNode = (x) => x.hasAttribute('data-edit-handle') || x.hasAttribute('data-edit-ghost');
126
- const realChildren = (el) => Array.from(el.children).filter(x => !pluginNode(x));
325
+ const pluginNode = (x) => x.hasAttribute('data-edit-handle') || x.hasAttribute('data-edit-ghost') || x.hasAttribute('data-edit-grip');
326
+ // A component's roots past its first follow that first one (its <x-name> host) everywhere.
327
+ const groupOf = (el) => el && el._manifestHost && el._manifestHost.roots && el._manifestHost.roots[0] === el ? el._manifestHost.roots.slice() : [el];
328
+ const extraRoot = (x) => !!x._manifestHost && !!x._manifestHost.roots && x._manifestHost.roots[0] !== x;
329
+ const isHostEl = (el) => !!el && el.nodeType === 1 && el.hasAttribute('data-edit-host');
330
+ const idOf = (el) => el.getAttribute('data-edit-host') || el.getAttribute('data-edit-key');
331
+ const realChildren = (el) => Array.from(el.children).filter(x => !pluginNode(x) && !extraRoot(x));
127
332
  const sortableChildren = (c) => realChildren(c).filter(x => x.tagName !== 'TEMPLATE');
128
- // Identity of a row in a data area. `data-key` is the explicit form; without it,
129
- // fall back to the x-for's own :key so a plain list needs no extra attribute.
130
333
  let _keyWarned = false;
131
334
  function itemKey(el, area) {
132
335
  const explicit = el.getAttribute('data-key');
133
336
  if (explicit != null) return explicit;
134
- const tpl = area.querySelector('template[x-for]'), expr = tpl && tpl._x_keyExpression;
337
+ const tpl = listTpl(area), expr = tpl && tpl._x_keyExpression;
135
338
  if (expr) { try { const v = window.Alpine.evaluate(el, expr); if (v != null) return String(v); } catch { } }
136
339
  if (!_keyWarned) { _keyWarned = true; console.warn('[edit] data area rows have no identity — add :key to the x-for or :data-key to the row, or the order cannot be stored'); }
137
340
  return null;
138
341
  }
139
- const STRUCT_ATTRS = new Set(['class', 'style', 'data-component', 'data-edit-area', 'data-edit-field', 'data-edit-sizable', 'data-edit-movable', 'draggable', 'contenteditable', 'x-text', 'x-html', 'id']);
140
- const componentParams = (area) => { const r = area.querySelector('[data-component]') || area; return [...r.attributes].filter(a => !STRUCT_ATTRS.has(a.name) && a.value.trim()); };
141
342
 
142
- /* ---- Unit helpers (shared by move + size) ---- */
343
+ /* ---- Structure: text elements are pieces, never containers ---- */
344
+ const TEXT_TAGS = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'SPAN', 'A', 'LABEL', 'STRONG', 'EM', 'B', 'I', 'U', 'S', 'SMALL', 'CODE', 'MARK', 'SUB', 'SUP', 'Q', 'CITE', 'ABBR', 'TIME', 'DT', 'DD', 'FIGCAPTION', 'CAPTION', 'LEGEND', 'SUMMARY', 'BUTTON', 'OPTION']);
345
+ const ATOMIC_TAGS = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'svg', 'IFRAME', 'OBJECT', 'SELECT', 'TEXTAREA', 'PICTURE', 'MATH', 'math', 'TEMPLATE', 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TITLE', 'XMP']);
346
+ const ownText = (el) => [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
347
+ const isTextEl = (el) => TEXT_TAGS.has(el.tagName) || el.hasAttribute('data-rich-text') || ownText(el) || wholeLeaf(el);
348
+ // One leaf: a text element whose elements inside are all plain formatting, i.e. markup
349
+ // Rich Text's inline sanitizer keeps unchanged (Edit's own attributes aside); <br> included.
350
+ const LEAF_TAGS = new Set([...TEXT_TAGS, 'LI', 'TD', 'TH']);
351
+ function plainFormatted(el) {
352
+ if (!window.ManifestRichText) return false;
353
+ const c = el.cloneNode(true);
354
+ c.querySelectorAll('[data-edit-handle], [data-edit-ghost], [data-edit-grip]').forEach(n => n.remove());
355
+ commentsIn(c).forEach(n => n.remove());
356
+ if (!c.children.length) return true;
357
+ c.querySelectorAll('*').forEach(n => [...n.attributes].forEach(a => { if (a.name.startsWith('data-edit-')) n.removeAttribute(a.name); }));
358
+ return inlineClean(c.innerHTML) === c.innerHTML && [...c.querySelectorAll('*')].every(n => [...n.attributes].every(a => attrKept(n, a)));
359
+ }
360
+ // Text is required: a link around an image alone stays granular, so the image keeps its own identity.
361
+ const wholeLeaf = (el) => LEAF_TAGS.has(el.tagName) && realChildren(el).length > 0 && !!el.textContent.trim() && plainFormatted(el);
362
+ // The text leaf an element sits inside (not the leaf itself), attached or resting as a focused piece.
363
+ const leafHost = (el) => { for (let n = el && el.parentElement; n && n.nodeType === 1; n = n.parentElement) { if (n._editRtFormat) return n; if (n._edit) break; } return null; };
364
+ function isContainer(el) {
365
+ if (!el || el.nodeType !== 1 || ATOMIC_TAGS.has(el.tagName) || pluginNode(el)) return false;
366
+ return !isTextEl(el);
367
+ }
368
+
369
+ /* ---- Titling comments ----
370
+ A comment directly above an element (whitespace between, at most one newline)
371
+ titles it and moves with it, unless it opens its container. Mirrors the source
372
+ writer's leadOf (packages/run/serve.mjs). */
373
+ function leadNodes(el) {
374
+ const out = [];
375
+ let n = el.previousSibling;
376
+ for (;;) {
377
+ let ws = null;
378
+ if (n && n.nodeType === 3) { if (!/^[ \t]*(\r?\n)?[ \t]*$/.test(n.textContent)) break; ws = n; n = n.previousSibling; }
379
+ if (!n || n.nodeType !== 8) break;
380
+ let p = n.previousSibling;
381
+ while (p && p.nodeType === 3 && !p.textContent.trim()) p = p.previousSibling;
382
+ if (!p) break;
383
+ out.unshift(n, ...(ws ? [ws] : []));
384
+ n = n.previousSibling;
385
+ }
386
+ return out;
387
+ }
388
+ const startOf = (el) => el ? leadNodes(el)[0] || el : null;
389
+ function moveWith(el, place) { const lead = leadNodes(el), rest = groupOf(el).slice(1); place(el); if (lead.length) el.before(...lead); if (rest.length) el.after(...rest); }
390
+
391
+ /* ---- HTML content model ----
392
+ Drops the parser would re-nest, foster-parent or drop. Mirrors CONTENT_RULES in
393
+ the source writer (packages/run/serve.mjs). */
394
+ const CONTENT_RULES = {
395
+ only: {
396
+ table: ['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'script', 'template', 'style'],
397
+ thead: ['tr', 'script', 'template'], tbody: ['tr', 'script', 'template'], tfoot: ['tr', 'script', 'template'],
398
+ tr: ['td', 'th', 'script', 'template'], colgroup: ['col', 'template'],
399
+ ul: ['li', 'script', 'template'], ol: ['li', 'script', 'template'], menu: ['li', 'script', 'template'],
400
+ dl: ['dt', 'dd', 'div', 'script', 'template'],
401
+ select: ['option', 'optgroup', 'hr', 'script', 'template'], datalist: ['option', 'script', 'template'], optgroup: ['option', 'script', 'template']
402
+ },
403
+ parent: {
404
+ tr: ['thead', 'tbody', 'tfoot'], td: ['tr'], th: ['tr'], caption: ['table'], colgroup: ['table'], col: ['colgroup'],
405
+ thead: ['table'], tbody: ['table'], tfoot: ['table'], li: ['ul', 'ol', 'menu'], dt: ['dl', 'div'], dd: ['dl', 'div'],
406
+ option: ['select', 'datalist', 'optgroup'], optgroup: ['select']
407
+ },
408
+ interactive: ['a', 'button', 'input', 'select', 'textarea', 'details', 'label', 'iframe', 'embed', 'object', 'audio', 'video', 'keygen'],
409
+ phrasingOnly: ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre'],
410
+ block: ['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'table', 'ul', 'li', 'dd', 'dt']
411
+ };
412
+ function contentRefusal(child, parent, ancestors, subtree) {
413
+ const R = CONTENT_RULES;
414
+ if (R.only[parent] && !R.only[parent].includes(child)) return `<${parent}> takes only ${R.only[parent].filter(t => t !== 'script' && t !== 'template' && t !== 'style').join(', ')}`;
415
+ if (R.parent[child] && !R.parent[child].includes(parent)) return `<${child}> belongs in ${R.parent[child].join(' or ')}`;
416
+ if ((child === 'dt' || child === 'dd') && parent === 'div' && ancestors[1] !== 'dl') return `<${child}> belongs in a dl`;
417
+ if (subtree.has('form') && ancestors.includes('form')) return 'a form cannot contain a form';
418
+ if ((ancestors.includes('a') || ancestors.includes('button')) && R.interactive.some(t => subtree.has(t))) return 'interactive content cannot go inside a link or button';
419
+ if (R.phrasingOnly.includes(parent) && R.block.some(t => subtree.has(t))) return `<${parent}> takes only inline content`;
420
+ return null;
421
+ }
422
+ // Whether `piece` may be placed in `container` without the parser changing it.
423
+ function fits(piece, container) {
424
+ if (!piece || !container) return false;
425
+ if (piece.parentElement === container) return true;
426
+ const anc = [];
427
+ for (let a = container; a && a.nodeType === 1; a = a.parentElement) anc.push(a.tagName.toLowerCase());
428
+ // Template contents count: an x-if can render them right here (the server counts them too).
429
+ const sub = new Set();
430
+ const add = (root) => [root, ...root.querySelectorAll('*')].forEach(n => { if (n.nodeType !== 1) return; sub.add(n.tagName.toLowerCase()); if (n.tagName === 'TEMPLATE' && n.content) [...n.content.children].forEach(add); });
431
+ groupOf(piece).forEach(add);
432
+ // A component never goes inside its own markup, however deep it includes it
433
+ const around = new Set();
434
+ for (let a = container; a; a = a.parentElement) if (a._manifestComponent) around.add(a._manifestComponent);
435
+ if (around.size && groupOf(piece).some(r => [r, ...r.querySelectorAll('*')].some(n => n._manifestComponent && around.has(n._manifestComponent)))) return false;
436
+ return !contentRefusal(piece.tagName.toLowerCase(), container.tagName.toLowerCase(), anc, sub);
437
+ }
438
+
439
+ /* ---- Units ---- */
143
440
  const unitOf = (v) => { const m = String(v || '').match(/[a-z%]+$/i); return m ? m[0] : null; };
144
441
  function toUnit(px, unit, el, basisEl, dim) {
145
442
  if (unit === 'rem') return +(px / parseFloat(getComputedStyle(document.documentElement).fontSize)).toFixed(2);
146
443
  if (unit === 'em') return +(px / parseFloat(getComputedStyle(el).fontSize)).toFixed(2);
147
- if (unit === '%') { const b = (basisEl || el.parentElement).getBoundingClientRect(); return +(px / (dim === 'w' ? b.width : b.height) * 100).toFixed(2); }
444
+ if (unit === '%') { const b = (basisEl || el.parentElement).getBoundingClientRect(); const d = dim === 'w' ? b.width : b.height; return d ? +(px / d * 100).toFixed(2) : 0; }
148
445
  return Math.round(px);
149
446
  }
150
447
  function toPx(str, el, dim) {
@@ -157,42 +454,47 @@
157
454
  const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
158
455
 
159
456
 
160
- /* ---- Snapshots (static/data are area-snapshotted; component uses per-node deltas) ---- */
161
- const REVERT = '\u2205revert'; // sentinel; must not collide with a real class/text value
457
+ /* ---- Log ---- */
458
+ const REVERT = '∅revert';
459
+ let ver = 0;
460
+ const stamps = new WeakMap();
461
+
462
+ // Append a delta at the cursor. `join` undoes it together with the delta before it.
463
+ function pushDelta(d, persist) {
464
+ log.splice(cursor);
465
+ log.push(d); cursor = log.length; ver++;
466
+ if (persist === undefined) persist = deltaPersists(d);
467
+ if (persist) persisted.add(d);
468
+ saveState(); refresh();
469
+ return d;
470
+ }
471
+ function deltaPersists(d) {
472
+ if (d.kind === 'data-splice' || d.kind === 'show') return false;
473
+ if (d.kind === 'st-move') return persistOf(d.from) && persistOf(d.to);
474
+ return d.region != null && persistOf(d.region);
475
+ }
476
+
162
477
  function snapshot(area, kind) {
163
478
  if (kind === 'data') return { source: dataSourceName(area), order: sortableChildren(area).map(c => itemKey(c, area)) };
164
- return {}; // static & component use per-node typed deltas, not area snapshots
165
- }
166
- function cleanStaticHTML(area) {
167
- const c = area.cloneNode(true);
168
- c.querySelectorAll('[data-edit-handle], [data-edit-live]').forEach(n => n.remove());
169
- [c, ...c.querySelectorAll('*')].forEach(el => {
170
- [...el.attributes].forEach(a => { if (a.name.startsWith('data-edit-')) el.removeAttribute(a.name); });
171
- ['draggable', 'contenteditable', 'tabindex', 'role', 'aria-label'].forEach(a => el.removeAttribute(a));
172
- if (!el.getAttribute('class')) el.removeAttribute('class');
173
- });
174
- return c.innerHTML.trim().replace(/\n\s*\n/g, '\n');
479
+ return {};
175
480
  }
176
481
  async function applySnap(area, kind, snap) {
177
482
  if (!area || !snap) return;
178
- if (kind === 'static') { area.innerHTML = snap.html; if (snap.style != null) area.setAttribute('style', snap.style); }
179
- else if (kind === 'data') applyDataOrder(area, snap.order);
483
+ if (kind === 'data') applyDataOrder(area, snap.order);
180
484
  if (isActive(area)) armArea(area);
181
485
  }
182
486
  function applyDataOrder(area, order) {
183
- const expr = dataSourceExpr(area), tpl = area.querySelector('template[x-for]');
487
+ const expr = dataSourceExpr(area), tpl = listTpl(area);
184
488
  try {
185
489
  const arr = window.Alpine.evaluate(tpl, expr); if (!Array.isArray(arr)) return;
186
490
  const byKey = new Map(arr.map(it => [String(it.id), it]));
187
491
  const next = order.map(k => byKey.get(String(k))).filter(Boolean);
188
492
  arr.forEach(it => { if (!order.map(String).includes(String(it.id))) next.push(it); });
189
493
  arr.splice(0, arr.length, ...next);
190
- } catch {}
494
+ } catch { }
191
495
  }
192
496
 
193
- /* ---- Component edits: per-node deltas, scoped instance vs main ----
194
- cmp-main applies to EVERY instance of the component; cmp-inst to one region.
195
- prop is 'text' or 'class'; REVERT (prop '*') clears a node's instance overrides. */
497
+ /* ---- Component edits: cmp-main (every instance) / cmp-inst (one region) ---- */
196
498
  function componentState() {
197
499
  const main = {}, inst = {}, reverts = {}, sigs = {};
198
500
  for (let i = 0; i < cursor; i++) {
@@ -213,96 +515,87 @@
213
515
  }
214
516
  return { main, inst, reverts, sigs };
215
517
  }
518
+ const componentNodes = (root) => [root, ...root.querySelectorAll('*')].filter(el => el._editPath != null);
216
519
  function applyComponentState() {
217
520
  const { main, inst, sigs } = componentState();
218
- document.querySelectorAll('[data-edit-area]').forEach(area => {
219
- if (!area._edit || classify(area) !== 'component') return;
220
- const comp = componentName(area), region = key(area), root = area.querySelector('[data-component]'); if (!root) return;
221
- [root, ...root.querySelectorAll('[data-edit-path]')].forEach(el => { // include the root (path '') — edits on the parent
222
- const p = el.getAttribute('data-edit-path');
521
+ areas().forEach(area => {
522
+ if (classify(area) !== 'component') return;
523
+ const comp = componentName(area), region = key(area), root = cmpRoot(area); if (!root) return;
524
+ componentNodes(root).forEach(el => {
525
+ const p = el._editPath;
223
526
  const expect = sigs[comp + '|' + p];
224
527
  if (expect && nodeSig(el) !== expect) { console.warn('[edit] skip stale node (structure changed):', comp, p); return; }
225
528
  const iv = (inst[region] && inst[region][p]) || {}, mv = (main[comp] && main[comp][p]) || {};
226
529
  const base = area._baseText || {}, baseC = area._baseClass || {}, baseS = area._baseStyle || {};
227
530
  const pick = (prop, fallback) => iv[prop] !== undefined ? iv[prop] : (mv[prop] !== undefined ? mv[prop] : fallback);
228
531
  const text = pick('text', base[p]), cls = pick('class', baseC[p]), sty = pick('style', baseS[p]);
229
- if (text !== undefined) { const safe = sanitizeFor(el, text); if (el.innerHTML !== safe) el.innerHTML = safe; } // sanitize on apply (cloud overlays)
230
- if (cls !== undefined && el.getAttribute('class') !== cls) el.setAttribute('class', cls);
532
+ if (text !== undefined) { const safe = sanitizeFor(el, text); if (el.innerHTML !== safe) el.innerHTML = safe; }
533
+ if (cls !== undefined) writeClass(el, cls);
231
534
  if (sty !== undefined && el.getAttribute('style') !== sty) { if (sty === '') el.removeAttribute('style'); else el.setAttribute('style', sty); }
232
535
  });
233
536
  });
234
537
  }
235
- function commitComponentNode(area, el, prop, value) {
236
- const path = el.getAttribute('data-edit-path'), scope = area._editScope || 'instance', component = componentName(area), region = key(area);
237
- if (prop === 'text') value = sanitizeFor(el, value); // sanitize contentEditable on capture
238
- const before = prop === 'text' ? el._preEdit : prop === 'style' ? el._preStyle : el._preClass;
239
- if (before === value) return;
240
- log.splice(cursor);
538
+ function commitComponentNode(area, el, prop, value, before) {
539
+ const path = el._editPath, scope = area._editScope || 'instance', component = componentName(area), region = key(area);
540
+ if (path == null) return false;
541
+ if (prop === 'text') value = sanitizeFor(el, value);
542
+ if (before === undefined) before = prop === 'text' ? el._preEdit : prop === 'style' ? el._preStyle : el._preClass;
543
+ if (before === value) return false;
241
544
  const sig = nodeSig(el);
242
- log.push(scope === 'main' ? { kind: 'cmp-main', component, path, prop, value, before, sig } : { kind: 'cmp-inst', component, region, path, prop, value, before, sig });
243
- cursor = log.length; saveState(); applyComponentState(); refresh();
244
- }
245
- function revertNode(area, el) { log.splice(cursor); log.push({ kind: 'cmp-inst', component: componentName(area), region: key(area), path: el.getAttribute('data-edit-path'), prop: '*', value: REVERT }); cursor = log.length; saveState(); applyComponentState(); refresh(); }
246
- function revertAll(area) { log.splice(cursor); log.push({ kind: 'cmp-inst', component: componentName(area), region: key(area), path: '*', prop: '*', value: REVERT }); cursor = log.length; saveState(); applyComponentState(); refresh(); }
247
-
248
- /* ---- Static editing: per-node typed deltas (text/class/style) + reorder permutation.
249
- No whole-HTML snapshots — storage is O(edits), addresses not trees. ---- */
250
- // Ops fold up to the cursor; baselines come from the WHOLE log — the `before`
251
- // on the first delta that touched a prop is its authored value. That pairing is
252
- // what makes undo total: a prop with no surviving op is restored to baseline
253
- // instead of being left holding the edit. (applyThemeState works the same way.)
545
+ pushDelta(scope === 'main' ? { kind: 'cmp-main', component, path, prop, value, before, sig, region } : { kind: 'cmp-inst', component, region, path, prop, value, before, sig });
546
+ applyComponentState();
547
+ return true;
548
+ }
549
+ function revertNode(area, el) { pushDelta({ kind: 'cmp-inst', component: componentName(area), region: key(area), path: el._editPath, prop: '*', value: REVERT }); applyComponentState(); }
550
+ function revertAll(area) { pushDelta({ kind: 'cmp-inst', component: componentName(area), region: key(area), path: '*', prop: '*', value: REVERT }); applyComponentState(); }
551
+
552
+ /* ---- Static: per-node typed deltas + per-container child orders ----
553
+ Every key is a canonical identity (origin region + content key), so a delta
554
+ names the same element wherever it moves. Orders are keyed region → container
555
+ key ('' = the region root). Baselines come from the first delta that touched a
556
+ prop or container. */
254
557
  function staticState() {
255
- const node = {}, order = {}, sigs = {}, base = {}, html = {};
256
- // An applied move re-addresses every EARLIER delta it carried along (its remap:
257
- // the moved keys plus any twins whose ordinal shifted), so ops and baselines
258
- // land on the element's current region/key instead of its pre-move address.
259
- const moves = [];
260
- for (let i = 0; i < cursor; i++) if (log[i].kind === 'st-move' && log[i].remap) moves.push({ i, remap: log[i].remap });
261
- const addr = (i, region, path) => {
262
- for (const m of moves) {
263
- if (m.i <= i) continue;
264
- const hit = m.remap.find(r => r[0] === region && r[1] === path);
265
- if (hit) { region = hit[2]; path = hit[3]; }
266
- }
267
- return [region, path];
558
+ const node = {}, order = {}, orderBase = {}, sigs = {}, base = {}, html = {};
559
+ const setOrder = (o, region, parent, keys, first) => {
560
+ if (!keys) return;
561
+ const m = o[region] = o[region] || {}, p = parent || '';
562
+ if (first && p in m) return;
563
+ m[p] = keys.slice();
268
564
  };
269
565
  for (let i = 0; i < log.length; i++) {
270
566
  const d = log[i];
271
- if (d.kind === 'st-children' && d.html) Object.assign(html[d.region] = html[d.region] || {}, d.html);
272
- if (d.kind === 'st-move' && d.html) { (html[d.from] = html[d.from] || {})[d.key] = d.html; (html[d.to] = html[d.to] || {})[d.toKey] = d.html; } // both sides can rebuild it
273
- if (d.kind !== 'st-node') continue;
274
- const [region, path] = addr(i, d.region, d.path);
275
- const bk = region + '|' + path;
276
- const b = base[bk] = base[bk] || {};
277
- if (!(d.prop in b)) b[d.prop] = d.before;
278
- if (d.sig) sigs[bk] = d.sig;
567
+ if (d.kind === 'st-children') { if (d.html) Object.assign(html, d.html); setOrder(orderBase, d.region, d.parent, d.before, true); }
568
+ else if (d.kind === 'st-order') setOrder(orderBase, d.region, d.parent, d.before, true);
569
+ else if (d.kind === 'st-move') { setOrder(orderBase, d.from, d.fromParent, d.fromBefore, true); setOrder(orderBase, d.to, d.toParent, d.toBefore, true); }
570
+ else if (d.kind === 'st-node') {
571
+ const b = base[d.path] = base[d.path] || {};
572
+ if (!(d.prop in b)) b[d.prop] = d.before;
573
+ if (d.sig) sigs[d.path] = d.sig;
574
+ }
279
575
  }
576
+ const touched = new Set();
280
577
  for (let i = 0; i < cursor; i++) {
281
578
  const d = log[i];
282
- if (d.kind === 'st-node') { const [region, path] = addr(i, d.region, d.path); (node[region] = node[region] || {}); (node[region][path] = node[region][path] || {})[d.prop] = d.value; }
283
- else if (d.kind === 'st-order' || d.kind === 'st-children') order[d.region] = d.order;
284
- else if (d.kind === 'st-move') { order[d.from] = d.fromOrder; order[d.to] = d.toOrder; }
579
+ if (d.kind === 'st-node') (node[d.path] = node[d.path] || {})[d.prop] = d.value;
580
+ else if (d.kind === 'st-order' || d.kind === 'st-children') {
581
+ setOrder(order, d.region, d.parent, d.order); touched.add(d.parent || '#' + d.region);
582
+ if (d.copies) Object.entries(d.copies).forEach(([k, ops]) => { node[k] = Object.assign({}, ops, node[k]); });
583
+ } else if (d.kind === 'st-move') {
584
+ setOrder(order, d.from, d.fromParent, d.fromOrder); setOrder(order, d.to, d.toParent, d.toOrder);
585
+ touched.add(d.fromParent || '#' + d.from); touched.add(d.toParent || '#' + d.to);
586
+ }
285
587
  }
286
- // The markup for anything added or removed comes from the WHOLE log, the same
287
- // way baselines do — undoing a delete has to be able to rebuild the element,
288
- // and the delta that knows its markup is no longer applied.
289
- return { node, order, sigs, base, html };
588
+ return { node, order, orderBase, sigs, base, html, touched };
290
589
  }
291
590
 
292
- // A data area's own deltas carry only the id order, which can restore a sequence
293
- // but cannot resurrect a record — so adding and removing rows record the record
294
- // itself. In-session history only: what the array should hold is the app's to
295
- // persist, so these never travel to the source.
591
+ // Data rows: adding/removing records the record itself. In-session only.
296
592
  function commitSplice(area, index, record, op) {
297
- log.splice(cursor);
298
- log.push({ kind: 'data-splice', region: key(area), source: dataSourceName(area), index, op, record: JSON.parse(JSON.stringify(record)) });
299
- cursor = log.length; saveState(); refresh();
593
+ pushDelta({ kind: 'data-splice', region: key(area), source: dataSourceName(area), index, op, record: JSON.parse(JSON.stringify(record)) });
300
594
  setTimeout(() => { lastSnap[key(area)] = snapshot(area, classify(area)); }, 0);
301
595
  }
302
-
303
596
  function applySplice(d, undo) {
304
597
  const area = areaByKey(d.region); if (!area) return;
305
- const tpl = area.querySelector('template[x-for]'), expr = dataSourceExpr(area);
598
+ const tpl = listTpl(area), expr = dataSourceExpr(area);
306
599
  if (!tpl || !expr) return;
307
600
  let arr; try { arr = window.Alpine.evaluate(tpl, expr); } catch { return; }
308
601
  if (!Array.isArray(arr)) return;
@@ -312,266 +605,515 @@
312
605
  setTimeout(() => { lastSnap[d.region] = snapshot(area, classify(area)); }, 0);
313
606
  }
314
607
 
315
- // Structural change to a container's children: duplicate, delete, paste. Stored
316
- // as the resulting key order plus the markup of whatever the log introduced or
317
- // took away, so it stays proportional to the edit rather than snapshotting.
318
- function commitStructure(area, markup) {
319
- const region = key(area), order = staticKeys(area), before = lastOrder[region] || area._baseOrder || order;
608
+ const parentKey = (container, area) => container === area ? '' : (container.getAttribute('data-edit-key') || '');
609
+
610
+ // Duplicate, delete, paste: the container's resulting order plus the markup the log
611
+ // introduced or took away. `copies` seeds a copy's edits from its original.
612
+ function commitStructure(area, container, before, markup, copies) {
613
+ const order = staticKeys(container);
320
614
  if (eq(before, order) && !Object.keys(markup || {}).length) return;
321
- log.splice(cursor);
322
- log.push({ kind: 'st-children', region, order, before, html: markup || {} });
323
- cursor = log.length; lastOrder[region] = order; saveState(); refresh();
615
+ const d = { kind: 'st-children', region: key(area), parent: parentKey(container, area), order, before, html: markup || {} };
616
+ if (copies && Object.keys(copies).length) d.copies = copies;
617
+ pushDelta(d);
324
618
  }
325
- // Rebuilt markup carries no identity — blockHTML strips it so a duplicate cannot
326
- // inherit its original's. Stamp the key we are rebuilding it for, or the element
327
- // comes back nameless and the next fold drops it again.
328
619
  const materialize = (markup, k) => {
329
620
  if (!markup) return null;
330
621
  const t = document.createElement('template');
331
622
  t.innerHTML = markup.trim();
332
623
  const el = t.content.firstElementChild;
333
- if (el && k) el.setAttribute('data-edit-key', k);
624
+ if (el && k) el.setAttribute(/^[^@]*@[^@]*@X-[\w-]+:/.test(originOf(k)) ? 'data-edit-host' : 'data-edit-key', k);
334
625
  return el;
335
626
  };
627
+ const staticAreas = () => areas().filter(a => classify(a) === 'static' && !sharedKey(a));
628
+ // Removed elements, kept so undo puts back the same element (listeners, titling comment).
629
+ const detached = new Map();
630
+ function keyMap(roots) {
631
+ const m = new Map();
632
+ roots.forEach(area => [area, ...area.querySelectorAll('[data-edit-key]')].forEach(el => {
633
+ if (el.closest('[data-edit-ghost]')) return;
634
+ const k = el.getAttribute('data-edit-key');
635
+ if (k != null && !m.has(k)) m.set(k, el);
636
+ }));
637
+ roots.forEach(area => area.querySelectorAll('[data-edit-host]').forEach(el => { const k = el.getAttribute('data-edit-host'); if (!m.has(k)) m.set(k, el); }));
638
+ return m;
639
+ }
640
+
641
+ function applyStaticProp(el, prop, v) {
642
+ if (v === undefined) return;
643
+ if (prop === 'text') { const safe = sanitizeFor(el, v); if (el.innerHTML !== safe) el.innerHTML = safe; return; }
644
+ if (attrRefusal(prop, v, el.tagName)) return;
645
+ if (prop === 'class') return writeClass(el, v);
646
+ if (prop === 'style') return writeStyle(el, v);
647
+ if (v === null) { el.removeAttribute(prop); return; }
648
+ if (el.getAttribute(prop) !== v) el.setAttribute(prop, v);
649
+ }
650
+
651
+ // Edits whose element is gone from the source (an external change rewrote it): shown
652
+ // nowhere and never publishable, so they are surfaced ($edit.conflicts, `edit:conflict`,
653
+ // a warning) and dropped from the log. Checked against a freshly rendered source only.
654
+ function surfaceConflicts(fresh) {
655
+ // Only regions keyed on this page load: an unarmed (gated) region has no keys yet
656
+ const live = keyMap(staticAreas()), lost = [], regions = new Set(fresh.filter(a => a._marked).map(key));
657
+ const isStatic = (r) => regions.has(r) && !!areaByKey(r) && classify(areaByKey(r)) === 'static';
658
+ // Structure: a container the log restructured must still hold what the log started from.
659
+ // Children matched by position and tag whose own text changed in place are renamed to
660
+ // their new keys, so the reorder still applies, but only if no structural edit moved,
661
+ // added or removed them. Otherwise every structural edit of that region goes (the
662
+ // writer would refuse them), reported with the children that changed.
663
+ const first = new Map(), broken = new Map(), renames = new Map();
664
+ const note = (region, parent, before) => { const k = JSON.stringify([region, parent]); if (!first.has(k) && isStatic(region)) first.set(k, [region, parent, before || []]); };
665
+ log.forEach(d => {
666
+ if (d.kind === 'st-order' || d.kind === 'st-children') note(d.region, d.parent, d.before);
667
+ if (d.kind === 'st-move') { note(d.from, d.fromParent, d.fromBefore); note(d.to, d.toParent, d.toBefore); }
668
+ });
669
+ const tagOf = (k) => String(k).slice(String(k).indexOf('@', 1) + 1).split(':')[0];
670
+ // Keys a structural edit moved (out of the longest kept order), added or removed in a container
671
+ const moved = (region, parent) => {
672
+ const out = new Set();
673
+ const diff = (before, after) => {
674
+ before = before || []; after = after || [];
675
+ before.forEach(k => { if (!after.includes(k)) out.add(k); });
676
+ after.forEach(k => { if (!before.includes(k)) out.add(k); });
677
+ const pos = after.filter(k => before.includes(k)).map(k => before.indexOf(k));
678
+ const tails = [], prev = new Array(pos.length).fill(-1), idx = [];
679
+ pos.forEach((p, i) => { let lo = 0, hi = tails.length; while (lo < hi) { const m = (lo + hi) >> 1; if (pos[tails[m]] < p) lo = m + 1; else hi = m; } if (lo > 0) prev[i] = tails[lo - 1]; tails[lo] = i; });
680
+ const keep = new Set(); for (let i = tails.length ? tails[tails.length - 1] : -1; i >= 0; i = prev[i]) keep.add(i);
681
+ after.filter(k => before.includes(k)).forEach((k, i) => { if (!keep.has(i)) out.add(k); });
682
+ };
683
+ log.forEach(d => {
684
+ if ((d.kind === 'st-order' || d.kind === 'st-children') && d.region === region && (d.parent || '') === parent) diff(d.before, d.order);
685
+ if (d.kind === 'st-move') {
686
+ if (d.from === region && (d.fromParent || '') === parent) { out.add(d.key); diff(d.fromBefore, d.fromOrder); }
687
+ if (d.to === region && (d.toParent || '') === parent) { out.add(d.key); diff(d.toBefore, d.toOrder); }
688
+ }
689
+ });
690
+ return out;
691
+ };
692
+ first.forEach(([region, parent, before]) => {
693
+ let c = parent === '' ? areaByKey(region) : live.get(parent), renamedParent = null;
694
+ if (!c && parent !== '') {
695
+ // The container's own key changed with a child's text: found again through its unchanged children
696
+ const holders = new Set(before.map(k => live.get(k)).filter(Boolean).map(el => el.parentElement));
697
+ if (holders.size === 1) { c = [...holders][0]; renamedParent = c.getAttribute('data-edit-key'); }
698
+ }
699
+ const now = c ? staticKeys(c) : [];
700
+ if (c && now.join('\n') === before.join('\n') && !renamedParent) return;
701
+ const changed = [];
702
+ before.forEach((k, i) => { if (now[i] !== k) changed.push({ was: k, now: now[i] ?? null }); });
703
+ now.slice(before.length).forEach(k => changed.push({ was: null, now: k }));
704
+ const shift = moved(region, parent);
705
+ const same = !!c && now.length === before.length && before.every((k, i) => tagOf(k) === tagOf(now[i]));
706
+ // A rename is a child whose own text changed in place: its old key is gone from the whole
707
+ // new source and its new key is new to the log (else elements were swapped or moved,
708
+ // which position cannot tell from a rename), its opening tag unchanged where a copy
709
+ // recorded it. Anything else is a conflict.
710
+ const known = new Set();
711
+ log.forEach(d => [d.before, d.order, d.fromBefore, d.toBefore, d.fromOrder, d.toOrder].forEach(a => Array.isArray(a) && a.forEach(k => known.add(k))));
712
+ const openTag = (el) => { const c2 = el.cloneNode(false); [...c2.attributes].forEach(a => { if (/^(data-edit-|data-rich-text|contenteditable$|role$|aria-multiline$|tabindex$)/.test(a.name)) c2.removeAttribute(a.name); }); return c2.outerHTML.replace(/<\/[^>]+>$/, ''); };
713
+ const copyTag = (k) => { for (const d of log) if (d.kind === 'st-children' && d.html) for (const [ck, h] of Object.entries(d.html)) if (ck[0] === '~' && originOf(ck) === k) { const t = document.createElement('template'); t.innerHTML = h; const e = t.content.firstElementChild; if (e) return openTag(e); } return null; };
714
+ const renamable = (x) => !!x.was && !!x.now && x.was[0] === '@' && !live.has(x.was) && !known.has(x.now)
715
+ && (() => { const t = copyTag(x.was), el = live.get(x.now); return !t || !el || t === openTag(el); })();
716
+ if (same && changed.every(x => renamable(x) && !shift.has(x.was))) {
717
+ const m = renames.get(region) || new Map();
718
+ changed.forEach(x => m.set(x.was, x.now));
719
+ if (renamedParent && renamedParent !== parent) m.set(parent, renamedParent);
720
+ renames.set(region, m);
721
+ } else broken.set(region, [...(broken.get(region) || []), { container: parent, changed: changed.map(x => ({ was: x.was && tagOf(x.was) && x.was.slice(x.was.indexOf('@', 1) + 1), now: x.now && x.now.slice(x.now.indexOf('@', 1) + 1) })) }]);
722
+ });
723
+ // Renamed children keep their place in every structural edit (their own text edits stay conflicts)
724
+ renames.forEach((m, region) => {
725
+ if (broken.has(region)) return;
726
+ const ren = (k) => typeof k !== 'string' ? k : m.has(k) ? m.get(k) : k[0] === '~' && m.has(originOf(k)) ? k.slice(0, k.length - originOf(k).length) + m.get(originOf(k)) : k;
727
+ const arr = (a) => Array.isArray(a) ? a.map(ren) : a;
728
+ const enc = (v) => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;'), dec = (v) => v.replace(/&quot;/g, '"').replace(/&amp;/g, '&');
729
+ const markup = (h) => typeof h === 'string' ? h.replace(/data-edit-key="([^"]*)"/g, (all, v) => `data-edit-key="${enc(ren(dec(v)))}"`) : h;
730
+ // A copy with no text of its own follows its source, as the writer copies it: shown = published
731
+ const typedCopy = (k) => log.some(d => d.kind === 'st-node' && d.path === k && d.prop === 'text');
732
+ const follow = (k, v) => {
733
+ if (k[0] !== '~' || !m.has(originOf(k)) || typedCopy(k)) return markup(v);
734
+ const src = live.get(m.get(originOf(k))); if (!src) return markup(v);
735
+ const t = document.createElement('template'); t.innerHTML = blockHTML(src, true);
736
+ const prefix = k.slice(0, k.length - originOf(k).length);
737
+ [t.content.firstElementChild, ...t.content.firstElementChild.querySelectorAll('[data-edit-key]')].forEach(n => { const nk = n.getAttribute('data-edit-key'); if (nk && nk[0] === '@') n.setAttribute('data-edit-key', prefix + nk); });
738
+ return t.innerHTML;
739
+ };
740
+ const keyed = (o) => o && typeof o === 'object' ? Object.fromEntries(Object.entries(o).map(([k, v]) => [ren(k), follow(k, v)])) : o;
741
+ log.forEach(d => {
742
+ if ((d.kind === 'st-order' || d.kind === 'st-children') && d.region === region) { d.parent = ren(d.parent); d.before = arr(d.before); d.order = arr(d.order); if (d.html) d.html = keyed(d.html); if (d.copies) d.copies = Object.fromEntries(Object.entries(d.copies).map(([k, v]) => [ren(k), v])); }
743
+ if (d.kind === 'st-node' && d.region === region && typeof d.path === 'string' && d.path[0] === '~') d.path = ren(d.path); // a copy is new content: its edits follow its source's new key
744
+ if (d.kind === 'st-move' && (d.from === region || d.to === region)) { d.key = ren(d.key); d.fromParent = ren(d.fromParent); d.toParent = ren(d.toParent); d.fromBefore = arr(d.fromBefore); d.toBefore = arr(d.toBefore); d.fromOrder = arr(d.fromOrder); d.toOrder = arr(d.toOrder); }
745
+ if (d.kind === 'lock' && d.region === region) d.key = ren(d.key);
746
+ });
747
+ ver++;
748
+ });
749
+ const structural = (d) => ((d.kind === 'st-order' || d.kind === 'st-children') && broken.has(d.region)) || (d.kind === 'st-move' && (broken.has(d.from) || broken.has(d.to)))
750
+ || (d.kind === 'st-node' && broken.has(d.region) && typeof d.path === 'string' && d.path[0] === '~');
751
+ const gone = (d) => (d.kind === 'st-node' && typeof d.path === 'string' && d.path[0] === '@' && isStatic(d.region) && !live.has(d.path)) || structural(d);
752
+ log.forEach((d, i) => { if (gone(d) && i < cursor && d.kind === 'st-node' && d.path[0] === '@') lost.push(d); });
753
+ if (!log.some(gone)) { if (renames.size) saveState(); return []; }
754
+ const latest = new Map();
755
+ lost.forEach(d => latest.set(d.path + '|' + d.prop, d));
756
+ const label = (k) => typeof k === 'string' ? originOf(k).slice(originOf(k).indexOf('@', 1) + 1) : '';
757
+ const found = [...latest.values()].map(d => ({ region: d.region, key: d.path, element: label(d.path), prop: d.prop, value: d.value, before: d.before,
758
+ reason: `${label(d.path)} changed in the source, so the ${d.prop === 'text' ? 'text' : d.prop} edited into it was not applied` }));
759
+ // A dropped structure carries what it held: the children that changed, the elements it moved,
760
+ // and every copy it made, with the copy's markup and the text typed into it
761
+ broken.forEach((containers, region) => {
762
+ const deltas = log.filter((d, i) => i < cursor && structural(d) && (d.region === region || d.from === region || d.to === region));
763
+ const movedKeys = new Set(), copies = [];
764
+ deltas.forEach(d => {
765
+ if (d.kind === 'st-move') movedKeys.add(d.key);
766
+ if (d.kind === 'st-order' || d.kind === 'st-children') moved(region, d.parent || '').forEach(k => movedKeys.add(k));
767
+ if (d.kind === 'st-children' && d.html) Object.entries(d.html).forEach(([k, h]) => {
768
+ if (k[0] !== '~') return;
769
+ const t = document.createElement('template'); t.innerHTML = h;
770
+ const typed = log.filter((x, i) => i < cursor && x.kind === 'st-node' && x.path === k && x.prop === 'text').pop();
771
+ copies.push({ of: label(k), html: h, text: typed ? typed.value : t.content.textContent.trim() });
772
+ });
773
+ });
774
+ const changed = containers.flatMap(c => c.changed);
775
+ const names = [...new Set(changed.map(x => x.was || x.now))];
776
+ const moves = [...new Set([...movedKeys].filter(k => k && k[0] === '@').map(label))];
777
+ const what = [moves.length && `moved ${moves.join(', ')}`, copies.length && `copied ${copies.map(c => `${c.of} ("${String(c.text).slice(0, 40)}")`).join(', ')}`].filter(Boolean).join('; ');
778
+ found.push({ region, key: '', element: containers.map(c => c.container ? label(c.container) : region).join(', '), prop: 'structure',
779
+ value: { changed, moved: moves, copies }, before: null,
780
+ reason: `${names.join(', ')} changed in the source where the layout was edited, so the layout edits (${what || 'reordering'}) were not applied` });
781
+ });
782
+ found.forEach(f => { conflicts.push(f); if (persistOf(f.region)) keptConflicts.add(f); });
783
+ let c = 0;
784
+ log = log.filter((d, i) => { const keep = !gone(d); if (keep && i < cursor) c++; return keep; });
785
+ cursor = c; ver++;
786
+ saveState(); refresh();
787
+ if (found.length) {
788
+ if (estore) estore.conflicts = conflicts.slice();
789
+ console.warn('[edit] edits to elements that changed in the source were dropped:\n' + found.map(f => ' ' + f.reason + (f.prop === 'structure' ? '' : `: "${String(f.value).slice(0, 80)}"`)).join('\n'));
790
+ document.dispatchEvent(new CustomEvent('edit:conflict', { detail: { conflicts: found } }));
791
+ }
792
+ return found;
793
+ }
336
794
 
795
+ // One pass over every static area: orders place elements by identity (moving them
796
+ // between areas when the log says so), then props are applied.
337
797
  function applyStaticState() {
338
- const { node, order, sigs, base, html } = staticState();
339
- document.querySelectorAll('[data-edit-area]').forEach(area => {
340
- if (!area._edit || classify(area) !== 'static') return;
341
- const region = key(area);
342
- [area, ...area.querySelectorAll('[data-edit-key]')].forEach(el => {
343
- const p = el.getAttribute('data-edit-key'), bk = region + '|' + p;
344
- const ops = (node[region] || {})[p], baseline = base[bk];
345
- if (!ops && !baseline) return;
346
- const expect = sigs[bk]; if (expect && nodeSig(el) !== expect) { console.warn('[edit] skip stale static node', region, p); return; }
347
- const eff = (prop) => ops && ops[prop] !== undefined ? ops[prop] : baseline && baseline[prop];
348
- const setAttr = (name, v) => { if (v === undefined) return; if (v === '') el.removeAttribute(name); else if (el.getAttribute(name) !== v) el.setAttribute(name, v); };
349
- const text = eff('text'); if (text !== undefined) { const safe = sanitizeFor(el, text); if (el.innerHTML !== safe) el.innerHTML = safe; }
350
- setAttr('class', eff('class'));
351
- setAttr('style', eff('style'));
352
- });
353
- const want = order[region] || area._baseOrder;
354
- if (want) {
355
- const by = {}, kids = sortableChildren(area), keys = staticKeys(area);
356
- kids.forEach((el, i) => { by[keys[i]] = el; });
357
- const markup = html[region] || {};
358
- const next = want.map(kk => by[kk] || materialize(markup[kk], kk)).filter(Boolean);
359
- // Reinsert at the managed range — never scramble real children past plugin handles.
360
- const anchor = kids.length ? kids[kids.length - 1].nextSibling : Array.from(area.children).find(pluginNode) || null;
361
- kids.forEach(el => { if (!next.includes(el)) el.remove(); });
362
- next.forEach(el => area.insertBefore(el, anchor));
798
+ const { node, order, orderBase, sigs, base, html } = staticState();
799
+ const roots = staticAreas();
800
+ const byId = keyMap(roots);
801
+ const entries = [];
802
+ new Set([...Object.keys(orderBase), ...Object.keys(order)]).forEach(region => {
803
+ const want = Object.assign({}, orderBase[region] || {}, order[region] || {});
804
+ Object.entries(want).forEach(([parent, keys]) => entries.push([region, parent, keys]));
805
+ });
806
+ const claimed = new Set();
807
+ entries.forEach(([, , keys]) => keys.forEach(k => claimed.add(k)));
808
+ const touchedAreas = new Set();
809
+ const get = (k) => {
810
+ let el = byId.get(k);
811
+ if (!el && detached.has(k)) { el = detached.get(k); detached.delete(k); byId.set(k, el); }
812
+ if (!el && html[k]) {
813
+ el = materialize(html[k], k);
814
+ if (el) { byId.set(k, el); el.querySelectorAll('[data-edit-key]').forEach(n => { const nk = n.getAttribute('data-edit-key'); if (!byId.has(nk)) byId.set(nk, n); }); }
363
815
  }
816
+ return el;
817
+ };
818
+ let progress = true;
819
+ while (entries.length && progress) {
820
+ progress = false;
821
+ for (let i = 0; i < entries.length; i++) {
822
+ const [region, parent, keys] = entries[i];
823
+ const c = parent ? byId.get(parent) : areaByKey(region);
824
+ if (!c || !c.isConnected) continue;
825
+ entries.splice(i--, 1); progress = true;
826
+ let kids = sortableChildren(c);
827
+ const next = keys.map(get).filter(el => el && el !== c && !el.contains(c));
828
+ const stay = kids.filter(el => !next.includes(el) && !claimed.has(idOf(el)) && !html[idOf(el)]);
829
+ // A component host's other roots go with its first
830
+ const target = [...next, ...stay].flatMap(groupOf);
831
+ kids = kids.flatMap(groupOf);
832
+ if (target.length === kids.length && target.every((el, j) => el === kids[j])) continue;
833
+ const anchor = kids.length ? kids[kids.length - 1].nextSibling : Array.from(c.children).find(pluginNode) || null;
834
+ const leads = new Map(target.map(el => [el, el.isConnected ? leadNodes(el) : el._editLead || []]));
835
+ kids.forEach(el => { if (target.includes(el)) return; el._editLead = leadNodes(el); el._editLead.forEach(n => n.remove()); el.remove(); const k = idOf(el); if (k) detached.set(k, el); });
836
+ target.forEach(el => { const from = regionOf(el); if (from) touchedAreas.add(from); c.insertBefore(el, anchor && anchor.parentNode === c ? anchor : null); const l = leads.get(el); if (l.length) el.before(...l); });
837
+ const a = regionOf(c); if (a) touchedAreas.add(a);
838
+ }
839
+ }
840
+ if (touchedAreas.size) resetAreas();
841
+ const live = touchedAreas.size ? keyMap(staticAreas()) : byId;
842
+ live.forEach((el, p) => {
843
+ const ops = node[p], baseline = base[p];
844
+ if (!ops && !baseline) return;
845
+ const expect = sigs[p]; if (expect && nodeSig(el) !== expect) { console.warn('[edit] skip stale static node', p); return; }
846
+ const props = new Set([...Object.keys(ops || {}), ...Object.keys(baseline || {})]);
847
+ props.forEach(prop => applyStaticProp(el, prop, ops && ops[prop] !== undefined ? ops[prop] : baseline && baseline[prop]));
364
848
  });
849
+ touchedAreas.forEach(a => { if (!a.isConnected) return; markStatic(a); if (a._armed && isActive(a)) armArea(a); });
365
850
  }
366
- function commitStaticNode(area, el, prop, value) {
851
+
852
+ // One delta per committed change. `opts.before` overrides the captured baseline;
853
+ // `opts.coalesce` folds a quick follow-up change to the same prop into the last delta.
854
+ function commitStaticNode(area, el, prop, value, opts = {}) {
367
855
  const region = key(area), path = el.getAttribute('data-edit-key');
368
- if (!path) return; // not an addressable node
856
+ if (!path || path[0] === '!') return false;
857
+ if (prop === 'text' && RAW_EDIT.has(el.tagName)) return false;
369
858
  if (prop === 'text') value = sanitizeFor(el, value);
370
- // _pre* comes from the focus/open listener; when an edit arrives without
371
- // one (programmatic focus, scripted writes), the arm-time baseline keeps
372
- // `before` truthful so undo can still reach the original.
373
- const fallback = prop === 'text' ? area._baseText && area._baseText[path] : prop === 'class' ? area._baseClass && area._baseClass[path] : area._baseStyle && area._baseStyle[path];
374
- const pre = prop === 'text' ? el._preEdit : prop === 'class' ? el._preClass : el._preStyle;
375
- const before = pre !== undefined ? pre : fallback;
376
- if (before === value) return;
377
- log.splice(cursor); log.push({ kind: 'st-node', region, path, prop, value, before, sig: nodeSig(el) }); cursor = log.length; saveState(); refresh();
378
- }
379
- /* ---- Data VALUE editing (opt-in .values): edit $x record fields. Not an HTML edit —
380
- it mutates the data source. A-side: mutate the in-memory $x array (reactive).
381
- B-side: local file cell write (dev server); cloud → $x.<source>.$update(id,{field}). ---- */
859
+ let before = opts.before;
860
+ if (before === undefined) {
861
+ const fallback = prop === 'text' ? area._baseText && area._baseText[path] : prop === 'class' ? area._baseClass && area._baseClass[path] : prop === 'style' ? area._baseStyle && area._baseStyle[path] : el.getAttribute(prop);
862
+ const pre = prop === 'text' ? el._preEdit : prop === 'class' ? el._preClass : prop === 'style' ? el._preStyle : undefined;
863
+ before = pre !== undefined ? pre : fallback;
864
+ }
865
+ if (before === value) return false;
866
+ if (prop === 'text' && document.activeElement !== el && el.innerHTML.trim() !== value) el.innerHTML = value;
867
+ const last = log[cursor - 1];
868
+ if (opts.coalesce && last && cursor === log.length && last.kind === 'st-node' && last.region === region && last.path === path && last.prop === prop && Date.now() - (stamps.get(last) || 0) < COALESCE_MS) {
869
+ if (last.before === value) { log.pop(); cursor--; } else { last.value = value; stamps.set(last, Date.now()); }
870
+ ver++; saveState(); refresh();
871
+ return true;
872
+ }
873
+ const d = pushDelta(Object.assign({ kind: 'st-node', region, path, prop, value, before, sig: nodeSig(el) }, opts.join ? { join: true } : {}));
874
+ if (opts.coalesce) stamps.set(d, Date.now());
875
+ return true;
876
+ }
877
+
878
+ /* ---- Data values (.data): edit $x record fields ---- */
382
879
  function dataValueState() {
383
880
  const s = {};
384
881
  for (let i = 0; i < cursor; i++) { const d = log[i]; if (d.kind === 'data-val') ((s[d.source] = s[d.source] || {})[d.id] = s[d.source][d.id] || {})[d.field] = d.value; }
385
882
  return s;
386
883
  }
884
+ // Every field the log touches: its value at the cursor, else the first delta's before (an undo back to the start).
387
885
  function applyDataValues() {
388
- const st = dataValueState();
886
+ const st = dataValueState(), first = new Map();
887
+ log.forEach(d => { if (d.kind === 'data-val') { const k = JSON.stringify([d.source, d.id, d.field]); if (!first.has(k)) first.set(k, d); } });
888
+ first.forEach(d => {
889
+ const r = (st[d.source] = st[d.source] || {})[d.id] = st[d.source][d.id] || {};
890
+ if (!(d.field in r) && d.before !== undefined) r[d.field] = d.before;
891
+ });
389
892
  areas().forEach(area => {
390
893
  if (classify(area) !== 'data') return;
391
894
  const source = dataSourceName(area), recs = st[source]; if (!recs) return;
392
- const tpl = area.querySelector('template[x-for]'), expr = dataSourceExpr(area);
393
- try { const arr = window.Alpine.evaluate(tpl, expr); if (Array.isArray(arr)) Object.entries(recs).forEach(([id, fields]) => { const rec = arr.find(r => String(r.id) === String(id)); if (rec) Object.entries(fields).forEach(([f, v]) => { if (rec[f] !== v) rec[f] = v; }); }); } catch {}
394
- if (isActive(area)) setTimeout(() => armDataValues(area), 0); // re-arm clones Alpine re-rendered
895
+ const tpl = listTpl(area), expr = dataSourceExpr(area);
896
+ try { const arr = window.Alpine.evaluate(tpl, expr); if (Array.isArray(arr)) Object.entries(recs).forEach(([id, fields]) => { const rec = arr.find(r => String(r.id) === String(id)); if (rec) Object.entries(fields).forEach(([f, v]) => { if (rec[f] !== v) rec[f] = v; }); }); } catch { }
897
+ if (isActive(area)) setTimeout(() => armDataValues(area), 0);
395
898
  });
396
899
  }
397
900
  function commitDataValue(area, source, id, field, value, el) {
398
901
  value = String(value);
399
- if (el && el.hasAttribute('data-edit-rich')) value = sanitizeFor(el, value);
902
+ if (el && el._editRich) value = sanitizeFor(el, value);
400
903
  const before = el ? el._preEdit : undefined;
401
- if (before === value) return;
402
- log.splice(cursor); log.push({ kind: 'data-val', source, id, field, value, before }); cursor = log.length; saveState();
403
- applyDataValues(); refresh();
404
- }
405
-
406
- // size/move write an element's inline style → static: per-node style op; data: n/a.
407
- // Size/move land here. The data regime has nowhere to put per-row geometry —
408
- // the data source holds values, not layout — so it stays transient there.
409
- function commitStyle(area, el) {
410
- const v = el.getAttribute('style') || '', kind = classify(area);
411
- if (kind === 'component') commitComponentNode(area, el, 'style', v);
412
- else if (kind === 'static') commitStaticNode(area, el, 'style', v);
413
- }
414
- function commitStaticOrder(area) {
415
- const region = key(area), order = staticKeys(area), before = lastOrder[region] || order;
416
- if (eq(before, order)) return;
417
- log.splice(cursor); log.push({ kind: 'st-order', region, order, before }); cursor = log.length; lastOrder[region] = order; saveState(); refresh();
418
- }
419
- // Positional re-key of a region: give every element the key a FRESH session would
420
- // derive (markStatic document order, ordinals by position among same-base keys).
421
- // That is what keeps client, server, and reload addressing the same elements after
422
- // a move shifts membership. Returns the renames as [oldKey, newKey] pairs.
423
- function rekeyRegion(area) {
424
- const seen = Object.create(null), changes = [];
425
- for (const el of [area, ...area.querySelectorAll('*')]) {
426
- if (el.hasAttribute('data-edit-handle') || el.hasAttribute('data-edit-ghost')) continue;
427
- if (el.parentElement && el.parentElement.closest('[data-text-edit]')) continue;
428
- const cur = el.getAttribute('data-edit-key');
429
- const base = cur ? ((/^([\s\S]*)#\d+$/.exec(cur) || [, cur])[1]) : staticKey(el); // ordinal = last #digits suffix
430
- const n = seen[base] = (seen[base] || 0) + 1;
431
- const k = n > 1 ? base + '#' + n : base;
432
- if (cur !== k) { el.setAttribute('data-edit-key', k); if (cur) changes.push([cur, k]); }
904
+ if (before === value) return false;
905
+ pushDelta({ kind: 'data-val', region: key(area), source, id, field, value, before });
906
+ applyDataValues();
907
+ return true;
908
+ }
909
+
910
+ // Size and freeform placement write inline style; plugin-applied declarations stay out.
911
+ function commitStyle(area, el, opts) {
912
+ const v = ownStyle(el), kind = classify(area);
913
+ if (kind === 'component') return commitComponentNode(area, el, 'style', v);
914
+ if (kind === 'static') return commitStaticNode(area, el, 'style', v, opts);
915
+ return false;
916
+ }
917
+ function commitOrder(area, container, before) {
918
+ const order = staticKeys(container);
919
+ if (eq(before, order)) return false;
920
+ pushDelta({ kind: 'st-order', region: key(area), parent: parentKey(container, area), order, before });
921
+ return true;
922
+ }
923
+ // A piece that already sits in its new container. Identity never changes, so one
924
+ // delta names both containers' orders, within an area or across areas.
925
+ function commitMove(fromArea, toArea, item, fromContainer, fromBefore, toBefore, opts = {}) {
926
+ const toContainer = item.parentElement;
927
+ const d = { kind: 'st-move', from: key(fromArea), to: key(toArea), fromParent: parentKey(fromContainer, fromArea), toParent: parentKey(toContainer, toArea), key: idOf(item), fromOrder: staticKeys(fromContainer), toOrder: staticKeys(toContainer), fromBefore, toBefore };
928
+ if (opts.join) d.join = true;
929
+ pushDelta(d);
930
+ resetAreas();
931
+ markStatic(toArea);
932
+ if (isActive(fromArea)) armArea(fromArea);
933
+ if (toArea !== fromArea && isActive(toArea)) armArea(toArea);
934
+ return d;
935
+ }
936
+
937
+ /* ---- Locks: runtime per-action locks, logged ---- */
938
+ // Static identities are global (they survive moves between areas); others are per area.
939
+ const lockSlot = (region, id) => /^[~@]/.test(id) ? id : region + '|' + id;
940
+ let lockVer = -1, lockMap = new Map();
941
+ function lockState() {
942
+ if (lockVer === ver) return lockMap;
943
+ lockMap = new Map();
944
+ for (let i = 0; i < cursor; i++) {
945
+ const d = log[i]; if (d.kind !== 'lock') continue;
946
+ const slot = lockSlot(d.region, d.key);
947
+ const m = lockMap.get(slot) || new Map(); lockMap.set(slot, m);
948
+ (d.actions && d.actions.length ? d.actions : LOCK_ACTIONS).forEach(a => m.set(a, d.value));
433
949
  }
434
- return changes;
435
- }
436
- // Replay/undo/redo of a move's key renames. Resolve-then-set so swapped ordinals
437
- // never collide mid-application; the moved element itself (region change) is
438
- // handled by applyStaticState's materialization, not here.
439
- function applyMoveKeys(d, invert) {
440
- if (!d.remap) return;
441
- const jobs = [];
442
- for (const [r, o, r2, n] of d.remap) {
443
- if (r !== r2) continue;
444
- const a = areaByKey(r); if (!a) continue;
445
- const findKey = invert ? n : o, setKey = invert ? o : n;
446
- const el = [a, ...a.querySelectorAll('[data-edit-key]')].find(e => e.getAttribute('data-edit-key') === findKey);
447
- if (el) jobs.push([el, setKey]);
950
+ lockVer = ver;
951
+ return lockMap;
952
+ }
953
+ const lockStateNow = () => lockState();
954
+ function identity(el, area) {
955
+ const kind = classify(area);
956
+ if (kind === 'static') return el.getAttribute('data-edit-key');
957
+ if (el === area) return '';
958
+ if (kind === 'component') return el._editPath != null ? 'path:' + el._editPath : null;
959
+ const list = listTpl(area);
960
+ if (list && el.parentElement === list.parentElement) { const k = itemKey(el, area); return k == null ? null : 'row:' + k; }
961
+ return null;
962
+ }
963
+ const actionOf = (cap) => cap === 'data' ? 'text' : cap === 'class' ? 'attrs' : cap;
964
+ // A component host's lock is keyed by its host key and covers its group and what is in it.
965
+ const hostOf = (n) => isHostEl(n) ? n : extraRoot(n) ? n._manifestHost.roots[0] : null;
966
+ function lockedFor(node, cap) {
967
+ const action = actionOf(cap);
968
+ const h0 = node && node.nodeType === 1 && hostOf(node);
969
+ if (h0) node = h0;
970
+ let region = h0 ? blockArea(h0) : regionOf(node);
971
+ const locks = lockState();
972
+ for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
973
+ let v;
974
+ const h = hostOf(n);
975
+ if (h && locks.size) { const m = locks.get(h.getAttribute('data-edit-host')); if (m && m.has(action)) v = m.get(action); }
976
+ if (v === undefined && region && locks.size && !(h && n === h0)) { const id = identity(n, region); const m = id != null && locks.get(lockSlot(key(region), id)); if (m && m.has(action)) v = m.get(action); }
977
+ if (v === undefined && n._editLock) v = n._editLock.has('*') || n._editLock.has(action);
978
+ if (v) return true;
979
+ if (n === region) { if (h && n !== h0) { region = blockArea(n); if (region) continue; } break; }
448
980
  }
449
- jobs.forEach(([el, k]) => el.setAttribute('data-edit-key', k));
450
- }
451
- // Cross-region move (static → static): one delta carries both region orders, so a
452
- // single undo puts the block back. The item must already sit in its new parent.
453
- // Both regions are re-keyed positionally and every rename travels on the delta
454
- // (remap), so earlier deltas on the moved block — and on ordinal-shifted twins —
455
- // keep addressing the right elements.
456
- function commitMove(fromArea, toArea, item) {
457
- const from = key(fromArea), to = key(toArea), fromKey = item.getAttribute('data-edit-key') || staticKey(item);
458
- const descOld = [...item.querySelectorAll('[data-edit-key]')].map(n => [n, n.getAttribute('data-edit-key')]);
459
- item.removeAttribute('data-edit-key');
460
- item.querySelectorAll('[data-edit-key]').forEach(n => n.removeAttribute('data-edit-key')); // arrival re-keys in the new region
461
- const fromPairs = rekeyRegion(fromArea).map(([o, n]) => [from, o, from, n]);
462
- const toPairs = rekeyRegion(toArea).map(([o, n]) => [to, o, to, n]);
463
- const toKey = item.getAttribute('data-edit-key');
464
- const remap = [[from, fromKey, to, toKey]];
465
- descOld.forEach(([n, o]) => { const nk = n.getAttribute('data-edit-key'); if (nk) remap.push([from, o, to, nk]); });
466
- remap.push(...fromPairs, ...toPairs);
467
- const html = blockHTML(item);
468
- log.splice(cursor);
469
- log.push({ kind: 'st-move', from, to, key: fromKey, toKey, fromOrder: staticKeys(fromArea), toOrder: staticKeys(toArea), html, remap });
470
- cursor = log.length;
471
- lastOrder[from] = staticKeys(fromArea); lastOrder[to] = staticKeys(toArea);
472
- markStatic(fromArea); armArea(toArea); // paths shift on both sides; the arrival needs baselines + affordances
473
- saveState(); refresh();
981
+ return false;
474
982
  }
983
+ function setLock(el, action, value) {
984
+ if (!el || el.nodeType !== 1) return false;
985
+ const h = hostOf(el);
986
+ const area = h ? blockArea(h) : regionOf(el); if (!area) return false;
987
+ if (action != null && !LOCK_ACTIONS.includes(action)) { console.warn('[edit] unknown lock action:', action); return false; }
988
+ if (h) { pushDelta({ kind: 'lock', region: key(area), key: h.getAttribute('data-edit-host'), actions: action ? [action] : [], value: !!value }); rearm(area); if (h._edit) rearm(h); return true; }
989
+ const id = identity(el, area);
990
+ if (id == null) { console.warn('[edit] this element has no stable identity to lock'); return false; }
991
+ pushDelta({ kind: 'lock', region: key(area), key: id, actions: action ? [action] : [], value: !!value });
992
+ rearm(area);
993
+ return true;
994
+ }
995
+ function rearm(area) { if (area && area._armed) { disarmArea(area); if (isActive(area)) armArea(area); } }
475
996
 
476
- /* ---- Commit / undo / redo (data area snapshots) ---- */
997
+ /* ---- Data area snapshots ---- */
477
998
  function commit(area) {
478
999
  if (!area) return;
479
1000
  const k = key(area), kind = classify(area), after = snapshot(area, kind), before = lastSnap[k];
480
1001
  if (before && eq(before, after)) return;
481
- log.splice(cursor); log.push({ region: k, kind, before: before ?? after, after });
482
- cursor = log.length; lastSnap[k] = after; saveState(); refresh();
1002
+ pushDelta({ region: k, kind, before: before ?? after, after });
1003
+ lastSnap[k] = after;
1004
+ }
1005
+ const dispatchApply = (d) => {
1006
+ if (d.kind === 'cmp-main' || d.kind === 'cmp-inst') applyComponentState();
1007
+ else if (d.kind === 'data-val') applyDataValues();
1008
+ else if (d.kind === 'theme') applyThemeState();
1009
+ else if (d.kind === 'lock') rearm(areaByKey(d.region));
1010
+ else applyStaticState();
1011
+ };
1012
+ // Returns a promise only when a data snapshot must settle, so undo stays synchronous otherwise.
1013
+ function applyShow(d, back) {
1014
+ const area = areaByKey(d.region); if (!area) return;
1015
+ const el = d.key === '' ? area : [area, ...area.querySelectorAll('[x-show]')].find(n => identity(n, area) === d.key);
1016
+ if (!el) return;
1017
+ try { setShow(el, d.expr, back ? d.before : d.value); } catch { }
483
1018
  }
484
- const dispatchApply = (d) => { if (d.kind === 'cmp-main' || d.kind === 'cmp-inst') applyComponentState(); else if (d.kind === 'data-val') applyDataValues(); else if (d.kind === 'theme') applyThemeState(); else applyStaticState(); };
485
- async function undo() {
486
- if (cursor === 0) return; const d = log[--cursor];
487
- if (d.kind === 'st-move') applyMoveKeys(d, true); // ordinal renames roll back before state re-applies
488
- if (d.kind === 'data-splice') applySplice(d, true);
489
- else if (d.kind === 'data') { await applySnap(areaByKey(d.region), d.kind, d.before); lastSnap[d.region] = d.before; }
1019
+ function step(d, back) {
1020
+ if (d.kind === 'show') { applyShow(d, back); return null; }
1021
+ if (d.kind === 'data-splice') applySplice(d, back);
1022
+ else if (d.kind === 'data') { const snap = back ? d.before : d.after; lastSnap[d.region] = snap; return applySnap(areaByKey(d.region), d.kind, snap); }
490
1023
  else dispatchApply(d);
1024
+ return null;
1025
+ }
1026
+ async function undo() {
1027
+ if (cursor === 0) return;
1028
+ let d;
1029
+ do { d = log[--cursor]; ver++; const p = step(d, true); if (p) await p; } while (d.join && cursor > 0);
491
1030
  rebaseFocused(); saveState(); refresh();
492
1031
  }
493
1032
  async function redo() {
494
- if (cursor >= log.length) return; const d = log[cursor++];
495
- if (d.kind === 'st-move') applyMoveKeys(d, false);
496
- if (d.kind === 'data-splice') applySplice(d, false);
497
- else if (d.kind === 'data') { await applySnap(areaByKey(d.region), d.kind, d.after); lastSnap[d.region] = d.after; }
498
- else dispatchApply(d);
1033
+ if (cursor >= log.length) return;
1034
+ do { const d = log[cursor++]; ver++; const p = step(d, false); if (p) await p; } while (cursor < log.length && log[cursor].join);
499
1035
  rebaseFocused(); saveState(); refresh();
500
1036
  }
501
1037
 
502
- /* ---- Projections (static/data); component projections come from componentState() ---- */
503
- function fold() { const s = {}; for (let i = 0; i < cursor; i++) { const d = log[i]; if (d.kind === 'static' || d.kind === 'data') s[d.region] = { kind: d.kind, snap: d.after }; } return s; }
504
- function patchFor(r, kind, snap) {
505
- if (kind === 'static') return { region: r, kind, op: 'writeHTML', target: `[x-edit="${r}"]`, html: snap.html, style: snap.style, note: 'static/style/size/move fold into the source HTML.' };
506
- return { region: r, kind, op: 'reorderData', source: snap.source, order: snap.order, note: `data mutation: $x.${snap.source}.$update(id,{order}).` };
507
- }
1038
+ /* ---- Projections ---- */
1039
+ function fold() { const s = {}; for (let i = 0; i < cursor; i++) { const d = log[i]; if (d.kind === 'data') s[d.region] = { kind: d.kind, snap: d.after }; } return s; }
1040
+ const patchFor = (r, kind, snap) => ({ region: r, kind, op: 'reorderData', source: snap.source, order: snap.order });
508
1041
 
509
1042
 
510
- /* ---- Block operations: copy, cut, paste, duplicate, delete ----
511
- Structural edits on a sortable child. The plugin performs them and logs them;
512
- it renders no menu, because what a context menu looks like belongs to the
513
- project. What it offers instead is enough to build one: an `edit:context`
514
- event carrying the block and where the pointer was, $edit.target for the block
515
- in play, and $edit.can(op) so a menu can disable what would not apply. */
516
- let clipboard = null; // { html, source } — the plugin's own, not the OS clipboard
517
- let target = null; // the block a menu is acting on
1043
+ /* ---- Pieces and block operations ---- */
1044
+ let clipboard = null;
1045
+ let target = null;
518
1046
 
519
- // A menu binds :disabled to can(), so what can() depends on has to be trackable.
520
- // The target and the clipboard are plain values, so changes to them are announced
521
- // through a counter on the store, which is reactive.
522
- const bump = () => { if (estore) estore.targetVersion = (estore.targetVersion || 0) + 1; };
1047
+ const bump = () => { if (estore) estore.version = (estore.version || 0) + 1; };
523
1048
  const setTarget = (el) => { target = el; bump(); };
524
1049
 
525
- // The addressable block for a node: the sortable child that contains it, or —
526
- // where the region is not sortable at all — the outermost element inside the area
527
- // that contains it. Deleting a heading should not require the region to have been
528
- // declared reorderable.
529
- function blockOf(node) {
530
- const area = node && node.closest && node.closest('[data-edit-area]');
531
- if (!area) return null;
532
- const sortable = node.closest('[data-edit-sortable]');
533
- if (sortable && sortable.closest('[data-edit-area]') === area) return sortable;
534
- let el = node;
535
- while (el && el.parentElement && el.parentElement !== area) el = el.parentElement;
536
- return el && el.parentElement === area ? el : null;
537
- }
538
-
539
- // The region a block BELONGS TO — from the parent, so a block that is itself an
540
- // x-edit region resolves to its outer region, never to itself.
541
- const blockArea = (el) => el && el.parentElement && el.parentElement.closest('[data-edit-area]');
542
- // A block that is itself an x-edit region owns its own delta addressing; moving
543
- // it across regions is refused (reordering it within its parent still works).
1050
+ // The innermost structural piece holding a node: a child of a container (static),
1051
+ // a row of the list (data), or a path-addressed node (component).
1052
+ function pieceOf(node) {
1053
+ if (node && node.nodeType !== 1) node = node.parentElement;
1054
+ const area = regionOf(node);
1055
+ // A component host is one piece of the area around it, whatever inside it was hit.
1056
+ if (isHostEl(node) && blockArea(node) && classify(blockArea(node)) === 'static') return node;
1057
+ if (!area || node === area) return null;
1058
+ const kind = classify(area);
1059
+ if (kind === 'data') {
1060
+ const tpl = listTpl(area), list = tpl && tpl.parentElement;
1061
+ for (let n = node; n && n !== area; n = n.parentElement) if (n.parentElement === list) return n;
1062
+ return null;
1063
+ }
1064
+ if (kind === 'component') {
1065
+ for (let n = node; n && n !== area; n = n.parentElement) if (n._editPath != null) return n;
1066
+ return null;
1067
+ }
1068
+ let host = null;
1069
+ for (let n = node; n && n !== area; n = n.parentElement) if (isHostEl(n) || extraRoot(n)) host = isHostEl(n) ? n : n._manifestHost.roots[0];
1070
+ if (host) return host;
1071
+ for (let n = node; n && n !== area; n = n.parentElement) {
1072
+ if (pluginNode(n)) return null;
1073
+ if (isContainer(n.parentElement)) return n;
1074
+ }
1075
+ return null;
1076
+ }
1077
+ const isPiece = (el) => !!el && el.nodeType === 1 && pieceOf(el) === el;
1078
+ const asPiece = (el) => isPiece(el) ? el : pieceOf(el);
1079
+ const blockArea = (el) => el && regionOf(el.parentElement);
544
1080
  const isRegionBlock = (el) => !!el && (!!el._edit || el.hasAttribute('data-edit-area'));
545
-
546
- // Markup without the plugin's own affordances, so a duplicate re-arms cleanly
547
- // rather than inheriting half-initialised state from its original.
548
- function blockHTML(el) {
1081
+ // A piece can be dragged when its container moves and nothing up to the region locks it.
1082
+ const movable = (el) => !!el && !!el.parentElement && !pluginNode(el) && !insideRendered(el) && capOf(el.parentElement, 'move') && !lockedFor(el, 'move') && classify(regionOf(el.parentElement)) !== 'component';
1083
+ function accepts(c) {
1084
+ if (!c || c.nodeType !== 1) return false;
1085
+ const area = regionOf(c);
1086
+ if (!area || !isActive(area) || !isContainer(c)) return false;
1087
+ for (let n = c; n && n !== area; n = n.parentElement) if (n._manifestComponent) return false; // a host's markup is another file's
1088
+ return capOf(c, 'move');
1089
+ }
1090
+
1091
+ // Markup without plugin affordances. `keepKeys` keeps identity so undo restores it.
1092
+ function blockHTML(el, keepKeys) {
549
1093
  const c = el.cloneNode(true);
550
- c.querySelectorAll('[data-edit-handle], [data-edit-live], [data-edit-ghost]').forEach(n => n.remove());
1094
+ const src = [el, ...el.querySelectorAll('*')], dst = [c, ...c.querySelectorAll('*')];
1095
+ dst.forEach((n, i) => { if (addedTab.has(src[i])) n.removeAttribute('tabindex'); });
1096
+ unrich(el, c);
1097
+ c.querySelectorAll('[data-edit-handle], [data-edit-live], [data-edit-ghost], [data-edit-grip]').forEach(n => n.remove());
551
1098
  [c, ...c.querySelectorAll('*')].forEach(n => {
552
- [...n.attributes].forEach(a => { if (a.name.startsWith('data-edit-')) n.removeAttribute(a.name); });
553
- ['contenteditable', 'draggable', 'tabindex', 'role', 'aria-label'].forEach(a => n.removeAttribute(a));
1099
+ [...n.attributes].forEach(a => { if (a.name.startsWith('data-edit-') && !(keepKeys && a.name === 'data-edit-key')) n.removeAttribute(a.name); });
1100
+ n.removeAttribute('contenteditable');
1101
+ n.removeAttribute('draggable');
554
1102
  });
555
1103
  return c.outerHTML;
556
1104
  }
557
1105
 
558
- /* ---- Data regime: the array is the truth, so operate on it ---- */
1106
+ /* ---- Data regime: the array is the truth ---- */
559
1107
  function dataOps(area) {
560
- const tpl = area.querySelector('template[x-for]'), expr = dataSourceExpr(area);
1108
+ const tpl = listTpl(area), expr = dataSourceExpr(area);
561
1109
  if (!tpl || !expr) return null;
562
- try {
563
- const arr = window.Alpine.evaluate(tpl, expr);
564
- return Array.isArray(arr) ? arr : null;
565
- } catch { return null; }
1110
+ try { const arr = window.Alpine.evaluate(tpl, expr); return Array.isArray(arr) ? arr : null; } catch { return null; }
566
1111
  }
567
-
568
1112
  const recordIndex = (area, el) => {
569
1113
  const arr = dataOps(area); if (!arr) return -1;
570
1114
  const k = itemKey(el, area);
571
1115
  return arr.findIndex(it => String(it && it.id != null ? it.id : it) === String(k));
572
1116
  };
573
-
574
- // A copy needs its own identity or the list has two rows claiming one key.
575
1117
  function freshId(arr, id) {
576
1118
  const taken = new Set(arr.map(it => String(it && it.id)));
577
1119
  if (!taken.has(String(id))) return String(id);
@@ -580,29 +1122,89 @@
580
1122
  return next;
581
1123
  }
582
1124
 
583
- /* ---- Operations ---- */
584
- function canDo(op, el) {
585
- const block = el || target;
586
- if (op === 'paste') return !!clipboard && !!(block ? blockArea(block) : areas().find(isActive));
587
- if (!block || locked(block)) return false;
588
- if (op === 'move' && isRegionBlock(block)) return false;
589
- const area = blockArea(block);
1125
+ /* ---- can() ---- */
1126
+ const ACTIONS = ['move', 'remove', 'duplicate', 'text', 'attrs', 'class', 'size', 'copy', 'cut', 'paste'];
1127
+ // Static areas take only static pieces (they carry source identity); a list takes any.
1128
+ const pasteFits = (area) => !!area && !!clipboard && (classify(area) !== 'static' || clipboard.kind === 'static');
1129
+ function pasteDest(el) {
1130
+ const block = el ? asPiece(el) : null;
1131
+ if (block) return accepts(block.parentElement) ? block.parentElement : null;
1132
+ const area = areas().find(a => isActive(a) && accepts(a));
1133
+ return area || null;
1134
+ }
1135
+ function canDo(action, el) {
1136
+ if (!ACTIONS.includes(action)) return false;
1137
+ if (action === 'paste') { const d = clipboard && pasteDest(el); return !!d && pasteFits(regionOf(d)); }
1138
+ if (!el || el.nodeType !== 1 || insideRendered(el)) return false;
1139
+ // A component host moves, duplicates and goes as a piece of the area around it
1140
+ if (isHostEl(el) && (action === 'move' || action === 'duplicate' || action === 'remove')) {
1141
+ const outer = blockArea(el);
1142
+ if (!outer || !isActive(outer)) return false;
1143
+ if (action === 'move') return movable(el);
1144
+ return capOf(el.parentElement, 'move') && !lockedFor(el, action);
1145
+ }
1146
+ const area = regionOf(el);
590
1147
  if (!area || !isActive(area)) return false;
591
- if (classify(area) === 'component') return false; // instances are overridden, not restructured
592
- return true;
1148
+ const kind = classify(area);
1149
+ if (action === 'size') return !!el._edit && el._edit.caps.has('size') && !lockedFor(el, 'size');
1150
+ // Text: an editable leaf (or a rich-editor host), never a container or a bound element.
1151
+ if (action === 'text') return capOf(el, 'text') && !el.hasAttribute('x-text') && !el.hasAttribute('x-html')
1152
+ && (el.getAttribute('contenteditable') === 'true' || !!el._pieceMode || el.hasAttribute('data-rich-text') || (el._editField != null && capOf(el, 'data')));
1153
+ // attrs: any attribute (static elements); class: the class list (static and component nodes).
1154
+ if ((action === 'attrs' || action === 'class') && kind === 'static' && el._manifestComponent) return false;
1155
+ if (action === 'attrs' || action === 'class') {
1156
+ if (kind === 'data' || lockedFor(el, 'attrs')) return false;
1157
+ const host = leafHost(el);
1158
+ if (host) return action === 'attrs' && capOf(host, 'text'); // inside text: through the leaf's text, never classes
1159
+ if (kind === 'component') return action === 'class' && el._editPath != null;
1160
+ const k = el.getAttribute('data-edit-key');
1161
+ return !!k && k[0] !== '!';
1162
+ }
1163
+ if (kind === 'component') return false;
1164
+ const block = asPiece(el);
1165
+ if (!block) return false;
1166
+ if (action === 'copy' || action === 'cut') return !isHostEl(block);
1167
+ if (action === 'move') return movable(block);
1168
+ const lockAs = action === 'duplicate' ? 'duplicate' : 'remove';
1169
+ return capOf(block.parentElement, 'move') && !lockedFor(block, lockAs);
593
1170
  }
594
1171
 
595
1172
  function copyBlock(el) {
596
- const block = el || target; if (!block) return false;
597
- const area = blockArea(block);
598
- if (!area) return false; // a whole region is a container, not a copyable block
599
- clipboard = { html: blockHTML(block), source: key(area) };
1173
+ const block = asPiece(el === undefined ? target : el); if (!block) return false;
1174
+ const area = blockArea(block); if (!area) return false;
1175
+ clipboard = { html: blockHTML(block, true), ops: subtreeOps(block), source: key(area), kind: classify(area) };
600
1176
  bump();
601
1177
  return true;
602
1178
  }
603
1179
 
1180
+ // The edits a subtree carries now, keyed by identity, for a copy to start from.
1181
+ function subtreeOps(el) {
1182
+ const ss = staticState(), ops = {};
1183
+ [el, ...el.querySelectorAll('[data-edit-key]')].forEach(n => { const k = n.getAttribute('data-edit-key'); if (k && ss.node[k]) ops[k] = Object.assign({}, ss.node[k]); });
1184
+ return ops;
1185
+ }
1186
+ // A copy gets fresh identities that still name the source element it copies.
1187
+ function stampCopy(made, ops) {
1188
+ const id = mint(), copies = {};
1189
+ [made, ...made.querySelectorAll('[data-edit-key]')].forEach(n => {
1190
+ const k = n.getAttribute('data-edit-key');
1191
+ if (!k || k[0] === '!') { n.setAttribute('data-edit-key', '!' + mint()); return; }
1192
+ const ck = '~' + id + '~' + originOf(k);
1193
+ n.setAttribute('data-edit-key', ck);
1194
+ if (ops && ops[k]) copies[ck] = Object.assign({}, ops[k]);
1195
+ });
1196
+ return copies;
1197
+ }
1198
+ function insertStatic(area, container, made, after, ops) {
1199
+ const before = staticKeys(container);
1200
+ const copies = stampCopy(made, ops);
1201
+ if (after) after.after(made); else container.appendChild(made);
1202
+ markStatic(area); armArea(area);
1203
+ commitStructure(area, container, before, { [made.getAttribute('data-edit-key')]: blockHTML(made, true) }, copies);
1204
+ }
1205
+
604
1206
  function duplicateBlock(el) {
605
- const block = el || target; if (!canDo('duplicate', block)) return false;
1207
+ const block = asPiece(el === undefined ? target : el); if (!canDo('duplicate', block)) return false;
606
1208
  const area = blockArea(block);
607
1209
  if (classify(area) === 'data') {
608
1210
  const arr = dataOps(area), i = recordIndex(area, block);
@@ -613,16 +1215,28 @@
613
1215
  commitSplice(area, i + 1, copy, 'insert');
614
1216
  return true;
615
1217
  }
616
- const made = materialize(blockHTML(block));
1218
+ if (isHostEl(block)) return duplicateHost(area, block);
1219
+ const made = materialize(blockHTML(block, true));
617
1220
  if (!made) return false;
618
- block.after(made);
619
- armArea(area); // the copy needs its own affordances
620
- commitStructure(area, { [staticKeys(area)[sortableChildren(area).indexOf(made)]]: blockHTML(made) });
1221
+ insertStatic(area, block.parentElement, made, block, subtreeOps(block));
1222
+ return true;
1223
+ }
1224
+ // A new instance of the component: its host copied in the source; on the page, its roots.
1225
+ function duplicateHost(area, block) {
1226
+ const container = block.parentElement, before = staticKeys(container), roots = groupOf(block);
1227
+ const made = roots.map(r => materialize(blockHTML(r)));
1228
+ if (made.some(m => !m)) return false;
1229
+ const host = { roots: made }, k = '~' + mint() + '~' + originOf(idOf(block));
1230
+ made.forEach(m => { m._manifestComponent = block._manifestComponent; m._manifestHost = host; m.removeAttribute('data-component'); });
1231
+ made[0].setAttribute('data-edit-host', k);
1232
+ roots[roots.length - 1].after(...made);
1233
+ markStatic(area); armArea(area);
1234
+ commitStructure(area, container, before, { [k]: made[0].outerHTML });
621
1235
  return true;
622
1236
  }
623
1237
 
624
1238
  function removeBlock(el) {
625
- const block = el || target; if (!canDo('remove', block)) return false;
1239
+ const block = asPiece(el === undefined ? target : el); if (!canDo('remove', block)) return false;
626
1240
  const area = blockArea(block);
627
1241
  if (classify(area) === 'data') {
628
1242
  const arr = dataOps(area), i = recordIndex(area, block);
@@ -630,40 +1244,56 @@
630
1244
  const gone = arr[i];
631
1245
  arr.splice(i, 1);
632
1246
  commitSplice(area, i, gone, 'remove');
1247
+ if (target === block) setTarget(null);
633
1248
  return true;
634
1249
  }
635
- const keys = staticKeys(area), gone = keys[sortableChildren(area).indexOf(block)];
636
- const markup = blockHTML(block);
1250
+ const container = block.parentElement, before = staticKeys(container);
1251
+ const gone = idOf(block), markup = isHostEl(block) ? block.outerHTML : blockHTML(block, true);
1252
+ block._editLead = leadNodes(block);
1253
+ block._editLead.forEach(n => n.remove());
1254
+ groupOf(block).slice(1).forEach(n => n.remove());
637
1255
  block.remove();
638
- if (target === block) setTarget(null);
639
- commitStructure(area, { [gone]: markup }); // keep the markup so undo can rebuild it
1256
+ detached.set(gone, block);
1257
+ if (target === block || (target && block.contains(target))) setTarget(null);
1258
+ commitStructure(area, container, before, { [gone]: markup });
640
1259
  return true;
641
1260
  }
642
1261
 
643
- const cutBlock = (el) => copyBlock(el) && removeBlock(el);
1262
+ const cutBlock = (el) => { const b = asPiece(el === undefined ? target : el); return canDo('remove', b) && copyBlock(b) && removeBlock(b); };
644
1263
 
645
- // Programmatic cross-region move (what a pointer drag commits): insert the block
646
- // into `dest` (area element or x-edit key) before `ref`, or at the end.
1264
+ // Programmatic move: into `dest` (container element or x-edit key) before `ref`, or at the end.
647
1265
  function moveBlock(el, dest, ref) {
648
- const block = el ? blockOf(el) || el : target; if (!block || locked(block) || isRegionBlock(block)) return false;
1266
+ const block = asPiece(el == null ? target : el);
1267
+ if (!block || !movable(block)) return false;
649
1268
  const from = blockArea(block);
650
- const to = typeof dest === 'string' ? areaByKey(dest) : dest;
651
- if (!from || !to || to === from || !to._edit) return false;
652
- if (!topArea(from) || !topArea(to)) return false; // a nested region can't persist a move — refuse, never drop it silently
653
- if (!isActive(from) || !isActive(to) || locked(to)) return false;
654
- if (classify(from) !== 'static' || classify(to) !== 'static' || !capOf(to, 'sort')) return false;
655
- if (block === to || block.contains(to)) return false;
656
- if (ref && ref.parentElement !== to) ref = null;
657
- if (ref) to.insertBefore(block, ref); else to.appendChild(block);
658
- commitMove(from, to, block);
1269
+ const container = typeof dest === 'string' ? areaByKey(dest) : dest;
1270
+ if (!container || container.nodeType !== 1 || container === block || block.contains(container)) return false;
1271
+ const to = regionOf(container);
1272
+ if (!from || !to || !isActive(from) || !isActive(to) || !accepts(container)) return false;
1273
+ if (ref && ref.parentElement !== container) ref = null;
1274
+ const fromContainer = block.parentElement;
1275
+ if (!fits(block, container)) return false;
1276
+ if (classify(from) === 'data') {
1277
+ if (container !== fromContainer) return false;
1278
+ moveWith(block, b => container.insertBefore(b, ref ? startOf(ref) : null));
1279
+ finishReorder(from, container);
1280
+ return true;
1281
+ }
1282
+ if (classify(from) !== 'static' || classify(to) !== 'static') return false;
1283
+ if (from !== to && isRegionBlock(block) && !isHostEl(block)) return false;
1284
+ const fromBefore = staticKeys(fromContainer), toBefore = staticKeys(container);
1285
+ moveWith(block, b => container.insertBefore(b, ref ? startOf(ref) : null));
1286
+ if (container === fromContainer) return commitOrder(from, container, fromBefore);
1287
+ commitMove(from, to, block, fromContainer, fromBefore, toBefore);
659
1288
  return true;
660
1289
  }
661
1290
 
662
1291
  function pasteBlock(el) {
663
1292
  if (!clipboard) return false;
664
- const block = el || target;
665
- const area = blockArea(block) || areas().find(isActive);
666
- if (!area || !isActive(area)) return false;
1293
+ const container = pasteDest(el === undefined ? target : el); if (!container) return false;
1294
+ const block = asPiece(el === undefined ? target : el);
1295
+ const area = regionOf(container);
1296
+ if (!pasteFits(area)) return false;
667
1297
  if (classify(area) === 'data') {
668
1298
  const arr = dataOps(area); if (!arr) return false;
669
1299
  const at = block ? recordIndex(area, block) + 1 : arr.length;
@@ -673,125 +1303,259 @@
673
1303
  commitSplice(area, at, record, 'insert');
674
1304
  return true;
675
1305
  }
676
- const made = materialize(clipboard.html); if (!made) return false;
677
- if (block) block.after(made); else area.appendChild(made);
678
- armArea(area);
679
- commitStructure(area, { [staticKeys(area)[sortableChildren(area).indexOf(made)]]: blockHTML(made) });
1306
+ if (classify(area) !== 'static') return false;
1307
+ const made = materialize(clipboard.html); if (!made || !fits(made, container)) return false;
1308
+ insertStatic(area, container, made, block && block.parentElement === container ? block : null, clipboard.ops);
680
1309
  return true;
681
1310
  }
682
1311
 
683
- /* ---- Wiring ----
684
- Hotkeys ride the focused block, which reorder already gives a tabindex. They
685
- stay out of the way of text: nothing fires while a caret is in a field, so
686
- Cmd+C inside a paragraph still copies the words. */
1312
+ /* ---- Block hotkeys (outside text) ---- */
687
1313
  const inText = (e) => e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
688
-
689
- function onBlockKey(e) {
690
- if (inText(e)) return;
691
- const block = blockOf(e.target); if (!block) return;
692
- const area = blockArea(block); if (!isActive(area)) return;
693
- const meta = e.metaKey || e.ctrlKey;
694
- let done = false;
695
- if (meta && e.key.toLowerCase() === 'c') done = copyBlock(block);
696
- else if (meta && e.key.toLowerCase() === 'x') done = cutBlock(block);
697
- else if (meta && e.key.toLowerCase() === 'v') done = pasteBlock(block);
698
- else if (meta && e.key.toLowerCase() === 'd') done = duplicateBlock(block);
699
- else if (!meta && (e.key === 'Delete' || e.key === 'Backspace')) done = removeBlock(block);
700
- if (done) { e.preventDefault(); e.stopPropagation(); }
701
- }
702
-
703
- /* ---- Right-click ----
704
- The event waits for the pointer to come up. A popover opened while the button
705
- is still down is closed again by the release that follows it — the browser's
706
- light dismiss decides on pointerup, and by then the menu was not yet open when
707
- the press was recorded. Firing after the release is the only way a menu opened
708
- in the handler survives. The context-menu key sends no release, so a short
709
- timer covers it. */
710
- let pendingContext = null;
711
-
712
- function onBlockContext(e, builtIn) {
713
- const area = blockArea(e.target);
714
- if (!area || !isActive(area)) return false;
715
- const block = blockOf(e.target);
716
- if (!block) return false; // off-block: leave the native menu alone
717
- setTarget(block);
718
- e.preventDefault();
719
-
720
- const fire = () => {
721
- clearTimeout(pendingContext); pendingContext = null;
722
- document.removeEventListener('pointerup', fire, true);
723
- const detail = { target: block, area, x: e.clientX, y: e.clientY, can: (op) => canDo(op, block) };
724
- const ev = new CustomEvent('edit:context', { bubbles: true, cancelable: true, detail });
725
- if (area.dispatchEvent(ev) && builtIn) builtIn(); // nobody took it
726
- };
727
- document.addEventListener('pointerup', fire, true);
728
- pendingContext = setTimeout(fire, 300);
729
- return true;
1314
+ function onBlockKey(e, block) {
1315
+ if (inText(e)) return false;
1316
+ const meta = e.metaKey || e.ctrlKey, k = (e.key || '').toLowerCase();
1317
+ if (meta && k === 'c') return copyBlock(block);
1318
+ if (meta && k === 'x') return cutBlock(block);
1319
+ if (meta && k === 'v') return pasteBlock(block);
1320
+ if (meta && k === 'd') return duplicateBlock(block);
1321
+ if (!meta && (e.key === 'Delete' || e.key === 'Backspace')) return removeBlock(block);
1322
+ return false;
730
1323
  }
731
1324
 
732
- const blockTarget = () => target;
733
- const setBlockTarget = (el) => setTarget(el && el.nodeType === 1 ? blockOf(el) || el : null);
1325
+ // Right-click records the piece under the pointer before any menu opens.
1326
+ function onContextCapture(e) {
1327
+ const area = regionOf(e.target && e.target.nodeType === 1 ? e.target : e.target && e.target.parentElement);
1328
+ if (!area || !isActive(area)) return;
1329
+ setTarget(pieceOf(e.target));
1330
+ }
1331
+ const setBlockTarget = (el) => setTarget(el && el.nodeType === 1 ? asPiece(el) || el : null);
734
1332
 
735
1333
 
736
- /* ---- Drag: reorder (in-flow, pointer+keyboard) OR move (positioned) ----
737
- Pointer-based so it works with mouse, touch, and pen (HTML5 DnD was mouse-only).
738
- Keyboard: focus an item, Space/Enter to grab, arrows to move, Enter/Esc to drop/cancel;
739
- Alt/Option+arrow moves the focused row one step; in text only Up/Down (Left/Right stay word-jump).
740
- CSS-var config:
741
- --edit-drop-reach 48px how far outside a cross-region destination a drop still lands in it */
1334
+ /* ---- Move: one pointer engine for reorder, nest, un-nest and cross-area moves ----
1335
+ Keyboard: Alt+Up/Down steps among siblings, Alt+Right nests into the previous
1336
+ container, Alt+Left moves out one level. Space/Enter grabs, arrows move, Enter drops.
1337
+ After a drop, CSS decides: in flow the piece takes DOM order; absolutely positioned
1338
+ it lands at the drop point.
1339
+ --edit-drop-reach 48px how far outside another area a drop still lands in it */
742
1340
  const isPositioned = (el) => { const p = getComputedStyle(el).position; return p === 'absolute' || p === 'fixed'; };
1341
+ const addedTab = new WeakSet();
743
1342
  let grabbed = null, liveRegion;
744
1343
  function announce(msg) { if (!liveRegion || !liveRegion.isConnected) { liveRegion = document.createElement('div'); liveRegion.setAttribute('data-edit-live', ''); liveRegion.setAttribute('aria-live', 'polite'); document.body.appendChild(liveRegion); } liveRegion.textContent = msg; }
745
1344
 
746
- function makeSortable(container) {
747
- sortableChildren(container).forEach(child => {
748
- if (locked(child)) return;
749
- if (isPositioned(child)) { if (!child._dragBound) { child._dragBound = true; makeMovable(child); } return; }
750
- child.setAttribute('data-edit-sortable', ''); child.tabIndex = 0; child.setAttribute('role', 'listitem');
751
- if (child._dragBound) return; child._dragBound = true;
752
- child.addEventListener('pointerdown', onPointerDown);
753
- child.addEventListener('keydown', onItemKeydown);
754
- child.addEventListener('keydown', onBlockKey);
1345
+ function collectPieces(area) {
1346
+ const kind = classify(area);
1347
+ if (kind === 'component') return [];
1348
+ if (kind === 'data') { const tpl = listTpl(area); return tpl ? sortableChildren(tpl.parentElement) : []; }
1349
+ const out = [];
1350
+ const walk = (c) => sortableChildren(c).forEach(ch => { if (ch.closest('[data-edit-ghost]')) return; if (ch._manifestComponent) { if (isHostEl(ch)) out.push(ch); return; } out.push(ch); if (isContainer(ch) && !isRendered(ch)) walk(ch); });
1351
+ if (isContainer(area)) walk(area);
1352
+ return out;
1353
+ }
1354
+ function unmarkPiece(el) { el.removeAttribute('data-edit-piece'); if (addedTab.has(el)) { el.removeAttribute('tabindex'); addedTab.delete(el); } }
1355
+ function markPieces(area) {
1356
+ const want = new Set(collectPieces(area).filter(movable));
1357
+ area.querySelectorAll('[data-edit-piece]').forEach(el => { if (!want.has(el) && regionOf(el.parentElement) === area) unmarkPiece(el); });
1358
+ want.forEach(el => {
1359
+ el.setAttribute('data-edit-piece', '');
1360
+ if (!el.hasAttribute('tabindex')) { el.tabIndex = 0; addedTab.add(el); }
755
1361
  });
756
- container.setAttribute('role', 'list');
757
- // x-for rows and any other late arrivals are not sortable unless we notice
758
- // them — arming only ever ran once, so a list that grows loses its handles.
759
- if (!container._sortObserver) {
760
- container._sortObserver = new MutationObserver(() => {
761
- if (dragged) return; // never re-arm mid-drag
762
- if (isActive(container.closest('[data-edit-area]'))) makeSortable(container);
763
- });
764
- container._sortObserver.observe(container, { childList: true });
1362
+ }
1363
+
1364
+ const flowAxis = (c) => { const cs = getComputedStyle(c); return /grid/.test(cs.display) || (/flex/.test(cs.display) && !String(cs.flexDirection || '').startsWith('column')) ? 'x' : 'y'; };
1365
+ // Beside a hovered piece: before it when the pointer is in its leading half.
1366
+ function beside(el, x, y, parent) {
1367
+ const r = el.getBoundingClientRect(), horiz = flowAxis(parent) === 'x';
1368
+ const lead = horiz ? x < r.left + r.width / 2 : y < r.top + r.height / 2;
1369
+ let ref = lead ? el : groupOf(el).pop().nextElementSibling;
1370
+ while (ref && (ref === dragged || ref.hasAttribute('data-edit-dragging'))) ref = ref.nextElementSibling;
1371
+ return { container: parent, ref };
1372
+ }
1373
+ // Middle band of a container nests; its edges insert before/after it.
1374
+ function inBand(r, horiz, x, y) {
1375
+ const lo = horiz ? r.left : r.top, size = horiz ? r.width : r.height, p = horiz ? x : y;
1376
+ if (!size) return true;
1377
+ const edge = Math.min(size * 0.25, 24);
1378
+ return p >= lo + edge && p <= lo + size - edge;
1379
+ }
1380
+ const inMiddle = (el, x, y, parent) => inBand(el.getBoundingClientRect(), flowAxis(parent) === 'x', x, y);
1381
+ const dropValid = (a) => !!a && !!a._edit && accepts(a) && classify(a) === 'static' && topArea(a);
1382
+ // Another area under the pointer, else the nearest within reach.
1383
+ function dropArea(x, y, origin) {
1384
+ const hit = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
1385
+ let hitArea = hit && regionOf(hit);
1386
+ if (hitArea && hitArea !== origin && origin.contains(hitArea)) hitArea = origin; // a component region inside: its host is one piece here
1387
+ if (hitArea && dropValid(hitArea)) return hitArea;
1388
+ const cands = [origin, ...document.querySelectorAll('[data-edit-drop-target]')].filter((a, i, all) => dropValid(a) && all.indexOf(a) === i);
1389
+ const inside = (r, pad) => x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad;
1390
+ let best = null, bestD = Infinity;
1391
+ for (const a of cands) {
1392
+ const r = a.getBoundingClientRect();
1393
+ const d = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
1394
+ const reach = toPx(cssVar(a, '--edit-drop-reach', ''), a, 'w') ?? 48;
1395
+ const p = a.parentElement;
1396
+ const column = p && p !== document.body && p !== document.documentElement && !cands.some(o => o !== a && p.contains(o)) && inside(p.getBoundingClientRect(), 0);
1397
+ if ((inside(r, reach) || column) && d < bestD) { best = a; bestD = d; }
1398
+ }
1399
+ return best;
1400
+ }
1401
+ function dropTarget(x, y, s) {
1402
+ if (s.dataList) return { container: s.home, ref: afterElement(s.home, x, y) };
1403
+ // Nested: the container's bands stay where they were before the ghost went in
1404
+ // (its size and the layout around it change once it does).
1405
+ const n = s.nested;
1406
+ if (n && n.el.isConnected && s.ghost.parentElement === n.el && x >= n.rect.left && x <= n.rect.right && y >= n.rect.top && y <= n.rect.bottom && inBand(n.rect, n.horiz, x, y)) return { container: n.el, ref: afterElement(n.el, x, y) };
1407
+ s.nested = null;
1408
+ const region = s.crossOK ? dropArea(x, y, s.area) : s.area;
1409
+ if (!region) return null;
1410
+ const hit = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
1411
+ if (hit && hit !== region && region.contains(hit)) {
1412
+ // Inside a component host: the host is what is hit (its markup is another file's)
1413
+ let from = hit;
1414
+ for (let n = hit; n && n !== region; n = n.parentElement) if (isHostEl(n) || extraRoot(n)) from = isHostEl(n) ? n : n._manifestHost.roots[0];
1415
+ for (let el = from; el && el !== region; el = el.parentElement) {
1416
+ if (el === s.item || s.item.contains(el) || pluginNode(el)) continue;
1417
+ const parent = el.parentElement;
1418
+ if (!isContainer(parent)) continue;
1419
+ if (isHostEl(el)) { if (accepts(parent) && fits(s.item, parent) && !s.item.contains(parent)) return beside(el, x, y, parent); continue; }
1420
+ if (isContainer(el) && accepts(el) && fits(s.item, el) && inMiddle(el, x, y, parent)) {
1421
+ if (s.ghost && s.ghost.parentElement !== el) s.nested = { el, rect: el.getBoundingClientRect(), horiz: flowAxis(parent) === 'x' };
1422
+ return { container: el, ref: afterElement(el, x, y) };
1423
+ }
1424
+ if (accepts(parent) && fits(s.item, parent) && !s.item.contains(parent)) return beside(el, x, y, parent);
1425
+ }
1426
+ }
1427
+ if (!s.crossOK && (!hit || !region.contains(hit))) { const c = s.ghost.parentElement; return { container: c, ref: afterElement(c, x, y) }; }
1428
+ return accepts(region) && fits(s.item, region) ? { container: region, ref: afterElement(region, x, y) } : null;
1429
+ }
1430
+ // Reading-order insertion: the first sibling past the pointer.
1431
+ function afterElement(c, x, y) {
1432
+ for (const el of sortableChildren(c)) {
1433
+ if (el === dragged || el.hasAttribute('data-edit-dragging') || lockedFor(el, 'move')) continue;
1434
+ const b = el.getBoundingClientRect(), cx = b.left + b.width / 2, cy = b.top + b.height / 2;
1435
+ const sameRow = Math.abs(cy - y) < b.height / 2;
1436
+ if (cy - y > b.height / 2 || (sameRow && cx > x)) return el;
765
1437
  }
1438
+ return null;
1439
+ }
1440
+ // Drop point in the piece's own units, relative to its offset parent.
1441
+ function placeAt(el, x, y, s) {
1442
+ const op = el.offsetParent || el.parentElement, r = op.getBoundingClientRect();
1443
+ let left = x - s.grabX - r.left - (op.clientLeft || 0) + (op.scrollLeft || 0);
1444
+ let top = y - s.grabY - r.top - (op.clientTop || 0) + (op.scrollTop || 0);
1445
+ const maxL = op.clientWidth - el.offsetWidth, maxT = op.clientHeight - el.offsetHeight;
1446
+ left = maxL > 0 ? clamp(left, 0, maxL) : Math.max(0, left);
1447
+ top = maxT > 0 ? clamp(top, 0, maxT) : Math.max(0, top);
1448
+ el.style.left = toUnit(left, s.lu, el, op, 'w') + s.lu;
1449
+ el.style.top = toUnit(top, s.tu, el, op, 'h') + s.tu;
1450
+ }
1451
+ const clearPlacement = (el) => { el.style.removeProperty('left'); el.style.removeProperty('top'); if (!el.getAttribute('style')) el.removeAttribute('style'); };
1452
+ const keysWithout = (c, item) => staticKeys(c).filter((k, i) => sortableChildren(c)[i] !== item);
1453
+
1454
+ function onAreaPointerDown(e) {
1455
+ const area = this;
1456
+ if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0) || e._editDrag) return;
1457
+ const t = e.target;
1458
+ if (!t || t.nodeType !== 1 || t.isContentEditable || t.hasAttribute('data-edit-handle')) return;
1459
+ if (t._pieceMode) { exitPieceMode(t); return; }
1460
+ let item = null;
1461
+ for (let n = t; n && n !== area; n = n.parentElement) if (n.hasAttribute('data-edit-piece') && movable(n)) { item = n; break; }
1462
+ if (!item) return;
1463
+ e._editDrag = true;
1464
+ startDrag(e, item, area);
1465
+ }
1466
+
1467
+ /* ---- Grip: text pieces, whose surface is for typing, drag from a handle at their inline-start edge ---- */
1468
+ let grip = null, gripFor = null, gripHovered = null, gripFocused = null, gripListening = false;
1469
+ // Text pieces, and rows that are themselves a data field (their surface is for typing too)
1470
+ const needsGrip = (p) => !!p && movable(p) && ((isTextEl(p) && capOf(p, 'text')) || p._editField != null);
1471
+ function gripPiece(node, area) {
1472
+ for (let n = node; n && n.nodeType === 1 && n !== area; n = n.parentElement) if (n.hasAttribute('data-edit-piece')) return needsGrip(n) ? n : null;
1473
+ return null;
766
1474
  }
767
- function onPointerDown(e) {
768
- const item = this, area = item.parentElement && item.parentElement.closest('[data-edit-area]'); // origin = the OUTER region, never the item itself
769
- if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0)) return;
770
- if (e.target.isContentEditable || e.target.hasAttribute('data-edit-handle')) return; // text/size win
771
- if (e._editDrag) return; e._editDrag = true; // one drag per gesture — the innermost sortable item claims it
772
- const container = item.parentElement;
773
- const crossOK = classify(area) === 'static' && !isRegionBlock(item) && topArea(area); // cross-region: top-level static → static only; a region-block only reorders in place
774
- let hovered = container;
775
- const homeNext = item.nextElementSibling; // where to put it back if cancelled
776
- const start = item.getBoundingClientRect();
777
- const preStyle = item.getAttribute('style');
778
- const grabX = e.clientX - start.left, grabY = e.clientY - start.top;
1475
+ function ensureGrip() {
1476
+ if (grip && grip.isConnected) return grip;
1477
+ grip = document.createElement('span');
1478
+ grip.setAttribute('data-edit-grip', '');
1479
+ grip.setAttribute('aria-hidden', 'true');
1480
+ grip.hidden = true;
1481
+ grip.addEventListener('pointerdown', onGripDown);
1482
+ grip.addEventListener('pointerleave', (e) => gripLeave(e.relatedTarget));
1483
+ document.body.appendChild(grip);
1484
+ if (!gripListening) {
1485
+ gripListening = true;
1486
+ window.addEventListener('scroll', placeGrip, { capture: true, passive: true });
1487
+ window.addEventListener('resize', placeGrip, { passive: true });
1488
+ }
1489
+ return grip;
1490
+ }
1491
+ function placeGrip() {
1492
+ if (!gripFor || !grip) return;
1493
+ if (!gripFor.isConnected) return hideGrip();
1494
+ const r = gripFor.getBoundingClientRect(), rtl = getComputedStyle(gripFor).direction === 'rtl';
1495
+ grip.dir = rtl ? 'rtl' : 'ltr';
1496
+ grip.style.top = r.top + 'px';
1497
+ grip.style.left = (rtl ? r.right : r.left) + 'px';
1498
+ }
1499
+ function showGrip(piece) {
1500
+ const area = regionOf(piece);
1501
+ if (dragged || !area || area._edit.bare || piece.closest('[data-edit-area="bare"]')) return hideGrip();
1502
+ const g = ensureGrip();
1503
+ // The grip lives on <body>: it takes the piece's own accent and sizes
1504
+ ['--edit-accent', '--edit-grip-size', '--edit-grip-offset'].forEach(v => { const x = rawVar(piece, v); if (x) g.style.setProperty(v, x); else g.style.removeProperty(v); });
1505
+ gripFor = piece; placeGrip(); g.hidden = false;
1506
+ }
1507
+ function hideGrip() { gripFor = null; if (grip) grip.hidden = true; }
1508
+ function gripOver(e, area) {
1509
+ if (dragged) return;
1510
+ const p = gripPiece(e.target, area);
1511
+ gripHovered = p;
1512
+ if (p) showGrip(p); else if (gripFocused) showGrip(gripFocused); else hideGrip();
1513
+ }
1514
+ function gripLeave(to) {
1515
+ if (to && grip && (to === grip || (gripFor && gripFor.contains(to)))) return;
1516
+ gripHovered = null;
1517
+ if (gripFocused && gripFocused.isConnected) showGrip(gripFocused); else hideGrip();
1518
+ }
1519
+ function gripFocus(e, area) {
1520
+ const p = gripPiece(e.target, area);
1521
+ gripFocused = p;
1522
+ if (p && !(gripHovered && gripHovered.isConnected)) showGrip(p);
1523
+ }
1524
+ function gripBlur(to) {
1525
+ if (to && gripFocused && gripFocused.contains(to)) return;
1526
+ gripFocused = null;
1527
+ if (!(gripHovered && gripHovered.isConnected)) hideGrip();
1528
+ }
1529
+ function onGripDown(e) {
1530
+ const piece = gripFor;
1531
+ if (!piece || (e.pointerType === 'mouse' && e.button !== 0)) return;
1532
+ const area = regionOf(piece);
1533
+ if (!isActive(area) || !movable(piece)) return;
1534
+ e.preventDefault(); e.stopPropagation();
1535
+ hideGrip();
1536
+ startDrag(e, piece, area);
1537
+ }
1538
+
1539
+ function startDrag(e, item, area) {
1540
+ const home = item.parentElement, homeNext = groupOf(item).pop().nextElementSibling, rest = groupOf(item).slice(1);
1541
+ const dataList = classify(area) === 'data';
1542
+ const s = {
1543
+ item, area, home, dataList, ghost: null,
1544
+ crossOK: !dataList && classify(area) === 'static' && (isHostEl(item) || !isRegionBlock(item)),
1545
+ homeBefore: dataList ? null : staticKeys(home),
1546
+ lu: unitOf(item.style.left) || 'px', tu: unitOf(item.style.top) || 'px'
1547
+ };
1548
+ const start = item.getBoundingClientRect(), preStyle = item.getAttribute('style'), preOwn = ownStyle(item), lead = leadNodes(item);
1549
+ s.grabX = e.clientX - start.left; s.grabY = e.clientY - start.top;
779
1550
  const sx = e.clientX, sy = e.clientY;
780
- let active = false, frame = 0, px = sx, py = sy, ghost = null;
1551
+ let active = false, frame = 0, px = sx, py = sy, last = null;
781
1552
 
782
- // The item leaves the flow and a stand-in takes its slot, so the gap that
783
- // opens is a real element the author can style — by default a translucent
784
- // copy of what is being dragged, showing exactly where it would land.
785
1553
  const lift = (ev) => {
786
- // Valid cross-region destinations announce themselves for the whole
787
- // drag: a dashed target outline, and a min-height (via CSS) so an
788
- // EMPTY region has a surface to drop onto at all.
789
- if (crossOK) areas().forEach(a => {
790
- if (a !== area && isActive(a) && !locked(a) && capOf(a, 'sort') && classify(a) === 'static' && topArea(a)) a.setAttribute('data-edit-drop-target', '');
791
- });
792
- ghost = item.cloneNode(true);
1554
+ if (s.crossOK) areas().forEach(a => { if (a !== area && dropValid(a)) a.setAttribute('data-edit-drop-target', ''); });
1555
+ const ghost = s.ghost = item.cloneNode(true);
1556
+ unrich(item, ghost);
793
1557
  ghost.setAttribute('data-edit-ghost', '');
794
- ghost.setAttribute('x-ignore', ''); // a clone must not re-bind
1558
+ ghost.setAttribute('x-ignore', '');
795
1559
  ghost.removeAttribute('id');
796
1560
  ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
797
1561
  [ghost, ...ghost.querySelectorAll('*')].forEach(n => {
@@ -799,40 +1563,33 @@
799
1563
  n.removeAttribute('contenteditable'); n.removeAttribute('tabindex'); n.removeAttribute('draggable');
800
1564
  });
801
1565
  item.before(ghost);
802
- item.style.position = 'fixed';
803
- item.style.width = start.width + 'px';
804
- item.style.height = start.height + 'px';
805
- item.style.margin = '0';
806
- item.style.pointerEvents = 'none';
1566
+ Object.assign(item.style, { position: 'fixed', width: start.width + 'px', height: start.height + 'px', margin: '0', pointerEvents: 'none' });
807
1567
  item.setAttribute('data-edit-dragging', '');
808
- area.setAttribute('data-edit-dragging-in', '');
809
1568
  dragged = ghost;
810
1569
  try { item.setPointerCapture(ev.pointerId); } catch { }
811
1570
  };
812
-
813
- const paint = () => {
814
- frame = 0;
815
- item.style.left = (px - grabX) + 'px';
816
- item.style.top = (py - grabY) + 'px';
1571
+ const paint = () => { frame = 0; item.style.left = (px - s.grabX) + 'px'; item.style.top = (py - s.grabY) + 'px'; };
1572
+ const placeGhost = (drop) => {
1573
+ const g = s.ghost, { container, ref } = drop;
1574
+ const same = last && last.container === container && last.ref === ref;
1575
+ last = drop;
1576
+ if (!same) container.insertBefore(g, ref && ref.parentElement === container ? startOf(ref) : null);
1577
+ if (isPositioned(g)) {
1578
+ if (!same) { if (container === home && item.parentElement === home) home.insertBefore(g, item); else if (g.nextElementSibling) container.appendChild(g); }
1579
+ placeAt(g, px, py, s);
1580
+ } else if (g.style.left || g.style.top) clearPlacement(g);
817
1581
  };
818
-
819
1582
  const onMove = (ev) => {
820
1583
  px = ev.clientX; py = ev.clientY;
821
1584
  if (!active) {
822
- if (Math.hypot(px - sx, py - sy) < 5) return; // threshold so taps/clicks still work
823
- active = true; lift(ev); paint();
1585
+ if (Math.hypot(px - sx, py - sy) < 5) return;
1586
+ active = true; hideGrip(); lift(ev); paint();
824
1587
  }
825
1588
  ev.preventDefault();
826
- const over = (crossOK && dropArea(px, py, area)) || ghost.parentElement; // cross-region drop search
827
- if (over !== hovered) {
828
- if (hovered !== container) hovered.removeAttribute('data-edit-dragging-in');
829
- hovered = over;
830
- if (hovered !== container) hovered.setAttribute('data-edit-dragging-in', '');
831
- }
832
- reorderOver(over, px, py);
1589
+ const drop = dropTarget(px, py, s);
1590
+ if (drop) placeGhost(drop);
833
1591
  if (!frame) frame = requestAnimationFrame(paint);
834
1592
  };
835
-
836
1593
  const settle = (cancelled) => {
837
1594
  document.removeEventListener('pointermove', onMove);
838
1595
  document.removeEventListener('pointerup', onUp);
@@ -840,30 +1597,26 @@
840
1597
  document.removeEventListener('keydown', onKey, true);
841
1598
  if (frame) cancelAnimationFrame(frame);
842
1599
  if (!active) return;
843
- if (cancelled) {
844
- ghost.remove();
845
- if (homeNext) container.insertBefore(item, homeNext); else container.appendChild(item);
846
- } else ghost.replaceWith(item);
847
- if (preStyle == null) item.removeAttribute('style'); else item.setAttribute('style', preStyle);
848
- item.removeAttribute('data-edit-dragging');
849
- area.removeAttribute('data-edit-dragging-in');
850
1600
  document.querySelectorAll('[data-edit-drop-target]').forEach(a => a.removeAttribute('data-edit-drop-target'));
851
- if (hovered !== container) hovered.removeAttribute('data-edit-dragging-in');
852
- ghost = null; dragged = null;
853
- if (cancelled) { announce('Cancelled'); return; }
854
- const dest = item.parentElement;
855
- if (crossOK && dest !== container && dest._edit) {
856
- // Re-validate at drop: a region locked or deactivated mid-drag
857
- // (reached through the ghost fallback) must not take the block.
858
- if (isActive(dest) && !locked(dest) && capOf(dest, 'sort') && topArea(dest)) { commitMove(area, dest, item); announce('Moved to ' + key(dest)); }
859
- else { if (homeNext) container.insertBefore(item, homeNext); else container.appendChild(item); announce('Cancelled'); }
860
- }
861
- else finishReorder(area);
1601
+ item.removeAttribute('data-edit-dragging');
1602
+ if (cancelled) s.ghost.remove(); else { s.ghost.replaceWith(item); if (lead.length) item.before(...lead); if (rest.length) item.after(...rest); }
1603
+ if (preStyle == null) item.removeAttribute('style'); else item.setAttribute('style', preStyle);
1604
+ s.ghost = null; dragged = null;
1605
+ const back = () => { if (homeNext && homeNext.parentElement === home) home.insertBefore(item, startOf(homeNext)); else home.appendChild(item); if (lead.length) item.before(...lead); if (rest.length) item.after(...rest); };
1606
+ if (cancelled) { back(); announce('Cancelled'); return; }
1607
+ const dest = item.parentElement, toArea = regionOf(dest);
1608
+ if (dest !== home && (s.dataList || !accepts(dest) || !fits(item, dest) || !toArea || (toArea !== area && !(s.crossOK && dropValid(toArea))))) { back(); announce('Cancelled'); return; }
1609
+ if (isPositioned(item)) placeAt(item, px, py, s);
1610
+ else if (item.style.left || item.style.top) clearPlacement(item);
1611
+ const styled = ownStyle(item) !== preOwn;
1612
+ if (s.dataList) { finishReorder(area, home); announce('Dropped'); return; }
1613
+ let did = false;
1614
+ if (dest === home) did = commitOrder(area, home, s.homeBefore);
1615
+ else { commitMove(area, toArea, item, home, s.homeBefore, keysWithout(dest, item)); did = true; }
1616
+ if (styled) commitStyle(regionOf(item), item, { before: preOwn, join: did });
1617
+ announce(toArea !== area ? 'Moved to ' + key(toArea) : 'Dropped');
862
1618
  };
863
1619
  const onUp = () => settle(false);
864
- // Escape puts it back, the way every drag is expected to be escapable. A
865
- // cancelled pointer (OS gesture, alert, pen out of range) also reverts —
866
- // otherwise the item wedges position:fixed with drop targets still lit.
867
1620
  const onCancel = () => settle(true);
868
1621
  const onKey = (ev) => { if (ev.key !== 'Escape') return; ev.preventDefault(); ev.stopPropagation(); settle(true); };
869
1622
  document.addEventListener('pointermove', onMove);
@@ -871,148 +1624,134 @@
871
1624
  document.addEventListener('pointercancel', onCancel);
872
1625
  document.addEventListener('keydown', onKey, true);
873
1626
  }
874
- function reorderOver(container, x, y) {
875
- if (!dragged) return;
876
- const ref = afterElement(container, x, y);
877
- if (dragged.parentElement === container && (ref === dragged || ref === dragged.nextElementSibling)) return;
878
- if (ref == null) container.appendChild(dragged); else container.insertBefore(dragged, ref);
879
- }
880
- // Cross-region drop target: the innermost valid region under the pointer, else
881
- // the nearest valid one within reach — regions are often shorter than their column.
882
- const dropValid = (a) => !!a && !!a._edit && isActive(a) && !locked(a) && capOf(a, 'sort') && classify(a) === 'static' && topArea(a);
883
- function dropArea(x, y, origin) {
884
- const hit = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
885
- for (let a = hit && hit.closest ? hit.closest('[data-edit-area]') : null; a; a = a.parentElement && a.parentElement.closest('[data-edit-area]')) {
886
- if (dropValid(a)) return a;
887
- }
888
- const cands = [origin, ...document.querySelectorAll('[data-edit-drop-target]')].filter((a, i, all) => dropValid(a) && all.indexOf(a) === i);
889
- const inside = (r, pad) => x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad;
890
- let best = null, bestD = Infinity;
891
- for (const a of cands) {
892
- const r = a.getBoundingClientRect();
893
- const d = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
894
- const reach = toPx(cssVar(a, '--edit-drop-reach', ''), a, 'w') ?? 48;
895
- // The block wrapping the region (its column) counts as its surface, unless it wraps another candidate too.
896
- const p = a.parentElement;
897
- const column = p && p !== document.body && p !== document.documentElement && !cands.some(o => o !== a && p.contains(o)) && inside(p.getBoundingClientRect(), 0);
898
- if ((inside(r, reach) || column) && d < bestD) { best = a; bestD = d; }
899
- }
900
- return best;
901
- }
902
- function finishReorder(area) { if (classify(area) === 'data') { applyDataOrder(area, sortableChildren(area).map(c => itemKey(c, area))); commit(area); } else commitStaticOrder(area); }
903
- // Reading-order insertion: block, inline, flex row/col/wrap, grid auto-flow.
904
- // The FIRST sibling past the pointer, not the nearest — nearest can pick an
905
- // element further down the list and makes the drop point jump around. Taking the
906
- // first is also self-stabilising: once the item lands, that sibling's midpoint is
907
- // behind the pointer, so it does not immediately swap back.
908
- function afterElement(c, x, y) {
909
- for (const el of sortableChildren(c)) {
910
- if (el === dragged || locked(el) || el.hasAttribute('data-edit-dragging')) continue;
911
- const b = el.getBoundingClientRect(), cx = b.left + b.width / 2, cy = b.top + b.height / 2;
912
- const sameRow = Math.abs(cy - y) <= b.height / 2;
913
- if (cy - y > b.height / 2 || (sameRow && cx > x)) return el;
914
- }
915
- return null;
1627
+
1628
+ function finishReorder(area, container) {
1629
+ if (classify(area) === 'data') { applyDataOrder(area, sortableChildren(container).map(c => itemKey(c, area))); commit(area); return true; }
1630
+ return false;
916
1631
  }
917
- // Keyboard reorder: grab → arrows move among siblings → drop. Space/Enter belong
918
- // to text and controls inside the row; Alt+arrow moves the row directly.
1632
+
1633
+ /* ---- Keyboard ---- */
919
1634
  const ownControl = (e, item) => e.target !== item && !!e.target.closest && item.contains(e.target.closest('a[href], button, summary, [role="button"], [role="link"]'));
920
- function onItemKeydown(e) {
921
- const item = this, area = item.closest('[data-edit-area]'); if (!isActive(area)) return;
922
- if (e._editRow) return; // the innermost row claims the key
923
- const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key];
924
- const wordJump = inText(e) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight');
925
- if (dir != null && e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey && !wordJump) {
926
- const home = item.parentElement && item.parentElement.closest('[data-edit-area]'); // the row's region, never the row itself
927
- if (!isActive(home)) return;
928
- e._editRow = true; e.preventDefault(); stepRow(item, home, dir); return;
1635
+ const siblingsOf = (item) => sortableChildren(item.parentElement).filter(c => c === item || !lockedFor(c, 'move'));
1636
+ function refocus(el) { if (document.activeElement !== el) try { el.focus(); } catch { } }
1637
+
1638
+ function onPieceKeydown(e, area) {
1639
+ if (e._editRow) return;
1640
+ const t = e.target;
1641
+ if (e.key === 'Enter' && t.matches && t.matches('a[href]') && islandLink(t, area)) return;
1642
+ if (t._pieceMode && e.key === 'Enter') { e._editRow = true; e.preventDefault(); exitPieceMode(t, true); return; }
1643
+ let item = null;
1644
+ for (let n = t; n && n !== area; n = n.parentElement) if (n.hasAttribute && n.hasAttribute('data-edit-piece') && movable(n)) { item = n; break; }
1645
+ if (!grabbed) { const b = asPiece(t); if (b && onBlockKey(e, b)) { e._editRow = true; e.preventDefault(); e.stopPropagation(); return; } }
1646
+ if (!item) return;
1647
+ const dir = { ArrowUp: -1, ArrowDown: 1 }[e.key], side = { ArrowLeft: -1, ArrowRight: 1 }[e.key];
1648
+ const plainAlt = e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey;
1649
+ if (plainAlt && dir != null && !(grabbed && grabbed.item === item)) { e._editRow = true; e.preventDefault(); stepRow(item, dir); return; }
1650
+ if (plainAlt && side != null && !inText(e)) { e._editRow = true; e.preventDefault(); if (side > 0) nestInto(item); else unnest(item); return; }
1651
+ if ((e.key === ' ' || e.key === 'Enter') && !inText(e) && !ownControl(e, item) && !e.altKey && !e.metaKey && !e.ctrlKey) {
1652
+ e._editRow = true; e.preventDefault();
1653
+ if (grabbed && grabbed.item === item) { dropGrab(); refocus(item); announce('Dropped'); }
1654
+ else { if (grabbed) cancelGrab(); grabbed = { item, area: blockArea(item), container: item.parentElement, next: groupOf(item).pop().nextElementSibling, before: classify(blockArea(item)) === 'data' ? null : staticKeys(item.parentElement) }; item.setAttribute('data-edit-dragging', 'keyboard'); announce('Grabbed — arrow keys to move, Enter to drop'); }
1655
+ return;
929
1656
  }
930
- if ((e.key === ' ' || e.key === 'Enter') && !inText(e) && !ownControl(e, item)) {
931
- e._editRow = true;
932
- e.preventDefault();
933
- if (grabbed === item) { grabbed = null; item.removeAttribute('data-edit-grabbed'); finishReorder(area); item.focus(); announce('Dropped'); }
934
- else { if (grabbed) grabbed.removeAttribute('data-edit-grabbed'); grabbed = item; item.setAttribute('data-edit-grabbed', ''); announce('Grabbed — arrow keys to move, Enter to drop'); }
1657
+ if (grabbed && grabbed.item === item) {
1658
+ if (e.key === 'Escape') { e._editRow = true; e.preventDefault(); cancelGrab(); refocus(item); announce('Cancelled'); return; }
1659
+ const step = dir ?? side; if (step == null) return;
1660
+ e._editRow = true; e.preventDefault();
1661
+ const sibs = siblingsOf(item), i = sibs.indexOf(item), j = i + step;
1662
+ if (j < 0 || j >= sibs.length) return;
1663
+ moveWith(item, it => { if (step > 0) groupOf(sibs[j]).pop().after(it); else startOf(sibs[j]).before(it); });
1664
+ refocus(item); announce(`Position ${j + 1} of ${sibs.length}`);
935
1665
  return;
936
1666
  }
937
- if (grabbed !== item) return;
938
- if (e.key === 'Escape') { e.preventDefault(); grabbed = null; item.removeAttribute('data-edit-grabbed'); item.focus(); announce('Cancelled'); return; }
939
- if (dir == null) return;
940
- e._editRow = true;
941
- e.preventDefault();
942
- const sibs = sortableChildren(item.parentElement).filter(c => !locked(c)), i = sibs.indexOf(item), j = i + dir;
943
- if (j < 0 || j >= sibs.length) return;
944
- if (dir > 0) sibs[j].after(item); else sibs[j].before(item);
945
- item.focus(); announce(`Position ${j + 1} of ${sibs.length}`);
946
- }
947
- // One step, committed at once. The row itself never leaves the DOM — its
948
- // neighbours move round it — so focus and the caret in its text survive.
949
- function stepRow(item, area, dir) {
950
- const sibs = sortableChildren(item.parentElement).filter(c => !locked(c)), i = sibs.indexOf(item), j = i + dir;
1667
+ if (t === item && !inText(e) && !e.altKey && !e.metaKey && !e.ctrlKey && (dir != null || side != null) && isPositioned(item)) { e._editRow = true; nudge(e, item); }
1668
+ }
1669
+ function dropGrab() {
1670
+ const g = grabbed; grabbed = null; if (!g) return;
1671
+ g.item.removeAttribute('data-edit-dragging');
1672
+ if (g.before) commitOrder(g.area, g.container, g.before); else finishReorder(g.area, g.container);
1673
+ }
1674
+ function cancelGrab() {
1675
+ const g = grabbed; grabbed = null; if (!g) return;
1676
+ g.item.removeAttribute('data-edit-dragging');
1677
+ moveWith(g.item, it => { if (g.next && g.next.parentElement === g.container) g.container.insertBefore(it, startOf(g.next)); else g.container.appendChild(it); });
1678
+ }
1679
+ // One step, committed at once. Neighbours move round the piece, so focus and caret survive.
1680
+ function stepRow(item, dir) {
1681
+ const area = blockArea(item), container = item.parentElement;
1682
+ const sibs = siblingsOf(item), i = sibs.indexOf(item), j = i + dir;
951
1683
  if (i < 0 || j < 0 || j >= sibs.length) { announce(`Position ${i + 1} of ${sibs.length}`); return; }
1684
+ const before = classify(area) === 'data' ? null : staticKeys(container);
952
1685
  const other = sibs[j], between = [];
953
1686
  for (let n = dir > 0 ? item.nextElementSibling : item.previousElementSibling; n && n !== other; n = dir > 0 ? n.nextElementSibling : n.previousElementSibling) between.push(n);
954
- if (dir > 0) { item.before(other); between.forEach(n => item.before(n)); }
955
- else { item.after(other); between.forEach(n => item.after(n)); }
1687
+ // Neighbours move round the piece, each with its titling comment.
1688
+ const block = (n) => [...leadNodes(n), ...groupOf(n)];
1689
+ if (dir > 0) { const at = startOf(item); [other, ...between].filter(n => !extraRoot(n)).forEach(n => at.before(...block(n))); }
1690
+ else { let after = groupOf(item).pop(); [...between.slice().reverse(), other].filter(n => !extraRoot(n)).forEach(n => { after.after(...block(n)); after = groupOf(n).pop(); }); }
956
1691
  const focused = document.activeElement, sel = window.getSelection && window.getSelection();
957
1692
  const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
958
- finishReorder(area);
1693
+ if (before) commitOrder(area, container, before); else finishReorder(area, container);
959
1694
  if (focused && focused.isConnected && document.activeElement !== focused) {
960
1695
  focused.focus();
961
1696
  if (range) try { sel.removeAllRanges(); sel.addRange(range); } catch { }
962
1697
  }
963
1698
  announce(`Moved to position ${j + 1} of ${sibs.length}`);
964
1699
  }
965
- // Move mode: positioned elements → pointer-drag left/top (touch-capable) + keyboard nudge, unit-preserving.
966
- function makeMovable(el) {
967
- el.setAttribute('data-edit-movable', ''); el.tabIndex = 0; el.setAttribute('role', 'application'); el.setAttribute('aria-label', 'Draggable; arrow keys to move');
968
- el.addEventListener('pointerdown', (e) => {
969
- const area = el.closest('[data-edit-area]');
970
- if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0) || e.target.isContentEditable || e.target.hasAttribute('data-edit-handle')) return;
971
- e.preventDefault(); el._preStyle = el.getAttribute('style') || '';
972
- const cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
973
- const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
974
- const baseL = parseFloat(cs.left) || 0, baseT = parseFloat(cs.top) || 0, sx = e.clientX, sy = e.clientY;
975
- const ov = showOverlay();
976
- // Clamped to the containing block: a freeform card must not leave its canvas.
977
- const maxL = () => Math.max(0, parent.clientWidth - el.offsetWidth), maxT = () => Math.max(0, parent.clientHeight - el.offsetHeight);
978
- const move = (ev) => { ev.preventDefault(); el.style.left = toUnit(Math.min(maxL(), Math.max(0, baseL + (ev.clientX - sx))), lu, el, parent, 'w') + lu; el.style.top = toUnit(Math.min(maxT(), Math.max(0, baseT + (ev.clientY - sy))), tu, el, parent, 'h') + tu; };
979
- const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); hideOverlay(ov); commitStyle(area, el); };
980
- document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
981
- });
982
- el.addEventListener('keydown', (e) => {
983
- const area = el.closest('[data-edit-area]'); if (!isActive(area)) return;
984
- const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (!d) return;
985
- e.preventDefault();
986
- if (!el._moveTimer) el._preStyle = el.getAttribute('style') || ''; // baseline at start of a key burst
987
- const step = e.shiftKey ? 16 : 4, cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
988
- const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
989
- const maxL = Math.max(0, parent.clientWidth - el.offsetWidth), maxT = Math.max(0, parent.clientHeight - el.offsetHeight);
990
- if (d[0]) el.style.left = toUnit(Math.min(maxL, Math.max(0, (parseFloat(cs.left) || 0) + d[0] * step)), lu, el, parent, 'w') + lu;
991
- if (d[1]) el.style.top = toUnit(Math.min(maxT, Math.max(0, (parseFloat(cs.top) || 0) + d[1] * step)), tu, el, parent, 'h') + tu;
992
- clearTimeout(el._moveTimer); el._moveTimer = setTimeout(() => { el._moveTimer = null; commitStyle(area, el); }, 350);
993
- });
1700
+ const describe = (el) => el === regionOf(el) ? key(el) : el.tagName.toLowerCase();
1701
+ function nestInto(item) {
1702
+ const area = blockArea(item);
1703
+ if (classify(area) !== 'static') { announce('Records stay in their list'); return false; }
1704
+ let prev = item.previousElementSibling;
1705
+ while (prev && (pluginNode(prev) || prev.tagName === 'TEMPLATE' || extraRoot(prev))) prev = prev.previousElementSibling;
1706
+ if (!prev || isHostEl(prev) || !accepts(prev) || !fits(item, prev)) { announce('Nothing to nest into'); return false; }
1707
+ const from = item.parentElement, fb = staticKeys(from), tb = staticKeys(prev);
1708
+ moveWith(item, it => prev.appendChild(it));
1709
+ commitMove(area, area, item, from, fb, tb);
1710
+ refocus(item); announce('Nested in ' + describe(prev));
1711
+ return true;
994
1712
  }
995
-
996
-
997
- /* ---- Size: drag handles → unit-preserving width/height (folded-in resize) ----
998
- CSS-var config (cascades by selector, keeps the directive tight):
999
- --edit-size both | x | y | none which axes (default both)
1000
- --edit-size-edges e.g. "right bottom-right" explicit handle list (logical start/end ok)
1001
- --edit-size-handle 1rem handle hit area
1002
- --edit-size-snap 12rem 24rem snap stops (both axes)
1003
- --edit-size-snap-x/-y per-axis stop overrides
1004
- --edit-size-snap-distance 1rem magnet tolerance (NOT a grid step)
1005
- --edit-size-snap-distance-x/-y per-axis tolerance
1006
- --edit-size-collapse-x/-y 120px collapse below this → [data-edit-collapsed] + 'edit:collapse' event
1007
- (min/max come from the element's native min-/max-width/height; % against the containing block.) */
1713
+ function unnest(item) {
1714
+ const area = blockArea(item), from = item.parentElement;
1715
+ if (classify(area) !== 'static') { announce('Records stay in their list'); return false; }
1716
+ const out = from && from !== area ? from.parentElement : null;
1717
+ if (!out || !(out === area || area.contains(out)) || !accepts(out)) { announce('Already at the top level'); return false; }
1718
+ if (!fits(item, out)) { announce('Cannot move out here'); return false; }
1719
+ const fb = staticKeys(from), tb = staticKeys(out);
1720
+ moveWith(item, it => groupOf(from).pop().after(it));
1721
+ commitMove(area, area, item, from, fb, tb);
1722
+ refocus(item); announce('Moved out to ' + describe(out));
1723
+ return true;
1724
+ }
1725
+ // Arrow nudge for a focused, absolutely positioned piece; a key burst commits once.
1726
+ function nudge(e, el) {
1727
+ const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (!d) return;
1728
+ e.preventDefault();
1729
+ if (!el._moveTimer) el._preStyle = ownStyle(el);
1730
+ const step = e.shiftKey ? 16 : 4, cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
1731
+ const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
1732
+ const maxL = Math.max(0, parent.clientWidth - el.offsetWidth), maxT = Math.max(0, parent.clientHeight - el.offsetHeight);
1733
+ const nl = Math.max(0, (parseFloat(cs.left) || 0) + d[0] * step), nt = Math.max(0, (parseFloat(cs.top) || 0) + d[1] * step);
1734
+ if (d[0]) el.style.left = toUnit(maxL ? Math.min(maxL, nl) : nl, lu, el, parent, 'w') + lu;
1735
+ if (d[1]) el.style.top = toUnit(maxT ? Math.min(maxT, nt) : nt, tu, el, parent, 'h') + tu;
1736
+ clearTimeout(el._moveTimer); el._moveTimer = setTimeout(() => { el._moveTimer = null; commitStyle(regionOf(el), el); }, 350);
1737
+ }
1738
+
1739
+
1740
+ /* ---- Size: edge handles → unit-preserving width/height ----
1741
+ --edit-size both | x | y | none axes (default both)
1742
+ --edit-size-edges "right bottom-right" handle list (logical start/end ok)
1743
+ --edit-size-handle 1rem handle hit area
1744
+ --edit-size-snap(-x/-y) 12rem 24rem snap stops; unset = no snapping
1745
+ --edit-size-snap-distance(-x/-y) 1rem magnet reach; unset = always the nearest stop
1746
+ --edit-size-close(-x/-y) 4rem below this, an x-show element closes
1747
+ `none` on any --edit-size-* cancels an inherited value. Min/max come from native min-/max-width/height. */
1008
1748
  let overlayEl;
1009
- // Fires while dragging (done:false) and once on commit (done:true), so consumers
1010
- // that must re-measure — charts, virtual lists — can track the drag and settle.
1749
+ // edit:size fires while dragging (done:false) and once on commit (done:true).
1011
1750
  function sizeEvent(el, done) {
1012
1751
  const r = el.getBoundingClientRect();
1013
1752
  el.dispatchEvent(new CustomEvent('edit:size', {
1014
1753
  bubbles: true,
1015
- detail: { width: r.width, height: r.height, css: { width: el.style.width, height: el.style.height }, collapsed: el.hasAttribute('data-edit-collapsed'), done }
1754
+ detail: { width: r.width, height: r.height, css: { width: el.style.width, height: el.style.height }, done }
1016
1755
  }));
1017
1756
  }
1018
1757
  function showOverlay() { if (!overlayEl) { overlayEl = document.createElement('div'); overlayEl.setAttribute('data-edit-overlay', ''); document.body.appendChild(overlayEl); } overlayEl.style.display = 'block'; return overlayEl; }
@@ -1024,9 +1763,7 @@
1024
1763
  const rtl = isRTL(el);
1025
1764
  return pos.replace(/start/g, rtl ? 'right' : 'left').replace(/end/g, rtl ? 'left' : 'right');
1026
1765
  }
1027
- // Containing-block size for % limits: the parent's content box in flow, the
1028
- // offsetParent's padding box when absolute, the viewport when fixed. An
1029
- // auto-height parent has no definite height, so % height limits drop (null).
1766
+ // Containing-block size for % limits (null when the height is not definite).
1030
1767
  function blockSize(el, dim) {
1031
1768
  const pos = getComputedStyle(el).position;
1032
1769
  if (pos === 'fixed') return dim === 'w' ? document.documentElement.clientWidth : document.documentElement.clientHeight;
@@ -1069,30 +1806,27 @@
1069
1806
  const axes = cssVar(el, '--edit-size', 'both');
1070
1807
  if (axes === 'none') return;
1071
1808
  if (el._sizeBound) return; el._sizeBound = true;
1072
- el.setAttribute('data-edit-sizable', '');
1073
- // Anchor guard: handles are positioned against this element, so it must establish a
1074
- // containing block. The CSS sets position:relative via :where() (overridable); if an
1075
- // author's CSS won and left it static, set it here so handles never detach.
1076
- if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
1077
- const edges = (cssVar(el, '--edit-size-edges', '').trim() || defaultEdges(axes).join(' ')).split(/\s+/).filter(Boolean);
1809
+ // Handles need a containing block; this position is the plugin's, never logged or published.
1810
+ const pos = getComputedStyle(el).position;
1811
+ if (!pos || pos === 'static') { el.style.position = 'relative'; el._editPos = true; }
1812
+ const edges = (cssVar(el, '--edit-size-edges', '') || defaultEdges(axes).join(' ')).split(/\s+/).filter(Boolean);
1078
1813
  edges.forEach(pos => {
1079
- const phys = physical(pos, el); // keep logical class for CSS; resolve to physical for behavior
1814
+ const phys = physical(pos, el);
1080
1815
  const h = document.createElement('span');
1081
- h.setAttribute('data-edit-handle', pos); // logical value drives the CSS; `phys` drives behaviour
1816
+ h.setAttribute('data-edit-handle', pos);
1082
1817
  h.tabIndex = 0; h.setAttribute('role', 'slider'); h.setAttribute('aria-label', `Resize ${pos}`);
1083
1818
  h.addEventListener('pointerdown', (e) => startSize(e, el, phys));
1084
1819
  h.addEventListener('keydown', (e) => keyResize(e, el, phys));
1085
1820
  el.appendChild(h);
1086
1821
  });
1087
1822
  }
1088
- // Keyboard a11y: focus a handle, arrow keys resize (Shift = larger step), debounced commit.
1089
- let _keyTimer;
1823
+ // Keyboard: arrows resize (Shift = larger step); a key burst commits once per element.
1090
1824
  function keyResize(e, el, pos) {
1091
1825
  const map = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
1092
1826
  if (!map[e.key]) return;
1093
- const area = el.closest('[data-edit-area]'); if (!isActive(area)) return;
1094
- e.preventDefault();
1095
- if (!_keyTimer) el._preStyle = el.getAttribute('style') || ''; // baseline at start of a key burst
1827
+ const area = regionOf(el); if (!isActive(area)) return;
1828
+ e.preventDefault(); e.stopPropagation();
1829
+ if (!el._keyTimer) el._preStyle = ownStyle(el);
1096
1830
  const [kx, ky] = map[e.key], step = e.shiftKey ? 32 : 8, cs = getComputedStyle(el);
1097
1831
  const right = pos.includes('right'), left = pos.includes('left'), top = pos.includes('top'), bottom = pos.includes('bottom');
1098
1832
  const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
@@ -1100,50 +1834,98 @@
1100
1834
  if ((right || left) && kx) { const w = clamp((parseFloat(cs.width) || 0) + kx * step * (right ? 1 : -1), minW, maxW); el.style.width = toUnit(w, wu, el, el.parentElement, 'w') + wu; }
1101
1835
  if ((top || bottom) && ky) { const h = clamp((parseFloat(cs.height) || 0) + ky * step * (bottom ? 1 : -1), minH, maxH); el.style.height = toUnit(h, hu, el, el.parentElement, 'h') + hu; }
1102
1836
  sizeEvent(el, false);
1103
- clearTimeout(_keyTimer); _keyTimer = setTimeout(() => { _keyTimer = null; commitStyle(area, el); sizeEvent(el, true); }, 350); // coalesce key bursts into one delta
1837
+ clearTimeout(el._keyTimer); el._keyTimer = setTimeout(() => { el._keyTimer = null; commitStyle(regionOf(el), el); sizeEvent(el, true); }, 350);
1838
+ }
1839
+ // An axis value wins; an explicit axis `none` cancels the base value too.
1840
+ function axisVar(el, base, axis) {
1841
+ const a = rawVar(el, `${base}-${axis}`);
1842
+ if (a) return a === 'none' ? '' : a;
1843
+ const b = rawVar(el, base);
1844
+ return b === 'none' ? '' : b;
1845
+ }
1846
+ function snapStops(el, axis, dim) {
1847
+ return axisVar(el, '--edit-size-snap', axis).split(/\s+/).filter(Boolean).map(s => toPx(s, el, dim)).filter(v => v != null);
1848
+ }
1849
+ function snapTo(v, stops, reach) {
1850
+ if (!stops.length) return v;
1851
+ let best = v, d = Infinity;
1852
+ for (const p of stops) { const dd = Math.abs(v - p); if (dd < d) { d = dd; best = p; } }
1853
+ return reach == null || d <= reach ? best : v;
1854
+ }
1855
+ const lengthVar = (el, base, axis, dim) => { const v = axisVar(el, base, axis); return v ? toPx(v, el, dim) : null; };
1856
+ // Close rides x-show: only an identifier or member path (no calls) can be set false.
1857
+ const ASSIGNABLE = /^\s*[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*|\s*\[\s*(?:'[^'\\]*'|"[^"\\]*"|\d+)\s*\])*\s*$/;
1858
+ const warnedShow = new WeakSet();
1859
+ function closable(el) {
1860
+ const expr = el.getAttribute('x-show');
1861
+ if (expr == null) return null;
1862
+ if (ASSIGNABLE.test(expr)) return expr;
1863
+ if (!warnedShow.has(el)) { warnedShow.add(el); console.warn('[edit] --edit-size-close needs an assignable x-show expression (e.g. x-show="open"); close is off here:', expr); }
1864
+ return null;
1104
1865
  }
1105
- function snapStops(el, axisVar, dim) {
1106
- const list = cssVar(el, axisVar, '') || cssVar(el, '--edit-size-snap', '');
1107
- return list.split(/\s+/).filter(Boolean).map(s => toPx(s, el, dim)).filter(v => v != null);
1866
+ const showable = (v) => { try { return JSON.stringify(v) !== undefined ? JSON.parse(JSON.stringify(v)) : true; } catch { return true; } };
1867
+ function setShow(el, expr, value) { window.Alpine.evaluate(el, `${expr} = ${JSON.stringify(value)}`); }
1868
+ // Closing is logged with the size change, so one undo reopens it.
1869
+ function closeVia(area, el, expr, join) {
1870
+ let before;
1871
+ try { before = showable(window.Alpine.evaluate(el, expr)); setShow(el, expr, false); } catch (err) { console.warn('[edit] could not close via x-show:', err); return false; }
1872
+ const id = identity(el, area);
1873
+ if (id != null) pushDelta(Object.assign({ kind: 'show', region: key(area), key: id, expr, before, value: false }, join ? { join: true } : {}));
1874
+ return true;
1108
1875
  }
1109
1876
  function startSize(e, el, pos) {
1110
- const area = el.closest('[data-edit-area]');
1111
- if (!isActive(area) || e.button !== 0) return;
1112
- e.preventDefault(); e.stopPropagation(); el._preStyle = el.getAttribute('style') || '';
1877
+ const area = regionOf(el);
1878
+ if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0) || e.button > 0) return;
1879
+ e.preventDefault(); e.stopPropagation();
1880
+ const preStyle = el.getAttribute('style'); el._preStyle = ownStyle(el);
1113
1881
  const cs = getComputedStyle(el), rect = el.getBoundingClientRect();
1114
1882
  const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
1115
1883
  const baseW = parseFloat(cs.width) || rect.width, baseH = parseFloat(cs.height) || rect.height, sx = e.clientX, sy = e.clientY;
1116
1884
  const { minW, maxW, minH, maxH } = limits(el, cs);
1117
1885
  const right = pos.includes('right'), left = pos.includes('left'), bottom = pos.includes('bottom'), top = pos.includes('top');
1118
- const tolX = toPx(cssVar(el, '--edit-size-snap-distance-x', '') || cssVar(el, '--edit-size-snap-distance', '0'), el, 'w') || 0;
1119
- const tolY = toPx(cssVar(el, '--edit-size-snap-distance-y', '') || cssVar(el, '--edit-size-snap-distance', '0'), el, 'h') || 0;
1120
- const snapW = snapStops(el, '--edit-size-snap-x', 'w'), snapH = snapStops(el, '--edit-size-snap-y', 'h');
1121
- const collapseX = toPx(cssVar(el, '--edit-size-collapse-x', ''), el, 'w');
1122
- const collapseY = toPx(cssVar(el, '--edit-size-collapse-y', ''), el, 'h');
1123
- const snap = (v, pts, tol) => { if (!tol) return v; for (const p of pts) if (Math.abs(v - p) <= tol) return p; return v; };
1124
- const ov = showOverlay();
1125
- let lastW = baseW, lastH = baseH;
1886
+ const reachX = lengthVar(el, '--edit-size-snap-distance', 'x', 'w'), reachY = lengthVar(el, '--edit-size-snap-distance', 'y', 'h');
1887
+ const snapW = snapStops(el, 'x', 'w'), snapH = snapStops(el, 'y', 'h');
1888
+ const closeX = lengthVar(el, '--edit-size-close', 'x', 'w'), closeY = lengthVar(el, '--edit-size-close', 'y', 'h');
1889
+ const showExpr = closeX != null || closeY != null ? closable(el) : null;
1890
+ try { e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); } catch { }
1891
+ showOverlay();
1892
+ let rawW = baseW, rawH = baseH;
1126
1893
  const move = (ev) => {
1127
1894
  const dx = ev.clientX - sx, dy = ev.clientY - sy;
1128
- if (right || left) { lastW = snap(clamp(baseW + (right ? dx : -dx), minW, maxW), snapW, tolX); el.style.width = toUnit(lastW, wu, el, el.parentElement, 'w') + wu; }
1129
- if (top || bottom) { lastH = snap(clamp(baseH + (bottom ? dy : -dy), minH, maxH), snapH, tolY); el.style.height = toUnit(lastH, hu, el, el.parentElement, 'h') + hu; }
1130
- const collapsed = (collapseX != null && lastW < collapseX) || (collapseY != null && lastH < collapseY);
1131
- el.toggleAttribute('data-edit-collapsed', collapsed);
1895
+ if (right || left) { rawW = baseW + (right ? dx : -dx); el.style.width = toUnit(snapTo(clamp(rawW, minW, maxW), snapW, reachX), wu, el, el.parentElement, 'w') + wu; }
1896
+ if (top || bottom) { rawH = baseH + (bottom ? dy : -dy); el.style.height = toUnit(snapTo(clamp(rawH, minH, maxH), snapH, reachY), hu, el, el.parentElement, 'h') + hu; }
1132
1897
  sizeEvent(el, false);
1133
1898
  };
1899
+ const end = () => {
1900
+ document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); document.removeEventListener('pointercancel', cancel);
1901
+ hideOverlay();
1902
+ };
1903
+ const restore = () => { if (preStyle == null) el.removeAttribute('style'); else el.setAttribute('style', preStyle); };
1904
+ const cancel = () => { end(); restore(); sizeEvent(el, true); };
1134
1905
  const up = () => {
1135
- document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); hideOverlay(ov);
1136
- if (el.hasAttribute('data-edit-collapsed')) el.dispatchEvent(new CustomEvent('edit:collapse', { bubbles: true }));
1906
+ end();
1907
+ const shutX = (right || left) && closeX != null && rawW < closeX, shutY = (top || bottom) && closeY != null && rawH < closeY;
1908
+ if ((shutX || shutY) && showExpr) {
1909
+ // Closed at the minimum it can have; with none, at the size it had, so it reopens sensibly.
1910
+ const atMin = (shutX ? minW : 0) > 0 || (shutY ? minH : 0) > 0;
1911
+ restore();
1912
+ if (atMin) {
1913
+ if (shutX && minW > 0) el.style.width = toUnit(minW, wu, el, el.parentElement, 'w') + wu;
1914
+ if (shutY && minH > 0) el.style.height = toUnit(minH, hu, el, el.parentElement, 'h') + hu;
1915
+ }
1916
+ const sized = commitStyle(area, el); sizeEvent(el, true);
1917
+ closeVia(area, el, showExpr, sized);
1918
+ return;
1919
+ }
1920
+ if (shutX || shutY) { restore(); sizeEvent(el, true); return; } // close configured but not possible here: leave it as it was
1137
1921
  commitStyle(area, el); sizeEvent(el, true);
1138
1922
  };
1139
- document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
1923
+ document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); document.addEventListener('pointercancel', cancel);
1140
1924
  }
1141
1925
 
1142
1926
 
1143
1927
  /* ---- Inline text editing ---- */
1144
- // Focusing a text leaf selects its content, so the common case — replace the
1145
- // text — is one click and type. Deferred a frame so it wins over the click's
1146
- // own caret placement; a second click still collapses to a caret for fine edits.
1928
+ // Focus selects the leaf's content; deferred a frame to win over the click's caret.
1147
1929
  function selectAllIn(el) {
1148
1930
  requestAnimationFrame(() => {
1149
1931
  if (dragged || grabbed) return; // reordering, not editing
@@ -1151,13 +1933,113 @@
1151
1933
  try { const r = document.createRange(); r.selectNodeContents(el); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); } catch { }
1152
1934
  });
1153
1935
  }
1154
- // Where x-text-edit is declared, it owns the element: this plugin neither makes
1936
+ // A link clicked in an active area is content: neither the router nor the browser follows it,
1937
+ // whatever the contenteditable state (a piece at rest is contenteditable=false). A
1938
+ // contenteditable=false island that is not a resting piece keeps its links. Runs on window,
1939
+ // capture, ahead of the router's document listener.
1940
+ function islandLink(link, area) {
1941
+ const off = link.closest('[contenteditable="false"]');
1942
+ return !!off && area.contains(off) && !off.hasAttribute('data-edit-piece-mode');
1943
+ }
1944
+ function onAreaLinkClick(e) {
1945
+ const link = e.target && e.target.closest && e.target.closest('a[href]');
1946
+ const area = link && regionOf(link);
1947
+ if (!area || !isActive(area) || islandLink(link, area)) return;
1948
+ e.preventDefault();
1949
+ }
1950
+
1951
+ /* ---- Rich Text engine: every editable leaf is an inline Rich Text field ---- */
1952
+ const richLeaves = new Set();
1953
+ let rtMissing = false;
1954
+ function rtEngine() {
1955
+ const RT = window.ManifestRichText;
1956
+ if (RT && typeof RT.attach === 'function') return RT;
1957
+ if (!rtMissing) { rtMissing = true; console.warn('[edit] Rich Text is not loaded; text leaves fall back to plain contenteditable'); }
1958
+ return null;
1959
+ }
1960
+ // Edit's own listeners are bound before this, so they run ahead of Rich Text's.
1961
+ function editable(el, format) {
1962
+ el._editRtFormat = format;
1963
+ if (el._editRt || el._pieceMode) return; // a focused piece re-attaches when it leaves piece mode
1964
+ const RT = rtEngine();
1965
+ el._editRtPrior = ['role', 'aria-multiline'].map(a => [a, el.getAttribute(a)]);
1966
+ const h = RT && RT.attach(el, { mode: 'inline', breaks: true, all: false, format, onChange: () => { } });
1967
+ if (!h) { el.setAttribute('contenteditable', 'true'); return; }
1968
+ el._editRt = h;
1969
+ el.setAttribute('data-edit-text', '');
1970
+ richLeaves.add(el);
1971
+ }
1972
+ function unedit(el) {
1973
+ const h = el._editRt; if (!h) return;
1974
+ el._editRt = null; richLeaves.delete(el);
1975
+ el.removeAttribute('data-edit-text');
1976
+ h.detach();
1977
+ }
1978
+ // Leaves inside `area`, and any no longer on the page.
1979
+ const uneditIn = (area) => richLeaves.forEach(el => { if (!el.isConnected || (area && area.contains(el))) unedit(el); });
1980
+ // Rich Text's history spans one edit session: it restarts at focus and when Edit's history moves the text.
1981
+ const restartRich = (el) => { const h = el._editRt; if (h) h.value = h.value; };
1982
+ /* Undo/redo in a leaf, one session at a time. While focused, each Rich Text step
1983
+ (a typing burst, a command, a paste) undoes and redoes on its own. Past the
1984
+ session's start, Edit's history takes over; a session with steps still to redo
1985
+ first becomes one Edit step (start → furthest state), logged and undone, so Edit's
1986
+ redo brings it back whole. Ctrl+Y redoes, as in Edit. */
1987
+ function richHistoryKey(e) {
1988
+ const el = this, h = el._editRt, dir = historyDir(e);
1989
+ if (!h || !dir) return;
1990
+ if (h.can(dir)) { e.preventDefault(); e.stopImmediatePropagation(); h[dir](); return; }
1991
+ const region = regionOf(el);
1992
+ if (dir === 'undo' && region && isActive(region) && !hotkeysOff(el)) handOff(el); // only when Edit's history will act
1993
+ onHistoryKey(e);
1994
+ if (e.defaultPrevented) e.stopImmediatePropagation();
1995
+ }
1996
+ function handOff(el) {
1997
+ const h = el._editRt;
1998
+ if (!h || !h.can('redo') || !el._commitText) return;
1999
+ const top = sessionTop(el);
2000
+ if (el._commitText(top)) undo();
2001
+ }
2002
+ // Drops follow the area's rule (none while active), ahead of Rich Text's own drop handling.
2003
+ function noDrop(el) {
2004
+ el.addEventListener('drop', (e) => { const a = regionOf(el); if (a && isActive(a)) { e.preventDefault(); e.stopImmediatePropagation(); } });
2005
+ }
2006
+ // The session's furthest state: the newest entry in the leaf's Rich Text history.
2007
+ function sessionTop(el) {
2008
+ const html = el._editRt.newest();
2009
+ if (!(el._editField && !el._editRich)) return html.trim();
2010
+ const t = document.createElement('template'); t.innerHTML = html; return t.content.textContent;
2011
+ }
2012
+ // Copies and ghosts of a leaf carry none of the engine's attributes or --rich-text-* state.
2013
+ function unrich(src, copy) {
2014
+ const s = [src, ...src.querySelectorAll('*')], c = [copy, ...copy.querySelectorAll('*')];
2015
+ c.forEach(n => { const st = n.getAttribute('style'); if (st && /--rich-text-/.test(st)) { const v = stripRuntime(st); v ? n.setAttribute('style', v) : n.removeAttribute('style'); } });
2016
+ s.forEach((n, i) => {
2017
+ if (!n._editRt || !c[i]) return;
2018
+ [...c[i].attributes].forEach(a => { if (a.name.startsWith('data-rich-text')) c[i].removeAttribute(a.name); });
2019
+ (n._editRtPrior || []).forEach(([a, v]) => v == null ? c[i].removeAttribute(a) : c[i].setAttribute(a, v));
2020
+ });
2021
+ }
2022
+ // `commit(value?)` logs the leaf's text against its session start (_preEdit).
2023
+ function bindLeaf(el, commit, input) {
2024
+ if (el._textBound) return; el._textBound = true;
2025
+ el._commitText = commit;
2026
+ el.addEventListener('focus', () => { restartRich(el); el._preEdit = leafText(el); selectAllIn(el); });
2027
+ el.addEventListener('keydown', (e) => { if (e.key === 'Escape' && el.isContentEditable) { e.preventDefault(); e.stopImmediatePropagation(); leaveText(el); } });
2028
+ el.addEventListener('keydown', richHistoryKey);
2029
+ noDrop(el);
2030
+ if (input) el.addEventListener('input', input);
2031
+ el.addEventListener('blur', () => commit());
2032
+ }
2033
+ // A rich editor other than this leaf's own owns `el`.
2034
+ const richOwned = (el) => { const o = el.closest('[data-rich-text]'); return !!o && (o !== el || !el._editRt); };
2035
+
2036
+ // Where x-rich-text is declared, it owns the element: this plugin neither makes
1155
2037
  // its leaves contenteditable nor competes for the caret. If the rich editor has
1156
2038
  // no expression of its own, we capture what it produces as an ordinary text
1157
2039
  // delta, so a rich field inside a page-editing region publishes like any other.
1158
2040
  function armRichText(area) {
1159
- area.querySelectorAll('[data-text-edit]:not([data-text-edit-bound])').forEach(el => {
1160
- if (!capOf(el, 'text') || locked(el)) return;
2041
+ area.querySelectorAll('[data-rich-text]:not([data-rich-text-bound])').forEach(el => {
2042
+ if (el._editRt || !capOf(el, 'text') || !/^[@~]/.test(el.getAttribute('data-edit-key') || '')) return;
1161
2043
  if (el._richBound) return; el._richBound = true;
1162
2044
  el.addEventListener('focusin', () => { el._preEdit = el.innerHTML.trim(); });
1163
2045
  el.addEventListener('blur', () => {
@@ -1168,186 +2050,240 @@
1168
2050
  });
1169
2051
  }
1170
2052
 
1171
- // STATIC: literal leaves only (skip bound nodes; those belong to data/component).
2053
+ // Static: literal leaves only.
1172
2054
  function armText(area) {
1173
2055
  armRichText(area);
1174
- // A region whose root IS the text — <p x-edit.text="note">…</p> — has no
1175
- // descendant leaves; the area itself is the editable leaf.
2056
+ // An area that is itself the text leaf.
1176
2057
  const nodes = [...area.querySelectorAll('*')];
1177
- if (!realChildren(area).length) nodes.unshift(area); // size handles are children too
2058
+ if (!realChildren(area).length || wholeLeaf(area)) nodes.unshift(area); // size handles are children too
1178
2059
  nodes.forEach(el => {
1179
- if (realChildren(el).length || !el.textContent.trim() || el.closest('template') || el.hasAttribute('data-edit-handle')) return;
1180
- if (el.closest('[data-text-edit]')) return; // the rich editor owns this subtree
1181
- if (!capOf(el, 'text') || el.hasAttribute('x-text') || el.hasAttribute('x-html')) return;
1182
- el.setAttribute('contenteditable', 'true');
1183
- if (el._textBound) return; el._textBound = true;
1184
- el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); selectAllIn(el); });
1185
- el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); el.blur(); } }); // Escape finishes the edit, like the rich editor
1186
- el.addEventListener('blur', () => commitStaticNode(regionOf(el) || area, el, 'text', el.innerHTML.trim())); // live region: the element may have moved since arming
2060
+ if ((realChildren(el).length && !wholeLeaf(el)) || !el.textContent.trim() || el.closest('template') || el.hasAttribute('data-edit-handle')) return;
2061
+ if (el._manifestComponent && el !== area) return; // a component host: its markup is another file's
2062
+ if (richOwned(el)) return; // the rich editor owns this subtree
2063
+ if (el.closest('[contenteditable="false"]:not([data-edit-piece-mode])')) return; // an authored non-editable island
2064
+ if (!capOf(el, 'text') || isRendered(el) || RAW_EDIT.has(el.tagName)) return;
2065
+ if (!/^[@~]/.test(el.getAttribute('data-edit-key') || '')) return; // only what will be logged and published
2066
+ bindLeaf(el, (v) => commitStaticNode(regionOf(el) || area, el, 'text', v ?? leafHTML(el))); // live region: the element may have moved since arming
2067
+ editable(el, 'html');
1187
2068
  });
1188
2069
  }
1189
2070
 
1190
- /* ---- DATA value editing (opt-in .data): make $x-bound leaves in an x-for list editable,
1191
- but ONLY when the binding is a SIMPLE field ref of the loop var (p.name) — computed
1192
- expressions (e.g. '$' + p.price) stay read-only. Edit → data mutation, not HTML.
1193
- (Scope today: x-for list item fields. Direct $x.a.b bindings outside a list are a
1194
- natural extension — different addressing {source,path} + key-value write-back.) ---- */
2071
+ /* ---- Data values (.data): x-for fields bound to a plain `item.field` are editable ---- */
1195
2072
  function armDataValues(area) {
1196
- const tpl = area.querySelector('template[x-for]'); if (!tpl) return;
2073
+ const tpl = listTpl(area); if (!tpl) return;
1197
2074
  const lv = (tpl.getAttribute('x-for').match(/^\s*([\w$]+)\s+in\b/) || [])[1]; if (!lv) return;
1198
2075
  const source = dataSourceName(area), fieldRe = new RegExp('^\\s*' + lv + '\\.([\\w$]+)\\s*$');
1199
2076
  sortableChildren(area).forEach(clone => {
1200
- const id = clone.getAttribute('data-key'); if (id == null) return;
1201
- clone.querySelectorAll('*').forEach(el => {
1202
- if (el.children.length || el.hasAttribute('data-edit-handle')) return;
2077
+ // The record: :data-key names its id; otherwise the row's own x-for scope gives the record (the writer matches records by id)
2078
+ let id = clone.getAttribute('data-key');
2079
+ if (id == null) { let rec = null; try { rec = window.Alpine.$data(clone)[lv]; } catch { } if (rec && rec.id != null) id = String(rec.id); }
2080
+ if (id == null) return;
2081
+ [clone, ...clone.querySelectorAll('*')].forEach(el => {
1203
2082
  const rich = el.hasAttribute('x-html');
2083
+ if ((el.children.length && !(rich && plainFormatted(el))) || el.hasAttribute('data-edit-handle')) return;
1204
2084
  const expr = el.getAttribute('x-text') || el.getAttribute('x-html'); if (!expr) return;
1205
2085
  const m = expr.match(fieldRe); if (!m) return;
1206
2086
  const field = m[1];
1207
- el.setAttribute('data-edit-field', field);
1208
- // A field bound with x-html can hold markup, so it is styleable like
1209
- // any other text — the value simply carries the tags. Bound with
1210
- // x-text it cannot, and offering the controls there would write
1211
- // markup the binding renders as literal characters. The author's
1212
- // choice of binding is the whole distinction.
1213
- el.toggleAttribute('data-edit-rich', rich);
1214
- // The rich editor owns the caret where it is present.
1215
- if (!el.hasAttribute('data-text-edit')) el.setAttribute('contenteditable', 'true');
1216
- if (el._dvBound) return; el._dvBound = true;
1217
- const read = () => rich ? el.innerHTML.trim() : el.textContent;
1218
- el.addEventListener('focus', () => { el._preEdit = read(); if (!el.hasAttribute('data-text-edit')) selectAllIn(el); }); // the rich editor owns its caret
1219
- el.addEventListener('focusin', () => { el._preEdit = read(); });
1220
- el.addEventListener('blur', () => commitDataValue(area, source, id, field, read(), el), true);
2087
+ el._editField = field;
2088
+ // x-html fields hold markup; x-text fields are plain.
2089
+ el._editRich = rich;
2090
+ if (!el._dvBound) {
2091
+ el._dvBound = true;
2092
+ const read = () => rich ? leafHTML(el) : el.textContent;
2093
+ el._commitText = (v) => commitDataValue(area, source, id, field, v ?? read(), el);
2094
+ el.addEventListener('focus', () => { restartRich(el); el._preEdit = leafText(el); if (!authoredRich(el)) selectAllIn(el); }); // the rich editor owns its caret
2095
+ el.addEventListener('focusin', () => { el._preEdit = leafText(el); });
2096
+ el.addEventListener('keydown', richHistoryKey);
2097
+ noDrop(el);
2098
+ el.addEventListener('blur', () => el._commitText(), true);
2099
+ }
2100
+ if (!authoredRich(el)) editable(el, rich ? 'html' : 'plain');
1221
2101
  });
1222
2102
  });
1223
2103
  }
1224
2104
 
1225
- /* ---- COMPONENT editing: text leaves addressed by structural path. Right-click an
1226
- instance for the scope (this instance vs all) + per-element classes + revert. ---- */
1227
- const componentName = (area) => { const r = area.querySelector('[data-component]'); return (r?.getAttribute('data-component') || '').replace(/-\d+$/, ''); };
1228
- // Paths index authored children only (realChildren): plugin-injected nodes never count.
2105
+ /* ---- Components: nodes addressed by structural path; scope via $edit.scope ---- */
2106
+ const componentName = (area) => { const r = cmpRoot(area); return (r?.getAttribute('data-component') || '').replace(/-\d+$/, ''); };
1229
2107
  function pathOf(node, root) { const idx = []; let n = node; while (n && n !== root && n.parentElement) { idx.unshift(realChildren(n.parentElement).indexOf(n)); n = n.parentElement; } return idx.join('.'); }
1230
2108
  function nodeByPath(root, path) { let el = root; for (const i of path.split('.').map(Number)) { el = realChildren(el)[i]; if (!el) return null; } return el; }
1231
- // While editing in 'All' scope, mirror the edit to every OTHER instance live — but
1232
- // skip the source element (don't fight the caret) and skip any instance that has its
1233
- // OWN committed override for this node/prop (instance overrides always win).
2109
+ // 'main' scope: mirror live to other instances without their own override.
1234
2110
  function liveMainPropagate(area, sourceEl, prop, value) {
1235
2111
  if ((area._editScope || 'instance') !== 'main') return;
1236
- const comp = componentName(area), path = sourceEl.getAttribute('data-edit-path'), { inst } = componentState();
1237
- document.querySelectorAll('[data-edit-area]').forEach(a => {
1238
- if (!a._edit || classify(a) !== 'component' || componentName(a) !== comp) return;
1239
- const root = a.querySelector('[data-component]'); if (!root) return;
2112
+ const comp = componentName(area), path = sourceEl._editPath, { inst } = componentState();
2113
+ areas().forEach(a => {
2114
+ if (classify(a) !== 'component' || componentName(a) !== comp) return;
2115
+ const root = cmpRoot(a); if (!root) return;
1240
2116
  const el = path === '' ? root : nodeByPath(root, path); if (!el || el === sourceEl) return;
1241
2117
  const iv = (inst[key(a)] && inst[key(a)][path]) || {}; if (iv[prop] !== undefined) return; // override wins
1242
- if (prop === 'class') { if (el.getAttribute('class') !== value) el.setAttribute('class', value); }
2118
+ if (prop === 'class') writeClass(el, value);
1243
2119
  else if (el.innerHTML !== value) el.innerHTML = value;
1244
2120
  });
1245
2121
  }
1246
2122
  function armComponent(area) {
1247
- const root = area.querySelector('[data-component]'); if (!root) return;
1248
- if (!area._editScope) { area._editScope = 'instance'; area.setAttribute('data-edit-scope', 'instance'); }
2123
+ const root = cmpRoot(area); if (!root) return;
2124
+ if (!area._editScope) area._editScope = 'instance';
1249
2125
  area._baseText = area._baseText || {}; area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {};
1250
- // Mark the root AND every element with a path → all are class-editable (incl. the parent).
1251
- const markClass = (el) => { const p = pathOf(el, root); el.setAttribute('data-edit-path', p); if (!(p in area._baseClass)) area._baseClass[p] = el.getAttribute('class') || ''; if (!(p in area._baseStyle)) area._baseStyle[p] = el.getAttribute('style') || ''; };
2126
+ const markClass = (el) => { const p = pathOf(el, root); el._editPath = p; if (!(p in area._baseClass)) area._baseClass[p] = el.getAttribute('class') || ''; if (!(p in area._baseStyle)) area._baseStyle[p] = el.getAttribute('style') || ''; };
1252
2127
  markClass(root);
1253
2128
  root.querySelectorAll('*').forEach(el => { if (!el.hasAttribute('data-edit-handle')) markClass(el); });
1254
- // Text leaves also become contenteditable. Capture innerHTML (not textContent) so
1255
- // nested inline elements like <i>/<br> the user adds are preserved.
1256
2129
  armRichText(area);
1257
- root.querySelectorAll('[data-edit-path]').forEach(el => {
1258
- if (el.children.length || !el.textContent.trim() || !capOf(el, 'text')) return;
1259
- if (el.closest('[data-text-edit]')) return; // the rich editor owns this subtree
1260
- const p = el.getAttribute('data-edit-path');
2130
+ componentNodes(root).forEach(el => {
2131
+ if ((el.children.length && !wholeLeaf(el)) || !el.textContent.trim() || !capOf(el, 'text')) return;
2132
+ if (richOwned(el)) return; // the rich editor owns this subtree
2133
+ const p = el._editPath;
1261
2134
  if (!(p in area._baseText)) area._baseText[p] = el.innerHTML.trim();
1262
- el.setAttribute('contenteditable', 'true');
1263
- if (el._textBound) return; el._textBound = true;
1264
- el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); selectAllIn(el); });
1265
- el.addEventListener('input', () => liveMainPropagate(area, el, 'text', el.innerHTML));
1266
- el.addEventListener('blur', () => commitComponentNode(area, el, 'text', el.innerHTML.trim()));
2135
+ bindLeaf(el, (v) => commitComponentNode(area, el, 'text', v ?? leafHTML(el)), () => liveMainPropagate(area, el, 'text', el.innerHTML));
2136
+ editable(el, 'html');
1267
2137
  });
1268
2138
  }
1269
- let cmpMenu;
1270
- function openComponentMenu(e, area) {
1271
- if (!isActive(area)) return; e.preventDefault(); e.stopPropagation();
1272
- const target = e.target.closest('[data-edit-path]');
1273
- if (!cmpMenu) {
1274
- cmpMenu = document.createElement('div'); cmpMenu.setAttribute('data-edit-menu', ''); cmpMenu.setAttribute('popover', 'manual');
1275
- cmpMenu.addEventListener('pointerdown', ev => ev.stopPropagation());
1276
- document.body.appendChild(cmpMenu);
1277
- document.addEventListener('pointerdown', () => { if (cmpMenu && cmpMenu.matches(':popover-open')) cmpMenu.hidePopover(); });
2139
+ // Escape leaves text with the piece focused, so Alt+arrows move it.
2140
+ function leaveText(el) {
2141
+ el.blur();
2142
+ const piece = [el, ...ancestorsIn(el)].find(n => n.hasAttribute && n.hasAttribute('data-edit-piece'));
2143
+ if (!piece) return;
2144
+ if (piece.isContentEditable) {
2145
+ piece._pieceMode = true;
2146
+ piece.setAttribute('data-edit-piece-mode', '');
2147
+ unedit(piece); // a focused piece is not a text field
2148
+ piece.setAttribute('contenteditable', 'false');
2149
+ piece.addEventListener('focusout', () => exitPieceMode(piece), { once: true });
1278
2150
  }
1279
- if (target) target._preClass = target.getAttribute('class') || '';
1280
- const cur = area._editScope || 'instance';
1281
- cmpMenu.innerHTML =
1282
- `<small>Scope · ${componentName(area)}</small>`
1283
- + `<div class="row"><button class="ghost sm" data-s="instance">${cur === 'instance' ? '✓ ' : ''}This instance</button><button class="ghost sm" data-s="main">${cur === 'main' ? '✓ ' : ''}All</button></div>`
1284
- + (target ? `<small>Classes · ${target.tagName.toLowerCase()} · live</small><input type="text" spellcheck="false" data-cls>` : '')
1285
- + `<div class="row">${target ? '<button class="ghost sm" data-a="revert">Revert element</button>' : ''}<button class="ghost sm" data-a="revertall">Revert all</button></div>`;
1286
- cmpMenu.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { area._editScope = b.getAttribute('data-s'); area.setAttribute('data-edit-scope', area._editScope); area.setAttribute('data-edit-label', `${key(area)} · component · ${area._editScope}`); openComponentMenu(e, area); });
1287
- const cls = cmpMenu.querySelector('[data-cls]');
1288
- if (cls && target) {
1289
- cls.value = target.getAttribute('class') || '';
1290
- cls.addEventListener('input', () => { target.setAttribute('class', cls.value); liveMainPropagate(area, target, 'class', cls.value); });
1291
- cls.addEventListener('change', () => commitComponentNode(area, target, 'class', cls.value));
1292
- cls.addEventListener('keydown', ev => { if (ev.key === 'Enter') cls.blur(); });
2151
+ try { piece.focus(); } catch { }
2152
+ }
2153
+ const ancestorsIn = (el) => { const out = [], area = regionOf(el); for (let n = el.parentElement; n && n !== area; n = n.parentElement) out.push(n); return out; };
2154
+ function exitPieceMode(el, edit) {
2155
+ if (!el._pieceMode) return;
2156
+ el._pieceMode = false;
2157
+ el.removeAttribute('data-edit-piece-mode');
2158
+ if (el.getAttribute('contenteditable') === 'false') { el.removeAttribute('contenteditable'); editable(el, el._editRtFormat || 'html'); }
2159
+ if (edit) { try { el.focus(); } catch { } selectAllIn(el); }
2160
+ }
2161
+
2162
+
2163
+ /* ---- Attributes and classes (API only; any area) ----
2164
+ Each change is one logged delta. With a :class binding, only the static
2165
+ class attribute is edited; the binding keeps merging its own classes. */
2166
+ const bindingOf = (el, name) => el.getAttribute(':' + name) ?? el.getAttribute('x-bind:' + name);
2167
+ function boundClasses(el) {
2168
+ const expr = bindingOf(el, 'class'); if (expr == null) return [];
2169
+ let v; try { v = window.Alpine.evaluate(el, expr); } catch { return []; }
2170
+ const out = [];
2171
+ const add = (x) => String(x).split(/\s+/).filter(Boolean).forEach(c => out.push(c));
2172
+ if (typeof v === 'string') add(v);
2173
+ else if (Array.isArray(v)) v.flat(Infinity).filter(Boolean).forEach(add);
2174
+ else if (v && typeof v === 'object') Object.entries(v).forEach(([k, on]) => { if (on) add(k); });
2175
+ return out;
2176
+ }
2177
+ const splitClasses = (s) => String(s || '').split(/\s+/).filter(Boolean);
2178
+ function readClass(el) {
2179
+ const bound = new Set(boundClasses(el));
2180
+ return splitClasses(el.getAttribute('class')).filter(c => !bound.has(c)).join(' ');
2181
+ }
2182
+ function writeClass(el, value) {
2183
+ const bound = boundClasses(el);
2184
+ const next = [...new Set([...splitClasses(value), ...bound])].join(' ');
2185
+ if (!next) { if (el.hasAttribute('class')) el.removeAttribute('class'); return; }
2186
+ if (el.getAttribute('class') !== next) el.setAttribute('class', next);
2187
+ }
2188
+
2189
+ function attrTarget(el) {
2190
+ if (el == null) el = target;
2191
+ if (!el || el.nodeType !== 1) return null;
2192
+ const area = regionOf(el);
2193
+ if (!area || !isActive(area)) return null;
2194
+ const kind = classify(area);
2195
+ // A component root also carries its placeholder's class and attributes
2196
+ if (kind === 'static' && el._manifestComponent) return null;
2197
+ return { el, area, kind };
2198
+ }
2199
+ function setAttr(name, value, el) {
2200
+ const t = attrTarget(el); if (!t) return false;
2201
+ name = String(name || '').trim().toLowerCase();
2202
+ const v = value === true ? '' : (value === false || value == null) ? null : String(value);
2203
+ const refused = attrRefusal(name, v, t.el.tagName);
2204
+ if (refused) { console.warn(`[edit] attribute refused (${refused}):`, name); return false; }
2205
+ if (lockedFor(t.el, 'attrs')) return false;
2206
+ const host = t.kind !== 'data' && leafHost(t.el);
2207
+ if (host) return setInLeaf(t, host, name, v);
2208
+ if (name === 'class') return setClass(t, v || '');
2209
+ if (t.kind === 'data') { console.warn('[edit] list rows are rendered from data; attributes on them are not editable'); return false; }
2210
+ if (t.kind === 'component') { console.warn('[edit] component nodes take class changes only'); return false; }
2211
+ if (!/^[@~]/.test(t.el.getAttribute('data-edit-key') || '')) return false;
2212
+ if (bindingOf(t.el, name) != null) console.warn(`[edit] ${name} is bound on this element; the binding will override the edited value`);
2213
+ const before = t.el.getAttribute(name);
2214
+ if (before === v) return false;
2215
+ if (v === null) t.el.removeAttribute(name); else t.el.setAttribute(name, v);
2216
+ return commitStaticNode(t.area, t.el, name, v, { before, coalesce: true });
2217
+ }
2218
+ // Inside a Rich Text leaf an attribute is part of the leaf's text: one text delta, kept only if the sanitizer keeps it.
2219
+ function setInLeaf(t, host, name, v) {
2220
+ if (lockedFor(host, 'text') || t.el.getAttribute(name) === v) return false;
2221
+ const focused = document.activeElement === host;
2222
+ const before = focused && host._preEdit !== undefined ? host._preEdit : leafText(host), was = t.el.getAttribute(name), cur = leafText(host);
2223
+ const put = (x) => x === null ? t.el.removeAttribute(name) : t.el.setAttribute(name, x);
2224
+ put(v);
2225
+ if (leafText(host) === cur) { put(was); console.warn(`[edit] ${name} is not kept inside text`); return false; }
2226
+ const value = leafHTML(host);
2227
+ const done = t.kind === 'component' ? commitComponentNode(t.area, host, 'text', value, before) : commitStaticNode(regionOf(host), host, 'text', value, { before });
2228
+ if (focused) { restartRich(host); host._preEdit = leafText(host); }
2229
+ return done;
2230
+ }
2231
+ function setClass(t, value) {
2232
+ if (t.kind === 'data') { console.warn('[edit] list rows are rendered from data; attributes on them are not editable'); return false; }
2233
+ if (leafHost(t.el)) { console.warn('[edit] class is not kept inside text'); return false; }
2234
+ const before = readClass(t.el);
2235
+ value = splitClasses(value).join(' ');
2236
+ if (before === value) return false;
2237
+ writeClass(t.el, value);
2238
+ if (t.kind === 'component') {
2239
+ if (t.el._editPath == null) return false;
2240
+ liveMainPropagate(t.area, t.el, 'class', t.el.getAttribute('class') || '');
2241
+ return commitComponentNode(t.area, t.el, 'class', value, before);
1293
2242
  }
1294
- const hide = () => { if (cmpMenu.matches(':popover-open')) cmpMenu.hidePopover(); };
1295
- const rv = cmpMenu.querySelector('[data-a="revert"]'); if (rv) rv.onclick = () => { revertNode(area, target); hide(); };
1296
- cmpMenu.querySelector('[data-a="revertall"]').onclick = () => { revertAll(area); hide(); };
1297
- cmpMenu.style.left = Math.min(e.clientX, innerWidth - 240) + 'px'; cmpMenu.style.top = Math.min(e.clientY, innerHeight - 180) + 'px';
1298
- if (!cmpMenu.matches(':popover-open')) { try { cmpMenu.showPopover(); } catch { } }
1299
- if (cls) setTimeout(() => cls.focus(), 0);
1300
- }
1301
-
1302
-
1303
- /* ---- Style: right-click → live utility-class input (no Apply; commit on close) ---- */
1304
- let menu;
1305
- function closeMenu() {
1306
- if (!menu || !menu.matches(':popover-open')) return;
1307
- if (menu._dirty && menu._target) { commitStaticNode(menu._target.closest('[data-edit-area]'), menu._target, 'class', menu._target.getAttribute('class') || ''); menu._dirty = false; }
1308
- try { menu.hidePopover(); } catch { } menu._target = null;
1309
- }
1310
- function ensureMenu() {
1311
- if (menu) return menu;
1312
- // A real popover: the top layer is above every stacking context, so the menu
1313
- // cannot be painted over by a resize handle or anything else with a z-index.
1314
- menu = document.createElement('div'); menu.setAttribute('data-edit-menu', ''); menu.setAttribute('popover', 'manual');
1315
- menu.innerHTML = '<small>Classes <span data-tag></span> · live</small><input type="text" spellcheck="false"><div class="row"><button class="ghost sm" data-a="close">Done</button></div>';
1316
- menu.addEventListener('pointerdown', e => e.stopPropagation());
1317
- const input = menu.querySelector('input');
1318
- input.addEventListener('input', () => { if (menu._target) { menu._target.setAttribute('class', input.value); menu._dirty = true; } });
1319
- input.addEventListener('keydown', e => { if (e.key === 'Enter') closeMenu(); });
1320
- menu.querySelector('[data-a="close"]').addEventListener('click', closeMenu);
1321
- document.body.appendChild(menu);
1322
- document.addEventListener('pointerdown', closeMenu);
1323
- document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
1324
- return menu;
1325
- }
1326
- function openMenu(el, x, y) {
1327
- const m = ensureMenu(); m._target = el; m._dirty = false; el._preClass = el.getAttribute('class') || '';
1328
- m.querySelector('[data-tag]').textContent = '· ' + el.tagName.toLowerCase();
1329
- const input = m.querySelector('input'); input.value = el.getAttribute('class') || '';
1330
- m.style.left = Math.min(x, innerWidth - 280) + 'px'; m.style.top = Math.min(y, innerHeight - 130) + 'px';
1331
- if (!m.matches(':popover-open')) { try { m.showPopover(); } catch { } }
1332
- setTimeout(() => { input.focus(); input.select(); }, 0);
1333
- }
1334
- // The class menu is authoring chrome, so it only opens where chrome was asked for.
1335
- function openClassMenu(area, e) {
1336
- if (!area._edit.authoring) return;
1337
- let t = e.target;
1338
- while (t && t !== area.parentElement && !(capOf(t, 'style') && !locked(t) && !t.hasAttribute('data-edit-handle'))) t = t.parentElement;
1339
- if (!t || !capOf(t, 'style') || locked(t)) return;
1340
- openMenu(t, e.clientX, e.clientY);
1341
- }
1342
-
1343
-
1344
- /* ---- Theme regime: edit CSS custom properties (x-edit.cssvar) ----
1345
- Not DOM-element editing. A variable set on :root is global; set on an x-edit
1346
- theme scope's element it cascades to that subtree ONLY (sandboxed theme), so the
1347
- same var name can live at both levels without collision. Controls are inputs that
1348
- declare their target as "scope:--var" (area/scope key) or bare "--var" (global).
1349
- Live = setProperty (var()-referencing utilities update for free); persisted as
1350
- {kind:'theme', scope, var, value}. Net-new variables are a later step. */
2243
+ if (!/^[@~]/.test(t.el.getAttribute('data-edit-key') || '')) return false;
2244
+ return commitStaticNode(t.area, t.el, 'class', value, { before, coalesce: true });
2245
+ }
2246
+ function editClasses(el, fn) {
2247
+ const t = attrTarget(el); if (!t) return false;
2248
+ if (lockedFor(t.el, 'attrs')) return false;
2249
+ const list = splitClasses(readClass(t.el));
2250
+ const next = fn(list);
2251
+ return next ? setClass(t, next.join(' ')) : false;
2252
+ }
2253
+ const classesApi = {
2254
+ add(cls, el) { const add = splitClasses(cls); return editClasses(el, l => [...l, ...add.filter(c => !l.includes(c))]); },
2255
+ remove(cls, el) { const rm = new Set(splitClasses(cls)); return editClasses(el, l => l.filter(c => !rm.has(c))); },
2256
+ toggle(cls, el, force) {
2257
+ const t = attrTarget(el); if (!t) return false;
2258
+ const names = splitClasses(cls), on = force ?? !names.every(c => t.el.classList.contains(c));
2259
+ return on ? classesApi.add(cls, t.el) : classesApi.remove(cls, t.el);
2260
+ },
2261
+ set(value, el) { const t = attrTarget(el); return !!t && !lockedFor(t.el, 'attrs') && setClass(t, value || ''); },
2262
+ has(cls, el) { void (estore && estore.version); const t = el == null ? target : el; return !!t && t.nodeType === 1 && splitClasses(cls).every(c => t.classList.contains(c)); },
2263
+ list(el) { void (estore && estore.version); const t = el == null ? target : el; return t && t.nodeType === 1 ? splitClasses(readClass(t)) : []; }
2264
+ };
2265
+
2266
+ /* ---- Component scope and revert ---- */
2267
+ const componentAreaOf = (el) => { const a = el && regionOf(el); return a && classify(a) === 'component' ? a : null; };
2268
+ function scopeOf(el) { const a = componentAreaOf(el == null ? target : el); return a ? a._editScope || 'instance' : null; }
2269
+ function setScope(value, el) {
2270
+ const a = componentAreaOf(el == null ? target : el);
2271
+ if (!a || (value !== 'instance' && value !== 'main')) return false;
2272
+ a._editScope = value; bump();
2273
+ return true;
2274
+ }
2275
+ function revert(el) {
2276
+ if (el == null) el = target;
2277
+ const a = componentAreaOf(el); if (!a) return false;
2278
+ if (el === a) revertAll(a);
2279
+ else if (el._editPath != null) revertNode(a, el);
2280
+ else return false;
2281
+ return true;
2282
+ }
2283
+
2284
+
2285
+ /* ---- Theme: CSS custom properties (x-edit.cssvar="--var" or "scope:--var") ----
2286
+ Global on :root, or sandboxed to an x-edit.theme scope's subtree. */
1351
2287
  const cssvarControls = new Set();
1352
2288
  const themeBaseline = {}; // "scope|var" → original inline value ('' if none) — for revert-to-baseline
1353
2289
  const themeTouched = new Set(); // every scope|var ever applied
@@ -1355,10 +2291,11 @@
1355
2291
  const themeTargetEl = (scope) => (scope && themeScopes[scope]) || document.documentElement;
1356
2292
  const cvValue = (el) => el.value + (el.dataset.unit || ''); // data-unit lets a range/number drive a unit'd var
1357
2293
 
1358
- function registerCssVar(el, expression) {
2294
+ function registerCssVar(el, expression, modifiers) {
1359
2295
  const spec = (expression || '').trim();
1360
- const m = spec.match(/^([\w-]+)\s*:\s*(--[\w-]+)$/); // "scope:--var" vs bare "--var" (global)
2296
+ const m = spec.match(/^([\w-]+)\s*:\s*(--[\w-]+)$/);
1361
2297
  el._cssvar = m ? { scope: m[1], var: m[2] } : { scope: null, var: spec };
2298
+ el._cssvar.persist = !!(modifiers && modifiers.includes('persist'));
1362
2299
  cssvarControls.add(el);
1363
2300
  }
1364
2301
  function syncCssVarInput(el) {
@@ -1375,7 +2312,7 @@
1375
2312
  syncCssVarInput(el);
1376
2313
  if (el._cvBound) return; el._cvBound = true;
1377
2314
  el.addEventListener('input', () => themeTargetEl(cv.scope).style.setProperty(cv.var, cvValue(el))); // live, no log
1378
- el.addEventListener('change', () => commitTheme(cv.scope, cv.var, cvValue(el))); // commit
2315
+ el.addEventListener('change', () => commitTheme(cv.scope, cv.var, cvValue(el), cv.persist || (s => !!s && !!(s._editPersist || (s._edit && s._edit.persist)))(cv.scope && themeScopes[cv.scope])));
1379
2316
  }
1380
2317
  function armThemeControls() { cssvarControls.forEach(armCssVar); }
1381
2318
 
@@ -1384,8 +2321,7 @@
1384
2321
  for (let i = 0; i < cursor; i++) { const d = log[i]; if (d.kind === 'theme') s[tkey(d.scope, d.var)] = d; }
1385
2322
  return s;
1386
2323
  }
1387
- // Re-derive every touched var from the log: state value if present, else the captured
1388
- // baseline (setProperty to restore an authored inline value; removeProperty if none).
2324
+ // Re-derive every touched var from the log, else its captured baseline.
1389
2325
  function applyThemeState() {
1390
2326
  const st = themeState();
1391
2327
  new Set([...themeTouched, ...Object.keys(st)]).forEach(kk => {
@@ -1396,14 +2332,58 @@
1396
2332
  });
1397
2333
  cssvarControls.forEach(syncCssVarInput);
1398
2334
  }
1399
- function commitTheme(scope, v, value) {
2335
+ function rebaseTheme() {
2336
+ Object.keys(themeBaseline).forEach(kk => { const sep = kk.indexOf('|'); themeBaseline[kk] = themeTargetEl(kk.slice(0, sep) || null).style.getPropertyValue(kk.slice(sep + 1)); });
2337
+ themeTouched.clear();
2338
+ }
2339
+ // A custom-property value must stay inside its declaration (same tokenizer as the
2340
+ // source writer): strings and url(...) are opaque; script schemes in url() and
2341
+ // expression() are refused anywhere; data: urls only as images.
2342
+ function themeValueRefusal(v) {
2343
+ const s = String(v == null ? '' : v);
2344
+ if (!s.trim()) return 'empty';
2345
+ if (/expression\s*\(/i.test(s)) return 'expression()';
2346
+ const stack = [];
2347
+ for (let i = 0; i < s.length; i++) {
2348
+ const ch = s[i];
2349
+ if (ch === '"' || ch === "'") {
2350
+ let j = i + 1;
2351
+ for (; j < s.length && s[j] !== ch; j++) { if (s[j] === '\\') j++; else if (s[j] === '\n') return 'unbalanced quotes'; }
2352
+ if (j >= s.length) return 'unbalanced quotes';
2353
+ i = j; continue;
2354
+ }
2355
+ if (/^url\(/i.test(s.slice(i, i + 4)) && !/[\w-]/.test(s[i - 1] || '')) {
2356
+ let j = i + 4, q = null, body = '';
2357
+ for (; j < s.length; j++) {
2358
+ const c = s[j];
2359
+ if (q) { if (c === '\\') { body += s[++j] || ''; continue; } if (c === q) { q = null; continue; } body += c; continue; }
2360
+ if (c === '"' || c === "'") { q = c; continue; }
2361
+ if (c === ')') break;
2362
+ body += c;
2363
+ }
2364
+ if (j >= s.length) return 'unterminated url()';
2365
+ const u = body.replace(/[\u0000-\u0020\u007f]/g, '').toLowerCase();
2366
+ if (/^(javascript|vbscript):/.test(u)) return 'script URL';
2367
+ if (u.startsWith('data:') && !u.startsWith('data:image/')) return 'data URL';
2368
+ i = j; continue;
2369
+ }
2370
+ if (ch === ';' || ch === '{' || ch === '}') return `contains ${ch}`;
2371
+ if (s.startsWith('/*', i) || s.startsWith('<!--', i) || s.startsWith('</', i)) return 'contains a comment or markup';
2372
+ if (ch === '(' || ch === '[') stack.push(ch);
2373
+ else if (ch === ')' || ch === ']') { if (stack.pop() !== (ch === ')' ? '(' : '[')) return 'unbalanced brackets'; }
2374
+ }
2375
+ return stack.length ? 'unbalanced brackets' : null;
2376
+ }
2377
+ function commitTheme(scope, v, value, persist) {
2378
+ if (!/^--[\w-]+$/.test(v || '')) { console.warn('[edit] x-edit.cssvar needs a custom property name (--name):', v); return; }
2379
+ const why = themeValueRefusal(value);
2380
+ if (why) { console.warn(`[edit] theme value refused (${why}):`, value); return; }
1400
2381
  const kk = tkey(scope, v); themeTouched.add(kk);
1401
2382
  themeTargetEl(scope).style.setProperty(v, value);
1402
2383
  const cur = themeState()[kk]; if (cur && cur.value === value) return; // dedupe
1403
- log.splice(cursor); log.push({ kind: 'theme', scope: scope || null, var: v, value }); cursor = log.length; saveState(); refresh();
2384
+ pushDelta({ kind: 'theme', scope: scope || null, var: v, value }, !!persist);
1404
2385
  }
1405
- // B-side: each theme var → its scope's target CSS file (data-edit-theme-file on the
1406
- // scope element); global vars carry no file → the server falls back to the theme file.
2386
+ // Each var → its scope's data-edit-theme-file, else the server's theme file.
1407
2387
  function themePatches() {
1408
2388
  return Object.values(themeState()).map(d => {
1409
2389
  const el = d.scope ? themeScopes[d.scope] : null;
@@ -1412,173 +2392,220 @@
1412
2392
  }
1413
2393
 
1414
2394
 
1415
- /* ---- Arm / disarm an area for its regime + caps ---- */
1416
- // Static nodes are addressed by a content-derived key, not by their position
1417
- // among siblings. Position was stable while the tree was, but adding, removing or
1418
- // duplicating a block shifts every address after it — so an edit recorded before
1419
- // a structural change replayed onto the wrong element afterwards.
1420
- //
1421
- // The key is derived once, from what the element held when the area was first
1422
- // armed, and never recomputed for an element that already has one. That is what
1423
- // makes it survive both kinds of change: a later text edit does not move the
1424
- // address, and a reload derives the same key again because the source it loads is
1425
- // the same source the key was first taken from.
2395
+ /* ---- Arm / disarm ---- */
2396
+ // Identity is assigned once, on the first mark, from what the source holds: the
2397
+ // origin area plus a content key (ordinal per repeat), and the element's path.
2398
+ // Later arrivals that are not copies get a non-source id and never publish.
2399
+ const originAt = new Map();
1426
2400
  function markStatic(area) {
2401
+ const region = key(area), first = !area._marked;
2402
+ area._marked = true;
1427
2403
  area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {}; area._baseText = area._baseText || {};
1428
2404
  const seen = Object.create(null);
1429
- const markEl = (el) => {
2405
+ const markEl = (el, path) => {
1430
2406
  let k = el.getAttribute('data-edit-key');
1431
- if (k) {
1432
- // Keep the tally in step so a new element never claims an ordinal
1433
- // an existing one already holds.
1434
- // The ordinal is the LAST #digits suffix — authored text may contain '#'.
1435
- const m = /^([\s\S]*)#(\d+)$/.exec(k);
1436
- const base = m ? m[1] : k, n = m ? m[2] : undefined;
1437
- seen[base] = Math.max(seen[base] || 0, n ? +n : 1);
1438
- } else {
1439
- const base = el.tagName + ':' + (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
1440
- const n = seen[base] = (seen[base] || 0) + 1;
1441
- k = n > 1 ? base + '#' + n : base;
2407
+ if (!k) {
2408
+ if (first) {
2409
+ const base = staticKey(el);
2410
+ const n = seen[base] = (seen[base] || 0) + 1;
2411
+ k = canonKey(region, n > 1 ? base + '#' + n : base);
2412
+ originAt.set(k, path);
2413
+ } else k = '!' + mint();
1442
2414
  el.setAttribute('data-edit-key', k);
1443
2415
  }
1444
- el.setAttribute('data-edit-path', pathOf(el, area)); // still what the source writer navigates by
1445
- if (!(k in area._baseClass)) area._baseClass[k] = el.getAttribute('class') || '';
1446
- if (!(k in area._baseStyle)) area._baseStyle[k] = el.getAttribute('style') || '';
2416
+ if (!(k in area._baseClass)) area._baseClass[k] = readClass(el);
2417
+ if (!(k in area._baseStyle)) area._baseStyle[k] = ownStyle(el);
1447
2418
  if (!(k in area._baseText) && !el.children.length) area._baseText[k] = el.innerHTML.trim();
1448
2419
  };
1449
- markEl(area);
1450
- // Stop at a rich editor: its internals are its own, and marking them would
1451
- // both litter its content and let applyStaticState fight it for the same nodes.
1452
- area.querySelectorAll('*').forEach(el => {
1453
- if (el.hasAttribute('data-edit-handle')) return;
1454
- if (el.parentElement && el.parentElement.closest('[data-text-edit]')) return;
1455
- markEl(el);
1456
- });
1457
- if (!area._baseOrder) area._baseOrder = staticKeys(area);
1458
- if (!(key(area) in lastOrder)) lastOrder[key(area)] = area._baseOrder;
2420
+ // Inside a Rich Text leaf: counted, as the source writer counts them, never keyed.
2421
+ const count = (el) => { for (const c of el.children) { const b = staticKey(c); seen[b] = (seen[b] || 0) + 1; count(c); } };
2422
+ // The writer never descends into x-rich-text* hosts (fields, menus, controls) or rendered hosts.
2423
+ const opaque = (el) => authoredRich(el) || isRendered(el) || [...el.attributes].some(a => a.name.startsWith('x-rich-text'));
2424
+ const walk = (el, path) => {
2425
+ markEl(el, path);
2426
+ if (el !== area && opaque(el)) return;
2427
+ if (el._editRt || ((first || el._editRtFormat) && wholeLeaf(el) && capOf(el, 'text') && !isRendered(el))) { if (first) count(el); return; }
2428
+ let i = 0;
2429
+ for (const c of el.children) {
2430
+ if (pluginNode(c) || c.closest('[data-edit-ghost]') || isGenerated(c)) continue;
2431
+ const p = path === '' ? String(i) : path + '.' + i;
2432
+ // Another component's markup is one opaque element here, its <x-name> host
2433
+ // (data-edit-host; its first root stands for it, the rest follow it)
2434
+ if (c._manifestComponent) {
2435
+ if (extraRoot(c)) continue;
2436
+ if (!c.hasAttribute('data-edit-host')) {
2437
+ // Marked while still a placeholder: the components plugin carried its key over
2438
+ const carried = c.getAttribute('data-edit-key'), own = canonKey(region, 'X-' + c._manifestComponent.toUpperCase() + ':');
2439
+ let k = carried && (carried === own || carried.startsWith(own + '#')) ? carried : '!' + mint();
2440
+ if (first && k[0] === '!') { const b = 'X-' + c._manifestComponent.toUpperCase() + ':', n = seen[b] = (seen[b] || 0) + 1; k = canonKey(region, n > 1 ? b + '#' + n : b); originAt.set(k, p); }
2441
+ c.setAttribute('data-edit-host', k);
2442
+ }
2443
+ i++; continue;
2444
+ }
2445
+ walk(c, p);
2446
+ i++;
2447
+ }
2448
+ };
2449
+ walk(area, '');
1459
2450
  }
2451
+ const warnedRendered = new Set(), renderedRegions = new Set();
1460
2452
  function armArea(area) {
2453
+ if (area._edit.rendered) renderedRegions.add(key(area));
2454
+ if (area._edit.rendered && !warnedRendered.has(key(area))) { warnedRendered.add(key(area)); console.warn(`[edit] x-edit="${key(area)}" is inside content ${area._edit.rendered} renders: it edits on the page (and reaches onPublish), but mnfst-run cannot write it to a source file`); }
2455
+ uneditIn(null);
1461
2456
  const kind = classify(area);
1462
- area.setAttribute('data-edit-armed', '');
1463
- // Chrome is opt-in: a sortable list should look like a list, not like a page
1464
- // being edited. The attributes are all CSS asks for — what is shown, and
1465
- // whether it is shown at all, is decided in the stylesheet.
1466
- area.toggleAttribute('data-edit-authoring', !!area._edit.authoring);
1467
- // .authoring regions get the UI scope by default; anything else opts in
1468
- // by putting data-edit-ui on the region or an ancestor. Only remove what
1469
- // the plugin itself added, never an author's attribute.
1470
- if (area._edit.authoring && !area.hasAttribute('data-edit-ui')) { area.setAttribute('data-edit-ui', ''); area._uiByPlugin = true; }
1471
- area.setAttribute('data-edit-label', kind === 'component' ? `${key(area)} · component · ${area._editScope || 'instance'}` : `${key(area)} · ${kind}`);
1472
2457
  if (kind === 'static') markStatic(area);
1473
- [area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (capOf(c, 'sort') && !locked(c)) makeSortable(c); });
2458
+ markPieces(area);
1474
2459
  [area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (ownsCap(c, 'size')) armSize(c); });
1475
- if (kind === 'component') armComponent(area); else armText(area);
1476
- if (!area._dndBound) {
1477
- area._dndBound = true;
1478
- // Native HTML5 drag (a text selection, an image, a link) must never
1479
- // start inside a region: it drops SELECTED TEXT into neighbouring
1480
- // editable leaves, silently merging elements. All region dragging is
1481
- // pointer-based.
2460
+ if (kind === 'component') armComponent(area); else if (kind === 'static') armText(area);
2461
+ if (kind === 'data' && capOf(area, 'data')) armDataValues(area);
2462
+ if (!area._editBound) {
2463
+ area._editBound = true;
2464
+ area.addEventListener('pointerdown', onAreaPointerDown);
2465
+ area.addEventListener('keydown', (e) => { if (isActive(area)) onPieceKeydown(e, area); });
2466
+ area.addEventListener('pointerover', (e) => { if (isActive(area)) gripOver(e, area); });
2467
+ area.addEventListener('pointerleave', (e) => gripLeave(e.relatedTarget));
2468
+ area.addEventListener('focusin', (e) => { if (isActive(area)) gripFocus(e, area); });
2469
+ area.addEventListener('focusout', (e) => gripBlur(e.relatedTarget));
2470
+ // Native HTML5 drag would drop selected text into neighbouring leaves.
1482
2471
  area.addEventListener('dragstart', (e) => { if (isActive(area)) e.preventDefault(); });
1483
- area.addEventListener('drop', (e) => { if (isActive(area)) e.preventDefault(); }); // nothing drops INTO a region either (an OS image drop would merge into a leaf)
2472
+ area.addEventListener('drop', (e) => { if (isActive(area)) e.preventDefault(); });
1484
2473
  }
1485
- if (!area._ctxBound) {
1486
- area._ctxBound = true;
1487
- // One binding for the whole area: report the block, and fall back to the
1488
- // built-in authoring menu only if the project did not take the event.
1489
- area.addEventListener('contextmenu', (e) => {
1490
- if (!isActive(area)) return;
1491
- const builtIn = () => classify(area) === 'component' ? openComponentMenu(e, area) : openClassMenu(area, e);
1492
- if (onBlockContext(e, builtIn)) return;
1493
- if (!area._edit.authoring) return;
1494
- e.preventDefault();
1495
- builtIn();
2474
+ if (!area._editObserver) {
2475
+ area._editObserver = new MutationObserver((recs) => {
2476
+ if (dragged || area._rearmQueued || recs.every(r => r.target.isContentEditable)) return;
2477
+ area._rearmQueued = true;
2478
+ queueMicrotask(() => { area._rearmQueued = false; if (area._armed && isActive(area) && !dragged) armArea(area); });
1496
2479
  });
2480
+ area._editObserver.observe(area, { childList: true, subtree: true });
1497
2481
  }
1498
- if (kind === 'data' && capOf(area, 'data')) armDataValues(area);
1499
2482
  }
1500
2483
  function disarmArea(area) {
2484
+ if (gripFor && area.contains(gripFor)) hideGrip();
2485
+ [area, ...area.querySelectorAll('[data-edit-area]')].forEach(e => { if (e._editPos) { e.style.removeProperty('position'); e._editPos = false; } });
1501
2486
  area.querySelectorAll('[data-edit-handle]').forEach(h => h.remove());
1502
- area.querySelectorAll('[contenteditable]').forEach(e => e.removeAttribute('contenteditable'));
1503
- area.querySelectorAll('[data-edit-sortable]').forEach(e => { ['data-edit-sortable', 'data-edit-grabbed', 'data-edit-dragging', 'tabindex', 'role', 'draggable'].forEach(a => e.removeAttribute(a)); e._dragBound = false; });
1504
- [area, ...area.querySelectorAll('[data-edit-sizable],[data-edit-movable]')].forEach(e => { e.removeAttribute('data-edit-sizable'); e.removeAttribute('data-edit-movable'); e._sizeBound = false; });
1505
- area.removeAttribute('data-edit-armed'); area.removeAttribute('data-edit-label');
1506
- if (area._uiByPlugin) { area.removeAttribute('data-edit-ui'); area._uiByPlugin = false; }
2487
+ uneditIn(area);
2488
+ [area, ...area.querySelectorAll('[contenteditable]')].forEach(e => { if (e.hasAttribute('contenteditable')) e.removeAttribute('contenteditable'); e._pieceMode = false; e.removeAttribute('data-edit-piece-mode'); });
2489
+ area.querySelectorAll('[data-edit-piece]').forEach(unmarkPiece);
2490
+ [area, ...area.querySelectorAll('[data-edit-area]')].forEach(e => { e._sizeBound = false; });
2491
+ if (grabbed && area.contains(grabbed.item)) cancelGrab();
1507
2492
  }
1508
2493
 
1509
- /* ---- Activation: per-area, always-on by default (.gated needs $edit.on()) ---- */
2494
+ /* ---- Activation: always-on by default; .gated waits for $edit.on() ---- */
1510
2495
  let estore, booted = false, armQueued = false;
1511
- const isActive = (a) => !!a && (!a._edit.gated || (estore && estore.active));
1512
- const anyActive = () => areas().some(isActive);
1513
- function armAll() { areas().forEach(a => { if (isActive(a) && !a._armed) { lastSnap[key(a)] = snapshot(a, classify(a)); armArea(a); a._armed = true; } }); armThemeControls(); refresh(); booted = true; }
1514
-
1515
- /* ---- Late regions: arm when Alpine initialises them, release when it destroys them ---- */
1516
- // Arming ran once at boot, so anything rendered later — x-if, a route change, a lazy
1517
- // component, x-markdown output — stayed inert. Every one of those paths goes through
1518
- // Alpine.initTree, so the x-edit directive itself is the hook; no observer needed.
1519
- // Deferred one microtask so the tree Alpine is walking is complete before baselines
1520
- // are captured, and batched so a burst of regions costs one pass.
2496
+ const isActive = (a) => !!a && !!a._edit && (!a._edit.gated || !!(estore && estore.active)) && !sharedKey(a);
2497
+ const warnedShared = new Set();
2498
+ function armAll() {
2499
+ const fresh = [];
2500
+ areas().forEach(a => {
2501
+ if (!sharedKey(a)) return;
2502
+ if (!warnedShared.has(key(a))) { warnedShared.add(key(a)); console.warn(`[edit] x-edit="${key(a)}" is on the page more than once (a component used several times); it is not editable here`); }
2503
+ if (a._armed) { disarmArea(a); a._armed = false; }
2504
+ });
2505
+ areas().forEach(a => { if (isActive(a) && !a._armed) { if (!a._editBound) fresh.push(a); lastSnap[key(a)] = snapshot(a, classify(a)); armArea(a); a._armed = true; } });
2506
+ // A region mounted again after boot (x-if, a deferred route) shows the edits the log still holds for it
2507
+ if (booted && fresh.length) {
2508
+ if (log.some(d => /^st-/.test(d.kind))) { surfaceConflicts(fresh); applyStaticState(); }
2509
+ if (log.some(d => d.kind === 'cmp-main' || d.kind === 'cmp-inst')) applyComponentState();
2510
+ }
2511
+ armThemeControls(); refresh(); booted = true;
2512
+ }
2513
+
2514
+ // Regions rendered after boot (x-if, routes, lazy components) arm through the directive.
1521
2515
  function armLater() {
1522
- if (!booted || armQueued) return; // the boot pass still owns start-up ordering
2516
+ if (!booted || armQueued) return;
1523
2517
  armQueued = true;
1524
2518
  queueMicrotask(() => { armQueued = false; armAll(); });
1525
2519
  }
1526
- // Runs from Alpine's directive cleanup (x-if/x-for teardown, or its own mutation
1527
- // observer), so a removed region drops its observers and its registry slot.
1528
2520
  function releaseEdit(el) {
1529
2521
  Object.keys(themeScopes).forEach(k => { if (themeScopes[k] === el) delete themeScopes[k]; });
1530
2522
  cssvarControls.delete(el);
1531
2523
  if (!editEls.has(el)) return;
1532
2524
  if (el._armed) { disarmArea(el); el._armed = false; }
1533
- [el, ...el.querySelectorAll('*')].forEach(n => { if (n._sortObserver) { n._sortObserver.disconnect(); n._sortObserver = null; } });
1534
- editEls.delete(el); delete el._edit;
2525
+ if (el._editObserver) { el._editObserver.disconnect(); el._editObserver = null; }
2526
+ editEls.delete(el); delete el._edit; resetAreas();
1535
2527
  el.removeAttribute('data-edit-area');
2528
+ if (target && (target === el || el.contains(target))) setTarget(null);
1536
2529
  refresh();
1537
2530
  }
1538
- // Build B-side source patches from the edit set (storage-agnostic; overlay
1539
- // already auto-persists to localStorage on commit).
1540
- function buildPatches() {
1541
- const patches = Object.entries(fold()).filter(([k]) => authoringRegion(k)).map(([k, v]) => patchFor(k, v.kind, v.snap)); // data
1542
- const toEdits = (paths, area, fileOrder) => {
1543
- const e = [];
1544
- Object.entries(paths).forEach(([k, props]) => {
1545
- // Keys address across a session; the source writer navigates by
1546
- // position, so resolve to where the element actually sits now (the
1547
- // server applies order before edits, so current position is the
1548
- // right one). A deleted element has nothing to write.
1549
- const el = area && (area.getAttribute('data-edit-key') === k ? area : area.querySelector(`[data-edit-key="${CSS.escape(k)}"]`));
1550
- let path = el ? pathOf(el, area) : k;
1551
- if (area && !el) return;
1552
- // RULE: paths must be valid against the file state the server will reach —
1553
- // a pending st-children change can't be applied there (a short order is
1554
- // skipped), so address by the block's position in the UNMODIFIED file.
1555
- if (el && el !== area && fileOrder) {
1556
- let block = el; while (block.parentElement && block.parentElement !== area) block = block.parentElement;
1557
- const fi = fileOrder.indexOf(block.getAttribute('data-edit-key'));
1558
- if (fi < 0) return; // block not in the file (added this session) — nothing to address
1559
- const segs = path.split('.'); segs[0] = String(fi); path = segs.join('.');
1560
- }
1561
- Object.entries(props).forEach(([prop, value]) => e.push({ path, key: k, prop, value }));
1562
- });
1563
- return e;
2531
+
2532
+ /* ---- Source patches: every area ----
2533
+ A static area publishes its FINAL structure as a tree of identities. Each node
2534
+ names a source element (key, plus its origin path as a fallback), carries its
2535
+ own edits, and lists children only where structure changed beneath it; a node
2536
+ without children keeps its source markup. Copies name the element they copy.
2537
+ Removed source elements are listed so the writer can tell them from omissions. */
2538
+ const treeKids = (el) => realChildren(el).filter(c => !isGenerated(c));
2539
+ function staticTrees(ss) {
2540
+ const out = [];
2541
+ const editsFor = (id) => {
2542
+ const ops = ss.node[id]; if (!ops) return null;
2543
+ const e = {};
2544
+ Object.entries(ops).forEach(([prop, v]) => { e[prop] = (prop === 'class' || prop === 'style') && v === '' ? null : v; });
2545
+ return Object.keys(e).length ? e : null;
1564
2546
  };
1565
- for (let i = 0; i < cursor; i++) { // cross-region moves travel in log order, ahead of per-region ops
2547
+ const nontrivial = (n) => n.edits || n.children || n.key[0] === '~';
2548
+ const live = keyMap(staticAreas());
2549
+ staticAreas().forEach(area => {
2550
+ const region = key(area), comp = sourceComponent(area);
2551
+ const ids = new Proxy({}, { get: (_, k) => { const o = originOf(String(k)), a = areaByKey(decodeURIComponent(o.slice(1, o.indexOf('@', 1)))); const m = baseIds && a && baseIds[sourceFile(a)]; return m ? m[o] : undefined; } });
2552
+ // The children a restructured container had when the page loaded (the server refuses
2553
+ // the rewrite if the source no longer has them).
2554
+ const baseOf = (parent) => {
2555
+ const list = parent === '' ? (ss.orderBase[region] || {})[''] : Object.values(ss.orderBase).map(m => m[parent]).find(Boolean);
2556
+ return list ? list.filter(k => k[0] === '@').map(k => { const b = { key: k }, at = originAt.get(k); if (at != null) b.at = at; if (ids && ids[k]) b.fp = ids[k]; return b; }) : null;
2557
+ };
2558
+ const build = (el, isRoot) => {
2559
+ const host = !isRoot && el.getAttribute('data-edit-host');
2560
+ if (host) { if (host[0] === '!') return null; const h = { key: host }, at = originAt.get(host); if (at != null) h.at = at; if (ids && ids[host]) h.fp = ids[host]; return h; }
2561
+ if (!isRoot && el._manifestComponent) return null;
2562
+ const id = el.getAttribute('data-edit-key');
2563
+ if (!id || id[0] === '!') return null;
2564
+ const n = { key: id };
2565
+ const at = originAt.get(originOf(id)); if (at != null) n.at = at;
2566
+ if (ids && ids[originOf(id)]) n.fp = ids[originOf(id)];
2567
+ const e = editsFor(id); if (e) n.edits = e;
2568
+ if (!(e && 'text' in e) && !isRendered(el) && !el.hasAttribute('data-rich-text')) {
2569
+ const kids = treeKids(el).map(c => build(c)).filter(Boolean);
2570
+ const structural = ss.touched.has(isRoot ? '#' + region : id);
2571
+ if (id[0] === '~' || structural) { n.children = kids; const b = structural && id[0] !== '~' && baseOf(isRoot ? '' : id); if (b) n.base = b; }
2572
+ else if (kids.some(nontrivial)) { n.children = kids.filter(nontrivial); n.path = true; } // only a path to edits: the source keeps its own order
2573
+ }
2574
+ return n;
2575
+ };
2576
+ const tree = build(area, true);
2577
+ if (!tree || !(tree.edits || tree.children)) return;
2578
+ delete tree.at; delete tree.fp;
2579
+ out.push(comp ? { kind: 'static-tree', region, component: comp, tree } : { kind: 'static-tree', region, tree });
2580
+ });
2581
+ // Removed: named by a structural delta's `before`, absent from every current order.
2582
+ const placedKeys = new Set();
2583
+ new Set([...Object.keys(ss.orderBase), ...Object.keys(ss.order)]).forEach(r => {
2584
+ Object.values(Object.assign({}, ss.orderBase[r] || {}, ss.order[r] || {})).forEach(ks => ks.forEach(k => placedKeys.add(k)));
2585
+ });
2586
+ const removed = new Set();
2587
+ for (let i = 0; i < cursor; i++) {
1566
2588
  const d = log[i];
1567
- if (d.kind !== 'st-move' || !authoringRegion(d.from) || !authoringRegion(d.to)) continue;
1568
- patches.push({ kind: 'static-move', from: d.from, to: d.to, key: d.key, toKey: d.toKey, toOrder: d.toOrder });
2589
+ if (d.kind === 'st-children') d.before.forEach(k => { if (k && k[0] === '@' && !placedKeys.has(k) && !live.has(k)) removed.add(k); });
1569
2590
  }
1570
- const ss = staticState(); // static: per-node ops + reorder permutation (no whole HTML)
1571
- new Set([...Object.keys(ss.node), ...Object.keys(ss.order)]).forEach(region => {
1572
- if (!authoringRegion(region)) return;
1573
- const area = areaByKey(region);
1574
- const structural = log.slice(0, cursor).some(d => d.kind === 'st-children' && d.region === region);
1575
- const edits = toEdits(ss.node[region] || {}, area, structural ? (area && area._baseOrder) || [] : null);
1576
- // A moved region always gets its folded order (pre-move reorders are NOT
1577
- // in the move splice); the server skips a reorder that is already true.
1578
- const order = ss.order[region] || null;
1579
- if (edits.length || order) patches.push({ kind: 'static', region, edits, order });
2591
+ const regionOfKey = (k) => decodeURIComponent(k.slice(1, k.indexOf('@', 1)));
2592
+ removed.forEach(k => {
2593
+ const r = regionOfKey(k);
2594
+ let p = out.find(x => x.region === r);
2595
+ if (!p) { const a = areaByKey(r); if (!a || sharedKey(a)) return; const c = sourceComponent(a); p = c ? { kind: 'static-tree', region: r, component: c, tree: { key: '' } } : { kind: 'static-tree', region: r, tree: { key: '' } }; out.push(p); }
2596
+ (p.removed = p.removed || []).push(k);
1580
2597
  });
1581
- const dv = dataValueState(); // data-value edits → field writes (local file / cloud $update)
2598
+ return out;
2599
+ }
2600
+ function buildPatches() {
2601
+ const patches = Object.entries(fold()).map(([k, v]) => patchFor(k, v.kind, v.snap));
2602
+ staticTrees(staticState()).forEach(p => patches.push(p));
2603
+ const toEdits = (paths) => {
2604
+ const e = [];
2605
+ Object.entries(paths).forEach(([path, props]) => Object.entries(props).forEach(([prop, value]) => e.push({ path, key: path, prop, value })));
2606
+ return e;
2607
+ };
2608
+ const dv = dataValueState();
1582
2609
  Object.entries(dv).forEach(([source, recs]) => Object.entries(recs).forEach(([id, fields]) => Object.entries(fields).forEach(([field, value]) => patches.push({ kind: 'data-val', source, id, field, value }))));
1583
2610
  const { main, inst, reverts } = componentState();
1584
2611
  Object.entries(main).forEach(([component, paths]) => { const edits = toEdits(paths); if (edits.length) patches.push({ kind: 'component', scope: 'main', component, edits }); });
@@ -1587,85 +2614,287 @@
1587
2614
  const edits = toEdits(inst[region] || {}), rv = [...(reverts[region] || [])];
1588
2615
  if (edits.length || rv.length) patches.push({ kind: 'component', scope: 'instance', component: componentName(area), region, edits, reverts: rv });
1589
2616
  });
1590
- themePatches().forEach(p => patches.push(p)); // theme: CSS-var value writes (scoped → its file; global → theme file)
2617
+ themePatches().forEach(p => patches.push(p));
1591
2618
  return patches;
1592
2619
  }
1593
- function publish() {
1594
- const patches = buildPatches();
1595
- if (estore && typeof estore.onPublish === 'function') return Promise.resolve(estore.onPublish(patches, { log, cursor }));
1596
- if (!patches.length) return Promise.resolve({ results: [] });
1597
- return fetch('/__edit/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patches) }).then(r => r.json()); // default: local dev-server source writer (authoring)
1598
- }
1599
- function on() { if (estore) estore.active = true; armAll(); }
1600
- function off() { if (estore) estore.active = false; areas().forEach(a => { if (a._edit.gated && a._armed) { disarmArea(a); a._armed = false; } }); closeMenu(); refresh(); }
1601
- // Runtime lock — meta, not content: applied immediately, not logged. Locks
1602
- // the target and its subtree (descendants without their own x-edit inherit).
1603
- function setLock(el, val) {
1604
- if (!el) return;
1605
- if (!el._edit) { el._edit = { key: `lock-${++autoN}`, caps: new Set(), lock: val, gated: false }; el.setAttribute('data-edit-area', ''); editEls.add(el); }
1606
- else { el._edit.lock = val; if (val) el._edit.caps = new Set(); }
1607
- if (val) { el.querySelectorAll('[data-edit-handle]').forEach(h => h.remove()); el.removeAttribute('draggable'); el.removeAttribute('contenteditable'); el.removeAttribute('data-edit-sizable'); el._sizeBound = false; el.querySelectorAll('[draggable],[contenteditable]').forEach(n => { n.removeAttribute('draggable'); n.removeAttribute('contenteditable'); }); }
1608
- const area = areas().find(a => a === el || a.contains(el));
1609
- if (area && isActive(area)) armArea(area);
1610
- }
1611
-
1612
-
1613
- /* ---- Authoring chrome: floating toolbar (undo/redo/publish/reset). A convenience
1614
- over the $edit magic — built on first use and shown only where there is
1615
- something to edit, so it never sits on a route with no editable area. ---- */
1616
- let bar;
1617
- function buildUI() {
1618
- if (bar) return bar;
1619
- bar = document.createElement('div'); bar.setAttribute('data-edit-toolbar', '');
1620
- bar.innerHTML = '<button class="ghost" data-a="undo">↶ Undo</button><button class="ghost" data-a="redo">↷ Redo</button><button class="brand" data-a="publish">Publish</button><button class="ghost" data-a="reset">Reset</button>';
1621
- bar.addEventListener('click', e => {
1622
- const btn = e.target.closest('button'); if (!btn) return; const a = btn.getAttribute('data-a');
1623
- if (a === 'undo') undo(); if (a === 'redo') redo(); if (a === 'reset') { localStorage.removeItem(LS_KEY); location.reload(); }
1624
- if (a === 'publish') {
1625
- const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Publishing…';
1626
- publish().then(r => { const rs = r.results || []; const w = rs.filter(x => x.status === 'written').length, sk = rs.filter(x => x.status === 'skipped').length; btn.textContent = `Wrote ${w}${sk ? ` · ${sk} skipped` : ''}`; })
1627
- .catch(() => btn.textContent = 'Failed').finally(() => { btn.disabled = false; setTimeout(() => btn.textContent = orig, 2800); });
2620
+ // The source now holds every published edit: history starts again from here, and
2621
+ // identities are re-derived from the page as a fresh load of that source would.
2622
+ // Deltas for areas not on the page were not published; they stay, as do those of
2623
+ // `keep` (areas a publish left out), whose identities are left as they are.
2624
+ function rebase(keep) {
2625
+ const mounted = (r) => r == null || (!!areaByKey(r) && !(keep && keep.has(r)));
2626
+ const pending = (d) => (d.kind === 'st-move' ? [d.from, d.to] : [d.region]).some(r => !mounted(r));
2627
+ // Runtime locks are app state, not source: carried over by element.
2628
+ const locks = [];
2629
+ const lockState = lockStateNow();
2630
+ keyMap(staticAreas().filter(a => !(keep && keep.has(key(a))))).forEach((el, k) => { const m = lockState.get(k); if (m && m.size) locks.push([el, new Map(m), k === el.getAttribute('data-edit-host')]); });
2631
+ const nonStaticLocks = log.slice(0, cursor).filter(d => d.kind === 'lock' && !/^[~@]/.test(d.key) && !pending(d));
2632
+ let c = 0;
2633
+ log = log.filter((d, i) => { const keep = pending(d); if (keep && i < cursor) c++; return keep; });
2634
+ cursor = c; ver++;
2635
+ const relock = () => {
2636
+ const add = (d) => { log.splice(cursor, 0, d); cursor++; if (persistOf(d.region)) persisted.add(d); };
2637
+ nonStaticLocks.forEach(add);
2638
+ locks.forEach(([el, m, host]) => {
2639
+ const a = host ? blockArea(el) : regionOf(el), k = el.getAttribute(host ? 'data-edit-host' : 'data-edit-key'); if (!a || !k) return;
2640
+ [true, false].forEach(v => { const acts = [...m].filter(([, x]) => x === v).map(([act]) => act); if (acts.length) add({ kind: 'lock', region: key(a), key: k, actions: acts.length === LOCK_ACTIONS.length ? [] : acts, value: v }); });
2641
+ });
2642
+ ver++;
2643
+ };
2644
+ saveState();
2645
+ const keptAt = keep ? [...originAt].filter(([k]) => keep.has(decodeURIComponent(k.slice(1, k.indexOf('@', 1))))) : [];
2646
+ originAt.clear();
2647
+ keptAt.forEach(([k, p]) => originAt.set(k, p));
2648
+ areas().forEach(a => {
2649
+ if (keep && keep.has(key(a))) return;
2650
+ const kind = classify(a);
2651
+ a._baseText = {}; a._baseClass = {}; a._baseStyle = {};
2652
+ if (kind === 'static') {
2653
+ [a, ...a.querySelectorAll('[data-edit-key]')].forEach(n => { if (regionOf(n) === a) n.removeAttribute('data-edit-key'); });
2654
+ a.querySelectorAll('[data-edit-host]').forEach(n => { if (regionOf(n.parentElement) === a) n.removeAttribute('data-edit-host'); });
2655
+ a._marked = false; markStatic(a);
1628
2656
  }
2657
+ if (kind === 'component' && a._armed) armComponent(a);
2658
+ lastSnap[key(a)] = snapshot(a, kind);
1629
2659
  });
1630
- document.body.appendChild(bar);
1631
- return bar;
2660
+ rebaseTheme();
2661
+ relock();
2662
+ saveState(); refresh();
2663
+ }
2664
+ const published = (r) => !(r && Array.isArray(r.results) && r.results.some(x => x && x.status === 'error'));
2665
+ // Source file hashes as the page loaded them (mnfst-run --edit); a publish carries them
2666
+ // so the server can refuse when a file changed underneath this page.
2667
+ // With them, each source element's fingerprint as loaded: the server refuses an edit
2668
+ // whose element no longer matches (changed, or a twin that moved).
2669
+ let baseFiles = null, baseIds = null;
2670
+ function loadBase(filesOnly) {
2671
+ if (!/^https?:$/.test(location.protocol) || typeof fetch !== 'function') return Promise.resolve(null);
2672
+ return fetch('/__edit/base', { cache: 'no-store' }).then(r => r.ok ? r.json() : null).then(j => {
2673
+ if (j && j.files) baseFiles = j.files;
2674
+ if (j && j.ids && !filesOnly) baseIds = j.ids;
2675
+ return baseFiles;
2676
+ }).catch(() => null);
2677
+ }
2678
+ // Stylesheets and data hot-swap: their new hash applies. HTML waits for the reload
2679
+ // (a cancelled one leaves the page on what it loaded); our own writes advance on publish.
2680
+ // HTML that changes before the base arrives makes a late base untrustworthy.
2681
+ let movedUnbased = false;
2682
+ window.addEventListener('manifest:source-change', (e) => {
2683
+ const d = e.detail || {}, html = /\.html?$/i.test(d.file || '');
2684
+ if (html && !baseIds) movedUnbased = true;
2685
+ if (baseFiles && d.file && d.hash && !html) baseFiles[d.file] = d.hash;
2686
+ });
2687
+ // A refusal for files changed since load keeps the log and reports them ($edit.stale,
2688
+ // `edit:stale`). Reload to take the new source, or publish({ rebase: true }) to write
2689
+ // the edits over it; the history is kept until a publish succeeds.
2690
+ // The live reload a successful publish of ours triggers is not taken: the page is
2691
+ // already rebased, and typing begun since would be lost. Reloads that arrive while a
2692
+ // publish is in flight wait for its answer; anything not written by it still reloads.
2693
+ // After a stale refusal or a rebase write the page shows an older source than the file,
2694
+ // so no reload is ours to skip, and with no live reload coming the page reloads itself.
2695
+ // A reload never discards edits it cannot restore: with unpersisted edits pending, the
2696
+ // page stays and says so (edit:stale), so they can be published first.
2697
+ let ownWrites = null, inFlight = 0, held = [], resync = false, heldReload = null;
2698
+ const ours = (files) => !resync && !!ownWrites && Date.now() < ownWrites.until && files.length > 0 && files.every(f => f && ownWrites.files[f.rel] && ownWrites.files[f.rel] === f.hash);
2699
+ // A reload is safe when every applied edit survives it (persisted), and no focused leaf holds
2700
+ // typing not yet logged (a persisting one is committed first). Undone steps never hold it.
2701
+ // Typing in a focused leaf, not yet logged (it is, on blur): committed now when `force` or its region persists
2702
+ function commitFocused(force) {
2703
+ const f = document.activeElement;
2704
+ if (!f || !f._commitText || f._preEdit === undefined || !f.isContentEditable || leafText(f) === f._preEdit) return true;
2705
+ const a = regionOf(f);
2706
+ if (!a || (!force && !a._edit.persist)) return false;
2707
+ f._commitText(); restartRich(f); f._preEdit = leafText(f);
2708
+ return true;
2709
+ }
2710
+ // Grows a set of held regions by the regions moves tie to them (`via`: held -> the region it is tied to).
2711
+ function tie(held) {
2712
+ const via = new Map();
2713
+ for (let grew = true; grew;) {
2714
+ grew = false;
2715
+ log.slice(0, cursor).forEach(d => {
2716
+ if (d.kind !== 'st-move' || held.has(d.from) === held.has(d.to)) return;
2717
+ const [had, add] = held.has(d.from) ? [d.from, d.to] : [d.to, d.from];
2718
+ held.add(add); via.set(add, via.get(had) || had); grew = true;
2719
+ });
2720
+ }
2721
+ held.via = via;
2722
+ return held;
2723
+ }
2724
+ // Edits mnfst-run can't write (a generated region's own, a move in or out of one, and a piece
2725
+ // moved from one) never hold a reload: it regenerates that markup (.persist keeps them).
2726
+ // A writable region a move ties to one still holds: it is published once that move is undone.
2727
+ const keyRegion = (k) => { if (typeof k !== 'string') return null; const o = originOf(k); return o[0] === '@' ? o.slice(1, o.indexOf('@', 1)) : null; };
2728
+ const unwritableEdit = (d) => renderedRegions.has(d.region) || renderedRegions.has(d.from) || renderedRegions.has(d.to) || renderedRegions.has(keyRegion(d.path)) || renderedRegions.has(keyRegion(d.key));
2729
+ const regionsOf = (ds) => [...new Set(ds.flatMap(d => [d.region, d.from, d.to]).filter(Boolean))];
2730
+ const unsaved = () => log.slice(0, cursor).filter(d => d.kind !== 'lock' && !persisted.has(d));
2731
+ function focusedRegion() { const f = document.activeElement, a = f && f._commitText && regionOf(f); return a ? key(a) : null; }
2732
+ function reloadSafe() {
2733
+ if (!commitFocused(false) && !renderedRegions.has(focusedRegion())) return false;
2734
+ // Unpersisted edits exist only for regions of this document (mounted or not): they hold it
2735
+ return unsaved().every(unwritableEdit);
2736
+ }
2737
+ function warnDiscarded() {
2738
+ const f = focusedRegion(), lost = regionsOf(unsaved().filter(unwritableEdit));
2739
+ if (f && renderedRegions.has(f) && !commitFocused(false) && !lost.includes(f)) lost.push(f);
2740
+ if (lost.length) console.warn(`[edit] the source changed: reloading discards the unpublished edits of ${lost.map(r => `x-edit="${r}"`).join(', ')} that mnfst-run can't write (generated content, or a piece moved from it); .persist keeps them`);
2741
+ }
2742
+ function reloadNow() { warnDiscarded(); location.reload(); }
2743
+ // A held reload is taken as soon as nothing it would lose remains
2744
+ function takeHeldReload() { if (heldReload && reloadSafe()) { heldReload = null; reloadNow(); } }
2745
+ function reloadIfSafe(files) {
2746
+ if (reloadSafe()) return reloadNow();
2747
+ heldReload = files;
2748
+ const changed = files.map(f => f && f.rel).filter(Boolean);
2749
+ if (estore) estore.stale = [...new Set([...(estore.stale || []), ...changed])];
2750
+ const holding = regionsOf(unsaved().filter(d => !unwritableEdit(d))).filter(r => !renderedRegions.has(r));
2751
+ const f = focusedRegion(); if (f && !renderedRegions.has(f) && !holding.includes(f) && !commitFocused(false)) holding.push(f);
2752
+ const off = tie(new Set(renderedRegions)), tied = holding.filter(r => off.has(r));
2753
+ const name = (rs) => rs.map(r => `x-edit="${r}"`).join(', ');
2754
+ console.warn(`[edit] the source changed, but a reload would lose unpublished edits${holding.length ? ' to ' + name(holding) : ''}: publish (with rebase), then reload`
2755
+ + (tied.length ? `. ${name(tied)} ${tied.length > 1 ? 'are' : 'is'} tied by a move to ${name([...new Set(tied.map(r => off.via.get(r)))])}, which mnfst-run can't write: undo that move to publish ${tied.length > 1 ? 'them' : 'it'}, or publish the rest` : ''));
2756
+ document.dispatchEvent(new CustomEvent('edit:stale', { detail: { files: changed } }));
2757
+ }
2758
+ window.addEventListener('manifest:live-reload', (e) => {
2759
+ const files = (e.detail && e.detail.files) || [];
2760
+ if (inFlight) { e.preventDefault(); held.push(files); return; }
2761
+ if (ours(files)) { e.preventDefault(); return; }
2762
+ if (!reloadSafe()) { e.preventDefault(); reloadIfSafe(files); }
2763
+ else warnDiscarded();
2764
+ });
2765
+ function settled(r, rebased) {
2766
+ inFlight = Math.max(0, inFlight - 1);
2767
+ if (r && r.base) ownWrites = { files: Object.assign({}, r.base), until: Date.now() + 3000 };
2768
+ if (r && Array.isArray(r.stale) && r.stale.length) resync = true;
2769
+ if (rebased && published(r) && r.base) { resync = true; setTimeout(() => { if (resync) reloadIfSafe(Object.keys(r.base).map(rel => ({ rel }))); }, 1500); }
2770
+ if (inFlight) return;
2771
+ const pending = held; held = [];
2772
+ if (pending.some(f => !ours(f))) reloadIfSafe(pending.flat());
2773
+ }
2774
+ // Publishes run one at a time: a second waits for the first, then builds on its result.
2775
+ let publishing = Promise.resolve();
2776
+ function publish(opts) {
2777
+ const next = publishing.then(() => publishNow(opts));
2778
+ publishing = next.catch(() => { });
2779
+ return next;
2780
+ }
2781
+ // Once a rebase writes over HTML changed elsewhere, the page shows less than the file:
2782
+ // its HTML hashes are not taken (a plain publish is stale until a reload), nor the
2783
+ // fingerprints of keys with twins there (which may not be the elements this page shows).
2784
+ let overNewer = false;
2785
+ function publishNow(opts) {
2786
+ const done = (r, keep) => {
2787
+ if (r && r.base && baseFiles) Object.entries(r.base).forEach(([f, h]) => { if (!overNewer || !/\.html?$/i.test(f)) baseFiles[f] = h; });
2788
+ if (r && r.ids && baseIds) {
2789
+ if (!overNewer) baseIds = r.ids;
2790
+ else Object.entries(r.ids).forEach(([f, m]) => { baseIds[f] = Object.assign({}, baseIds[f]); Object.entries(m || {}).forEach(([k, fp]) => { if (!String(fp).includes('.')) baseIds[f][k] = fp; }); });
2791
+ }
2792
+ const stale = r && Array.isArray(r.stale) ? r.stale : [];
2793
+ if (estore) estore.stale = stale;
2794
+ if (stale.length) document.dispatchEvent(new CustomEvent('edit:stale', { detail: { files: stale } }));
2795
+ if (published(keep ? { results: r.results.filter(x => !(x && keep.has(x.region))) } : r)) rebase(keep);
2796
+ takeHeldReload();
2797
+ return r;
2798
+ };
2799
+ // An area the writer could not resolve does not hold the others back: they are sent
2800
+ // again without it (and areas a move ties to it); its edits stay, its refusal is returned.
2801
+ const leftOut = (r, patches) => {
2802
+ if (!r || !Array.isArray(r.results) || (Array.isArray(r.stale) && r.stale.length)) return null;
2803
+ const failed = new Set(r.results.filter(x => x && x.failed != null).map(x => x.failed));
2804
+ if (!failed.size || r.results.some((x, i) => x && x.status === 'error' && !failed.has(patches[i] && patches[i].region))) return null;
2805
+ tie(failed);
2806
+ return patches.some(p => !failed.has(p.region)) ? failed : null;
2807
+ };
2808
+ // An area inside generated content (x-markdown output and the like) edits on the page,
2809
+ // but its markup is in no file mnfst-run can write: it is refused by name, not sent.
2810
+ const renderedBy = (region) => { const a = areaByKey(region); return a && a._edit.rendered; };
2811
+ const unwritable = (p) => (p.kind === 'static-tree' || (p.kind === 'component' && p.scope === 'instance')) && !!renderedBy(p.region);
2812
+ const send = () => {
2813
+ commitFocused(true); // typing under way publishes with the rest (identities are re-derived from the page after)
2814
+ const patches = buildPatches();
2815
+ if (estore && typeof estore.onPublish === 'function') return Promise.resolve(estore.onPublish(patches, { log, cursor })).then(done);
2816
+ if (!patches.length) { takeHeldReload(); return Promise.resolve({ results: [] }); } // nothing to send: a held reload goes now
2817
+ const post = (ps) => { inFlight++; return fetch('/__edit/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ patches: ps, base: baseFiles }) }).then(r => r.json()).catch(err => { settled(null); throw err; }); };
2818
+ const off = tie(new Set(patches.filter(unwritable).map(p => p.region)));
2819
+ const offResult = (p) => ({ region: p.region, status: 'error', reason: renderedBy(p.region) && unwritable(p)
2820
+ ? `x-edit="${p.region}" is inside content ${renderedBy(p.region)} renders, so mnfst-run can't write it to a source file`
2821
+ : `not published: tied by a move to x-edit="${off.via.get(p.region)}", which mnfst-run can't write` });
2822
+ const sendable = patches.filter(p => !off.has(p.region));
2823
+ // The sent part's answer, merged with what was held back, as one result per patch.
2824
+ const finish = (r, held, sent) => {
2825
+ const keep = new Set([...off, ...(held || [])]);
2826
+ const results = patches.map(p => off.has(p.region) ? offResult(p) : r.results[sent.indexOf(p)]);
2827
+ const o = done(Object.assign({}, r, { results }), keep.size ? keep : undefined); settled(r, !!(opts && opts.rebase)); return o;
2828
+ };
2829
+ if (!sendable.length) { const o = done({ results: patches.map(offResult) }, off); return Promise.resolve(o); }
2830
+ return post(sendable).then(r => {
2831
+ const out = leftOut(r, sendable);
2832
+ if (!out) return finish(r, null, sendable);
2833
+ settled(r, false);
2834
+ const rest = sendable.filter(p => !out.has(p.region));
2835
+ return post(rest).then(r2 => {
2836
+ const results = sendable.map((p, i) => out.has(p.region)
2837
+ ? (r.results[i] && r.results[i].status === 'error' ? r.results[i] : { region: p.region, status: 'error', reason: `not published: tied by a move to x-edit="${out.via.get(p.region) || [...out].find(x => !out.via.has(x))}", which failed` })
2838
+ : r2.results[rest.indexOf(p)]);
2839
+ return finish(Object.assign({}, r2, { results }), out, sendable);
2840
+ });
2841
+ });
2842
+ };
2843
+ // mnfst-run gets a publish only with the base the page loaded; a missing one is
2844
+ // fetched once more, else the publish is refused and the history kept.
2845
+ const refuse = (reason) => { const patches = buildPatches(); return patches.length ? done({ results: patches.map(p => ({ region: p.region, status: 'error', reason })) }) : { results: [] }; };
2846
+ const custom = estore && typeof estore.onPublish === 'function';
2847
+ const based = custom || (baseFiles && baseIds) ? Promise.resolve(null)
2848
+ : movedUnbased ? Promise.resolve('the source changed before this page could read its base from the dev server; reload to continue')
2849
+ : loadBase().then(() => baseFiles && baseIds ? null : 'the dev server\'s base (/__edit/base) could not be read, so nothing can be checked against the source; nothing was published');
2850
+ const rebaseSend = () => {
2851
+ const was = Object.assign({}, baseFiles);
2852
+ return loadBase(true).then(() => { if (Object.keys(baseFiles || {}).some(f => /\.html?$/i.test(f) && was[f] !== baseFiles[f])) overNewer = true; return send(); });
2853
+ };
2854
+ return based.then(why => why ? refuse(why) : opts && opts.rebase ? rebaseSend() : send());
1632
2855
  }
1633
- // An area hidden by the router is still in the DOM, so ask the layout, not the tree.
1634
- const onScreen = (el) => el.checkVisibility ? el.checkVisibility() : !!el.offsetParent;
2856
+ function on() { if (estore) estore.active = true; armAll(); }
2857
+ function off() {
2858
+ if (estore) estore.active = false;
2859
+ areas().forEach(a => { if (a._edit.gated && a._armed) { disarmArea(a); a._armed = false; } });
2860
+ refresh();
2861
+ }
2862
+
2863
+
1635
2864
  function refresh() {
1636
- if (estore) { estore.canUndo = cursor > 0; estore.canRedo = cursor < log.length; }
1637
- // Script decides presence — it is the only thing that knows which route is on
1638
- // screen. The stylesheet decides whether it is seen.
1639
- const authoring = areas().some(a => a._edit.authoring && isActive(a) && onScreen(a));
1640
- document.documentElement.toggleAttribute('data-edit-active', authoring);
1641
- if (!authoring) return;
1642
- buildUI();
1643
- bar.querySelector('[data-a="undo"]').disabled = cursor === 0;
1644
- bar.querySelector('[data-a="redo"]').disabled = cursor >= log.length;
2865
+ if (!estore) return;
2866
+ estore.canUndo = cursor > 0;
2867
+ estore.canRedo = cursor < log.length;
2868
+ estore.version = (estore.version || 0) + 1;
2869
+ if (heldReload) setTimeout(takeHeldReload, 0);
1645
2870
  }
1646
2871
 
1647
2872
  /* ---- Undo/redo hotkeys: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Ctrl+Y ----
1648
2873
  On wherever a region is active; data-edit-hotkeys="off" on a region or any
1649
2874
  ancestor opts it out. Text that is still being typed keeps the browser's own
1650
- undo until it commits on blur; fields and x-text-edit keep theirs always. */
1651
- const leafText = (el) => el.hasAttribute('data-edit-field') && !el.hasAttribute('data-edit-rich') ? el.textContent : el.innerHTML.trim();
2875
+ undo until it commits on blur; fields and x-rich-text keep theirs always. A leaf
2876
+ on Rich Text uses Rich Text's history while it has steps (richHistoryKey). */
2877
+ const leafText = (el) => el._editField && !el._editRich ? el.textContent : el._editRt ? sanitizeFor(el, el.innerHTML.trim()) : el.innerHTML.trim();
1652
2878
  const hotkeysOff = (el) => !!el.closest('[data-edit-hotkeys="off"]');
1653
- // A leaf the user was typing in commits on blur against its focus-time value;
1654
- // after history rewrites it under the caret, that value must follow (again
1655
- // once Alpine has re-rendered a data field).
2879
+ // A focused leaf's baseline follows history rewrites under the caret.
1656
2880
  function rebaseFocused() {
1657
2881
  const el = document.activeElement;
1658
2882
  if (!el || el._preEdit === undefined || !el.isContentEditable) return;
1659
- const sync = () => { if (document.activeElement === el) el._preEdit = leafText(el); };
2883
+ const sync = () => { if (document.activeElement === el) { restartRich(el); el._preEdit = leafText(el); } };
1660
2884
  sync(); setTimeout(sync, 0);
1661
2885
  }
1662
- function onHistoryKey(e) {
1663
- if (e.defaultPrevented || e.altKey || !(e.metaKey || e.ctrlKey) || dragged) return; // never mid-drag
2886
+ function historyDir(e) {
2887
+ if (e.altKey || !(e.metaKey || e.ctrlKey)) return null;
1664
2888
  const k = (e.key || '').toLowerCase();
1665
- const isRedo = (k === 'z' && e.shiftKey) || (k === 'y' && e.ctrlKey && !e.metaKey && !e.shiftKey);
1666
- if (!isRedo && !(k === 'z' && !e.shiftKey)) return;
2889
+ if (k === 'z') return e.shiftKey ? 'redo' : 'undo';
2890
+ return k === 'y' && e.ctrlKey && !e.metaKey && !e.shiftKey ? 'redo' : null;
2891
+ }
2892
+ function onHistoryKey(e) {
2893
+ if (e.defaultPrevented || dragged) return; // never mid-drag
2894
+ const dir = historyDir(e); if (!dir) return;
2895
+ const isRedo = dir === 'redo';
1667
2896
  const t = e.target && e.target.nodeType === 1 ? e.target : document.body;
1668
- if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.closest('[data-text-edit]')) return;
2897
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || authoredRich(t.closest('[data-rich-text]'))) return;
1669
2898
  const region = t.closest('[data-edit-area]');
1670
2899
  if (region) {
1671
2900
  const top = regionOf(region);
@@ -1681,61 +2910,80 @@
1681
2910
  }
1682
2911
 
1683
2912
 
1684
- /* ---- Restore persisted edits + boot (registers x-edit directive + $edit magic) ---- */
2913
+ /* ---- Restore persisted edits + boot ---- */
2914
+ // Deltas for areas that no longer persist are dropped before replay.
2915
+ function pruneLoaded() {
2916
+ const stale = (d) => {
2917
+ const regions = d.kind === 'st-move' ? [d.from, d.to] : d.region != null ? [d.region] : [];
2918
+ return regions.some(r => { const a = areaByKey(r); return a && !a._edit.persist; });
2919
+ };
2920
+ if (!log.some(stale)) return;
2921
+ let c = 0;
2922
+ const kept = log.filter((d, i) => { const keep = !stale(d); if (keep && i < cursor) c++; return keep; });
2923
+ log = kept; cursor = c; ver++;
2924
+ saveState();
2925
+ }
1685
2926
  function restore() {
1686
2927
  if (!log.length) return;
2928
+ pruneLoaded();
1687
2929
  for (const [k, v] of Object.entries(fold())) { const area = areaByKey(k); if (area && v.kind === 'data') waitForData(area, () => applySnap(area, 'data', v.snap)); }
1688
- // A replayed move must first converge live keys to the recorded ones (ordinal
1689
- // renames from collisions). Skipped when the source already holds the move
1690
- // (post-save reload): fresh derivation then matches the recorded order.
1691
- for (let i = 0; i < cursor; i++) {
1692
- const d = log[i];
1693
- if (d.kind !== 'st-move' || !d.remap) continue;
1694
- const ta = areaByKey(d.to);
1695
- if (!ta || eq(staticKeys(ta), d.toOrder)) continue;
1696
- applyMoveKeys(d, false);
1697
- }
1698
- if (log.some(d => d.kind === 'st-node' || d.kind === 'st-order' || d.kind === 'st-move')) applyStaticState();
2930
+ if (log.some(d => /^st-/.test(d.kind))) { surfaceConflicts(areas()); applyStaticState(); }
1699
2931
  if (log.some(d => d.kind === 'cmp-main' || d.kind === 'cmp-inst')) applyComponentState();
1700
2932
  if (log.some(d => d.kind === 'data-val')) { const da = areas().find(a => classify(a) === 'data'); if (da) waitForData(da, applyDataValues); }
1701
2933
  if (log.some(d => d.kind === 'theme')) applyThemeState();
2934
+ if (log.some(d => d.kind === 'lock')) areas().forEach(rearm);
2935
+ refresh();
1702
2936
  }
1703
- function waitForData(area, cb) { const expr = dataSourceExpr(area), tpl = area.querySelector('template[x-for]'); let n = 0; const t = setInterval(() => { let r = false; try { r = Array.isArray(window.Alpine.evaluate(tpl, expr)); } catch {} if (r) { clearInterval(t); cb(); } else if (++n > 100) clearInterval(t); }, 50); }
2937
+ function waitForData(area, cb) { const expr = dataSourceExpr(area), tpl = listTpl(area); let n = 0; const t = setInterval(() => { let r = false; try { r = Array.isArray(window.Alpine.evaluate(tpl, expr)); } catch { } if (r) { clearInterval(t); cb(); } else if (++n > 100) clearInterval(t); }, 50); }
1704
2938
  function init() {
1705
2939
  if (!window.Alpine || !Alpine.directive) return;
2940
+ // Alpine walks only x-data trees: a page with none still gets its areas
2941
+ if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
1706
2942
  Alpine.directive('edit', (el, { modifiers, expression }, { cleanup }) => {
1707
2943
  registerEdit(el, modifiers, expression);
1708
- armLater(); // a region rendered after boot arms itself
1709
- cleanup(() => releaseEdit(el)); // ...and lets go when Alpine tears it down
2944
+ armLater();
2945
+ cleanup(() => releaseEdit(el));
1710
2946
  });
1711
2947
  Alpine.store('edit', {
1712
- active: false, canUndo: false, canRedo: false,
1713
- onPublish: null, // author sets a fn(patches, {log,cursor}) → route to cloud/custom
2948
+ active: false, canUndo: false, canRedo: false, version: 0, stale: [], conflicts: [],
2949
+ onPublish: null,
1714
2950
  toggle() { this.active ? off() : on(); }, on() { on(); }, off() { off(); },
1715
- undo() { undo(); }, redo() { redo(); }, lock(el) { setLock(el, true); }, unlock(el) { setLock(el, false); },
1716
- publish() { return publish(); },
1717
- // Block operations. Called with no element they act on the block the last
1718
- // right-click reported, which is what a context menu wants.
1719
- targetVersion: 0, // trackable: see blocks.js
1720
- get target() { void this.targetVersion; return blockTarget(); },
2951
+ undo() { return undo(); }, redo() { return redo(); },
2952
+ publish(opts) { return publish(opts); },
2953
+ patches() { return buildPatches(); },
2954
+ export() { return JSON.parse(JSON.stringify({ log, cursor })); },
2955
+ get target() { void this.version; return target; },
1721
2956
  set target(el) { setBlockTarget(el); },
1722
- can(op, el) { void this.targetVersion; return canDo(op, el); },
2957
+ can(action, el) { void this.version; return canDo(action, el === undefined ? target : el); },
2958
+ lock(el, action) { return setLock(el === undefined ? target : el, action, true); },
2959
+ unlock(el, action) { return setLock(el === undefined ? target : el, action, false); },
1723
2960
  copy(el) { return copyBlock(el); },
1724
2961
  cut(el) { return cutBlock(el); },
1725
2962
  paste(el) { return pasteBlock(el); },
1726
2963
  duplicate(el) { return duplicateBlock(el); },
1727
2964
  remove(el) { return removeBlock(el); },
1728
2965
  move(el, dest, before) { return moveBlock(el, dest, before); },
1729
- block(node) { return blockOf(node); },
1730
- patches() { return buildPatches(); }, // resolved B-side source patches
1731
- export() { return JSON.parse(JSON.stringify({ log, cursor })); } // A-side overlay (e.g. push to Appwrite)
2966
+ nest(el) { const p = asPiece(el === undefined ? target : el); return !!p && movable(p) && nestInto(p); },
2967
+ unnest(el) { const p = asPiece(el === undefined ? target : el); return !!p && movable(p) && unnest(p); },
2968
+ block(node) { return pieceOf(node); },
2969
+ attr(name, value, el) { return setAttr(name, value, el); },
2970
+ classes: classesApi,
2971
+ scope(value, el) { void this.version; return value === undefined ? scopeOf(el) : setScope(value, el); },
2972
+ revert(el) { return revert(el); },
2973
+ clearConflicts() { conflicts = []; this.conflicts = []; saveState(); }
1732
2974
  });
1733
2975
  estore = Alpine.store('edit');
1734
2976
  Alpine.magic('edit', () => Alpine.store('edit'));
1735
2977
  loadState();
1736
- setTimeout(() => { armAll(); restore(); }, 450); // arm (captures baselines) THEN re-apply persisted edits
1737
- window.addEventListener('manifest:route-change', () => setTimeout(refresh, 0)); // chrome follows the route
2978
+ loadBase();
2979
+ setTimeout(() => { armAll(); restore(); }, 450);
1738
2980
  document.addEventListener('keydown', onHistoryKey);
2981
+ window.addEventListener('click', onAreaLinkClick, true);
2982
+ window.addEventListener('auxclick', onAreaLinkClick, true);
2983
+ document.addEventListener('contextmenu', onContextCapture, true);
2984
+ document.addEventListener('dropdown:context', onContextCapture, true);
2985
+ // SPA navigation: the state so far is saved under the page it was made on.
2986
+ window.addEventListener('manifest:route-change', () => { if (lsPath !== location.pathname) { lsPath = location.pathname; saveState(); } });
1739
2987
  }
1740
2988
  document.addEventListener('alpine:init', init);
1741
2989
  if (window.Alpine && Alpine.directive) setTimeout(init, 0);