mnfst 0.5.195 → 0.5.197

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.
@@ -0,0 +1,1373 @@
1
+ /* manifest.edit.js — built from scripts/edit/ */
2
+
3
+ (function () {
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. */
17
+
18
+ (function () {
19
+ const LS_KEY = 'mnfst-edit-log';
20
+ const SCHEMA = 6; // 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)
23
+ let dragged = null, autoN = 0;
24
+
25
+ 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.
34
+ function staticKeys(container) {
35
+ const seen = Object.create(null);
36
+ return sortableChildren(container).map(el => {
37
+ const assigned = el.getAttribute('data-edit-key');
38
+ if (assigned) return assigned;
39
+ const base = staticKey(el);
40
+ const n = seen[base] = (seen[base] || 0) + 1;
41
+ return n > 1 ? base + '#' + n : base;
42
+ });
43
+ }
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.region == null || authoringRegion(d.region));
51
+
52
+ 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) })); };
53
+ const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
54
+ const cssVar = (el, name, fallback) => { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; };
55
+
56
+ /* ---- Safety helpers ---- */
57
+ // Structural signature for a node at a path — guards against the source structure
58
+ // drifting under a stored delta (apply only if the node still matches).
59
+ const nodeSig = (el) => el ? el.tagName.toLowerCase() : '';
60
+ // Sanitize contentEditable / replayed HTML: allowlist inline tags, strip everything
61
+ // else (attributes, scripts, comments, javascript: hrefs). Critical once overlays can
62
+ // come from other users (cloud). Unknown tags are unwrapped to their text.
63
+ const SAFE_TAGS = new Set(['B', 'I', 'EM', 'STRONG', 'U', 'S', 'SMALL', 'CODE', 'MARK', 'SUB', 'SUP', 'BR', 'SPAN', 'A']);
64
+ // An element owned by the rich editor carries block markup this plugin's own
65
+ // allowlist would flatten, so let that plugin vet its own content. Everything
66
+ // else keeps the conservative inline-only policy.
67
+ const sanitizeFor = (el, html) => el && el.hasAttribute && el.hasAttribute('data-text-edit') && window.ManifestTextEdit
68
+ ? window.ManifestTextEdit.sanitize(html, true, true)
69
+ : sanitizeHTML(html);
70
+
71
+ function sanitizeHTML(html) {
72
+ const t = document.createElement('template'); t.innerHTML = String(html == null ? '' : html);
73
+ const walk = (node) => [...node.childNodes].forEach(c => {
74
+ if (c.nodeType === 8) return c.remove(); // comments
75
+ if (c.nodeType !== 1) return;
76
+ if (!SAFE_TAGS.has(c.tagName)) { c.replaceWith(document.createTextNode(c.textContent)); return; } // unwrap unknown tag
77
+ [...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); });
78
+ walk(c);
79
+ });
80
+ walk(t.content);
81
+ return t.innerHTML;
82
+ }
83
+
84
+ /* ---- x-edit registry ---- */
85
+ const editEls = new Set();
86
+ const themeScopes = {}; // scope key → element (CSS-var cascade target for x-edit.cssvar)
87
+ function registerEdit(el, modifiers, expression) {
88
+ if (modifiers.includes('cssvar')) return registerCssVar(el, expression); // theme control, not an area
89
+ const caps = new Set(ALL_CAPS.filter(c => modifiers.includes(c)));
90
+ const lock = modifiers.includes('lock') || modifiers.includes('none');
91
+ const theme = modifiers.includes('theme'); // theme scope: vars set here cascade to this subtree only
92
+ const k = (expression || '').trim() || `area-${++autoN}`;
93
+ if (theme) themeScopes[k] = el;
94
+ if (theme && !caps.size && !lock) return; // pure theme scope — a cascade target, NOT an editable area (so it doesn't swallow nested areas)
95
+ if (!caps.size && !lock) ['sort', 'text', 'style'].forEach(c => caps.add(c)); // size is opt-in
96
+ if ((expression || '').trim() && [...editEls].some(e => e._edit && e._edit.key === k)) console.warn('[edit] duplicate x-edit key (deltas will mis-route):', k);
97
+ el._edit = { key: k, caps, lock, gated: modifiers.includes('gated'), theme, authoring: modifiers.includes('authoring') };
98
+ el.setAttribute('data-edit-area', '');
99
+ editEls.add(el);
100
+ }
101
+ const areas = () => [...editEls].filter(el => { for (let n = el.parentElement; n; n = n.parentElement) if (n._edit) return false; return true; });
102
+ const areaByKey = (k) => areas().find(a => a._edit.key === k);
103
+ const key = (area) => area._edit.key;
104
+ function editInfo(node) { for (let n = node; n; n = n.parentElement) if (n._edit) return n._edit; return null; }
105
+ const capOf = (node, cap) => { const i = editInfo(node); return !!i && !i.lock && i.caps.has(cap); };
106
+ const locked = (node) => { const i = editInfo(node); return !!i && i.lock; };
107
+ const ownsCap = (el, cap) => !!el._edit && !el._edit.lock && el._edit.caps.has(cap);
108
+
109
+ /* ---- Regime classification ---- */
110
+ function classify(area) {
111
+ if (area.querySelector('template[x-for]')) return 'data';
112
+ if (area.matches('[data-component]') || area.querySelector('[data-component]')) return 'component';
113
+ return 'static';
114
+ }
115
+ 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; }
116
+ const dataSourceName = (area) => { const e = dataSourceExpr(area); const m = e && e.match(/\$x\.(\w+)/); return m ? m[1] : (e || 'source'); };
117
+ const sortableChildren = (c) => Array.from(c.children).filter(x => x.tagName !== 'TEMPLATE' && !x.hasAttribute('data-edit-handle'));
118
+ // Identity of a row in a data area. `data-key` is the explicit form; without it,
119
+ // fall back to the x-for's own :key so a plain list needs no extra attribute.
120
+ let _keyWarned = false;
121
+ function itemKey(el, area) {
122
+ const explicit = el.getAttribute('data-key');
123
+ if (explicit != null) return explicit;
124
+ const tpl = area.querySelector('template[x-for]'), expr = tpl && tpl._x_keyExpression;
125
+ if (expr) { try { const v = window.Alpine.evaluate(el, expr); if (v != null) return String(v); } catch { } }
126
+ 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'); }
127
+ return null;
128
+ }
129
+ 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']);
130
+ const componentParams = (area) => { const r = area.querySelector('[data-component]') || area; return [...r.attributes].filter(a => !STRUCT_ATTRS.has(a.name) && a.value.trim()); };
131
+
132
+ /* ---- Unit helpers (shared by move + size) ---- */
133
+ const unitOf = (v) => { const m = String(v || '').match(/[a-z%]+$/i); return m ? m[0] : null; };
134
+ function toUnit(px, unit, el, basisEl, dim) {
135
+ if (unit === 'rem') return +(px / parseFloat(getComputedStyle(document.documentElement).fontSize)).toFixed(2);
136
+ if (unit === 'em') return +(px / parseFloat(getComputedStyle(el).fontSize)).toFixed(2);
137
+ if (unit === '%') { const b = (basisEl || el.parentElement).getBoundingClientRect(); return +(px / (dim === 'w' ? b.width : b.height) * 100).toFixed(2); }
138
+ return Math.round(px);
139
+ }
140
+ function toPx(str, el, dim) {
141
+ const v = parseFloat(str); if (isNaN(v)) return null; const u = unitOf(str) || 'px';
142
+ if (u === 'rem') return v * parseFloat(getComputedStyle(document.documentElement).fontSize);
143
+ if (u === 'em') return v * parseFloat(getComputedStyle(el).fontSize);
144
+ if (u === '%') { const b = (el.parentElement || el).getBoundingClientRect(); return v / 100 * (dim === 'h' ? b.height : b.width); }
145
+ return v;
146
+ }
147
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
148
+
149
+
150
+ /* ---- Snapshots (static/data are area-snapshotted; component uses per-node deltas) ---- */
151
+ const REVERT = '\u2205revert'; // sentinel; must not collide with a real class/text value
152
+ function snapshot(area, kind) {
153
+ if (kind === 'data') return { source: dataSourceName(area), order: sortableChildren(area).map(c => itemKey(c, area)) };
154
+ return {}; // static & component use per-node typed deltas, not area snapshots
155
+ }
156
+ function cleanStaticHTML(area) {
157
+ const c = area.cloneNode(true);
158
+ c.querySelectorAll('[data-edit-handle], [data-edit-live]').forEach(n => n.remove());
159
+ [c, ...c.querySelectorAll('*')].forEach(el => {
160
+ [...el.attributes].forEach(a => { if (a.name.startsWith('data-edit-')) el.removeAttribute(a.name); });
161
+ ['draggable', 'contenteditable', 'tabindex', 'role', 'aria-label'].forEach(a => el.removeAttribute(a));
162
+ if (!el.getAttribute('class')) el.removeAttribute('class');
163
+ });
164
+ return c.innerHTML.trim().replace(/\n\s*\n/g, '\n');
165
+ }
166
+ async function applySnap(area, kind, snap) {
167
+ if (!area || !snap) return;
168
+ if (kind === 'static') { area.innerHTML = snap.html; if (snap.style != null) area.setAttribute('style', snap.style); }
169
+ else if (kind === 'data') applyDataOrder(area, snap.order);
170
+ if (isActive(area)) armArea(area);
171
+ }
172
+ function applyDataOrder(area, order) {
173
+ const expr = dataSourceExpr(area), tpl = area.querySelector('template[x-for]');
174
+ try {
175
+ const arr = window.Alpine.evaluate(tpl, expr); if (!Array.isArray(arr)) return;
176
+ const byKey = new Map(arr.map(it => [String(it.id), it]));
177
+ const next = order.map(k => byKey.get(String(k))).filter(Boolean);
178
+ arr.forEach(it => { if (!order.map(String).includes(String(it.id))) next.push(it); });
179
+ arr.splice(0, arr.length, ...next);
180
+ } catch {}
181
+ }
182
+
183
+ /* ---- Component edits: per-node deltas, scoped instance vs main ----
184
+ cmp-main applies to EVERY instance of the component; cmp-inst to one region.
185
+ prop is 'text' or 'class'; REVERT (prop '*') clears a node's instance overrides. */
186
+ function componentState() {
187
+ const main = {}, inst = {}, reverts = {}, sigs = {};
188
+ for (let i = 0; i < cursor; i++) {
189
+ const d = log[i];
190
+ if (d.kind === 'cmp-main' || d.kind === 'cmp-inst') if (d.sig) sigs[d.component + '|' + d.path] = d.sig;
191
+ if (d.kind === 'cmp-main') { (main[d.component] = main[d.component] || {}); (main[d.component][d.path] = main[d.component][d.path] || {})[d.prop] = d.value; }
192
+ else if (d.kind === 'cmp-inst') {
193
+ const r = d.region;
194
+ if (d.value === REVERT) {
195
+ reverts[r] = reverts[r] || new Set();
196
+ if (d.path === '*') { delete inst[r]; reverts[r] = new Set(['*']); }
197
+ else { if (inst[r]) delete inst[r][d.path]; reverts[r].add(d.path); }
198
+ } else {
199
+ inst[r] = inst[r] || {}; (inst[r][d.path] = inst[r][d.path] || {})[d.prop] = d.value;
200
+ if (reverts[r]) { reverts[r].delete(d.path); reverts[r].delete('*'); }
201
+ }
202
+ }
203
+ }
204
+ return { main, inst, reverts, sigs };
205
+ }
206
+ function applyComponentState() {
207
+ const { main, inst, sigs } = componentState();
208
+ document.querySelectorAll('[data-edit-area]').forEach(area => {
209
+ if (!area._edit || classify(area) !== 'component') return;
210
+ const comp = componentName(area), region = key(area), root = area.querySelector('[data-component]'); if (!root) return;
211
+ [root, ...root.querySelectorAll('[data-edit-path]')].forEach(el => { // include the root (path '') — edits on the parent
212
+ const p = el.getAttribute('data-edit-path');
213
+ const expect = sigs[comp + '|' + p];
214
+ if (expect && nodeSig(el) !== expect) { console.warn('[edit] skip stale node (structure changed):', comp, p); return; }
215
+ const iv = (inst[region] && inst[region][p]) || {}, mv = (main[comp] && main[comp][p]) || {};
216
+ const base = area._baseText || {}, baseC = area._baseClass || {}, baseS = area._baseStyle || {};
217
+ const pick = (prop, fallback) => iv[prop] !== undefined ? iv[prop] : (mv[prop] !== undefined ? mv[prop] : fallback);
218
+ const text = pick('text', base[p]), cls = pick('class', baseC[p]), sty = pick('style', baseS[p]);
219
+ if (text !== undefined) { const safe = sanitizeFor(el, text); if (el.innerHTML !== safe) el.innerHTML = safe; } // sanitize on apply (cloud overlays)
220
+ if (cls !== undefined && el.getAttribute('class') !== cls) el.setAttribute('class', cls);
221
+ if (sty !== undefined && el.getAttribute('style') !== sty) { if (sty === '') el.removeAttribute('style'); else el.setAttribute('style', sty); }
222
+ });
223
+ });
224
+ }
225
+ function commitComponentNode(area, el, prop, value) {
226
+ const path = el.getAttribute('data-edit-path'), scope = area._editScope || 'instance', component = componentName(area), region = key(area);
227
+ if (prop === 'text') value = sanitizeFor(el, value); // sanitize contentEditable on capture
228
+ const before = prop === 'text' ? el._preEdit : prop === 'style' ? el._preStyle : el._preClass;
229
+ if (before === value) return;
230
+ log.splice(cursor);
231
+ const sig = nodeSig(el);
232
+ log.push(scope === 'main' ? { kind: 'cmp-main', component, path, prop, value, before, sig } : { kind: 'cmp-inst', component, region, path, prop, value, before, sig });
233
+ cursor = log.length; saveState(); applyComponentState(); refresh();
234
+ }
235
+ 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(); }
236
+ 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(); }
237
+
238
+ /* ---- Static editing: per-node typed deltas (text/class/style) + reorder permutation.
239
+ No whole-HTML snapshots — storage is O(edits), addresses not trees. ---- */
240
+ // Ops fold up to the cursor; baselines come from the WHOLE log — the `before`
241
+ // on the first delta that touched a prop is its authored value. That pairing is
242
+ // what makes undo total: a prop with no surviving op is restored to baseline
243
+ // instead of being left holding the edit. (applyThemeState works the same way.)
244
+ function staticState() {
245
+ const node = {}, order = {}, sigs = {}, base = {}, html = {};
246
+ for (const d of log) {
247
+ if (d.kind === 'st-children' && d.html) Object.assign(html[d.region] = html[d.region] || {}, d.html);
248
+ if (d.kind !== 'st-node') continue;
249
+ const bk = d.region + '|' + d.path;
250
+ const b = base[bk] = base[bk] || {};
251
+ if (!(d.prop in b)) b[d.prop] = d.before;
252
+ if (d.sig) sigs[bk] = d.sig;
253
+ }
254
+ for (let i = 0; i < cursor; i++) {
255
+ const d = log[i];
256
+ if (d.kind === 'st-node') { (node[d.region] = node[d.region] || {}); (node[d.region][d.path] = node[d.region][d.path] || {})[d.prop] = d.value; }
257
+ else if (d.kind === 'st-order' || d.kind === 'st-children') order[d.region] = d.order;
258
+ }
259
+ // The markup for anything added or removed comes from the WHOLE log, the same
260
+ // way baselines do — undoing a delete has to be able to rebuild the element,
261
+ // and the delta that knows its markup is no longer applied.
262
+ return { node, order, sigs, base, html };
263
+ }
264
+
265
+ // A data area's own deltas carry only the id order, which can restore a sequence
266
+ // but cannot resurrect a record — so adding and removing rows record the record
267
+ // itself. In-session history only: what the array should hold is the app's to
268
+ // persist, so these never travel to the source.
269
+ function commitSplice(area, index, record, op) {
270
+ log.splice(cursor);
271
+ log.push({ kind: 'data-splice', region: key(area), source: dataSourceName(area), index, op, record: JSON.parse(JSON.stringify(record)) });
272
+ cursor = log.length; saveState(); refresh();
273
+ setTimeout(() => { lastSnap[key(area)] = snapshot(area, classify(area)); }, 0);
274
+ }
275
+
276
+ function applySplice(d, undo) {
277
+ const area = areaByKey(d.region); if (!area) return;
278
+ const tpl = area.querySelector('template[x-for]'), expr = dataSourceExpr(area);
279
+ if (!tpl || !expr) return;
280
+ let arr; try { arr = window.Alpine.evaluate(tpl, expr); } catch { return; }
281
+ if (!Array.isArray(arr)) return;
282
+ const inserting = undo ? d.op === 'remove' : d.op === 'insert';
283
+ if (inserting) arr.splice(Math.min(d.index, arr.length), 0, JSON.parse(JSON.stringify(d.record)));
284
+ else arr.splice(d.index, 1);
285
+ setTimeout(() => { lastSnap[d.region] = snapshot(area, classify(area)); }, 0);
286
+ }
287
+
288
+ // Structural change to a container's children: duplicate, delete, paste. Stored
289
+ // as the resulting key order plus the markup of whatever the log introduced or
290
+ // took away, so it stays proportional to the edit rather than snapshotting.
291
+ function commitStructure(area, markup) {
292
+ const region = key(area), order = staticKeys(area), before = lastOrder[region] || area._baseOrder || order;
293
+ if (eq(before, order) && !Object.keys(markup || {}).length) return;
294
+ log.splice(cursor);
295
+ log.push({ kind: 'st-children', region, order, before, html: markup || {} });
296
+ cursor = log.length; lastOrder[region] = order; saveState(); refresh();
297
+ }
298
+ // Rebuilt markup carries no identity — blockHTML strips it so a duplicate cannot
299
+ // inherit its original's. Stamp the key we are rebuilding it for, or the element
300
+ // comes back nameless and the next fold drops it again.
301
+ const materialize = (markup, k) => {
302
+ if (!markup) return null;
303
+ const t = document.createElement('template');
304
+ t.innerHTML = markup.trim();
305
+ const el = t.content.firstElementChild;
306
+ if (el && k) el.setAttribute('data-edit-key', k);
307
+ return el;
308
+ };
309
+
310
+ function applyStaticState() {
311
+ const { node, order, sigs, base, html } = staticState();
312
+ document.querySelectorAll('[data-edit-area]').forEach(area => {
313
+ if (!area._edit || classify(area) !== 'static') return;
314
+ const region = key(area);
315
+ [area, ...area.querySelectorAll('[data-edit-key]')].forEach(el => {
316
+ const p = el.getAttribute('data-edit-key'), bk = region + '|' + p;
317
+ const ops = (node[region] || {})[p], baseline = base[bk];
318
+ if (!ops && !baseline) return;
319
+ const expect = sigs[bk]; if (expect && nodeSig(el) !== expect) { console.warn('[edit] skip stale static node', region, p); return; }
320
+ const eff = (prop) => ops && ops[prop] !== undefined ? ops[prop] : baseline && baseline[prop];
321
+ const setAttr = (name, v) => { if (v === undefined) return; if (v === '') el.removeAttribute(name); else if (el.getAttribute(name) !== v) el.setAttribute(name, v); };
322
+ const text = eff('text'); if (text !== undefined) { const safe = sanitizeFor(el, text); if (el.innerHTML !== safe) el.innerHTML = safe; }
323
+ setAttr('class', eff('class'));
324
+ setAttr('style', eff('style'));
325
+ });
326
+ const want = order[region] || area._baseOrder;
327
+ if (want) {
328
+ const by = {}, kids = sortableChildren(area), keys = staticKeys(area);
329
+ kids.forEach((el, i) => { by[keys[i]] = el; });
330
+ const markup = html[region] || {};
331
+ const next = want.map(kk => by[kk] || materialize(markup[kk], kk)).filter(Boolean);
332
+ kids.forEach(el => { if (!next.includes(el)) el.remove(); });
333
+ next.forEach(el => area.appendChild(el));
334
+ }
335
+ });
336
+ }
337
+ function commitStaticNode(area, el, prop, value) {
338
+ const region = key(area), path = el.getAttribute('data-edit-key');
339
+ if (!path) return; // not an addressable node
340
+ if (prop === 'text') value = sanitizeFor(el, value);
341
+ const before = prop === 'text' ? el._preEdit : prop === 'class' ? el._preClass : el._preStyle;
342
+ if (before === value) return;
343
+ log.splice(cursor); log.push({ kind: 'st-node', region, path, prop, value, before, sig: nodeSig(el) }); cursor = log.length; saveState(); refresh();
344
+ }
345
+ /* ---- Data VALUE editing (opt-in .values): edit $x record fields. Not an HTML edit —
346
+ it mutates the data source. A-side: mutate the in-memory $x array (reactive).
347
+ B-side: local file cell write (dev server); cloud → $x.<source>.$update(id,{field}). ---- */
348
+ function dataValueState() {
349
+ const s = {};
350
+ 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; }
351
+ return s;
352
+ }
353
+ function applyDataValues() {
354
+ const st = dataValueState();
355
+ areas().forEach(area => {
356
+ if (classify(area) !== 'data') return;
357
+ const source = dataSourceName(area), recs = st[source]; if (!recs) return;
358
+ const tpl = area.querySelector('template[x-for]'), expr = dataSourceExpr(area);
359
+ 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 {}
360
+ if (isActive(area)) setTimeout(() => armDataValues(area), 0); // re-arm clones Alpine re-rendered
361
+ });
362
+ }
363
+ function commitDataValue(area, source, id, field, value, el) {
364
+ value = String(value);
365
+ if (el && el.hasAttribute('data-edit-rich')) value = sanitizeFor(el, value);
366
+ const before = el ? el._preEdit : undefined;
367
+ if (before === value) return;
368
+ log.splice(cursor); log.push({ kind: 'data-val', source, id, field, value, before }); cursor = log.length; saveState();
369
+ applyDataValues(); refresh();
370
+ }
371
+
372
+ // size/move write an element's inline style → static: per-node style op; data: n/a.
373
+ // Size/move land here. The data regime has nowhere to put per-row geometry —
374
+ // the data source holds values, not layout — so it stays transient there.
375
+ function commitStyle(area, el) {
376
+ const v = el.getAttribute('style') || '', kind = classify(area);
377
+ if (kind === 'component') commitComponentNode(area, el, 'style', v);
378
+ else if (kind === 'static') commitStaticNode(area, el, 'style', v);
379
+ }
380
+ function commitStaticOrder(area) {
381
+ const region = key(area), order = staticKeys(area), before = lastOrder[region] || order;
382
+ if (eq(before, order)) return;
383
+ log.splice(cursor); log.push({ kind: 'st-order', region, order, before }); cursor = log.length; lastOrder[region] = order; saveState(); refresh();
384
+ }
385
+
386
+ /* ---- Commit / undo / redo (data area snapshots) ---- */
387
+ function commit(area) {
388
+ if (!area) return;
389
+ const k = key(area), kind = classify(area), after = snapshot(area, kind), before = lastSnap[k];
390
+ if (before && eq(before, after)) return;
391
+ log.splice(cursor); log.push({ region: k, kind, before: before ?? after, after });
392
+ cursor = log.length; lastSnap[k] = after; saveState(); refresh();
393
+ }
394
+ 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(); };
395
+ async function undo() {
396
+ if (cursor === 0) return; const d = log[--cursor];
397
+ if (d.kind === 'data-splice') applySplice(d, true);
398
+ else if (d.kind === 'data') { await applySnap(areaByKey(d.region), d.kind, d.before); lastSnap[d.region] = d.before; }
399
+ else dispatchApply(d);
400
+ saveState(); refresh();
401
+ }
402
+ async function redo() {
403
+ if (cursor >= log.length) return; const d = log[cursor++];
404
+ if (d.kind === 'data-splice') applySplice(d, false);
405
+ else if (d.kind === 'data') { await applySnap(areaByKey(d.region), d.kind, d.after); lastSnap[d.region] = d.after; }
406
+ else dispatchApply(d);
407
+ saveState(); refresh();
408
+ }
409
+
410
+ /* ---- Projections (static/data); component projections come from componentState() ---- */
411
+ 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; }
412
+ function patchFor(r, kind, snap) {
413
+ 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.' };
414
+ return { region: r, kind, op: 'reorderData', source: snap.source, order: snap.order, note: `data mutation: $x.${snap.source}.$update(id,{order}).` };
415
+ }
416
+
417
+
418
+ /* ---- Block operations: copy, cut, paste, duplicate, delete ----
419
+ Structural edits on a sortable child. The plugin performs them and logs them;
420
+ it renders no menu, because what a context menu looks like belongs to the
421
+ project. What it offers instead is enough to build one: an `edit:context`
422
+ event carrying the block and where the pointer was, $edit.target for the block
423
+ in play, and $edit.can(op) so a menu can disable what would not apply. */
424
+ let clipboard = null; // { html, source } — the plugin's own, not the OS clipboard
425
+ let target = null; // the block a menu is acting on
426
+
427
+ // A menu binds :disabled to can(), so what can() depends on has to be trackable.
428
+ // The target and the clipboard are plain values, so changes to them are announced
429
+ // through a counter on the store, which is reactive.
430
+ const bump = () => { if (estore) estore.targetVersion = (estore.targetVersion || 0) + 1; };
431
+ const setTarget = (el) => { target = el; bump(); };
432
+
433
+ // The addressable block for a node: the sortable child that contains it, or —
434
+ // where the region is not sortable at all — the outermost element inside the area
435
+ // that contains it. Deleting a heading should not require the region to have been
436
+ // declared reorderable.
437
+ function blockOf(node) {
438
+ const area = node && node.closest && node.closest('[data-edit-area]');
439
+ if (!area) return null;
440
+ const sortable = node.closest('[data-edit-sortable]');
441
+ if (sortable && sortable.closest('[data-edit-area]') === area) return sortable;
442
+ let el = node;
443
+ while (el && el.parentElement && el.parentElement !== area) el = el.parentElement;
444
+ return el && el.parentElement === area ? el : null;
445
+ }
446
+
447
+ const blockArea = (el) => el && el.closest('[data-edit-area]');
448
+
449
+ // Markup without the plugin's own affordances, so a duplicate re-arms cleanly
450
+ // rather than inheriting half-initialised state from its original.
451
+ function blockHTML(el) {
452
+ const c = el.cloneNode(true);
453
+ c.querySelectorAll('[data-edit-handle], [data-edit-live], [data-edit-ghost]').forEach(n => n.remove());
454
+ [c, ...c.querySelectorAll('*')].forEach(n => {
455
+ [...n.attributes].forEach(a => { if (a.name.startsWith('data-edit-')) n.removeAttribute(a.name); });
456
+ ['contenteditable', 'draggable', 'tabindex', 'role', 'aria-label'].forEach(a => n.removeAttribute(a));
457
+ });
458
+ return c.outerHTML;
459
+ }
460
+
461
+ /* ---- Data regime: the array is the truth, so operate on it ---- */
462
+ function dataOps(area) {
463
+ const tpl = area.querySelector('template[x-for]'), expr = dataSourceExpr(area);
464
+ if (!tpl || !expr) return null;
465
+ try {
466
+ const arr = window.Alpine.evaluate(tpl, expr);
467
+ return Array.isArray(arr) ? arr : null;
468
+ } catch { return null; }
469
+ }
470
+
471
+ const recordIndex = (area, el) => {
472
+ const arr = dataOps(area); if (!arr) return -1;
473
+ const k = itemKey(el, area);
474
+ return arr.findIndex(it => String(it && it.id != null ? it.id : it) === String(k));
475
+ };
476
+
477
+ // A copy needs its own identity or the list has two rows claiming one key.
478
+ function freshId(arr, id) {
479
+ const taken = new Set(arr.map(it => String(it && it.id)));
480
+ if (!taken.has(String(id))) return String(id);
481
+ let n = 2, next = `${id}-copy`;
482
+ while (taken.has(next)) next = `${id}-copy-${n++}`;
483
+ return next;
484
+ }
485
+
486
+ /* ---- Operations ---- */
487
+ function canDo(op, el) {
488
+ const block = el || target;
489
+ if (op === 'paste') return !!clipboard && !!(block ? blockArea(block) : areas().find(isActive));
490
+ if (!block || locked(block)) return false;
491
+ const area = blockArea(block);
492
+ if (!area || !isActive(area)) return false;
493
+ if (classify(area) === 'component') return false; // instances are overridden, not restructured
494
+ return true;
495
+ }
496
+
497
+ function copyBlock(el) {
498
+ const block = el || target; if (!block) return false;
499
+ clipboard = { html: blockHTML(block), source: key(blockArea(block)) };
500
+ bump();
501
+ return true;
502
+ }
503
+
504
+ function duplicateBlock(el) {
505
+ const block = el || target; if (!canDo('duplicate', block)) return false;
506
+ const area = blockArea(block);
507
+ if (classify(area) === 'data') {
508
+ const arr = dataOps(area), i = recordIndex(area, block);
509
+ if (!arr || i < 0) return false;
510
+ const copy = JSON.parse(JSON.stringify(arr[i]));
511
+ if (copy && copy.id != null) copy.id = freshId(arr, copy.id);
512
+ arr.splice(i + 1, 0, copy);
513
+ commitSplice(area, i + 1, copy, 'insert');
514
+ return true;
515
+ }
516
+ const made = materialize(blockHTML(block));
517
+ if (!made) return false;
518
+ block.after(made);
519
+ armArea(area); // the copy needs its own affordances
520
+ commitStructure(area, { [staticKeys(area)[sortableChildren(area).indexOf(made)]]: blockHTML(made) });
521
+ return true;
522
+ }
523
+
524
+ function removeBlock(el) {
525
+ const block = el || target; if (!canDo('remove', block)) return false;
526
+ const area = blockArea(block);
527
+ if (classify(area) === 'data') {
528
+ const arr = dataOps(area), i = recordIndex(area, block);
529
+ if (!arr || i < 0) return false;
530
+ const gone = arr[i];
531
+ arr.splice(i, 1);
532
+ commitSplice(area, i, gone, 'remove');
533
+ return true;
534
+ }
535
+ const keys = staticKeys(area), gone = keys[sortableChildren(area).indexOf(block)];
536
+ const markup = blockHTML(block);
537
+ block.remove();
538
+ if (target === block) setTarget(null);
539
+ commitStructure(area, { [gone]: markup }); // keep the markup so undo can rebuild it
540
+ return true;
541
+ }
542
+
543
+ const cutBlock = (el) => copyBlock(el) && removeBlock(el);
544
+
545
+ function pasteBlock(el) {
546
+ if (!clipboard) return false;
547
+ const block = el || target;
548
+ const area = blockArea(block) || areas().find(isActive);
549
+ if (!area || !isActive(area)) return false;
550
+ if (classify(area) === 'data') {
551
+ const arr = dataOps(area); if (!arr) return false;
552
+ const at = block ? recordIndex(area, block) + 1 : arr.length;
553
+ const t = document.createElement('template'); t.innerHTML = clipboard.html;
554
+ const record = { id: freshId(arr, 'pasted'), label: (t.content.textContent || '').trim() };
555
+ arr.splice(at, 0, record);
556
+ commitSplice(area, at, record, 'insert');
557
+ return true;
558
+ }
559
+ const made = materialize(clipboard.html); if (!made) return false;
560
+ if (block) block.after(made); else area.appendChild(made);
561
+ armArea(area);
562
+ commitStructure(area, { [staticKeys(area)[sortableChildren(area).indexOf(made)]]: blockHTML(made) });
563
+ return true;
564
+ }
565
+
566
+ /* ---- Wiring ----
567
+ Hotkeys ride the focused block, which reorder already gives a tabindex. They
568
+ stay out of the way of text: nothing fires while a caret is in a field, so
569
+ Cmd+C inside a paragraph still copies the words. */
570
+ const inText = (e) => e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
571
+
572
+ function onBlockKey(e) {
573
+ if (inText(e)) return;
574
+ const block = blockOf(e.target); if (!block) return;
575
+ const area = blockArea(block); if (!isActive(area)) return;
576
+ const meta = e.metaKey || e.ctrlKey;
577
+ let done = false;
578
+ if (meta && e.key.toLowerCase() === 'c') done = copyBlock(block);
579
+ else if (meta && e.key.toLowerCase() === 'x') done = cutBlock(block);
580
+ else if (meta && e.key.toLowerCase() === 'v') done = pasteBlock(block);
581
+ else if (meta && e.key.toLowerCase() === 'd') done = duplicateBlock(block);
582
+ else if (!meta && (e.key === 'Delete' || e.key === 'Backspace')) done = removeBlock(block);
583
+ if (done) { e.preventDefault(); e.stopPropagation(); }
584
+ }
585
+
586
+ /* ---- Right-click ----
587
+ The event waits for the pointer to come up. A popover opened while the button
588
+ is still down is closed again by the release that follows it — the browser's
589
+ light dismiss decides on pointerup, and by then the menu was not yet open when
590
+ the press was recorded. Firing after the release is the only way a menu opened
591
+ in the handler survives. The context-menu key sends no release, so a short
592
+ timer covers it. */
593
+ let pendingContext = null;
594
+
595
+ function onBlockContext(e, builtIn) {
596
+ const area = blockArea(e.target);
597
+ if (!area || !isActive(area)) return false;
598
+ const block = blockOf(e.target);
599
+ if (!block) return false; // off-block: leave the native menu alone
600
+ setTarget(block);
601
+ e.preventDefault();
602
+
603
+ const fire = () => {
604
+ clearTimeout(pendingContext); pendingContext = null;
605
+ document.removeEventListener('pointerup', fire, true);
606
+ const detail = { target: block, area, x: e.clientX, y: e.clientY, can: (op) => canDo(op, block) };
607
+ const ev = new CustomEvent('edit:context', { bubbles: true, cancelable: true, detail });
608
+ if (area.dispatchEvent(ev) && builtIn) builtIn(); // nobody took it
609
+ };
610
+ document.addEventListener('pointerup', fire, true);
611
+ pendingContext = setTimeout(fire, 300);
612
+ return true;
613
+ }
614
+
615
+ const blockTarget = () => target;
616
+ const setBlockTarget = (el) => setTarget(el && el.nodeType === 1 ? blockOf(el) || el : null);
617
+
618
+
619
+ /* ---- Drag: reorder (in-flow, pointer+keyboard) OR move (positioned) ----
620
+ Pointer-based so it works with mouse, touch, and pen (HTML5 DnD was mouse-only).
621
+ Keyboard: focus an item, Space/Enter to grab, arrows to move, Enter/Esc to drop/cancel. */
622
+ const isPositioned = (el) => { const p = getComputedStyle(el).position; return p === 'absolute' || p === 'fixed'; };
623
+ let grabbed = null, liveRegion;
624
+ 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; }
625
+
626
+ function makeSortable(container) {
627
+ sortableChildren(container).forEach(child => {
628
+ if (locked(child)) return;
629
+ if (isPositioned(child)) { if (!child._dragBound) { child._dragBound = true; makeMovable(child); } return; }
630
+ child.setAttribute('data-edit-sortable', ''); child.tabIndex = 0; child.setAttribute('role', 'listitem');
631
+ if (child._dragBound) return; child._dragBound = true;
632
+ child.addEventListener('pointerdown', onPointerDown);
633
+ child.addEventListener('keydown', onItemKeydown);
634
+ child.addEventListener('keydown', onBlockKey);
635
+ });
636
+ container.setAttribute('role', 'list');
637
+ // x-for rows and any other late arrivals are not sortable unless we notice
638
+ // them — arming only ever ran once, so a list that grows loses its handles.
639
+ if (!container._sortObserver) {
640
+ container._sortObserver = new MutationObserver(() => {
641
+ if (dragged) return; // never re-arm mid-drag
642
+ if (isActive(container.closest('[data-edit-area]'))) makeSortable(container);
643
+ });
644
+ container._sortObserver.observe(container, { childList: true });
645
+ }
646
+ }
647
+ function onPointerDown(e) {
648
+ const item = this, area = item.closest('[data-edit-area]');
649
+ if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0)) return;
650
+ if (e.target.isContentEditable || e.target.hasAttribute('data-edit-handle')) return; // text/size win
651
+ const container = item.parentElement;
652
+ const homeNext = item.nextElementSibling; // where to put it back if cancelled
653
+ const start = item.getBoundingClientRect();
654
+ const preStyle = item.getAttribute('style');
655
+ const grabX = e.clientX - start.left, grabY = e.clientY - start.top;
656
+ const sx = e.clientX, sy = e.clientY;
657
+ let active = false, frame = 0, px = sx, py = sy, ghost = null;
658
+
659
+ // The item leaves the flow and a stand-in takes its slot, so the gap that
660
+ // opens is a real element the author can style — by default a translucent
661
+ // copy of what is being dragged, showing exactly where it would land.
662
+ const lift = (ev) => {
663
+ ghost = item.cloneNode(true);
664
+ ghost.setAttribute('data-edit-ghost', '');
665
+ ghost.setAttribute('x-ignore', ''); // a clone must not re-bind
666
+ ghost.removeAttribute('id');
667
+ ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
668
+ [ghost, ...ghost.querySelectorAll('*')].forEach(n => {
669
+ [...n.attributes].forEach(a => { if (a.name.startsWith('data-edit-') && a.name !== 'data-edit-ghost') n.removeAttribute(a.name); });
670
+ n.removeAttribute('contenteditable'); n.removeAttribute('tabindex'); n.removeAttribute('draggable');
671
+ });
672
+ item.before(ghost);
673
+ item.style.position = 'fixed';
674
+ item.style.width = start.width + 'px';
675
+ item.style.height = start.height + 'px';
676
+ item.style.margin = '0';
677
+ item.style.pointerEvents = 'none';
678
+ item.setAttribute('data-edit-dragging', '');
679
+ area.setAttribute('data-edit-dragging-in', '');
680
+ dragged = ghost;
681
+ try { item.setPointerCapture(ev.pointerId); } catch { }
682
+ };
683
+
684
+ const paint = () => {
685
+ frame = 0;
686
+ item.style.left = (px - grabX) + 'px';
687
+ item.style.top = (py - grabY) + 'px';
688
+ };
689
+
690
+ const onMove = (ev) => {
691
+ px = ev.clientX; py = ev.clientY;
692
+ if (!active) {
693
+ if (Math.hypot(px - sx, py - sy) < 5) return; // threshold so taps/clicks still work
694
+ active = true; lift(ev); paint();
695
+ }
696
+ ev.preventDefault();
697
+ reorderOver(container, px, py);
698
+ if (!frame) frame = requestAnimationFrame(paint);
699
+ };
700
+
701
+ const settle = (cancelled) => {
702
+ document.removeEventListener('pointermove', onMove);
703
+ document.removeEventListener('pointerup', onUp);
704
+ document.removeEventListener('keydown', onKey, true);
705
+ if (frame) cancelAnimationFrame(frame);
706
+ if (!active) return;
707
+ if (cancelled) {
708
+ ghost.remove();
709
+ if (homeNext) container.insertBefore(item, homeNext); else container.appendChild(item);
710
+ } else ghost.replaceWith(item);
711
+ if (preStyle == null) item.removeAttribute('style'); else item.setAttribute('style', preStyle);
712
+ item.removeAttribute('data-edit-dragging');
713
+ area.removeAttribute('data-edit-dragging-in');
714
+ ghost = null; dragged = null;
715
+ if (cancelled) announce('Cancelled'); else finishReorder(area);
716
+ };
717
+ const onUp = () => settle(false);
718
+ // Escape puts it back, the way every drag is expected to be escapable.
719
+ const onKey = (ev) => { if (ev.key !== 'Escape') return; ev.preventDefault(); ev.stopPropagation(); settle(true); };
720
+ document.addEventListener('pointermove', onMove);
721
+ document.addEventListener('pointerup', onUp);
722
+ document.addEventListener('keydown', onKey, true);
723
+ }
724
+ function reorderOver(container, x, y) {
725
+ if (!dragged || dragged.parentElement !== container) return;
726
+ const ref = afterElement(container, x, y);
727
+ if (ref === dragged || ref === dragged.nextElementSibling) return;
728
+ if (ref == null) container.appendChild(dragged); else container.insertBefore(dragged, ref);
729
+ }
730
+ function finishReorder(area) { if (classify(area) === 'data') { applyDataOrder(area, sortableChildren(area).map(c => itemKey(c, area))); commit(area); } else commitStaticOrder(area); }
731
+ // Reading-order insertion: block, inline, flex row/col/wrap, grid auto-flow.
732
+ // The FIRST sibling past the pointer, not the nearest — nearest can pick an
733
+ // element further down the list and makes the drop point jump around. Taking the
734
+ // first is also self-stabilising: once the item lands, that sibling's midpoint is
735
+ // behind the pointer, so it does not immediately swap back.
736
+ function afterElement(c, x, y) {
737
+ for (const el of sortableChildren(c)) {
738
+ if (el === dragged || locked(el) || el.hasAttribute('data-edit-dragging')) continue;
739
+ const b = el.getBoundingClientRect(), cx = b.left + b.width / 2, cy = b.top + b.height / 2;
740
+ const sameRow = Math.abs(cy - y) <= b.height / 2;
741
+ if (cy - y > b.height / 2 || (sameRow && cx > x)) return el;
742
+ }
743
+ return null;
744
+ }
745
+ // Keyboard reorder: grab → arrows move among siblings → drop.
746
+ function onItemKeydown(e) {
747
+ const item = this, area = item.closest('[data-edit-area]'); if (!isActive(area)) return;
748
+ if (e.key === ' ' || e.key === 'Enter') {
749
+ e.preventDefault();
750
+ if (grabbed === item) { grabbed = null; item.removeAttribute('data-edit-grabbed'); finishReorder(area); item.focus(); announce('Dropped'); }
751
+ else { if (grabbed) grabbed.removeAttribute('data-edit-grabbed'); grabbed = item; item.setAttribute('data-edit-grabbed', ''); announce('Grabbed — arrow keys to move, Enter to drop'); }
752
+ return;
753
+ }
754
+ if (grabbed !== item) return;
755
+ if (e.key === 'Escape') { e.preventDefault(); grabbed = null; item.removeAttribute('data-edit-grabbed'); item.focus(); announce('Cancelled'); return; }
756
+ const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key];
757
+ if (dir == null) return;
758
+ e.preventDefault();
759
+ const sibs = sortableChildren(item.parentElement).filter(c => !locked(c)), i = sibs.indexOf(item), j = i + dir;
760
+ if (j < 0 || j >= sibs.length) return;
761
+ if (dir > 0) sibs[j].after(item); else sibs[j].before(item);
762
+ item.focus(); announce(`Position ${j + 1} of ${sibs.length}`);
763
+ }
764
+ // Move mode: positioned elements → pointer-drag left/top (touch-capable) + keyboard nudge, unit-preserving.
765
+ function makeMovable(el) {
766
+ el.setAttribute('data-edit-movable', ''); el.tabIndex = 0; el.setAttribute('role', 'application'); el.setAttribute('aria-label', 'Draggable; arrow keys to move');
767
+ el.addEventListener('pointerdown', (e) => {
768
+ const area = el.closest('[data-edit-area]');
769
+ if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0) || e.target.isContentEditable || e.target.hasAttribute('data-edit-handle')) return;
770
+ e.preventDefault(); el._preStyle = el.getAttribute('style') || '';
771
+ const cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
772
+ const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
773
+ const baseL = parseFloat(cs.left) || 0, baseT = parseFloat(cs.top) || 0, sx = e.clientX, sy = e.clientY;
774
+ const ov = showOverlay();
775
+ const move = (ev) => { ev.preventDefault(); el.style.left = toUnit(baseL + (ev.clientX - sx), lu, el, parent, 'w') + lu; el.style.top = toUnit(baseT + (ev.clientY - sy), tu, el, parent, 'h') + tu; };
776
+ const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); hideOverlay(ov); commitStyle(area, el); };
777
+ document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
778
+ });
779
+ el.addEventListener('keydown', (e) => {
780
+ const area = el.closest('[data-edit-area]'); if (!isActive(area)) return;
781
+ const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (!d) return;
782
+ e.preventDefault();
783
+ if (!el._moveTimer) el._preStyle = el.getAttribute('style') || ''; // baseline at start of a key burst
784
+ const step = e.shiftKey ? 16 : 4, cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
785
+ const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
786
+ if (d[0]) el.style.left = toUnit((parseFloat(cs.left) || 0) + d[0] * step, lu, el, parent, 'w') + lu;
787
+ if (d[1]) el.style.top = toUnit((parseFloat(cs.top) || 0) + d[1] * step, tu, el, parent, 'h') + tu;
788
+ clearTimeout(el._moveTimer); el._moveTimer = setTimeout(() => { el._moveTimer = null; commitStyle(area, el); }, 350);
789
+ });
790
+ }
791
+
792
+
793
+ /* ---- Size: drag handles → unit-preserving width/height (folded-in resize) ----
794
+ CSS-var config (cascades by selector, keeps the directive tight):
795
+ --edit-size both | x | y | none which axes (default both)
796
+ --edit-size-edges e.g. "right bottom-right" explicit handle list (logical start/end ok)
797
+ --edit-size-handle 1rem handle hit area
798
+ --edit-size-snap 12rem 24rem snap stops (both axes)
799
+ --edit-size-snap-x/-y per-axis stop overrides
800
+ --edit-size-snap-distance 1rem magnet tolerance (NOT a grid step)
801
+ --edit-size-snap-distance-x/-y per-axis tolerance
802
+ --edit-size-collapse-x/-y 120px collapse below this → [data-edit-collapsed] + 'edit:collapse' event
803
+ (min/max come from the element's native min-/max-width/height.) */
804
+ let overlayEl;
805
+ // Fires while dragging (done:false) and once on commit (done:true), so consumers
806
+ // that must re-measure — charts, virtual lists — can track the drag and settle.
807
+ function sizeEvent(el, done) {
808
+ const r = el.getBoundingClientRect();
809
+ el.dispatchEvent(new CustomEvent('edit:size', {
810
+ bubbles: true,
811
+ detail: { width: r.width, height: r.height, css: { width: el.style.width, height: el.style.height }, collapsed: el.hasAttribute('data-edit-collapsed'), done }
812
+ }));
813
+ }
814
+ function showOverlay() { if (!overlayEl) { overlayEl = document.createElement('div'); overlayEl.setAttribute('data-edit-overlay', ''); document.body.appendChild(overlayEl); } overlayEl.style.display = 'block'; return overlayEl; }
815
+ function hideOverlay() { if (overlayEl) overlayEl.style.display = 'none'; }
816
+
817
+ const isRTL = (el) => getComputedStyle(el).direction === 'rtl';
818
+ // Resolve logical start/end to physical left/right per the element's direction.
819
+ function physical(pos, el) {
820
+ const rtl = isRTL(el);
821
+ return pos.replace(/start/g, rtl ? 'right' : 'left').replace(/end/g, rtl ? 'left' : 'right');
822
+ }
823
+ const defaultEdges = (axes) => axes === 'x' ? ['left', 'right'] : axes === 'y' ? ['top', 'bottom'] : ['top', 'right', 'bottom', 'left', 'top-left', 'top-right', 'bottom-left', 'bottom-right'];
824
+
825
+ function armSize(el) {
826
+ const axes = cssVar(el, '--edit-size', 'both');
827
+ if (axes === 'none') return;
828
+ if (el._sizeBound) return; el._sizeBound = true;
829
+ el.setAttribute('data-edit-sizable', '');
830
+ // Anchor guard: handles are positioned against this element, so it must establish a
831
+ // containing block. The CSS sets position:relative via :where() (overridable); if an
832
+ // author's CSS won and left it static, set it here so handles never detach.
833
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
834
+ const edges = (cssVar(el, '--edit-size-edges', '').trim() || defaultEdges(axes).join(' ')).split(/\s+/).filter(Boolean);
835
+ edges.forEach(pos => {
836
+ const phys = physical(pos, el); // keep logical class for CSS; resolve to physical for behavior
837
+ const h = document.createElement('span');
838
+ h.setAttribute('data-edit-handle', pos); // logical value drives the CSS; `phys` drives behaviour
839
+ h.tabIndex = 0; h.setAttribute('role', 'slider'); h.setAttribute('aria-label', `Resize ${pos}`);
840
+ h.addEventListener('pointerdown', (e) => startSize(e, el, phys));
841
+ h.addEventListener('keydown', (e) => keyResize(e, el, phys));
842
+ el.appendChild(h);
843
+ });
844
+ }
845
+ // Keyboard a11y: focus a handle, arrow keys resize (Shift = larger step), debounced commit.
846
+ let _keyTimer;
847
+ function keyResize(e, el, pos) {
848
+ const map = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
849
+ if (!map[e.key]) return;
850
+ const area = el.closest('[data-edit-area]'); if (!isActive(area)) return;
851
+ e.preventDefault();
852
+ if (!_keyTimer) el._preStyle = el.getAttribute('style') || ''; // baseline at start of a key burst
853
+ const [kx, ky] = map[e.key], step = e.shiftKey ? 32 : 8, cs = getComputedStyle(el);
854
+ const right = pos.includes('right'), left = pos.includes('left'), top = pos.includes('top'), bottom = pos.includes('bottom');
855
+ const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
856
+ const minW = parseFloat(cs.minWidth) || 0, maxW = parseFloat(cs.maxWidth) || Infinity, minH = parseFloat(cs.minHeight) || 0, maxH = parseFloat(cs.maxHeight) || Infinity;
857
+ 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; }
858
+ 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; }
859
+ sizeEvent(el, false);
860
+ clearTimeout(_keyTimer); _keyTimer = setTimeout(() => { _keyTimer = null; commitStyle(area, el); sizeEvent(el, true); }, 350); // coalesce key bursts into one delta
861
+ }
862
+ function snapStops(el, axisVar, dim) {
863
+ const list = cssVar(el, axisVar, '') || cssVar(el, '--edit-size-snap', '');
864
+ return list.split(/\s+/).filter(Boolean).map(s => toPx(s, el, dim)).filter(v => v != null);
865
+ }
866
+ function startSize(e, el, pos) {
867
+ const area = el.closest('[data-edit-area]');
868
+ if (!isActive(area) || e.button !== 0) return;
869
+ e.preventDefault(); e.stopPropagation(); el._preStyle = el.getAttribute('style') || '';
870
+ const cs = getComputedStyle(el), rect = el.getBoundingClientRect();
871
+ const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
872
+ const baseW = parseFloat(cs.width) || rect.width, baseH = parseFloat(cs.height) || rect.height, sx = e.clientX, sy = e.clientY;
873
+ const minW = parseFloat(cs.minWidth) || 0, maxW = parseFloat(cs.maxWidth) || Infinity;
874
+ const minH = parseFloat(cs.minHeight) || 0, maxH = parseFloat(cs.maxHeight) || Infinity;
875
+ const right = pos.includes('right'), left = pos.includes('left'), bottom = pos.includes('bottom'), top = pos.includes('top');
876
+ const tolX = toPx(cssVar(el, '--edit-size-snap-distance-x', '') || cssVar(el, '--edit-size-snap-distance', '0'), el, 'w') || 0;
877
+ const tolY = toPx(cssVar(el, '--edit-size-snap-distance-y', '') || cssVar(el, '--edit-size-snap-distance', '0'), el, 'h') || 0;
878
+ const snapW = snapStops(el, '--edit-size-snap-x', 'w'), snapH = snapStops(el, '--edit-size-snap-y', 'h');
879
+ const collapseX = toPx(cssVar(el, '--edit-size-collapse-x', ''), el, 'w');
880
+ const collapseY = toPx(cssVar(el, '--edit-size-collapse-y', ''), el, 'h');
881
+ const snap = (v, pts, tol) => { if (!tol) return v; for (const p of pts) if (Math.abs(v - p) <= tol) return p; return v; };
882
+ const ov = showOverlay();
883
+ let lastW = baseW, lastH = baseH;
884
+ const move = (ev) => {
885
+ const dx = ev.clientX - sx, dy = ev.clientY - sy;
886
+ 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; }
887
+ 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; }
888
+ const collapsed = (collapseX != null && lastW < collapseX) || (collapseY != null && lastH < collapseY);
889
+ el.toggleAttribute('data-edit-collapsed', collapsed);
890
+ sizeEvent(el, false);
891
+ };
892
+ const up = () => {
893
+ document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); hideOverlay(ov);
894
+ if (el.hasAttribute('data-edit-collapsed')) el.dispatchEvent(new CustomEvent('edit:collapse', { bubbles: true }));
895
+ commitStyle(area, el); sizeEvent(el, true);
896
+ };
897
+ document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
898
+ }
899
+
900
+
901
+ /* ---- Inline text editing ---- */
902
+ // Where x-text-edit is declared, it owns the element: this plugin neither makes
903
+ // its leaves contenteditable nor competes for the caret. If the rich editor has
904
+ // no expression of its own, we capture what it produces as an ordinary text
905
+ // delta, so a rich field inside a page-editing region publishes like any other.
906
+ function armRichText(area) {
907
+ area.querySelectorAll('[data-text-edit]:not([data-text-edit-bound])').forEach(el => {
908
+ if (!capOf(el, 'text') || locked(el)) return;
909
+ if (el._richBound) return; el._richBound = true;
910
+ el.addEventListener('focusin', () => { el._preEdit = el.innerHTML.trim(); });
911
+ el.addEventListener('blur', () => {
912
+ const commit = classify(area) === 'component' ? commitComponentNode : commitStaticNode;
913
+ commit(area, el, 'text', el.innerHTML.trim());
914
+ }, true);
915
+ });
916
+ }
917
+
918
+ // STATIC: literal leaves only (skip bound nodes; those belong to data/component).
919
+ function armText(area) {
920
+ armRichText(area);
921
+ area.querySelectorAll('*').forEach(el => {
922
+ if (el.children.length || !el.textContent.trim() || el.closest('template') || el.hasAttribute('data-edit-handle')) return;
923
+ if (el.closest('[data-text-edit]')) return; // the rich editor owns this subtree
924
+ if (!capOf(el, 'text') || el.hasAttribute('x-text') || el.hasAttribute('x-html')) return;
925
+ el.setAttribute('contenteditable', 'true');
926
+ if (el._textBound) return; el._textBound = true;
927
+ el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); const d = el.closest('[draggable="true"]'); if (d) d.setAttribute('draggable', 'false'); });
928
+ el.addEventListener('blur', () => { const d = el.closest('[data-edit-area] [draggable="false"]'); if (d) d.setAttribute('draggable', 'true'); commitStaticNode(area, el, 'text', el.innerHTML.trim()); });
929
+ });
930
+ }
931
+
932
+ /* ---- DATA value editing (opt-in .data): make $x-bound leaves in an x-for list editable,
933
+ but ONLY when the binding is a SIMPLE field ref of the loop var (p.name) — computed
934
+ expressions (e.g. '$' + p.price) stay read-only. Edit → data mutation, not HTML.
935
+ (Scope today: x-for list item fields. Direct $x.a.b bindings outside a list are a
936
+ natural extension — different addressing {source,path} + key-value write-back.) ---- */
937
+ function armDataValues(area) {
938
+ const tpl = area.querySelector('template[x-for]'); if (!tpl) return;
939
+ const lv = (tpl.getAttribute('x-for').match(/^\s*([\w$]+)\s+in\b/) || [])[1]; if (!lv) return;
940
+ const source = dataSourceName(area), fieldRe = new RegExp('^\\s*' + lv + '\\.([\\w$]+)\\s*$');
941
+ sortableChildren(area).forEach(clone => {
942
+ const id = clone.getAttribute('data-key'); if (id == null) return;
943
+ clone.querySelectorAll('*').forEach(el => {
944
+ if (el.children.length || el.hasAttribute('data-edit-handle')) return;
945
+ const rich = el.hasAttribute('x-html');
946
+ const expr = el.getAttribute('x-text') || el.getAttribute('x-html'); if (!expr) return;
947
+ const m = expr.match(fieldRe); if (!m) return;
948
+ const field = m[1];
949
+ el.setAttribute('data-edit-field', field);
950
+ // A field bound with x-html can hold markup, so it is styleable like
951
+ // any other text — the value simply carries the tags. Bound with
952
+ // x-text it cannot, and offering the controls there would write
953
+ // markup the binding renders as literal characters. The author's
954
+ // choice of binding is the whole distinction.
955
+ el.toggleAttribute('data-edit-rich', rich);
956
+ // The rich editor owns the caret where it is present.
957
+ if (!el.hasAttribute('data-text-edit')) el.setAttribute('contenteditable', 'true');
958
+ if (el._dvBound) return; el._dvBound = true;
959
+ const read = () => rich ? el.innerHTML.trim() : el.textContent;
960
+ el.addEventListener('focus', () => { el._preEdit = read(); });
961
+ el.addEventListener('focusin', () => { el._preEdit = read(); });
962
+ el.addEventListener('blur', () => commitDataValue(area, source, id, field, read(), el), true);
963
+ });
964
+ });
965
+ }
966
+
967
+ /* ---- COMPONENT editing: text leaves addressed by structural path. Right-click an
968
+ instance for the scope (this instance vs all) + per-element classes + revert. ---- */
969
+ const componentName = (area) => { const r = area.querySelector('[data-component]'); return (r?.getAttribute('data-component') || '').replace(/-\d+$/, ''); };
970
+ function pathOf(node, root) { const idx = []; let n = node; while (n && n !== root && n.parentElement) { idx.unshift(Array.from(n.parentElement.children).indexOf(n)); n = n.parentElement; } return idx.join('.'); }
971
+ function nodeByPath(root, path) { let el = root; for (const i of path.split('.').map(Number)) { el = el.children[i]; if (!el) return null; } return el; }
972
+ // While editing in 'All' scope, mirror the edit to every OTHER instance live — but
973
+ // skip the source element (don't fight the caret) and skip any instance that has its
974
+ // OWN committed override for this node/prop (instance overrides always win).
975
+ function liveMainPropagate(area, sourceEl, prop, value) {
976
+ if ((area._editScope || 'instance') !== 'main') return;
977
+ const comp = componentName(area), path = sourceEl.getAttribute('data-edit-path'), { inst } = componentState();
978
+ document.querySelectorAll('[data-edit-area]').forEach(a => {
979
+ if (!a._edit || classify(a) !== 'component' || componentName(a) !== comp) return;
980
+ const root = a.querySelector('[data-component]'); if (!root) return;
981
+ const el = path === '' ? root : nodeByPath(root, path); if (!el || el === sourceEl) return;
982
+ const iv = (inst[key(a)] && inst[key(a)][path]) || {}; if (iv[prop] !== undefined) return; // override wins
983
+ if (prop === 'class') { if (el.getAttribute('class') !== value) el.setAttribute('class', value); }
984
+ else if (el.innerHTML !== value) el.innerHTML = value;
985
+ });
986
+ }
987
+ function armComponent(area) {
988
+ const root = area.querySelector('[data-component]'); if (!root) return;
989
+ if (!area._editScope) { area._editScope = 'instance'; area.setAttribute('data-edit-scope', 'instance'); }
990
+ area._baseText = area._baseText || {}; area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {};
991
+ // Mark the root AND every element with a path → all are class-editable (incl. the parent).
992
+ 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') || ''; };
993
+ markClass(root);
994
+ root.querySelectorAll('*').forEach(el => { if (!el.hasAttribute('data-edit-handle')) markClass(el); });
995
+ // Text leaves also become contenteditable. Capture innerHTML (not textContent) so
996
+ // nested inline elements like <i>/<br> the user adds are preserved.
997
+ armRichText(area);
998
+ root.querySelectorAll('[data-edit-path]').forEach(el => {
999
+ if (el.children.length || !el.textContent.trim() || !capOf(el, 'text')) return;
1000
+ if (el.closest('[data-text-edit]')) return; // the rich editor owns this subtree
1001
+ const p = el.getAttribute('data-edit-path');
1002
+ if (!(p in area._baseText)) area._baseText[p] = el.innerHTML.trim();
1003
+ el.setAttribute('contenteditable', 'true');
1004
+ if (el._textBound) return; el._textBound = true;
1005
+ el.addEventListener('focus', () => { el._preEdit = el.innerHTML.trim(); });
1006
+ el.addEventListener('input', () => liveMainPropagate(area, el, 'text', el.innerHTML));
1007
+ el.addEventListener('blur', () => commitComponentNode(area, el, 'text', el.innerHTML.trim()));
1008
+ });
1009
+ }
1010
+ let cmpMenu;
1011
+ function openComponentMenu(e, area) {
1012
+ if (!isActive(area)) return; e.preventDefault(); e.stopPropagation();
1013
+ const target = e.target.closest('[data-edit-path]');
1014
+ if (!cmpMenu) {
1015
+ cmpMenu = document.createElement('div'); cmpMenu.setAttribute('data-edit-menu', ''); cmpMenu.setAttribute('popover', 'manual');
1016
+ cmpMenu.addEventListener('pointerdown', ev => ev.stopPropagation());
1017
+ document.body.appendChild(cmpMenu);
1018
+ document.addEventListener('pointerdown', () => { if (cmpMenu && cmpMenu.matches(':popover-open')) cmpMenu.hidePopover(); });
1019
+ }
1020
+ if (target) target._preClass = target.getAttribute('class') || '';
1021
+ const cur = area._editScope || 'instance';
1022
+ cmpMenu.innerHTML =
1023
+ `<small>Scope · ${componentName(area)}</small>`
1024
+ + `<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>`
1025
+ + (target ? `<small>Classes · ${target.tagName.toLowerCase()} · live</small><input type="text" spellcheck="false" data-cls>` : '')
1026
+ + `<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>`;
1027
+ 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); });
1028
+ const cls = cmpMenu.querySelector('[data-cls]');
1029
+ if (cls && target) {
1030
+ cls.value = target.getAttribute('class') || '';
1031
+ cls.addEventListener('input', () => { target.setAttribute('class', cls.value); liveMainPropagate(area, target, 'class', cls.value); });
1032
+ cls.addEventListener('change', () => commitComponentNode(area, target, 'class', cls.value));
1033
+ cls.addEventListener('keydown', ev => { if (ev.key === 'Enter') cls.blur(); });
1034
+ }
1035
+ const hide = () => { if (cmpMenu.matches(':popover-open')) cmpMenu.hidePopover(); };
1036
+ const rv = cmpMenu.querySelector('[data-a="revert"]'); if (rv) rv.onclick = () => { revertNode(area, target); hide(); };
1037
+ cmpMenu.querySelector('[data-a="revertall"]').onclick = () => { revertAll(area); hide(); };
1038
+ cmpMenu.style.left = Math.min(e.clientX, innerWidth - 240) + 'px'; cmpMenu.style.top = Math.min(e.clientY, innerHeight - 180) + 'px';
1039
+ if (!cmpMenu.matches(':popover-open')) { try { cmpMenu.showPopover(); } catch { } }
1040
+ if (cls) setTimeout(() => cls.focus(), 0);
1041
+ }
1042
+
1043
+
1044
+ /* ---- Style: right-click → live utility-class input (no Apply; commit on close) ---- */
1045
+ let menu;
1046
+ function closeMenu() {
1047
+ if (!menu || !menu.matches(':popover-open')) return;
1048
+ if (menu._dirty && menu._target) { commitStaticNode(menu._target.closest('[data-edit-area]'), menu._target, 'class', menu._target.getAttribute('class') || ''); menu._dirty = false; }
1049
+ try { menu.hidePopover(); } catch { } menu._target = null;
1050
+ }
1051
+ function ensureMenu() {
1052
+ if (menu) return menu;
1053
+ // A real popover: the top layer is above every stacking context, so the menu
1054
+ // cannot be painted over by a resize handle or anything else with a z-index.
1055
+ menu = document.createElement('div'); menu.setAttribute('data-edit-menu', ''); menu.setAttribute('popover', 'manual');
1056
+ 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>';
1057
+ menu.addEventListener('pointerdown', e => e.stopPropagation());
1058
+ const input = menu.querySelector('input');
1059
+ input.addEventListener('input', () => { if (menu._target) { menu._target.setAttribute('class', input.value); menu._dirty = true; } });
1060
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') closeMenu(); });
1061
+ menu.querySelector('[data-a="close"]').addEventListener('click', closeMenu);
1062
+ document.body.appendChild(menu);
1063
+ document.addEventListener('pointerdown', closeMenu);
1064
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
1065
+ return menu;
1066
+ }
1067
+ function openMenu(el, x, y) {
1068
+ const m = ensureMenu(); m._target = el; m._dirty = false; el._preClass = el.getAttribute('class') || '';
1069
+ m.querySelector('[data-tag]').textContent = '· ' + el.tagName.toLowerCase();
1070
+ const input = m.querySelector('input'); input.value = el.getAttribute('class') || '';
1071
+ m.style.left = Math.min(x, innerWidth - 280) + 'px'; m.style.top = Math.min(y, innerHeight - 130) + 'px';
1072
+ if (!m.matches(':popover-open')) { try { m.showPopover(); } catch { } }
1073
+ setTimeout(() => { input.focus(); input.select(); }, 0);
1074
+ }
1075
+ // The class menu is authoring chrome, so it only opens where chrome was asked for.
1076
+ function openClassMenu(area, e) {
1077
+ if (!area._edit.authoring) return;
1078
+ let t = e.target;
1079
+ while (t && t !== area.parentElement && !(capOf(t, 'style') && !locked(t) && !t.hasAttribute('data-edit-handle'))) t = t.parentElement;
1080
+ if (!t || !capOf(t, 'style') || locked(t)) return;
1081
+ openMenu(t, e.clientX, e.clientY);
1082
+ }
1083
+
1084
+
1085
+ /* ---- Theme regime: edit CSS custom properties (x-edit.cssvar) ----
1086
+ Not DOM-element editing. A variable set on :root is global; set on an x-edit
1087
+ theme scope's element it cascades to that subtree ONLY (sandboxed theme), so the
1088
+ same var name can live at both levels without collision. Controls are inputs that
1089
+ declare their target as "scope:--var" (area/scope key) or bare "--var" (global).
1090
+ Live = setProperty (var()-referencing utilities update for free); persisted as
1091
+ {kind:'theme', scope, var, value}. Net-new variables are a later step. */
1092
+ const cssvarControls = new Set();
1093
+ const themeBaseline = {}; // "scope|var" → original inline value ('' if none) — for revert-to-baseline
1094
+ const themeTouched = new Set(); // every scope|var ever applied
1095
+ const tkey = (scope, v) => (scope || '') + '|' + v;
1096
+ const themeTargetEl = (scope) => (scope && themeScopes[scope]) || document.documentElement;
1097
+ const cvValue = (el) => el.value + (el.dataset.unit || ''); // data-unit lets a range/number drive a unit'd var
1098
+
1099
+ function registerCssVar(el, expression) {
1100
+ const spec = (expression || '').trim();
1101
+ const m = spec.match(/^([\w-]+)\s*:\s*(--[\w-]+)$/); // "scope:--var" vs bare "--var" (global)
1102
+ el._cssvar = m ? { scope: m[1], var: m[2] } : { scope: null, var: spec };
1103
+ cssvarControls.add(el);
1104
+ }
1105
+ function syncCssVarInput(el) {
1106
+ const cv = el._cssvar; if (!cv || !('value' in el)) return;
1107
+ const cur = getComputedStyle(themeTargetEl(cv.scope)).getPropertyValue(cv.var).trim();
1108
+ if (!cur) return;
1109
+ const next = el.dataset.unit ? String(parseFloat(cur)) : cur;
1110
+ if (el.value !== next) el.value = next;
1111
+ }
1112
+ function armCssVar(el) {
1113
+ const cv = el._cssvar; if (!cv || !cv.var) return;
1114
+ const kk = tkey(cv.scope, cv.var);
1115
+ if (!(kk in themeBaseline)) themeBaseline[kk] = themeTargetEl(cv.scope).style.getPropertyValue(cv.var);
1116
+ syncCssVarInput(el);
1117
+ if (el._cvBound) return; el._cvBound = true;
1118
+ el.addEventListener('input', () => themeTargetEl(cv.scope).style.setProperty(cv.var, cvValue(el))); // live, no log
1119
+ el.addEventListener('change', () => commitTheme(cv.scope, cv.var, cvValue(el))); // commit
1120
+ }
1121
+ function armThemeControls() { cssvarControls.forEach(armCssVar); }
1122
+
1123
+ function themeState() {
1124
+ const s = {};
1125
+ for (let i = 0; i < cursor; i++) { const d = log[i]; if (d.kind === 'theme') s[tkey(d.scope, d.var)] = d; }
1126
+ return s;
1127
+ }
1128
+ // Re-derive every touched var from the log: state value if present, else the captured
1129
+ // baseline (setProperty to restore an authored inline value; removeProperty if none).
1130
+ function applyThemeState() {
1131
+ const st = themeState();
1132
+ new Set([...themeTouched, ...Object.keys(st)]).forEach(kk => {
1133
+ themeTouched.add(kk);
1134
+ const sep = kk.indexOf('|'), scope = kk.slice(0, sep) || null, v = kk.slice(sep + 1);
1135
+ const el = themeTargetEl(scope), eff = st[kk] ? st[kk].value : themeBaseline[kk];
1136
+ if (eff) el.style.setProperty(v, eff); else el.style.removeProperty(v);
1137
+ });
1138
+ cssvarControls.forEach(syncCssVarInput);
1139
+ }
1140
+ function commitTheme(scope, v, value) {
1141
+ const kk = tkey(scope, v); themeTouched.add(kk);
1142
+ themeTargetEl(scope).style.setProperty(v, value);
1143
+ const cur = themeState()[kk]; if (cur && cur.value === value) return; // dedupe
1144
+ log.splice(cursor); log.push({ kind: 'theme', scope: scope || null, var: v, value }); cursor = log.length; saveState(); refresh();
1145
+ }
1146
+ // B-side: each theme var → its scope's target CSS file (data-edit-theme-file on the
1147
+ // scope element); global vars carry no file → the server falls back to the theme file.
1148
+ function themePatches() {
1149
+ return Object.values(themeState()).map(d => {
1150
+ const el = d.scope ? themeScopes[d.scope] : null;
1151
+ return { kind: 'theme', scope: d.scope || null, var: d.var, value: d.value, file: el ? el.getAttribute('data-edit-theme-file') || null : null };
1152
+ });
1153
+ }
1154
+
1155
+
1156
+ /* ---- Arm / disarm an area for its regime + caps ---- */
1157
+ // Static nodes are addressed by a content-derived key, not by their position
1158
+ // among siblings. Position was stable while the tree was, but adding, removing or
1159
+ // duplicating a block shifts every address after it — so an edit recorded before
1160
+ // a structural change replayed onto the wrong element afterwards.
1161
+ //
1162
+ // The key is derived once, from what the element held when the area was first
1163
+ // armed, and never recomputed for an element that already has one. That is what
1164
+ // makes it survive both kinds of change: a later text edit does not move the
1165
+ // address, and a reload derives the same key again because the source it loads is
1166
+ // the same source the key was first taken from.
1167
+ function markStatic(area) {
1168
+ area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {}; area._baseText = area._baseText || {};
1169
+ const seen = Object.create(null);
1170
+ const markEl = (el) => {
1171
+ let k = el.getAttribute('data-edit-key');
1172
+ if (k) {
1173
+ // Keep the tally in step so a new element never claims an ordinal
1174
+ // an existing one already holds.
1175
+ const [base, n] = k.split('#');
1176
+ seen[base] = Math.max(seen[base] || 0, n ? +n : 1);
1177
+ } else {
1178
+ const base = el.tagName + ':' + (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24);
1179
+ const n = seen[base] = (seen[base] || 0) + 1;
1180
+ k = n > 1 ? base + '#' + n : base;
1181
+ el.setAttribute('data-edit-key', k);
1182
+ }
1183
+ el.setAttribute('data-edit-path', pathOf(el, area)); // still what the source writer navigates by
1184
+ if (!(k in area._baseClass)) area._baseClass[k] = el.getAttribute('class') || '';
1185
+ if (!(k in area._baseStyle)) area._baseStyle[k] = el.getAttribute('style') || '';
1186
+ };
1187
+ markEl(area);
1188
+ // Stop at a rich editor: its internals are its own, and marking them would
1189
+ // both litter its content and let applyStaticState fight it for the same nodes.
1190
+ area.querySelectorAll('*').forEach(el => {
1191
+ if (el.hasAttribute('data-edit-handle')) return;
1192
+ if (el.parentElement && el.parentElement.closest('[data-text-edit]')) return;
1193
+ markEl(el);
1194
+ });
1195
+ if (!area._baseOrder) area._baseOrder = staticKeys(area);
1196
+ if (!(key(area) in lastOrder)) lastOrder[key(area)] = area._baseOrder;
1197
+ }
1198
+ function armArea(area) {
1199
+ const kind = classify(area);
1200
+ area.setAttribute('data-edit-armed', '');
1201
+ // Chrome is opt-in: a sortable list should look like a list, not like a page
1202
+ // being edited. The attributes are all CSS asks for — what is shown, and
1203
+ // whether it is shown at all, is decided in the stylesheet.
1204
+ area.toggleAttribute('data-edit-authoring', !!area._edit.authoring);
1205
+ area.setAttribute('data-edit-label', kind === 'component' ? `${key(area)} · component · ${area._editScope || 'instance'}` : `${key(area)} · ${kind}`);
1206
+ if (kind === 'static') markStatic(area);
1207
+ [area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (capOf(c, 'sort') && !locked(c)) makeSortable(c); });
1208
+ [area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (ownsCap(c, 'size')) armSize(c); });
1209
+ if (kind === 'component') armComponent(area); else armText(area);
1210
+ if (!area._ctxBound) {
1211
+ area._ctxBound = true;
1212
+ // One binding for the whole area: report the block, and fall back to the
1213
+ // built-in authoring menu only if the project did not take the event.
1214
+ area.addEventListener('contextmenu', (e) => {
1215
+ if (!isActive(area)) return;
1216
+ const builtIn = () => classify(area) === 'component' ? openComponentMenu(e, area) : openClassMenu(area, e);
1217
+ if (onBlockContext(e, builtIn)) return;
1218
+ if (!area._edit.authoring) return;
1219
+ e.preventDefault();
1220
+ builtIn();
1221
+ });
1222
+ }
1223
+ if (kind === 'data' && capOf(area, 'data')) armDataValues(area);
1224
+ }
1225
+ function disarmArea(area) {
1226
+ area.querySelectorAll('[data-edit-handle]').forEach(h => h.remove());
1227
+ area.querySelectorAll('[contenteditable]').forEach(e => e.removeAttribute('contenteditable'));
1228
+ 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; });
1229
+ [area, ...area.querySelectorAll('[data-edit-sizable],[data-edit-movable]')].forEach(e => { e.removeAttribute('data-edit-sizable'); e.removeAttribute('data-edit-movable'); e._sizeBound = false; });
1230
+ area.removeAttribute('data-edit-armed'); area.removeAttribute('data-edit-label');
1231
+ }
1232
+
1233
+ /* ---- Activation: per-area, always-on by default (.gated needs $edit.on()) ---- */
1234
+ let estore;
1235
+ const isActive = (a) => !!a && (!a._edit.gated || (estore && estore.active));
1236
+ const anyActive = () => areas().some(isActive);
1237
+ function armAll() { areas().forEach(a => { if (isActive(a) && !a._armed) { lastSnap[key(a)] = snapshot(a, classify(a)); armArea(a); a._armed = true; } }); armThemeControls(); refresh(); }
1238
+ // Build B-side source patches from the edit set (storage-agnostic; overlay
1239
+ // already auto-persists to localStorage on commit).
1240
+ function buildPatches() {
1241
+ const patches = Object.entries(fold()).filter(([k]) => authoringRegion(k)).map(([k, v]) => patchFor(k, v.kind, v.snap)); // data
1242
+ const toEdits = (paths, area) => {
1243
+ const e = [];
1244
+ Object.entries(paths).forEach(([k, props]) => {
1245
+ // Keys address across a session; the source writer navigates by
1246
+ // position, so resolve to where the element actually sits now. An
1247
+ // element that has since been deleted has nothing to write.
1248
+ const el = area && (area.getAttribute('data-edit-key') === k ? area : area.querySelector(`[data-edit-key="${CSS.escape(k)}"]`));
1249
+ const path = el ? el.getAttribute('data-edit-path') : k;
1250
+ if (area && !el) return;
1251
+ Object.entries(props).forEach(([prop, value]) => e.push({ path, key: k, prop, value }));
1252
+ });
1253
+ return e;
1254
+ };
1255
+ const ss = staticState(); // static: per-node ops + reorder permutation (no whole HTML)
1256
+ new Set([...Object.keys(ss.node), ...Object.keys(ss.order)]).forEach(region => {
1257
+ if (!authoringRegion(region)) return;
1258
+ const edits = toEdits(ss.node[region] || {}, areaByKey(region));
1259
+ if (edits.length || ss.order[region]) patches.push({ kind: 'static', region, edits, order: ss.order[region] || null });
1260
+ });
1261
+ const dv = dataValueState(); // data-value edits → field writes (local file / cloud $update)
1262
+ 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 }))));
1263
+ const { main, inst, reverts } = componentState();
1264
+ Object.entries(main).forEach(([component, paths]) => { const edits = toEdits(paths); if (edits.length) patches.push({ kind: 'component', scope: 'main', component, edits }); });
1265
+ new Set([...Object.keys(inst), ...Object.keys(reverts)]).forEach(region => {
1266
+ const area = areaByKey(region); if (!area) return;
1267
+ const edits = toEdits(inst[region] || {}), rv = [...(reverts[region] || [])];
1268
+ if (edits.length || rv.length) patches.push({ kind: 'component', scope: 'instance', component: componentName(area), region, edits, reverts: rv });
1269
+ });
1270
+ themePatches().forEach(p => patches.push(p)); // theme: CSS-var value writes (scoped → its file; global → theme file)
1271
+ return patches;
1272
+ }
1273
+ function publish() {
1274
+ const patches = buildPatches();
1275
+ if (estore && typeof estore.onPublish === 'function') return Promise.resolve(estore.onPublish(patches, { log, cursor }));
1276
+ if (!patches.length) return Promise.resolve({ results: [] });
1277
+ 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)
1278
+ }
1279
+ function on() { if (estore) estore.active = true; armAll(); }
1280
+ function off() { if (estore) estore.active = false; areas().forEach(a => { if (a._edit.gated && a._armed) { disarmArea(a); a._armed = false; } }); closeMenu(); refresh(); }
1281
+ // Runtime lock — meta, not content: applied immediately, not logged. Locks
1282
+ // the target and its subtree (descendants without their own x-edit inherit).
1283
+ function setLock(el, val) {
1284
+ if (!el) return;
1285
+ if (!el._edit) { el._edit = { key: `lock-${++autoN}`, caps: new Set(), lock: val, gated: false }; el.setAttribute('data-edit-area', ''); editEls.add(el); }
1286
+ else { el._edit.lock = val; if (val) el._edit.caps = new Set(); }
1287
+ 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'); }); }
1288
+ const area = areas().find(a => a === el || a.contains(el));
1289
+ if (area && isActive(area)) armArea(area);
1290
+ }
1291
+
1292
+
1293
+ /* ---- Authoring chrome: floating toolbar (undo/redo/publish/reset). A convenience
1294
+ over the $edit magic — built on first use and shown only where there is
1295
+ something to edit, so it never sits on a route with no editable area. ---- */
1296
+ let bar;
1297
+ function buildUI() {
1298
+ if (bar) return bar;
1299
+ bar = document.createElement('div'); bar.setAttribute('data-edit-toolbar', '');
1300
+ 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>';
1301
+ bar.addEventListener('click', e => {
1302
+ const btn = e.target.closest('button'); if (!btn) return; const a = btn.getAttribute('data-a');
1303
+ if (a === 'undo') undo(); if (a === 'redo') redo(); if (a === 'reset') { localStorage.removeItem(LS_KEY); location.reload(); }
1304
+ if (a === 'publish') {
1305
+ const orig = btn.textContent; btn.disabled = true; btn.textContent = 'Publishing…';
1306
+ 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` : ''}`; })
1307
+ .catch(() => btn.textContent = 'Failed').finally(() => { btn.disabled = false; setTimeout(() => btn.textContent = orig, 2800); });
1308
+ }
1309
+ });
1310
+ document.body.appendChild(bar);
1311
+ return bar;
1312
+ }
1313
+ // An area hidden by the router is still in the DOM, so ask the layout, not the tree.
1314
+ const onScreen = (el) => el.checkVisibility ? el.checkVisibility() : !!el.offsetParent;
1315
+ function refresh() {
1316
+ if (estore) { estore.canUndo = cursor > 0; estore.canRedo = cursor < log.length; }
1317
+ // Script decides presence — it is the only thing that knows which route is on
1318
+ // screen. The stylesheet decides whether it is seen.
1319
+ const authoring = areas().some(a => a._edit.authoring && isActive(a) && onScreen(a));
1320
+ document.documentElement.toggleAttribute('data-edit-active', authoring);
1321
+ if (!authoring) return;
1322
+ buildUI();
1323
+ bar.querySelector('[data-a="undo"]').disabled = cursor === 0;
1324
+ bar.querySelector('[data-a="redo"]').disabled = cursor >= log.length;
1325
+ }
1326
+
1327
+
1328
+ /* ---- Restore persisted edits + boot (registers x-edit directive + $edit magic) ---- */
1329
+ function restore() {
1330
+ if (!log.length) return;
1331
+ for (const [k, v] of Object.entries(fold())) { const area = areaByKey(k); if (area && v.kind === 'data') waitForData(area, () => applySnap(area, 'data', v.snap)); }
1332
+ if (log.some(d => d.kind === 'st-node' || d.kind === 'st-order')) applyStaticState();
1333
+ if (log.some(d => d.kind === 'cmp-main' || d.kind === 'cmp-inst')) applyComponentState();
1334
+ if (log.some(d => d.kind === 'data-val')) { const da = areas().find(a => classify(a) === 'data'); if (da) waitForData(da, applyDataValues); }
1335
+ if (log.some(d => d.kind === 'theme')) applyThemeState();
1336
+ }
1337
+ 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); }
1338
+ function init() {
1339
+ if (!window.Alpine || !Alpine.directive) return;
1340
+ Alpine.directive('edit', (el, { modifiers, expression }) => registerEdit(el, modifiers, expression));
1341
+ Alpine.store('edit', {
1342
+ active: false, canUndo: false, canRedo: false,
1343
+ onPublish: null, // author sets a fn(patches, {log,cursor}) → route to cloud/custom
1344
+ toggle() { this.active ? off() : on(); }, on() { on(); }, off() { off(); },
1345
+ undo() { undo(); }, redo() { redo(); }, lock(el) { setLock(el, true); }, unlock(el) { setLock(el, false); },
1346
+ publish() { return publish(); },
1347
+ // Block operations. Called with no element they act on the block the last
1348
+ // right-click reported, which is what a context menu wants.
1349
+ targetVersion: 0, // trackable: see blocks.js
1350
+ get target() { void this.targetVersion; return blockTarget(); },
1351
+ set target(el) { setBlockTarget(el); },
1352
+ can(op, el) { void this.targetVersion; return canDo(op, el); },
1353
+ copy(el) { return copyBlock(el); },
1354
+ cut(el) { return cutBlock(el); },
1355
+ paste(el) { return pasteBlock(el); },
1356
+ duplicate(el) { return duplicateBlock(el); },
1357
+ remove(el) { return removeBlock(el); },
1358
+ block(node) { return blockOf(node); },
1359
+ patches() { return buildPatches(); }, // resolved B-side source patches
1360
+ export() { return JSON.parse(JSON.stringify({ log, cursor })); } // A-side overlay (e.g. push to Appwrite)
1361
+ });
1362
+ estore = Alpine.store('edit');
1363
+ Alpine.magic('edit', () => Alpine.store('edit'));
1364
+ loadState();
1365
+ setTimeout(() => { armAll(); restore(); }, 450); // arm (captures baselines) THEN re-apply persisted edits
1366
+ window.addEventListener('manifest:route-change', () => setTimeout(refresh, 0)); // chrome follows the route
1367
+ }
1368
+ document.addEventListener('alpine:init', init);
1369
+ if (window.Alpine && Alpine.directive) setTimeout(init, 0);
1370
+ })();
1371
+
1372
+
1373
+ })();