mnfst 0.5.194 → 0.5.196
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/manifest.chat.js +17 -4
- package/lib/manifest.colorpicker.css +7 -2
- package/lib/manifest.colorpicker.js +8 -1
- package/lib/manifest.css +537 -189
- package/lib/manifest.d.ts +80 -1
- package/lib/manifest.datepicker.css +7 -2
- package/lib/manifest.datepicker.js +11 -2
- package/lib/manifest.dialog.css +12 -4
- package/lib/manifest.dropdown.css +7 -2
- package/lib/manifest.dropdowns.js +9 -1
- package/lib/manifest.edit.css +335 -0
- package/lib/manifest.edit.js +1373 -0
- package/lib/manifest.form.css +6 -4
- package/lib/manifest.integrity.json +7 -5
- package/lib/manifest.js +6 -1
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.resize.css +81 -120
- package/lib/manifest.sidebar.css +7 -2
- package/lib/manifest.text.edit.css +204 -0
- package/lib/manifest.text.edit.js +2020 -0
- package/package.json +2 -1
|
@@ -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
|
+
})();
|