mnfst 0.5.230 → 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;
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;
474
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);
491
- saveState(); refresh();
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);
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);
499
- saveState(); refresh();
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);
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,122 +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;
730
- }
731
-
732
- const blockTarget = () => target;
733
- const setBlockTarget = (el) => setTarget(el && el.nodeType === 1 ? blockOf(el) || el : null);
734
-
735
-
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. */
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;
1323
+ }
1324
+
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);
1332
+
1333
+
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 */
739
1340
  const isPositioned = (el) => { const p = getComputedStyle(el).position; return p === 'absolute' || p === 'fixed'; };
1341
+ const addedTab = new WeakSet();
740
1342
  let grabbed = null, liveRegion;
741
- function announce(msg) { if (!liveRegion) { liveRegion = document.createElement('div'); liveRegion.setAttribute('data-edit-live', ''); liveRegion.setAttribute('aria-live', 'polite'); document.body.appendChild(liveRegion); } liveRegion.textContent = msg; }
742
-
743
- function makeSortable(container) {
744
- sortableChildren(container).forEach(child => {
745
- if (locked(child)) return;
746
- if (isPositioned(child)) { if (!child._dragBound) { child._dragBound = true; makeMovable(child); } return; }
747
- child.setAttribute('data-edit-sortable', ''); child.tabIndex = 0; child.setAttribute('role', 'listitem');
748
- if (child._dragBound) return; child._dragBound = true;
749
- child.addEventListener('pointerdown', onPointerDown);
750
- child.addEventListener('keydown', onItemKeydown);
751
- child.addEventListener('keydown', onBlockKey);
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; }
1344
+
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); }
752
1361
  });
753
- container.setAttribute('role', 'list');
754
- // x-for rows and any other late arrivals are not sortable unless we notice
755
- // them — arming only ever ran once, so a list that grows loses its handles.
756
- if (!container._sortObserver) {
757
- container._sortObserver = new MutationObserver(() => {
758
- if (dragged) return; // never re-arm mid-drag
759
- if (isActive(container.closest('[data-edit-area]'))) makeSortable(container);
760
- });
761
- 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
+ }
762
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;
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;
763
1474
  }
764
- function onPointerDown(e) {
765
- const item = this, area = item.parentElement && item.parentElement.closest('[data-edit-area]'); // origin = the OUTER region, never the item itself
766
- if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0)) return;
767
- if (e.target.isContentEditable || e.target.hasAttribute('data-edit-handle')) return; // text/size win
768
- if (e._editDrag) return; e._editDrag = true; // one drag per gesture — the innermost sortable item claims it
769
- const container = item.parentElement;
770
- const crossOK = classify(area) === 'static' && !isRegionBlock(item) && topArea(area); // cross-region: top-level static → static only; a region-block only reorders in place
771
- let hovered = container;
772
- const homeNext = item.nextElementSibling; // where to put it back if cancelled
773
- const start = item.getBoundingClientRect();
774
- const preStyle = item.getAttribute('style');
775
- 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;
776
1550
  const sx = e.clientX, sy = e.clientY;
777
- let active = false, frame = 0, px = sx, py = sy, ghost = null;
1551
+ let active = false, frame = 0, px = sx, py = sy, last = null;
778
1552
 
779
- // The item leaves the flow and a stand-in takes its slot, so the gap that
780
- // opens is a real element the author can style — by default a translucent
781
- // copy of what is being dragged, showing exactly where it would land.
782
1553
  const lift = (ev) => {
783
- // Valid cross-region destinations announce themselves for the whole
784
- // drag: a dashed target outline, and a min-height (via CSS) so an
785
- // EMPTY region has a surface to drop onto at all.
786
- if (crossOK) areas().forEach(a => {
787
- if (a !== area && isActive(a) && !locked(a) && capOf(a, 'sort') && classify(a) === 'static' && topArea(a)) a.setAttribute('data-edit-drop-target', '');
788
- });
789
- 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);
790
1557
  ghost.setAttribute('data-edit-ghost', '');
791
- ghost.setAttribute('x-ignore', ''); // a clone must not re-bind
1558
+ ghost.setAttribute('x-ignore', '');
792
1559
  ghost.removeAttribute('id');
793
1560
  ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
794
1561
  [ghost, ...ghost.querySelectorAll('*')].forEach(n => {
@@ -796,40 +1563,33 @@
796
1563
  n.removeAttribute('contenteditable'); n.removeAttribute('tabindex'); n.removeAttribute('draggable');
797
1564
  });
798
1565
  item.before(ghost);
799
- item.style.position = 'fixed';
800
- item.style.width = start.width + 'px';
801
- item.style.height = start.height + 'px';
802
- item.style.margin = '0';
803
- item.style.pointerEvents = 'none';
1566
+ Object.assign(item.style, { position: 'fixed', width: start.width + 'px', height: start.height + 'px', margin: '0', pointerEvents: 'none' });
804
1567
  item.setAttribute('data-edit-dragging', '');
805
- area.setAttribute('data-edit-dragging-in', '');
806
1568
  dragged = ghost;
807
1569
  try { item.setPointerCapture(ev.pointerId); } catch { }
808
1570
  };
809
-
810
- const paint = () => {
811
- frame = 0;
812
- item.style.left = (px - grabX) + 'px';
813
- 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);
814
1581
  };
815
-
816
1582
  const onMove = (ev) => {
817
1583
  px = ev.clientX; py = ev.clientY;
818
1584
  if (!active) {
819
- if (Math.hypot(px - sx, py - sy) < 5) return; // threshold so taps/clicks still work
820
- active = true; lift(ev); paint();
1585
+ if (Math.hypot(px - sx, py - sy) < 5) return;
1586
+ active = true; hideGrip(); lift(ev); paint();
821
1587
  }
822
1588
  ev.preventDefault();
823
- const over = (crossOK && dropArea(px, py)) || ghost.parentElement; // cross-region drop search
824
- if (over !== hovered) {
825
- if (hovered !== container) hovered.removeAttribute('data-edit-dragging-in');
826
- hovered = over;
827
- if (hovered !== container) hovered.setAttribute('data-edit-dragging-in', '');
828
- }
829
- reorderOver(over, px, py);
1589
+ const drop = dropTarget(px, py, s);
1590
+ if (drop) placeGhost(drop);
830
1591
  if (!frame) frame = requestAnimationFrame(paint);
831
1592
  };
832
-
833
1593
  const settle = (cancelled) => {
834
1594
  document.removeEventListener('pointermove', onMove);
835
1595
  document.removeEventListener('pointerup', onUp);
@@ -837,30 +1597,26 @@
837
1597
  document.removeEventListener('keydown', onKey, true);
838
1598
  if (frame) cancelAnimationFrame(frame);
839
1599
  if (!active) return;
840
- if (cancelled) {
841
- ghost.remove();
842
- if (homeNext) container.insertBefore(item, homeNext); else container.appendChild(item);
843
- } else ghost.replaceWith(item);
844
- if (preStyle == null) item.removeAttribute('style'); else item.setAttribute('style', preStyle);
845
- item.removeAttribute('data-edit-dragging');
846
- area.removeAttribute('data-edit-dragging-in');
847
1600
  document.querySelectorAll('[data-edit-drop-target]').forEach(a => a.removeAttribute('data-edit-drop-target'));
848
- if (hovered !== container) hovered.removeAttribute('data-edit-dragging-in');
849
- ghost = null; dragged = null;
850
- if (cancelled) { announce('Cancelled'); return; }
851
- const dest = item.parentElement;
852
- if (crossOK && dest !== container && dest._edit) {
853
- // Re-validate at drop: a region locked or deactivated mid-drag
854
- // (reached through the ghost fallback) must not take the block.
855
- if (isActive(dest) && !locked(dest) && capOf(dest, 'sort') && topArea(dest)) { commitMove(area, dest, item); announce('Moved to ' + key(dest)); }
856
- else { if (homeNext) container.insertBefore(item, homeNext); else container.appendChild(item); announce('Cancelled'); }
857
- }
858
- 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');
859
1618
  };
860
1619
  const onUp = () => settle(false);
861
- // Escape puts it back, the way every drag is expected to be escapable. A
862
- // cancelled pointer (OS gesture, alert, pen out of range) also reverts —
863
- // otherwise the item wedges position:fixed with drop targets still lit.
864
1620
  const onCancel = () => settle(true);
865
1621
  const onKey = (ev) => { if (ev.key !== 'Escape') return; ev.preventDefault(); ev.stopPropagation(); settle(true); };
866
1622
  document.addEventListener('pointermove', onMove);
@@ -868,105 +1624,134 @@
868
1624
  document.addEventListener('pointercancel', onCancel);
869
1625
  document.addEventListener('keydown', onKey, true);
870
1626
  }
871
- function reorderOver(container, x, y) {
872
- if (!dragged) return;
873
- const ref = afterElement(container, x, y);
874
- if (dragged.parentElement === container && (ref === dragged || ref === dragged.nextElementSibling)) return;
875
- if (ref == null) container.appendChild(dragged); else container.insertBefore(dragged, ref);
876
- }
877
- // Cross-region drop target: the innermost active static sort region under the pointer.
878
- function dropArea(x, y) {
879
- const hit = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
880
- for (let a = hit && hit.closest ? hit.closest('[data-edit-area]') : null; a; a = a.parentElement && a.parentElement.closest('[data-edit-area]')) {
881
- if (a._edit && isActive(a) && !locked(a) && capOf(a, 'sort') && classify(a) === 'static' && topArea(a)) return a;
882
- }
883
- return null;
884
- }
885
- function finishReorder(area) { if (classify(area) === 'data') { applyDataOrder(area, sortableChildren(area).map(c => itemKey(c, area))); commit(area); } else commitStaticOrder(area); }
886
- // Reading-order insertion: block, inline, flex row/col/wrap, grid auto-flow.
887
- // The FIRST sibling past the pointer, not the nearest — nearest can pick an
888
- // element further down the list and makes the drop point jump around. Taking the
889
- // first is also self-stabilising: once the item lands, that sibling's midpoint is
890
- // behind the pointer, so it does not immediately swap back.
891
- function afterElement(c, x, y) {
892
- for (const el of sortableChildren(c)) {
893
- if (el === dragged || locked(el) || el.hasAttribute('data-edit-dragging')) continue;
894
- const b = el.getBoundingClientRect(), cx = b.left + b.width / 2, cy = b.top + b.height / 2;
895
- const sameRow = Math.abs(cy - y) <= b.height / 2;
896
- if (cy - y > b.height / 2 || (sameRow && cx > x)) return el;
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;
1631
+ }
1632
+
1633
+ /* ---- Keyboard ---- */
1634
+ const ownControl = (e, item) => e.target !== item && !!e.target.closest && item.contains(e.target.closest('a[href], button, summary, [role="button"], [role="link"]'));
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;
897
1656
  }
898
- return null;
899
- }
900
- // Keyboard reorder: grab → arrows move among siblings → drop.
901
- function onItemKeydown(e) {
902
- const item = this, area = item.closest('[data-edit-area]'); if (!isActive(area)) return;
903
- if (e.key === ' ' || e.key === 'Enter') {
904
- e.preventDefault();
905
- if (grabbed === item) { grabbed = null; item.removeAttribute('data-edit-grabbed'); finishReorder(area); item.focus(); announce('Dropped'); }
906
- 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}`);
907
1665
  return;
908
1666
  }
909
- if (grabbed !== item) return;
910
- if (e.key === 'Escape') { e.preventDefault(); grabbed = null; item.removeAttribute('data-edit-grabbed'); item.focus(); announce('Cancelled'); return; }
911
- const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key];
912
- if (dir == null) return;
913
- e.preventDefault();
914
- const sibs = sortableChildren(item.parentElement).filter(c => !locked(c)), i = sibs.indexOf(item), j = i + dir;
915
- if (j < 0 || j >= sibs.length) return;
916
- if (dir > 0) sibs[j].after(item); else sibs[j].before(item);
917
- item.focus(); announce(`Position ${j + 1} of ${sibs.length}`);
918
- }
919
- // Move mode: positioned elements → pointer-drag left/top (touch-capable) + keyboard nudge, unit-preserving.
920
- function makeMovable(el) {
921
- el.setAttribute('data-edit-movable', ''); el.tabIndex = 0; el.setAttribute('role', 'application'); el.setAttribute('aria-label', 'Draggable; arrow keys to move');
922
- el.addEventListener('pointerdown', (e) => {
923
- const area = el.closest('[data-edit-area]');
924
- if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0) || e.target.isContentEditable || e.target.hasAttribute('data-edit-handle')) return;
925
- e.preventDefault(); el._preStyle = el.getAttribute('style') || '';
926
- const cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
927
- const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
928
- const baseL = parseFloat(cs.left) || 0, baseT = parseFloat(cs.top) || 0, sx = e.clientX, sy = e.clientY;
929
- const ov = showOverlay();
930
- // Clamped to the containing block: a freeform card must not leave its canvas.
931
- const maxL = () => Math.max(0, parent.clientWidth - el.offsetWidth), maxT = () => Math.max(0, parent.clientHeight - el.offsetHeight);
932
- 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; };
933
- const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); hideOverlay(ov); commitStyle(area, el); };
934
- document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
935
- });
936
- el.addEventListener('keydown', (e) => {
937
- const area = el.closest('[data-edit-area]'); if (!isActive(area)) return;
938
- const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (!d) return;
939
- e.preventDefault();
940
- if (!el._moveTimer) el._preStyle = el.getAttribute('style') || ''; // baseline at start of a key burst
941
- const step = e.shiftKey ? 16 : 4, cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
942
- const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
943
- const maxL = Math.max(0, parent.clientWidth - el.offsetWidth), maxT = Math.max(0, parent.clientHeight - el.offsetHeight);
944
- 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;
945
- 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;
946
- clearTimeout(el._moveTimer); el._moveTimer = setTimeout(() => { el._moveTimer = null; commitStyle(area, el); }, 350);
947
- });
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;
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);
1685
+ const other = sibs[j], between = [];
1686
+ for (let n = dir > 0 ? item.nextElementSibling : item.previousElementSibling; n && n !== other; n = dir > 0 ? n.nextElementSibling : n.previousElementSibling) between.push(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(); }); }
1691
+ const focused = document.activeElement, sel = window.getSelection && window.getSelection();
1692
+ const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
1693
+ if (before) commitOrder(area, container, before); else finishReorder(area, container);
1694
+ if (focused && focused.isConnected && document.activeElement !== focused) {
1695
+ focused.focus();
1696
+ if (range) try { sel.removeAllRanges(); sel.addRange(range); } catch { }
1697
+ }
1698
+ announce(`Moved to position ${j + 1} of ${sibs.length}`);
1699
+ }
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;
948
1712
  }
949
-
950
-
951
- /* ---- Size: drag handles → unit-preserving width/height (folded-in resize) ----
952
- CSS-var config (cascades by selector, keeps the directive tight):
953
- --edit-size both | x | y | none which axes (default both)
954
- --edit-size-edges e.g. "right bottom-right" explicit handle list (logical start/end ok)
955
- --edit-size-handle 1rem handle hit area
956
- --edit-size-snap 12rem 24rem snap stops (both axes)
957
- --edit-size-snap-x/-y per-axis stop overrides
958
- --edit-size-snap-distance 1rem magnet tolerance (NOT a grid step)
959
- --edit-size-snap-distance-x/-y per-axis tolerance
960
- --edit-size-collapse-x/-y 120px collapse below this → [data-edit-collapsed] + 'edit:collapse' event
961
- (min/max come from the element's native min-/max-width/height.) */
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. */
962
1748
  let overlayEl;
963
- // Fires while dragging (done:false) and once on commit (done:true), so consumers
964
- // 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).
965
1750
  function sizeEvent(el, done) {
966
1751
  const r = el.getBoundingClientRect();
967
1752
  el.dispatchEvent(new CustomEvent('edit:size', {
968
1753
  bubbles: true,
969
- 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 }
970
1755
  }));
971
1756
  }
972
1757
  function showOverlay() { if (!overlayEl) { overlayEl = document.createElement('div'); overlayEl.setAttribute('data-edit-overlay', ''); document.body.appendChild(overlayEl); } overlayEl.style.display = 'block'; return overlayEl; }
@@ -978,88 +1763,169 @@
978
1763
  const rtl = isRTL(el);
979
1764
  return pos.replace(/start/g, rtl ? 'right' : 'left').replace(/end/g, rtl ? 'left' : 'right');
980
1765
  }
1766
+ // Containing-block size for % limits (null when the height is not definite).
1767
+ function blockSize(el, dim) {
1768
+ const pos = getComputedStyle(el).position;
1769
+ if (pos === 'fixed') return dim === 'w' ? document.documentElement.clientWidth : document.documentElement.clientHeight;
1770
+ const cb = pos === 'absolute' ? el.offsetParent || el.parentElement : el.parentElement;
1771
+ if (!cb) return null;
1772
+ const ps = getComputedStyle(cb), w = dim === 'w';
1773
+ const box = w ? cb.clientWidth : cb.clientHeight;
1774
+ if (pos === 'absolute') return box;
1775
+ if (!w && !definiteHeight(cb)) return null;
1776
+ return box - (parseFloat(w ? ps.paddingLeft : ps.paddingTop) || 0) - (parseFloat(w ? ps.paddingRight : ps.paddingBottom) || 0);
1777
+ }
1778
+ // Specified height via Typed OM; without it (Firefox) only an inline height counts.
1779
+ function definiteHeight(el) {
1780
+ for (let n = el, depth = 0; n && depth < 32; n = n.parentElement, depth++) {
1781
+ if (n === document.documentElement) return true; // % on the root resolves against the viewport
1782
+ const pos = getComputedStyle(n).position;
1783
+ if (pos === 'absolute' || pos === 'fixed') return true;
1784
+ let h;
1785
+ if (n.computedStyleMap) { try { h = String(n.computedStyleMap().get('height')).trim(); } catch { } }
1786
+ if (h == null) h = (n.style && n.style.height || '').trim() || 'auto';
1787
+ if (h === 'auto' || h === '') return false;
1788
+ if (!h.endsWith('%')) return true; // a length (or calc) is definite
1789
+ }
1790
+ return false;
1791
+ }
1792
+ function limitPx(v, el, dim, none) {
1793
+ v = String(v || '').trim();
1794
+ if (!v || v === 'none' || v === 'auto') return none;
1795
+ if (/^-?[\d.]+%$/.test(v)) { const b = blockSize(el, dim); return b == null ? none : parseFloat(v) / 100 * b; }
1796
+ const px = toPx(v, el, dim);
1797
+ return px == null ? none : px;
1798
+ }
1799
+ function limits(el, cs) {
1800
+ const minW = limitPx(cs.minWidth, el, 'w', 0), minH = limitPx(cs.minHeight, el, 'h', 0);
1801
+ return { minW, minH, maxW: Math.max(minW, limitPx(cs.maxWidth, el, 'w', Infinity)), maxH: Math.max(minH, limitPx(cs.maxHeight, el, 'h', Infinity)) }; // min wins, as in CSS
1802
+ }
981
1803
  const defaultEdges = (axes) => axes === 'x' ? ['left', 'right'] : axes === 'y' ? ['top', 'bottom'] : ['top', 'right', 'bottom', 'left', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
982
1804
 
983
1805
  function armSize(el) {
984
1806
  const axes = cssVar(el, '--edit-size', 'both');
985
1807
  if (axes === 'none') return;
986
1808
  if (el._sizeBound) return; el._sizeBound = true;
987
- el.setAttribute('data-edit-sizable', '');
988
- // Anchor guard: handles are positioned against this element, so it must establish a
989
- // containing block. The CSS sets position:relative via :where() (overridable); if an
990
- // author's CSS won and left it static, set it here so handles never detach.
991
- if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
992
- 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);
993
1813
  edges.forEach(pos => {
994
- const phys = physical(pos, el); // keep logical class for CSS; resolve to physical for behavior
1814
+ const phys = physical(pos, el);
995
1815
  const h = document.createElement('span');
996
- h.setAttribute('data-edit-handle', pos); // logical value drives the CSS; `phys` drives behaviour
1816
+ h.setAttribute('data-edit-handle', pos);
997
1817
  h.tabIndex = 0; h.setAttribute('role', 'slider'); h.setAttribute('aria-label', `Resize ${pos}`);
998
1818
  h.addEventListener('pointerdown', (e) => startSize(e, el, phys));
999
1819
  h.addEventListener('keydown', (e) => keyResize(e, el, phys));
1000
1820
  el.appendChild(h);
1001
1821
  });
1002
1822
  }
1003
- // Keyboard a11y: focus a handle, arrow keys resize (Shift = larger step), debounced commit.
1004
- let _keyTimer;
1823
+ // Keyboard: arrows resize (Shift = larger step); a key burst commits once per element.
1005
1824
  function keyResize(e, el, pos) {
1006
1825
  const map = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
1007
1826
  if (!map[e.key]) return;
1008
- const area = el.closest('[data-edit-area]'); if (!isActive(area)) return;
1009
- e.preventDefault();
1010
- 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);
1011
1830
  const [kx, ky] = map[e.key], step = e.shiftKey ? 32 : 8, cs = getComputedStyle(el);
1012
1831
  const right = pos.includes('right'), left = pos.includes('left'), top = pos.includes('top'), bottom = pos.includes('bottom');
1013
1832
  const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
1014
- const minW = parseFloat(cs.minWidth) || 0, maxW = parseFloat(cs.maxWidth) || Infinity, minH = parseFloat(cs.minHeight) || 0, maxH = parseFloat(cs.maxHeight) || Infinity;
1833
+ const { minW, maxW, minH, maxH } = limits(el, cs);
1015
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; }
1016
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; }
1017
1836
  sizeEvent(el, false);
1018
- 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;
1019
1865
  }
1020
- function snapStops(el, axisVar, dim) {
1021
- const list = cssVar(el, axisVar, '') || cssVar(el, '--edit-size-snap', '');
1022
- 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;
1023
1875
  }
1024
1876
  function startSize(e, el, pos) {
1025
- const area = el.closest('[data-edit-area]');
1026
- if (!isActive(area) || e.button !== 0) return;
1027
- 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);
1028
1881
  const cs = getComputedStyle(el), rect = el.getBoundingClientRect();
1029
1882
  const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
1030
1883
  const baseW = parseFloat(cs.width) || rect.width, baseH = parseFloat(cs.height) || rect.height, sx = e.clientX, sy = e.clientY;
1031
- const minW = parseFloat(cs.minWidth) || 0, maxW = parseFloat(cs.maxWidth) || Infinity;
1032
- const minH = parseFloat(cs.minHeight) || 0, maxH = parseFloat(cs.maxHeight) || Infinity;
1884
+ const { minW, maxW, minH, maxH } = limits(el, cs);
1033
1885
  const right = pos.includes('right'), left = pos.includes('left'), bottom = pos.includes('bottom'), top = pos.includes('top');
1034
- const tolX = toPx(cssVar(el, '--edit-size-snap-distance-x', '') || cssVar(el, '--edit-size-snap-distance', '0'), el, 'w') || 0;
1035
- const tolY = toPx(cssVar(el, '--edit-size-snap-distance-y', '') || cssVar(el, '--edit-size-snap-distance', '0'), el, 'h') || 0;
1036
- const snapW = snapStops(el, '--edit-size-snap-x', 'w'), snapH = snapStops(el, '--edit-size-snap-y', 'h');
1037
- const collapseX = toPx(cssVar(el, '--edit-size-collapse-x', ''), el, 'w');
1038
- const collapseY = toPx(cssVar(el, '--edit-size-collapse-y', ''), el, 'h');
1039
- const snap = (v, pts, tol) => { if (!tol) return v; for (const p of pts) if (Math.abs(v - p) <= tol) return p; return v; };
1040
- const ov = showOverlay();
1041
- 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;
1042
1893
  const move = (ev) => {
1043
1894
  const dx = ev.clientX - sx, dy = ev.clientY - sy;
1044
- 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; }
1045
- 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; }
1046
- const collapsed = (collapseX != null && lastW < collapseX) || (collapseY != null && lastH < collapseY);
1047
- 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; }
1048
1897
  sizeEvent(el, false);
1049
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); };
1050
1905
  const up = () => {
1051
- document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); hideOverlay(ov);
1052
- 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
1053
1921
  commitStyle(area, el); sizeEvent(el, true);
1054
1922
  };
1055
- document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
1923
+ document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); document.addEventListener('pointercancel', cancel);
1056
1924
  }
1057
1925
 
1058
1926
 
1059
1927
  /* ---- Inline text editing ---- */
1060
- // Focusing a text leaf selects its content, so the common case — replace the
1061
- // text — is one click and type. Deferred a frame so it wins over the click's
1062
- // 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.
1063
1929
  function selectAllIn(el) {
1064
1930
  requestAnimationFrame(() => {
1065
1931
  if (dragged || grabbed) return; // reordering, not editing
@@ -1067,13 +1933,113 @@
1067
1933
  try { const r = document.createRange(); r.selectNodeContents(el); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); } catch { }
1068
1934
  });
1069
1935
  }
1070
- // 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
1071
2037
  // its leaves contenteditable nor competes for the caret. If the rich editor has
1072
2038
  // no expression of its own, we capture what it produces as an ordinary text
1073
2039
  // delta, so a rich field inside a page-editing region publishes like any other.
1074
2040
  function armRichText(area) {
1075
- area.querySelectorAll('[data-text-edit]:not([data-text-edit-bound])').forEach(el => {
1076
- 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;
1077
2043
  if (el._richBound) return; el._richBound = true;
1078
2044
  el.addEventListener('focusin', () => { el._preEdit = el.innerHTML.trim(); });
1079
2045
  el.addEventListener('blur', () => {
@@ -1084,186 +2050,240 @@
1084
2050
  });
1085
2051
  }
1086
2052
 
1087
- // STATIC: literal leaves only (skip bound nodes; those belong to data/component).
2053
+ // Static: literal leaves only.
1088
2054
  function armText(area) {
1089
2055
  armRichText(area);
1090
- // A region whose root IS the text — <p x-edit.text="note">…</p> — has no
1091
- // descendant leaves; the area itself is the editable leaf.
2056
+ // An area that is itself the text leaf.
1092
2057
  const nodes = [...area.querySelectorAll('*')];
1093
- 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
1094
2059
  nodes.forEach(el => {
1095
- if (realChildren(el).length || !el.textContent.trim() || el.closest('template') || el.hasAttribute('data-edit-handle')) return;
1096
- if (el.closest('[data-text-edit]')) return; // the rich editor owns this subtree
1097
- if (!capOf(el, 'text') || el.hasAttribute('x-text') || el.hasAttribute('x-html')) return;
1098
- el.setAttribute('contenteditable', 'true');
1099
- if (el._textBound) return; el._textBound = true;
1100
- el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); selectAllIn(el); });
1101
- el.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); el.blur(); } }); // Escape finishes the edit, like the rich editor
1102
- 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');
1103
2068
  });
1104
2069
  }
1105
2070
 
1106
- /* ---- DATA value editing (opt-in .data): make $x-bound leaves in an x-for list editable,
1107
- but ONLY when the binding is a SIMPLE field ref of the loop var (p.name) — computed
1108
- expressions (e.g. '$' + p.price) stay read-only. Edit → data mutation, not HTML.
1109
- (Scope today: x-for list item fields. Direct $x.a.b bindings outside a list are a
1110
- 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 ---- */
1111
2072
  function armDataValues(area) {
1112
- const tpl = area.querySelector('template[x-for]'); if (!tpl) return;
2073
+ const tpl = listTpl(area); if (!tpl) return;
1113
2074
  const lv = (tpl.getAttribute('x-for').match(/^\s*([\w$]+)\s+in\b/) || [])[1]; if (!lv) return;
1114
2075
  const source = dataSourceName(area), fieldRe = new RegExp('^\\s*' + lv + '\\.([\\w$]+)\\s*$');
1115
2076
  sortableChildren(area).forEach(clone => {
1116
- const id = clone.getAttribute('data-key'); if (id == null) return;
1117
- clone.querySelectorAll('*').forEach(el => {
1118
- 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 => {
1119
2082
  const rich = el.hasAttribute('x-html');
2083
+ if ((el.children.length && !(rich && plainFormatted(el))) || el.hasAttribute('data-edit-handle')) return;
1120
2084
  const expr = el.getAttribute('x-text') || el.getAttribute('x-html'); if (!expr) return;
1121
2085
  const m = expr.match(fieldRe); if (!m) return;
1122
2086
  const field = m[1];
1123
- el.setAttribute('data-edit-field', field);
1124
- // A field bound with x-html can hold markup, so it is styleable like
1125
- // any other text — the value simply carries the tags. Bound with
1126
- // x-text it cannot, and offering the controls there would write
1127
- // markup the binding renders as literal characters. The author's
1128
- // choice of binding is the whole distinction.
1129
- el.toggleAttribute('data-edit-rich', rich);
1130
- // The rich editor owns the caret where it is present.
1131
- if (!el.hasAttribute('data-text-edit')) el.setAttribute('contenteditable', 'true');
1132
- if (el._dvBound) return; el._dvBound = true;
1133
- const read = () => rich ? el.innerHTML.trim() : el.textContent;
1134
- el.addEventListener('focus', () => { el._preEdit = read(); if (!el.hasAttribute('data-text-edit')) selectAllIn(el); }); // the rich editor owns its caret
1135
- el.addEventListener('focusin', () => { el._preEdit = read(); });
1136
- 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');
1137
2101
  });
1138
2102
  });
1139
2103
  }
1140
2104
 
1141
- /* ---- COMPONENT editing: text leaves addressed by structural path. Right-click an
1142
- instance for the scope (this instance vs all) + per-element classes + revert. ---- */
1143
- const componentName = (area) => { const r = area.querySelector('[data-component]'); return (r?.getAttribute('data-component') || '').replace(/-\d+$/, ''); };
1144
- // 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+$/, ''); };
1145
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('.'); }
1146
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; }
1147
- // While editing in 'All' scope, mirror the edit to every OTHER instance live — but
1148
- // skip the source element (don't fight the caret) and skip any instance that has its
1149
- // OWN committed override for this node/prop (instance overrides always win).
2109
+ // 'main' scope: mirror live to other instances without their own override.
1150
2110
  function liveMainPropagate(area, sourceEl, prop, value) {
1151
2111
  if ((area._editScope || 'instance') !== 'main') return;
1152
- const comp = componentName(area), path = sourceEl.getAttribute('data-edit-path'), { inst } = componentState();
1153
- document.querySelectorAll('[data-edit-area]').forEach(a => {
1154
- if (!a._edit || classify(a) !== 'component' || componentName(a) !== comp) return;
1155
- 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;
1156
2116
  const el = path === '' ? root : nodeByPath(root, path); if (!el || el === sourceEl) return;
1157
2117
  const iv = (inst[key(a)] && inst[key(a)][path]) || {}; if (iv[prop] !== undefined) return; // override wins
1158
- if (prop === 'class') { if (el.getAttribute('class') !== value) el.setAttribute('class', value); }
2118
+ if (prop === 'class') writeClass(el, value);
1159
2119
  else if (el.innerHTML !== value) el.innerHTML = value;
1160
2120
  });
1161
2121
  }
1162
2122
  function armComponent(area) {
1163
- const root = area.querySelector('[data-component]'); if (!root) return;
1164
- 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';
1165
2125
  area._baseText = area._baseText || {}; area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {};
1166
- // Mark the root AND every element with a path → all are class-editable (incl. the parent).
1167
- 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') || ''; };
1168
2127
  markClass(root);
1169
2128
  root.querySelectorAll('*').forEach(el => { if (!el.hasAttribute('data-edit-handle')) markClass(el); });
1170
- // Text leaves also become contenteditable. Capture innerHTML (not textContent) so
1171
- // nested inline elements like <i>/<br> the user adds are preserved.
1172
2129
  armRichText(area);
1173
- root.querySelectorAll('[data-edit-path]').forEach(el => {
1174
- if (el.children.length || !el.textContent.trim() || !capOf(el, 'text')) return;
1175
- if (el.closest('[data-text-edit]')) return; // the rich editor owns this subtree
1176
- 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;
1177
2134
  if (!(p in area._baseText)) area._baseText[p] = el.innerHTML.trim();
1178
- el.setAttribute('contenteditable', 'true');
1179
- if (el._textBound) return; el._textBound = true;
1180
- el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); selectAllIn(el); });
1181
- el.addEventListener('input', () => liveMainPropagate(area, el, 'text', el.innerHTML));
1182
- 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');
1183
2137
  });
1184
2138
  }
1185
- let cmpMenu;
1186
- function openComponentMenu(e, area) {
1187
- if (!isActive(area)) return; e.preventDefault(); e.stopPropagation();
1188
- const target = e.target.closest('[data-edit-path]');
1189
- if (!cmpMenu) {
1190
- cmpMenu = document.createElement('div'); cmpMenu.setAttribute('data-edit-menu', ''); cmpMenu.setAttribute('popover', 'manual');
1191
- cmpMenu.addEventListener('pointerdown', ev => ev.stopPropagation());
1192
- document.body.appendChild(cmpMenu);
1193
- 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 });
1194
2150
  }
1195
- if (target) target._preClass = target.getAttribute('class') || '';
1196
- const cur = area._editScope || 'instance';
1197
- cmpMenu.innerHTML =
1198
- `<small>Scope · ${componentName(area)}</small>`
1199
- + `<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>`
1200
- + (target ? `<small>Classes · ${target.tagName.toLowerCase()} · live</small><input type="text" spellcheck="false" data-cls>` : '')
1201
- + `<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>`;
1202
- 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); });
1203
- const cls = cmpMenu.querySelector('[data-cls]');
1204
- if (cls && target) {
1205
- cls.value = target.getAttribute('class') || '';
1206
- cls.addEventListener('input', () => { target.setAttribute('class', cls.value); liveMainPropagate(area, target, 'class', cls.value); });
1207
- cls.addEventListener('change', () => commitComponentNode(area, target, 'class', cls.value));
1208
- 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);
1209
2242
  }
1210
- const hide = () => { if (cmpMenu.matches(':popover-open')) cmpMenu.hidePopover(); };
1211
- const rv = cmpMenu.querySelector('[data-a="revert"]'); if (rv) rv.onclick = () => { revertNode(area, target); hide(); };
1212
- cmpMenu.querySelector('[data-a="revertall"]').onclick = () => { revertAll(area); hide(); };
1213
- cmpMenu.style.left = Math.min(e.clientX, innerWidth - 240) + 'px'; cmpMenu.style.top = Math.min(e.clientY, innerHeight - 180) + 'px';
1214
- if (!cmpMenu.matches(':popover-open')) { try { cmpMenu.showPopover(); } catch { } }
1215
- if (cls) setTimeout(() => cls.focus(), 0);
1216
- }
1217
-
1218
-
1219
- /* ---- Style: right-click → live utility-class input (no Apply; commit on close) ---- */
1220
- let menu;
1221
- function closeMenu() {
1222
- if (!menu || !menu.matches(':popover-open')) return;
1223
- if (menu._dirty && menu._target) { commitStaticNode(menu._target.closest('[data-edit-area]'), menu._target, 'class', menu._target.getAttribute('class') || ''); menu._dirty = false; }
1224
- try { menu.hidePopover(); } catch { } menu._target = null;
1225
- }
1226
- function ensureMenu() {
1227
- if (menu) return menu;
1228
- // A real popover: the top layer is above every stacking context, so the menu
1229
- // cannot be painted over by a resize handle or anything else with a z-index.
1230
- menu = document.createElement('div'); menu.setAttribute('data-edit-menu', ''); menu.setAttribute('popover', 'manual');
1231
- 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>';
1232
- menu.addEventListener('pointerdown', e => e.stopPropagation());
1233
- const input = menu.querySelector('input');
1234
- input.addEventListener('input', () => { if (menu._target) { menu._target.setAttribute('class', input.value); menu._dirty = true; } });
1235
- input.addEventListener('keydown', e => { if (e.key === 'Enter') closeMenu(); });
1236
- menu.querySelector('[data-a="close"]').addEventListener('click', closeMenu);
1237
- document.body.appendChild(menu);
1238
- document.addEventListener('pointerdown', closeMenu);
1239
- document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
1240
- return menu;
1241
- }
1242
- function openMenu(el, x, y) {
1243
- const m = ensureMenu(); m._target = el; m._dirty = false; el._preClass = el.getAttribute('class') || '';
1244
- m.querySelector('[data-tag]').textContent = '· ' + el.tagName.toLowerCase();
1245
- const input = m.querySelector('input'); input.value = el.getAttribute('class') || '';
1246
- m.style.left = Math.min(x, innerWidth - 280) + 'px'; m.style.top = Math.min(y, innerHeight - 130) + 'px';
1247
- if (!m.matches(':popover-open')) { try { m.showPopover(); } catch { } }
1248
- setTimeout(() => { input.focus(); input.select(); }, 0);
1249
- }
1250
- // The class menu is authoring chrome, so it only opens where chrome was asked for.
1251
- function openClassMenu(area, e) {
1252
- if (!area._edit.authoring) return;
1253
- let t = e.target;
1254
- while (t && t !== area.parentElement && !(capOf(t, 'style') && !locked(t) && !t.hasAttribute('data-edit-handle'))) t = t.parentElement;
1255
- if (!t || !capOf(t, 'style') || locked(t)) return;
1256
- openMenu(t, e.clientX, e.clientY);
1257
- }
1258
-
1259
-
1260
- /* ---- Theme regime: edit CSS custom properties (x-edit.cssvar) ----
1261
- Not DOM-element editing. A variable set on :root is global; set on an x-edit
1262
- theme scope's element it cascades to that subtree ONLY (sandboxed theme), so the
1263
- same var name can live at both levels without collision. Controls are inputs that
1264
- declare their target as "scope:--var" (area/scope key) or bare "--var" (global).
1265
- Live = setProperty (var()-referencing utilities update for free); persisted as
1266
- {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. */
1267
2287
  const cssvarControls = new Set();
1268
2288
  const themeBaseline = {}; // "scope|var" → original inline value ('' if none) — for revert-to-baseline
1269
2289
  const themeTouched = new Set(); // every scope|var ever applied
@@ -1271,10 +2291,11 @@
1271
2291
  const themeTargetEl = (scope) => (scope && themeScopes[scope]) || document.documentElement;
1272
2292
  const cvValue = (el) => el.value + (el.dataset.unit || ''); // data-unit lets a range/number drive a unit'd var
1273
2293
 
1274
- function registerCssVar(el, expression) {
2294
+ function registerCssVar(el, expression, modifiers) {
1275
2295
  const spec = (expression || '').trim();
1276
- const m = spec.match(/^([\w-]+)\s*:\s*(--[\w-]+)$/); // "scope:--var" vs bare "--var" (global)
2296
+ const m = spec.match(/^([\w-]+)\s*:\s*(--[\w-]+)$/);
1277
2297
  el._cssvar = m ? { scope: m[1], var: m[2] } : { scope: null, var: spec };
2298
+ el._cssvar.persist = !!(modifiers && modifiers.includes('persist'));
1278
2299
  cssvarControls.add(el);
1279
2300
  }
1280
2301
  function syncCssVarInput(el) {
@@ -1291,7 +2312,7 @@
1291
2312
  syncCssVarInput(el);
1292
2313
  if (el._cvBound) return; el._cvBound = true;
1293
2314
  el.addEventListener('input', () => themeTargetEl(cv.scope).style.setProperty(cv.var, cvValue(el))); // live, no log
1294
- 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])));
1295
2316
  }
1296
2317
  function armThemeControls() { cssvarControls.forEach(armCssVar); }
1297
2318
 
@@ -1300,8 +2321,7 @@
1300
2321
  for (let i = 0; i < cursor; i++) { const d = log[i]; if (d.kind === 'theme') s[tkey(d.scope, d.var)] = d; }
1301
2322
  return s;
1302
2323
  }
1303
- // Re-derive every touched var from the log: state value if present, else the captured
1304
- // baseline (setProperty to restore an authored inline value; removeProperty if none).
2324
+ // Re-derive every touched var from the log, else its captured baseline.
1305
2325
  function applyThemeState() {
1306
2326
  const st = themeState();
1307
2327
  new Set([...themeTouched, ...Object.keys(st)]).forEach(kk => {
@@ -1312,14 +2332,58 @@
1312
2332
  });
1313
2333
  cssvarControls.forEach(syncCssVarInput);
1314
2334
  }
1315
- 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; }
1316
2381
  const kk = tkey(scope, v); themeTouched.add(kk);
1317
2382
  themeTargetEl(scope).style.setProperty(v, value);
1318
2383
  const cur = themeState()[kk]; if (cur && cur.value === value) return; // dedupe
1319
- 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);
1320
2385
  }
1321
- // B-side: each theme var → its scope's target CSS file (data-edit-theme-file on the
1322
- // 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.
1323
2387
  function themePatches() {
1324
2388
  return Object.values(themeState()).map(d => {
1325
2389
  const el = d.scope ? themeScopes[d.scope] : null;
@@ -1328,173 +2392,220 @@
1328
2392
  }
1329
2393
 
1330
2394
 
1331
- /* ---- Arm / disarm an area for its regime + caps ---- */
1332
- // Static nodes are addressed by a content-derived key, not by their position
1333
- // among siblings. Position was stable while the tree was, but adding, removing or
1334
- // duplicating a block shifts every address after it — so an edit recorded before
1335
- // a structural change replayed onto the wrong element afterwards.
1336
- //
1337
- // The key is derived once, from what the element held when the area was first
1338
- // armed, and never recomputed for an element that already has one. That is what
1339
- // makes it survive both kinds of change: a later text edit does not move the
1340
- // address, and a reload derives the same key again because the source it loads is
1341
- // 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();
1342
2400
  function markStatic(area) {
2401
+ const region = key(area), first = !area._marked;
2402
+ area._marked = true;
1343
2403
  area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {}; area._baseText = area._baseText || {};
1344
2404
  const seen = Object.create(null);
1345
- const markEl = (el) => {
2405
+ const markEl = (el, path) => {
1346
2406
  let k = el.getAttribute('data-edit-key');
1347
- if (k) {
1348
- // Keep the tally in step so a new element never claims an ordinal
1349
- // an existing one already holds.
1350
- // The ordinal is the LAST #digits suffix — authored text may contain '#'.
1351
- const m = /^([\s\S]*)#(\d+)$/.exec(k);
1352
- const base = m ? m[1] : k, n = m ? m[2] : undefined;
1353
- seen[base] = Math.max(seen[base] || 0, n ? +n : 1);
1354
- } else {
1355
- const base = el.tagName + ':' + (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
1356
- const n = seen[base] = (seen[base] || 0) + 1;
1357
- 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();
1358
2414
  el.setAttribute('data-edit-key', k);
1359
2415
  }
1360
- el.setAttribute('data-edit-path', pathOf(el, area)); // still what the source writer navigates by
1361
- if (!(k in area._baseClass)) area._baseClass[k] = el.getAttribute('class') || '';
1362
- 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);
1363
2418
  if (!(k in area._baseText) && !el.children.length) area._baseText[k] = el.innerHTML.trim();
1364
2419
  };
1365
- markEl(area);
1366
- // Stop at a rich editor: its internals are its own, and marking them would
1367
- // both litter its content and let applyStaticState fight it for the same nodes.
1368
- area.querySelectorAll('*').forEach(el => {
1369
- if (el.hasAttribute('data-edit-handle')) return;
1370
- if (el.parentElement && el.parentElement.closest('[data-text-edit]')) return;
1371
- markEl(el);
1372
- });
1373
- if (!area._baseOrder) area._baseOrder = staticKeys(area);
1374
- 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, '');
1375
2450
  }
2451
+ const warnedRendered = new Set(), renderedRegions = new Set();
1376
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);
1377
2456
  const kind = classify(area);
1378
- area.setAttribute('data-edit-armed', '');
1379
- // Chrome is opt-in: a sortable list should look like a list, not like a page
1380
- // being edited. The attributes are all CSS asks for — what is shown, and
1381
- // whether it is shown at all, is decided in the stylesheet.
1382
- area.toggleAttribute('data-edit-authoring', !!area._edit.authoring);
1383
- // .authoring regions get the UI scope by default; anything else opts in
1384
- // by putting data-edit-ui on the region or an ancestor. Only remove what
1385
- // the plugin itself added, never an author's attribute.
1386
- if (area._edit.authoring && !area.hasAttribute('data-edit-ui')) { area.setAttribute('data-edit-ui', ''); area._uiByPlugin = true; }
1387
- area.setAttribute('data-edit-label', kind === 'component' ? `${key(area)} · component · ${area._editScope || 'instance'}` : `${key(area)} · ${kind}`);
1388
2457
  if (kind === 'static') markStatic(area);
1389
- [area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (capOf(c, 'sort') && !locked(c)) makeSortable(c); });
2458
+ markPieces(area);
1390
2459
  [area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (ownsCap(c, 'size')) armSize(c); });
1391
- if (kind === 'component') armComponent(area); else armText(area);
1392
- if (!area._dndBound) {
1393
- area._dndBound = true;
1394
- // Native HTML5 drag (a text selection, an image, a link) must never
1395
- // start inside a region: it drops SELECTED TEXT into neighbouring
1396
- // editable leaves, silently merging elements. All region dragging is
1397
- // 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.
1398
2471
  area.addEventListener('dragstart', (e) => { if (isActive(area)) e.preventDefault(); });
1399
- 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(); });
1400
2473
  }
1401
- if (!area._ctxBound) {
1402
- area._ctxBound = true;
1403
- // One binding for the whole area: report the block, and fall back to the
1404
- // built-in authoring menu only if the project did not take the event.
1405
- area.addEventListener('contextmenu', (e) => {
1406
- if (!isActive(area)) return;
1407
- const builtIn = () => classify(area) === 'component' ? openComponentMenu(e, area) : openClassMenu(area, e);
1408
- if (onBlockContext(e, builtIn)) return;
1409
- if (!area._edit.authoring) return;
1410
- e.preventDefault();
1411
- 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); });
1412
2479
  });
2480
+ area._editObserver.observe(area, { childList: true, subtree: true });
1413
2481
  }
1414
- if (kind === 'data' && capOf(area, 'data')) armDataValues(area);
1415
2482
  }
1416
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; } });
1417
2486
  area.querySelectorAll('[data-edit-handle]').forEach(h => h.remove());
1418
- area.querySelectorAll('[contenteditable]').forEach(e => e.removeAttribute('contenteditable'));
1419
- 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; });
1420
- [area, ...area.querySelectorAll('[data-edit-sizable],[data-edit-movable]')].forEach(e => { e.removeAttribute('data-edit-sizable'); e.removeAttribute('data-edit-movable'); e._sizeBound = false; });
1421
- area.removeAttribute('data-edit-armed'); area.removeAttribute('data-edit-label');
1422
- 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();
1423
2492
  }
1424
2493
 
1425
- /* ---- Activation: per-area, always-on by default (.gated needs $edit.on()) ---- */
2494
+ /* ---- Activation: always-on by default; .gated waits for $edit.on() ---- */
1426
2495
  let estore, booted = false, armQueued = false;
1427
- const isActive = (a) => !!a && (!a._edit.gated || (estore && estore.active));
1428
- const anyActive = () => areas().some(isActive);
1429
- 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; }
1430
-
1431
- /* ---- Late regions: arm when Alpine initialises them, release when it destroys them ---- */
1432
- // Arming ran once at boot, so anything rendered later — x-if, a route change, a lazy
1433
- // component, x-markdown output — stayed inert. Every one of those paths goes through
1434
- // Alpine.initTree, so the x-edit directive itself is the hook; no observer needed.
1435
- // Deferred one microtask so the tree Alpine is walking is complete before baselines
1436
- // 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.
1437
2515
  function armLater() {
1438
- if (!booted || armQueued) return; // the boot pass still owns start-up ordering
2516
+ if (!booted || armQueued) return;
1439
2517
  armQueued = true;
1440
2518
  queueMicrotask(() => { armQueued = false; armAll(); });
1441
2519
  }
1442
- // Runs from Alpine's directive cleanup (x-if/x-for teardown, or its own mutation
1443
- // observer), so a removed region drops its observers and its registry slot.
1444
2520
  function releaseEdit(el) {
1445
2521
  Object.keys(themeScopes).forEach(k => { if (themeScopes[k] === el) delete themeScopes[k]; });
1446
2522
  cssvarControls.delete(el);
1447
2523
  if (!editEls.has(el)) return;
1448
2524
  if (el._armed) { disarmArea(el); el._armed = false; }
1449
- [el, ...el.querySelectorAll('*')].forEach(n => { if (n._sortObserver) { n._sortObserver.disconnect(); n._sortObserver = null; } });
1450
- editEls.delete(el); delete el._edit;
2525
+ if (el._editObserver) { el._editObserver.disconnect(); el._editObserver = null; }
2526
+ editEls.delete(el); delete el._edit; resetAreas();
1451
2527
  el.removeAttribute('data-edit-area');
2528
+ if (target && (target === el || el.contains(target))) setTarget(null);
1452
2529
  refresh();
1453
2530
  }
1454
- // Build B-side source patches from the edit set (storage-agnostic; overlay
1455
- // already auto-persists to localStorage on commit).
1456
- function buildPatches() {
1457
- const patches = Object.entries(fold()).filter(([k]) => authoringRegion(k)).map(([k, v]) => patchFor(k, v.kind, v.snap)); // data
1458
- const toEdits = (paths, area, fileOrder) => {
1459
- const e = [];
1460
- Object.entries(paths).forEach(([k, props]) => {
1461
- // Keys address across a session; the source writer navigates by
1462
- // position, so resolve to where the element actually sits now (the
1463
- // server applies order before edits, so current position is the
1464
- // right one). A deleted element has nothing to write.
1465
- const el = area && (area.getAttribute('data-edit-key') === k ? area : area.querySelector(`[data-edit-key="${CSS.escape(k)}"]`));
1466
- let path = el ? pathOf(el, area) : k;
1467
- if (area && !el) return;
1468
- // RULE: paths must be valid against the file state the server will reach —
1469
- // a pending st-children change can't be applied there (a short order is
1470
- // skipped), so address by the block's position in the UNMODIFIED file.
1471
- if (el && el !== area && fileOrder) {
1472
- let block = el; while (block.parentElement && block.parentElement !== area) block = block.parentElement;
1473
- const fi = fileOrder.indexOf(block.getAttribute('data-edit-key'));
1474
- if (fi < 0) return; // block not in the file (added this session) — nothing to address
1475
- const segs = path.split('.'); segs[0] = String(fi); path = segs.join('.');
1476
- }
1477
- Object.entries(props).forEach(([prop, value]) => e.push({ path, key: k, prop, value }));
1478
- });
1479
- 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;
1480
2546
  };
1481
- 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++) {
1482
2588
  const d = log[i];
1483
- if (d.kind !== 'st-move' || !authoringRegion(d.from) || !authoringRegion(d.to)) continue;
1484
- 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); });
1485
2590
  }
1486
- const ss = staticState(); // static: per-node ops + reorder permutation (no whole HTML)
1487
- new Set([...Object.keys(ss.node), ...Object.keys(ss.order)]).forEach(region => {
1488
- if (!authoringRegion(region)) return;
1489
- const area = areaByKey(region);
1490
- const structural = log.slice(0, cursor).some(d => d.kind === 'st-children' && d.region === region);
1491
- const edits = toEdits(ss.node[region] || {}, area, structural ? (area && area._baseOrder) || [] : null);
1492
- // A moved region always gets its folded order (pre-move reorders are NOT
1493
- // in the move splice); the server skips a reorder that is already true.
1494
- const order = ss.order[region] || null;
1495
- 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);
1496
2597
  });
1497
- 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();
1498
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 }))));
1499
2610
  const { main, inst, reverts } = componentState();
1500
2611
  Object.entries(main).forEach(([component, paths]) => { const edits = toEdits(paths); if (edits.length) patches.push({ kind: 'component', scope: 'main', component, edits }); });
@@ -1503,118 +2614,376 @@
1503
2614
  const edits = toEdits(inst[region] || {}), rv = [...(reverts[region] || [])];
1504
2615
  if (edits.length || rv.length) patches.push({ kind: 'component', scope: 'instance', component: componentName(area), region, edits, reverts: rv });
1505
2616
  });
1506
- themePatches().forEach(p => patches.push(p)); // theme: CSS-var value writes (scoped → its file; global → theme file)
2617
+ themePatches().forEach(p => patches.push(p));
1507
2618
  return patches;
1508
2619
  }
1509
- function publish() {
1510
- const patches = buildPatches();
1511
- if (estore && typeof estore.onPublish === 'function') return Promise.resolve(estore.onPublish(patches, { log, cursor }));
1512
- if (!patches.length) return Promise.resolve({ results: [] });
1513
- 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)
1514
- }
1515
- function on() { if (estore) estore.active = true; armAll(); }
1516
- function off() { if (estore) estore.active = false; areas().forEach(a => { if (a._edit.gated && a._armed) { disarmArea(a); a._armed = false; } }); closeMenu(); refresh(); }
1517
- // Runtime lock — meta, not content: applied immediately, not logged. Locks
1518
- // the target and its subtree (descendants without their own x-edit inherit).
1519
- function setLock(el, val) {
1520
- if (!el) return;
1521
- if (!el._edit) { el._edit = { key: `lock-${++autoN}`, caps: new Set(), lock: val, gated: false }; el.setAttribute('data-edit-area', ''); editEls.add(el); }
1522
- else { el._edit.lock = val; if (val) el._edit.caps = new Set(); }
1523
- 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'); }); }
1524
- const area = areas().find(a => a === el || a.contains(el));
1525
- if (area && isActive(area)) armArea(area);
1526
- }
1527
-
1528
-
1529
- /* ---- Authoring chrome: floating toolbar (undo/redo/publish/reset). A convenience
1530
- over the $edit magic — built on first use and shown only where there is
1531
- something to edit, so it never sits on a route with no editable area. ---- */
1532
- let bar;
1533
- function buildUI() {
1534
- if (bar) return bar;
1535
- bar = document.createElement('div'); bar.setAttribute('data-edit-toolbar', '');
1536
- 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>';
1537
- bar.addEventListener('click', e => {
1538
- const btn = e.target.closest('button'); if (!btn) return; const a = btn.getAttribute('data-a');
1539
- if (a === 'undo') undo(); if (a === 'redo') redo(); if (a === 'reset') { localStorage.removeItem(LS_KEY); location.reload(); }
1540
- if (a === 'publish') {
1541
- const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Publishing…';
1542
- 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` : ''}`; })
1543
- .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);
1544
2656
  }
2657
+ if (kind === 'component' && a._armed) armComponent(a);
2658
+ lastSnap[key(a)] = snapshot(a, kind);
1545
2659
  });
1546
- document.body.appendChild(bar);
1547
- return bar;
2660
+ rebaseTheme();
2661
+ relock();
2662
+ saveState(); refresh();
1548
2663
  }
1549
- // An area hidden by the router is still in the DOM, so ask the layout, not the tree.
1550
- const onScreen = (el) => el.checkVisibility ? el.checkVisibility() : !!el.offsetParent;
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());
2855
+ }
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
+
1551
2864
  function refresh() {
1552
- if (estore) { estore.canUndo = cursor > 0; estore.canRedo = cursor < log.length; }
1553
- // Script decides presence — it is the only thing that knows which route is on
1554
- // screen. The stylesheet decides whether it is seen.
1555
- const authoring = areas().some(a => a._edit.authoring && isActive(a) && onScreen(a));
1556
- document.documentElement.toggleAttribute('data-edit-active', authoring);
1557
- if (!authoring) return;
1558
- buildUI();
1559
- bar.querySelector('[data-a="undo"]').disabled = cursor === 0;
1560
- 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);
2870
+ }
2871
+
2872
+ /* ---- Undo/redo hotkeys: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Ctrl+Y ----
2873
+ On wherever a region is active; data-edit-hotkeys="off" on a region or any
2874
+ ancestor opts it out. Text that is still being typed keeps the browser's own
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();
2878
+ const hotkeysOff = (el) => !!el.closest('[data-edit-hotkeys="off"]');
2879
+ // A focused leaf's baseline follows history rewrites under the caret.
2880
+ function rebaseFocused() {
2881
+ const el = document.activeElement;
2882
+ if (!el || el._preEdit === undefined || !el.isContentEditable) return;
2883
+ const sync = () => { if (document.activeElement === el) { restartRich(el); el._preEdit = leafText(el); } };
2884
+ sync(); setTimeout(sync, 0);
2885
+ }
2886
+ function historyDir(e) {
2887
+ if (e.altKey || !(e.metaKey || e.ctrlKey)) return null;
2888
+ const k = (e.key || '').toLowerCase();
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';
2896
+ const t = e.target && e.target.nodeType === 1 ? e.target : document.body;
2897
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || authoredRich(t.closest('[data-rich-text]'))) return;
2898
+ const region = t.closest('[data-edit-area]');
2899
+ if (region) {
2900
+ const top = regionOf(region);
2901
+ if (!top || !isActive(top) || hotkeysOff(region)) return;
2902
+ } else {
2903
+ if (t.isContentEditable) return; // someone else's editor
2904
+ if (!areas().some(a => isActive(a) && !hotkeysOff(a))) return;
2905
+ }
2906
+ if (t.isContentEditable && t._preEdit !== undefined && leafText(t) !== t._preEdit) return; // uncommitted typing: native undo
2907
+ if (isRedo ? cursor >= log.length : cursor === 0) return;
2908
+ e.preventDefault();
2909
+ isRedo ? redo() : undo();
1561
2910
  }
1562
2911
 
1563
2912
 
1564
- /* ---- 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
+ }
1565
2926
  function restore() {
1566
2927
  if (!log.length) return;
2928
+ pruneLoaded();
1567
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)); }
1568
- // A replayed move must first converge live keys to the recorded ones (ordinal
1569
- // renames from collisions). Skipped when the source already holds the move
1570
- // (post-save reload): fresh derivation then matches the recorded order.
1571
- for (let i = 0; i < cursor; i++) {
1572
- const d = log[i];
1573
- if (d.kind !== 'st-move' || !d.remap) continue;
1574
- const ta = areaByKey(d.to);
1575
- if (!ta || eq(staticKeys(ta), d.toOrder)) continue;
1576
- applyMoveKeys(d, false);
1577
- }
1578
- 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(); }
1579
2931
  if (log.some(d => d.kind === 'cmp-main' || d.kind === 'cmp-inst')) applyComponentState();
1580
2932
  if (log.some(d => d.kind === 'data-val')) { const da = areas().find(a => classify(a) === 'data'); if (da) waitForData(da, applyDataValues); }
1581
2933
  if (log.some(d => d.kind === 'theme')) applyThemeState();
2934
+ if (log.some(d => d.kind === 'lock')) areas().forEach(rearm);
2935
+ refresh();
1582
2936
  }
1583
- 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); }
1584
2938
  function init() {
1585
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', '{}');
1586
2942
  Alpine.directive('edit', (el, { modifiers, expression }, { cleanup }) => {
1587
2943
  registerEdit(el, modifiers, expression);
1588
- armLater(); // a region rendered after boot arms itself
1589
- cleanup(() => releaseEdit(el)); // ...and lets go when Alpine tears it down
2944
+ armLater();
2945
+ cleanup(() => releaseEdit(el));
1590
2946
  });
1591
2947
  Alpine.store('edit', {
1592
- active: false, canUndo: false, canRedo: false,
1593
- 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,
1594
2950
  toggle() { this.active ? off() : on(); }, on() { on(); }, off() { off(); },
1595
- undo() { undo(); }, redo() { redo(); }, lock(el) { setLock(el, true); }, unlock(el) { setLock(el, false); },
1596
- publish() { return publish(); },
1597
- // Block operations. Called with no element they act on the block the last
1598
- // right-click reported, which is what a context menu wants.
1599
- targetVersion: 0, // trackable: see blocks.js
1600
- 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; },
1601
2956
  set target(el) { setBlockTarget(el); },
1602
- 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); },
1603
2960
  copy(el) { return copyBlock(el); },
1604
2961
  cut(el) { return cutBlock(el); },
1605
2962
  paste(el) { return pasteBlock(el); },
1606
2963
  duplicate(el) { return duplicateBlock(el); },
1607
2964
  remove(el) { return removeBlock(el); },
1608
2965
  move(el, dest, before) { return moveBlock(el, dest, before); },
1609
- block(node) { return blockOf(node); },
1610
- patches() { return buildPatches(); }, // resolved B-side source patches
1611
- 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(); }
1612
2974
  });
1613
2975
  estore = Alpine.store('edit');
1614
2976
  Alpine.magic('edit', () => Alpine.store('edit'));
1615
2977
  loadState();
1616
- setTimeout(() => { armAll(); restore(); }, 450); // arm (captures baselines) THEN re-apply persisted edits
1617
- window.addEventListener('manifest:route-change', () => setTimeout(refresh, 0)); // chrome follows the route
2978
+ loadBase();
2979
+ setTimeout(() => { armAll(); restore(); }, 450);
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(); } });
1618
2987
  }
1619
2988
  document.addEventListener('alpine:init', init);
1620
2989
  if (window.Alpine && Alpine.directive) setTimeout(init, 0);