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