mnfst 0.5.231 → 0.5.232
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +170 -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 +2324 -1076
- package/lib/manifest.integrity.json +10 -10
- package/lib/manifest.js +119 -25
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.rich.text.css +253 -0
- package/lib/{manifest.text.edit.js → manifest.rich.text.js} +1153 -736
- 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.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,89 @@
|
|
|
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' || action === 'cut') return !isHostEl(block);
|
|
1167
|
+
if (action === 'move') return movable(block);
|
|
1168
|
+
const lockAs = action === 'duplicate' ? 'duplicate' : 'remove';
|
|
1169
|
+
return capOf(block.parentElement, 'move') && !lockedFor(block, lockAs);
|
|
593
1170
|
}
|
|
594
1171
|
|
|
595
1172
|
function copyBlock(el) {
|
|
596
|
-
const block = el
|
|
597
|
-
const area = blockArea(block);
|
|
598
|
-
|
|
599
|
-
clipboard = { html: blockHTML(block), source: key(area) };
|
|
1173
|
+
const block = asPiece(el === undefined ? target : el); if (!block) return false;
|
|
1174
|
+
const area = blockArea(block); if (!area) return false;
|
|
1175
|
+
clipboard = { html: blockHTML(block, true), ops: subtreeOps(block), source: key(area), kind: classify(area) };
|
|
600
1176
|
bump();
|
|
601
1177
|
return true;
|
|
602
1178
|
}
|
|
603
1179
|
|
|
1180
|
+
// The edits a subtree carries now, keyed by identity, for a copy to start from.
|
|
1181
|
+
function subtreeOps(el) {
|
|
1182
|
+
const ss = staticState(), ops = {};
|
|
1183
|
+
[el, ...el.querySelectorAll('[data-edit-key]')].forEach(n => { const k = n.getAttribute('data-edit-key'); if (k && ss.node[k]) ops[k] = Object.assign({}, ss.node[k]); });
|
|
1184
|
+
return ops;
|
|
1185
|
+
}
|
|
1186
|
+
// A copy gets fresh identities that still name the source element it copies.
|
|
1187
|
+
function stampCopy(made, ops) {
|
|
1188
|
+
const id = mint(), copies = {};
|
|
1189
|
+
[made, ...made.querySelectorAll('[data-edit-key]')].forEach(n => {
|
|
1190
|
+
const k = n.getAttribute('data-edit-key');
|
|
1191
|
+
if (!k || k[0] === '!') { n.setAttribute('data-edit-key', '!' + mint()); return; }
|
|
1192
|
+
const ck = '~' + id + '~' + originOf(k);
|
|
1193
|
+
n.setAttribute('data-edit-key', ck);
|
|
1194
|
+
if (ops && ops[k]) copies[ck] = Object.assign({}, ops[k]);
|
|
1195
|
+
});
|
|
1196
|
+
return copies;
|
|
1197
|
+
}
|
|
1198
|
+
function insertStatic(area, container, made, after, ops) {
|
|
1199
|
+
const before = staticKeys(container);
|
|
1200
|
+
const copies = stampCopy(made, ops);
|
|
1201
|
+
if (after) after.after(made); else container.appendChild(made);
|
|
1202
|
+
markStatic(area); armArea(area);
|
|
1203
|
+
commitStructure(area, container, before, { [made.getAttribute('data-edit-key')]: blockHTML(made, true) }, copies);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
604
1206
|
function duplicateBlock(el) {
|
|
605
|
-
const block = el
|
|
1207
|
+
const block = asPiece(el === undefined ? target : el); if (!canDo('duplicate', block)) return false;
|
|
606
1208
|
const area = blockArea(block);
|
|
607
1209
|
if (classify(area) === 'data') {
|
|
608
1210
|
const arr = dataOps(area), i = recordIndex(area, block);
|
|
@@ -613,16 +1215,28 @@
|
|
|
613
1215
|
commitSplice(area, i + 1, copy, 'insert');
|
|
614
1216
|
return true;
|
|
615
1217
|
}
|
|
616
|
-
|
|
1218
|
+
if (isHostEl(block)) return duplicateHost(area, block);
|
|
1219
|
+
const made = materialize(blockHTML(block, true));
|
|
617
1220
|
if (!made) return false;
|
|
618
|
-
block.
|
|
619
|
-
|
|
620
|
-
|
|
1221
|
+
insertStatic(area, block.parentElement, made, block, subtreeOps(block));
|
|
1222
|
+
return true;
|
|
1223
|
+
}
|
|
1224
|
+
// A new instance of the component: its host copied in the source; on the page, its roots.
|
|
1225
|
+
function duplicateHost(area, block) {
|
|
1226
|
+
const container = block.parentElement, before = staticKeys(container), roots = groupOf(block);
|
|
1227
|
+
const made = roots.map(r => materialize(blockHTML(r)));
|
|
1228
|
+
if (made.some(m => !m)) return false;
|
|
1229
|
+
const host = { roots: made }, k = '~' + mint() + '~' + originOf(idOf(block));
|
|
1230
|
+
made.forEach(m => { m._manifestComponent = block._manifestComponent; m._manifestHost = host; m.removeAttribute('data-component'); });
|
|
1231
|
+
made[0].setAttribute('data-edit-host', k);
|
|
1232
|
+
roots[roots.length - 1].after(...made);
|
|
1233
|
+
markStatic(area); armArea(area);
|
|
1234
|
+
commitStructure(area, container, before, { [k]: made[0].outerHTML });
|
|
621
1235
|
return true;
|
|
622
1236
|
}
|
|
623
1237
|
|
|
624
1238
|
function removeBlock(el) {
|
|
625
|
-
const block = el
|
|
1239
|
+
const block = asPiece(el === undefined ? target : el); if (!canDo('remove', block)) return false;
|
|
626
1240
|
const area = blockArea(block);
|
|
627
1241
|
if (classify(area) === 'data') {
|
|
628
1242
|
const arr = dataOps(area), i = recordIndex(area, block);
|
|
@@ -630,40 +1244,56 @@
|
|
|
630
1244
|
const gone = arr[i];
|
|
631
1245
|
arr.splice(i, 1);
|
|
632
1246
|
commitSplice(area, i, gone, 'remove');
|
|
1247
|
+
if (target === block) setTarget(null);
|
|
633
1248
|
return true;
|
|
634
1249
|
}
|
|
635
|
-
const
|
|
636
|
-
const markup = blockHTML(block);
|
|
1250
|
+
const container = block.parentElement, before = staticKeys(container);
|
|
1251
|
+
const gone = idOf(block), markup = isHostEl(block) ? block.outerHTML : blockHTML(block, true);
|
|
1252
|
+
block._editLead = leadNodes(block);
|
|
1253
|
+
block._editLead.forEach(n => n.remove());
|
|
1254
|
+
groupOf(block).slice(1).forEach(n => n.remove());
|
|
637
1255
|
block.remove();
|
|
638
|
-
|
|
639
|
-
|
|
1256
|
+
detached.set(gone, block);
|
|
1257
|
+
if (target === block || (target && block.contains(target))) setTarget(null);
|
|
1258
|
+
commitStructure(area, container, before, { [gone]: markup });
|
|
640
1259
|
return true;
|
|
641
1260
|
}
|
|
642
1261
|
|
|
643
|
-
const cutBlock = (el) =>
|
|
1262
|
+
const cutBlock = (el) => { const b = asPiece(el === undefined ? target : el); return canDo('remove', b) && copyBlock(b) && removeBlock(b); };
|
|
644
1263
|
|
|
645
|
-
// Programmatic
|
|
646
|
-
// into `dest` (area element or x-edit key) before `ref`, or at the end.
|
|
1264
|
+
// Programmatic move: into `dest` (container element or x-edit key) before `ref`, or at the end.
|
|
647
1265
|
function moveBlock(el, dest, ref) {
|
|
648
|
-
const block =
|
|
1266
|
+
const block = asPiece(el == null ? target : el);
|
|
1267
|
+
if (!block || !movable(block)) return false;
|
|
649
1268
|
const from = blockArea(block);
|
|
650
|
-
const
|
|
651
|
-
if (!
|
|
652
|
-
|
|
653
|
-
if (!isActive(from) || !isActive(to) ||
|
|
654
|
-
if (
|
|
655
|
-
|
|
656
|
-
if (
|
|
657
|
-
if (
|
|
658
|
-
|
|
1269
|
+
const container = typeof dest === 'string' ? areaByKey(dest) : dest;
|
|
1270
|
+
if (!container || container.nodeType !== 1 || container === block || block.contains(container)) return false;
|
|
1271
|
+
const to = regionOf(container);
|
|
1272
|
+
if (!from || !to || !isActive(from) || !isActive(to) || !accepts(container)) return false;
|
|
1273
|
+
if (ref && ref.parentElement !== container) ref = null;
|
|
1274
|
+
const fromContainer = block.parentElement;
|
|
1275
|
+
if (!fits(block, container)) return false;
|
|
1276
|
+
if (classify(from) === 'data') {
|
|
1277
|
+
if (container !== fromContainer) return false;
|
|
1278
|
+
moveWith(block, b => container.insertBefore(b, ref ? startOf(ref) : null));
|
|
1279
|
+
finishReorder(from, container);
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1282
|
+
if (classify(from) !== 'static' || classify(to) !== 'static') return false;
|
|
1283
|
+
if (from !== to && isRegionBlock(block) && !isHostEl(block)) return false;
|
|
1284
|
+
const fromBefore = staticKeys(fromContainer), toBefore = staticKeys(container);
|
|
1285
|
+
moveWith(block, b => container.insertBefore(b, ref ? startOf(ref) : null));
|
|
1286
|
+
if (container === fromContainer) return commitOrder(from, container, fromBefore);
|
|
1287
|
+
commitMove(from, to, block, fromContainer, fromBefore, toBefore);
|
|
659
1288
|
return true;
|
|
660
1289
|
}
|
|
661
1290
|
|
|
662
1291
|
function pasteBlock(el) {
|
|
663
1292
|
if (!clipboard) return false;
|
|
664
|
-
const
|
|
665
|
-
const
|
|
666
|
-
|
|
1293
|
+
const container = pasteDest(el === undefined ? target : el); if (!container) return false;
|
|
1294
|
+
const block = asPiece(el === undefined ? target : el);
|
|
1295
|
+
const area = regionOf(container);
|
|
1296
|
+
if (!pasteFits(area)) return false;
|
|
667
1297
|
if (classify(area) === 'data') {
|
|
668
1298
|
const arr = dataOps(area); if (!arr) return false;
|
|
669
1299
|
const at = block ? recordIndex(area, block) + 1 : arr.length;
|
|
@@ -673,125 +1303,259 @@
|
|
|
673
1303
|
commitSplice(area, at, record, 'insert');
|
|
674
1304
|
return true;
|
|
675
1305
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
commitStructure(area, { [staticKeys(area)[sortableChildren(area).indexOf(made)]]: blockHTML(made) });
|
|
1306
|
+
if (classify(area) !== 'static') return false;
|
|
1307
|
+
const made = materialize(clipboard.html); if (!made || !fits(made, container)) return false;
|
|
1308
|
+
insertStatic(area, container, made, block && block.parentElement === container ? block : null, clipboard.ops);
|
|
680
1309
|
return true;
|
|
681
1310
|
}
|
|
682
1311
|
|
|
683
|
-
/* ----
|
|
684
|
-
Hotkeys ride the focused block, which reorder already gives a tabindex. They
|
|
685
|
-
stay out of the way of text: nothing fires while a caret is in a field, so
|
|
686
|
-
Cmd+C inside a paragraph still copies the words. */
|
|
1312
|
+
/* ---- Block hotkeys (outside text) ---- */
|
|
687
1313
|
const inText = (e) => e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
|
688
|
-
|
|
689
|
-
|
|
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;
|
|
1314
|
+
function onBlockKey(e, block) {
|
|
1315
|
+
if (inText(e)) return false;
|
|
1316
|
+
const meta = e.metaKey || e.ctrlKey, k = (e.key || '').toLowerCase();
|
|
1317
|
+
if (meta && k === 'c') return copyBlock(block);
|
|
1318
|
+
if (meta && k === 'x') return cutBlock(block);
|
|
1319
|
+
if (meta && k === 'v') return pasteBlock(block);
|
|
1320
|
+
if (meta && k === 'd') return duplicateBlock(block);
|
|
1321
|
+
if (!meta && (e.key === 'Delete' || e.key === 'Backspace')) return removeBlock(block);
|
|
1322
|
+
return false;
|
|
730
1323
|
}
|
|
731
1324
|
|
|
732
|
-
|
|
733
|
-
|
|
1325
|
+
// Right-click records the piece under the pointer before any menu opens.
|
|
1326
|
+
function onContextCapture(e) {
|
|
1327
|
+
const area = regionOf(e.target && e.target.nodeType === 1 ? e.target : e.target && e.target.parentElement);
|
|
1328
|
+
if (!area || !isActive(area)) return;
|
|
1329
|
+
setTarget(pieceOf(e.target));
|
|
1330
|
+
}
|
|
1331
|
+
const setBlockTarget = (el) => setTarget(el && el.nodeType === 1 ? asPiece(el) || el : null);
|
|
734
1332
|
|
|
735
1333
|
|
|
736
|
-
/* ----
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
--edit-drop-reach
|
|
1334
|
+
/* ---- Move: one pointer engine for reorder, nest, un-nest and cross-area moves ----
|
|
1335
|
+
Keyboard: Alt+Up/Down steps among siblings, Alt+Right nests into the previous
|
|
1336
|
+
container, Alt+Left moves out one level. Space/Enter grabs, arrows move, Enter drops.
|
|
1337
|
+
After a drop, CSS decides: in flow the piece takes DOM order; absolutely positioned
|
|
1338
|
+
it lands at the drop point.
|
|
1339
|
+
--edit-drop-reach 48px how far outside another area a drop still lands in it */
|
|
742
1340
|
const isPositioned = (el) => { const p = getComputedStyle(el).position; return p === 'absolute' || p === 'fixed'; };
|
|
1341
|
+
const addedTab = new WeakSet();
|
|
743
1342
|
let grabbed = null, liveRegion;
|
|
744
1343
|
function announce(msg) { if (!liveRegion || !liveRegion.isConnected) { liveRegion = document.createElement('div'); liveRegion.setAttribute('data-edit-live', ''); liveRegion.setAttribute('aria-live', 'polite'); document.body.appendChild(liveRegion); } liveRegion.textContent = msg; }
|
|
745
1344
|
|
|
746
|
-
function
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1345
|
+
function collectPieces(area) {
|
|
1346
|
+
const kind = classify(area);
|
|
1347
|
+
if (kind === 'component') return [];
|
|
1348
|
+
if (kind === 'data') { const tpl = listTpl(area); return tpl ? sortableChildren(tpl.parentElement) : []; }
|
|
1349
|
+
const out = [];
|
|
1350
|
+
const walk = (c) => sortableChildren(c).forEach(ch => { if (ch.closest('[data-edit-ghost]')) return; if (ch._manifestComponent) { if (isHostEl(ch)) out.push(ch); return; } out.push(ch); if (isContainer(ch) && !isRendered(ch)) walk(ch); });
|
|
1351
|
+
if (isContainer(area)) walk(area);
|
|
1352
|
+
return out;
|
|
1353
|
+
}
|
|
1354
|
+
function unmarkPiece(el) { el.removeAttribute('data-edit-piece'); if (addedTab.has(el)) { el.removeAttribute('tabindex'); addedTab.delete(el); } }
|
|
1355
|
+
function markPieces(area) {
|
|
1356
|
+
const want = new Set(collectPieces(area).filter(movable));
|
|
1357
|
+
area.querySelectorAll('[data-edit-piece]').forEach(el => { if (!want.has(el) && regionOf(el.parentElement) === area) unmarkPiece(el); });
|
|
1358
|
+
want.forEach(el => {
|
|
1359
|
+
el.setAttribute('data-edit-piece', '');
|
|
1360
|
+
if (!el.hasAttribute('tabindex')) { el.tabIndex = 0; addedTab.add(el); }
|
|
755
1361
|
});
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const flowAxis = (c) => { const cs = getComputedStyle(c); return /grid/.test(cs.display) || (/flex/.test(cs.display) && !String(cs.flexDirection || '').startsWith('column')) ? 'x' : 'y'; };
|
|
1365
|
+
// Beside a hovered piece: before it when the pointer is in its leading half.
|
|
1366
|
+
function beside(el, x, y, parent) {
|
|
1367
|
+
const r = el.getBoundingClientRect(), horiz = flowAxis(parent) === 'x';
|
|
1368
|
+
const lead = horiz ? x < r.left + r.width / 2 : y < r.top + r.height / 2;
|
|
1369
|
+
let ref = lead ? el : groupOf(el).pop().nextElementSibling;
|
|
1370
|
+
while (ref && (ref === dragged || ref.hasAttribute('data-edit-dragging'))) ref = ref.nextElementSibling;
|
|
1371
|
+
return { container: parent, ref };
|
|
1372
|
+
}
|
|
1373
|
+
// Middle band of a container nests; its edges insert before/after it.
|
|
1374
|
+
function inBand(r, horiz, x, y) {
|
|
1375
|
+
const lo = horiz ? r.left : r.top, size = horiz ? r.width : r.height, p = horiz ? x : y;
|
|
1376
|
+
if (!size) return true;
|
|
1377
|
+
const edge = Math.min(size * 0.25, 24);
|
|
1378
|
+
return p >= lo + edge && p <= lo + size - edge;
|
|
1379
|
+
}
|
|
1380
|
+
const inMiddle = (el, x, y, parent) => inBand(el.getBoundingClientRect(), flowAxis(parent) === 'x', x, y);
|
|
1381
|
+
const dropValid = (a) => !!a && !!a._edit && accepts(a) && classify(a) === 'static' && topArea(a);
|
|
1382
|
+
// Another area under the pointer, else the nearest within reach.
|
|
1383
|
+
function dropArea(x, y, origin) {
|
|
1384
|
+
const hit = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
|
|
1385
|
+
let hitArea = hit && regionOf(hit);
|
|
1386
|
+
if (hitArea && hitArea !== origin && origin.contains(hitArea)) hitArea = origin; // a component region inside: its host is one piece here
|
|
1387
|
+
if (hitArea && dropValid(hitArea)) return hitArea;
|
|
1388
|
+
const cands = [origin, ...document.querySelectorAll('[data-edit-drop-target]')].filter((a, i, all) => dropValid(a) && all.indexOf(a) === i);
|
|
1389
|
+
const inside = (r, pad) => x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad;
|
|
1390
|
+
let best = null, bestD = Infinity;
|
|
1391
|
+
for (const a of cands) {
|
|
1392
|
+
const r = a.getBoundingClientRect();
|
|
1393
|
+
const d = Math.hypot(Math.max(r.left - x, 0, x - r.right), Math.max(r.top - y, 0, y - r.bottom));
|
|
1394
|
+
const reach = toPx(cssVar(a, '--edit-drop-reach', ''), a, 'w') ?? 48;
|
|
1395
|
+
const p = a.parentElement;
|
|
1396
|
+
const column = p && p !== document.body && p !== document.documentElement && !cands.some(o => o !== a && p.contains(o)) && inside(p.getBoundingClientRect(), 0);
|
|
1397
|
+
if ((inside(r, reach) || column) && d < bestD) { best = a; bestD = d; }
|
|
1398
|
+
}
|
|
1399
|
+
return best;
|
|
1400
|
+
}
|
|
1401
|
+
function dropTarget(x, y, s) {
|
|
1402
|
+
if (s.dataList) return { container: s.home, ref: afterElement(s.home, x, y) };
|
|
1403
|
+
// Nested: the container's bands stay where they were before the ghost went in
|
|
1404
|
+
// (its size and the layout around it change once it does).
|
|
1405
|
+
const n = s.nested;
|
|
1406
|
+
if (n && n.el.isConnected && s.ghost.parentElement === n.el && x >= n.rect.left && x <= n.rect.right && y >= n.rect.top && y <= n.rect.bottom && inBand(n.rect, n.horiz, x, y)) return { container: n.el, ref: afterElement(n.el, x, y) };
|
|
1407
|
+
s.nested = null;
|
|
1408
|
+
const region = s.crossOK ? dropArea(x, y, s.area) : s.area;
|
|
1409
|
+
if (!region) return null;
|
|
1410
|
+
const hit = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
|
|
1411
|
+
if (hit && hit !== region && region.contains(hit)) {
|
|
1412
|
+
// Inside a component host: the host is what is hit (its markup is another file's)
|
|
1413
|
+
let from = hit;
|
|
1414
|
+
for (let n = hit; n && n !== region; n = n.parentElement) if (isHostEl(n) || extraRoot(n)) from = isHostEl(n) ? n : n._manifestHost.roots[0];
|
|
1415
|
+
for (let el = from; el && el !== region; el = el.parentElement) {
|
|
1416
|
+
if (el === s.item || s.item.contains(el) || pluginNode(el)) continue;
|
|
1417
|
+
const parent = el.parentElement;
|
|
1418
|
+
if (!isContainer(parent)) continue;
|
|
1419
|
+
if (isHostEl(el)) { if (accepts(parent) && fits(s.item, parent) && !s.item.contains(parent)) return beside(el, x, y, parent); continue; }
|
|
1420
|
+
if (isContainer(el) && accepts(el) && fits(s.item, el) && inMiddle(el, x, y, parent)) {
|
|
1421
|
+
if (s.ghost && s.ghost.parentElement !== el) s.nested = { el, rect: el.getBoundingClientRect(), horiz: flowAxis(parent) === 'x' };
|
|
1422
|
+
return { container: el, ref: afterElement(el, x, y) };
|
|
1423
|
+
}
|
|
1424
|
+
if (accepts(parent) && fits(s.item, parent) && !s.item.contains(parent)) return beside(el, x, y, parent);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (!s.crossOK && (!hit || !region.contains(hit))) { const c = s.ghost.parentElement; return { container: c, ref: afterElement(c, x, y) }; }
|
|
1428
|
+
return accepts(region) && fits(s.item, region) ? { container: region, ref: afterElement(region, x, y) } : null;
|
|
1429
|
+
}
|
|
1430
|
+
// Reading-order insertion: the first sibling past the pointer.
|
|
1431
|
+
function afterElement(c, x, y) {
|
|
1432
|
+
for (const el of sortableChildren(c)) {
|
|
1433
|
+
if (el === dragged || el.hasAttribute('data-edit-dragging') || lockedFor(el, 'move')) continue;
|
|
1434
|
+
const b = el.getBoundingClientRect(), cx = b.left + b.width / 2, cy = b.top + b.height / 2;
|
|
1435
|
+
const sameRow = Math.abs(cy - y) < b.height / 2;
|
|
1436
|
+
if (cy - y > b.height / 2 || (sameRow && cx > x)) return el;
|
|
765
1437
|
}
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
// Drop point in the piece's own units, relative to its offset parent.
|
|
1441
|
+
function placeAt(el, x, y, s) {
|
|
1442
|
+
const op = el.offsetParent || el.parentElement, r = op.getBoundingClientRect();
|
|
1443
|
+
let left = x - s.grabX - r.left - (op.clientLeft || 0) + (op.scrollLeft || 0);
|
|
1444
|
+
let top = y - s.grabY - r.top - (op.clientTop || 0) + (op.scrollTop || 0);
|
|
1445
|
+
const maxL = op.clientWidth - el.offsetWidth, maxT = op.clientHeight - el.offsetHeight;
|
|
1446
|
+
left = maxL > 0 ? clamp(left, 0, maxL) : Math.max(0, left);
|
|
1447
|
+
top = maxT > 0 ? clamp(top, 0, maxT) : Math.max(0, top);
|
|
1448
|
+
el.style.left = toUnit(left, s.lu, el, op, 'w') + s.lu;
|
|
1449
|
+
el.style.top = toUnit(top, s.tu, el, op, 'h') + s.tu;
|
|
1450
|
+
}
|
|
1451
|
+
const clearPlacement = (el) => { el.style.removeProperty('left'); el.style.removeProperty('top'); if (!el.getAttribute('style')) el.removeAttribute('style'); };
|
|
1452
|
+
const keysWithout = (c, item) => staticKeys(c).filter((k, i) => sortableChildren(c)[i] !== item);
|
|
1453
|
+
|
|
1454
|
+
function onAreaPointerDown(e) {
|
|
1455
|
+
const area = this;
|
|
1456
|
+
if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0) || e._editDrag) return;
|
|
1457
|
+
const t = e.target;
|
|
1458
|
+
if (!t || t.nodeType !== 1 || t.isContentEditable || t.hasAttribute('data-edit-handle')) return;
|
|
1459
|
+
if (t._pieceMode) { exitPieceMode(t); return; }
|
|
1460
|
+
let item = null;
|
|
1461
|
+
for (let n = t; n && n !== area; n = n.parentElement) if (n.hasAttribute('data-edit-piece') && movable(n)) { item = n; break; }
|
|
1462
|
+
if (!item) return;
|
|
1463
|
+
e._editDrag = true;
|
|
1464
|
+
startDrag(e, item, area);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/* ---- Grip: text pieces, whose surface is for typing, drag from a handle at their inline-start edge ---- */
|
|
1468
|
+
let grip = null, gripFor = null, gripHovered = null, gripFocused = null, gripListening = false;
|
|
1469
|
+
// Text pieces, and rows that are themselves a data field (their surface is for typing too)
|
|
1470
|
+
const needsGrip = (p) => !!p && movable(p) && ((isTextEl(p) && capOf(p, 'text')) || p._editField != null);
|
|
1471
|
+
function gripPiece(node, area) {
|
|
1472
|
+
for (let n = node; n && n.nodeType === 1 && n !== area; n = n.parentElement) if (n.hasAttribute('data-edit-piece')) return needsGrip(n) ? n : null;
|
|
1473
|
+
return null;
|
|
766
1474
|
}
|
|
767
|
-
function
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1475
|
+
function ensureGrip() {
|
|
1476
|
+
if (grip && grip.isConnected) return grip;
|
|
1477
|
+
grip = document.createElement('span');
|
|
1478
|
+
grip.setAttribute('data-edit-grip', '');
|
|
1479
|
+
grip.setAttribute('aria-hidden', 'true');
|
|
1480
|
+
grip.hidden = true;
|
|
1481
|
+
grip.addEventListener('pointerdown', onGripDown);
|
|
1482
|
+
grip.addEventListener('pointerleave', (e) => gripLeave(e.relatedTarget));
|
|
1483
|
+
document.body.appendChild(grip);
|
|
1484
|
+
if (!gripListening) {
|
|
1485
|
+
gripListening = true;
|
|
1486
|
+
window.addEventListener('scroll', placeGrip, { capture: true, passive: true });
|
|
1487
|
+
window.addEventListener('resize', placeGrip, { passive: true });
|
|
1488
|
+
}
|
|
1489
|
+
return grip;
|
|
1490
|
+
}
|
|
1491
|
+
function placeGrip() {
|
|
1492
|
+
if (!gripFor || !grip) return;
|
|
1493
|
+
if (!gripFor.isConnected) return hideGrip();
|
|
1494
|
+
const r = gripFor.getBoundingClientRect(), rtl = getComputedStyle(gripFor).direction === 'rtl';
|
|
1495
|
+
grip.dir = rtl ? 'rtl' : 'ltr';
|
|
1496
|
+
grip.style.top = r.top + 'px';
|
|
1497
|
+
grip.style.left = (rtl ? r.right : r.left) + 'px';
|
|
1498
|
+
}
|
|
1499
|
+
function showGrip(piece) {
|
|
1500
|
+
const area = regionOf(piece);
|
|
1501
|
+
if (dragged || !area || area._edit.bare || piece.closest('[data-edit-area="bare"]')) return hideGrip();
|
|
1502
|
+
const g = ensureGrip();
|
|
1503
|
+
// The grip lives on <body>: it takes the piece's own accent and sizes
|
|
1504
|
+
['--edit-accent', '--edit-grip-size', '--edit-grip-offset'].forEach(v => { const x = rawVar(piece, v); if (x) g.style.setProperty(v, x); else g.style.removeProperty(v); });
|
|
1505
|
+
gripFor = piece; placeGrip(); g.hidden = false;
|
|
1506
|
+
}
|
|
1507
|
+
function hideGrip() { gripFor = null; if (grip) grip.hidden = true; }
|
|
1508
|
+
function gripOver(e, area) {
|
|
1509
|
+
if (dragged) return;
|
|
1510
|
+
const p = gripPiece(e.target, area);
|
|
1511
|
+
gripHovered = p;
|
|
1512
|
+
if (p) showGrip(p); else if (gripFocused) showGrip(gripFocused); else hideGrip();
|
|
1513
|
+
}
|
|
1514
|
+
function gripLeave(to) {
|
|
1515
|
+
if (to && grip && (to === grip || (gripFor && gripFor.contains(to)))) return;
|
|
1516
|
+
gripHovered = null;
|
|
1517
|
+
if (gripFocused && gripFocused.isConnected) showGrip(gripFocused); else hideGrip();
|
|
1518
|
+
}
|
|
1519
|
+
function gripFocus(e, area) {
|
|
1520
|
+
const p = gripPiece(e.target, area);
|
|
1521
|
+
gripFocused = p;
|
|
1522
|
+
if (p && !(gripHovered && gripHovered.isConnected)) showGrip(p);
|
|
1523
|
+
}
|
|
1524
|
+
function gripBlur(to) {
|
|
1525
|
+
if (to && gripFocused && gripFocused.contains(to)) return;
|
|
1526
|
+
gripFocused = null;
|
|
1527
|
+
if (!(gripHovered && gripHovered.isConnected)) hideGrip();
|
|
1528
|
+
}
|
|
1529
|
+
function onGripDown(e) {
|
|
1530
|
+
const piece = gripFor;
|
|
1531
|
+
if (!piece || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
|
1532
|
+
const area = regionOf(piece);
|
|
1533
|
+
if (!isActive(area) || !movable(piece)) return;
|
|
1534
|
+
e.preventDefault(); e.stopPropagation();
|
|
1535
|
+
hideGrip();
|
|
1536
|
+
startDrag(e, piece, area);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function startDrag(e, item, area) {
|
|
1540
|
+
const home = item.parentElement, homeNext = groupOf(item).pop().nextElementSibling, rest = groupOf(item).slice(1);
|
|
1541
|
+
const dataList = classify(area) === 'data';
|
|
1542
|
+
const s = {
|
|
1543
|
+
item, area, home, dataList, ghost: null,
|
|
1544
|
+
crossOK: !dataList && classify(area) === 'static' && (isHostEl(item) || !isRegionBlock(item)),
|
|
1545
|
+
homeBefore: dataList ? null : staticKeys(home),
|
|
1546
|
+
lu: unitOf(item.style.left) || 'px', tu: unitOf(item.style.top) || 'px'
|
|
1547
|
+
};
|
|
1548
|
+
const start = item.getBoundingClientRect(), preStyle = item.getAttribute('style'), preOwn = ownStyle(item), lead = leadNodes(item);
|
|
1549
|
+
s.grabX = e.clientX - start.left; s.grabY = e.clientY - start.top;
|
|
779
1550
|
const sx = e.clientX, sy = e.clientY;
|
|
780
|
-
let active = false, frame = 0, px = sx, py = sy,
|
|
1551
|
+
let active = false, frame = 0, px = sx, py = sy, last = null;
|
|
781
1552
|
|
|
782
|
-
// The item leaves the flow and a stand-in takes its slot, so the gap that
|
|
783
|
-
// opens is a real element the author can style — by default a translucent
|
|
784
|
-
// copy of what is being dragged, showing exactly where it would land.
|
|
785
1553
|
const lift = (ev) => {
|
|
786
|
-
|
|
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);
|
|
1554
|
+
if (s.crossOK) areas().forEach(a => { if (a !== area && dropValid(a)) a.setAttribute('data-edit-drop-target', ''); });
|
|
1555
|
+
const ghost = s.ghost = item.cloneNode(true);
|
|
1556
|
+
unrich(item, ghost);
|
|
793
1557
|
ghost.setAttribute('data-edit-ghost', '');
|
|
794
|
-
ghost.setAttribute('x-ignore', '');
|
|
1558
|
+
ghost.setAttribute('x-ignore', '');
|
|
795
1559
|
ghost.removeAttribute('id');
|
|
796
1560
|
ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
|
|
797
1561
|
[ghost, ...ghost.querySelectorAll('*')].forEach(n => {
|
|
@@ -799,40 +1563,33 @@
|
|
|
799
1563
|
n.removeAttribute('contenteditable'); n.removeAttribute('tabindex'); n.removeAttribute('draggable');
|
|
800
1564
|
});
|
|
801
1565
|
item.before(ghost);
|
|
802
|
-
item.style
|
|
803
|
-
item.style.width = start.width + 'px';
|
|
804
|
-
item.style.height = start.height + 'px';
|
|
805
|
-
item.style.margin = '0';
|
|
806
|
-
item.style.pointerEvents = 'none';
|
|
1566
|
+
Object.assign(item.style, { position: 'fixed', width: start.width + 'px', height: start.height + 'px', margin: '0', pointerEvents: 'none' });
|
|
807
1567
|
item.setAttribute('data-edit-dragging', '');
|
|
808
|
-
area.setAttribute('data-edit-dragging-in', '');
|
|
809
1568
|
dragged = ghost;
|
|
810
1569
|
try { item.setPointerCapture(ev.pointerId); } catch { }
|
|
811
1570
|
};
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1571
|
+
const paint = () => { frame = 0; item.style.left = (px - s.grabX) + 'px'; item.style.top = (py - s.grabY) + 'px'; };
|
|
1572
|
+
const placeGhost = (drop) => {
|
|
1573
|
+
const g = s.ghost, { container, ref } = drop;
|
|
1574
|
+
const same = last && last.container === container && last.ref === ref;
|
|
1575
|
+
last = drop;
|
|
1576
|
+
if (!same) container.insertBefore(g, ref && ref.parentElement === container ? startOf(ref) : null);
|
|
1577
|
+
if (isPositioned(g)) {
|
|
1578
|
+
if (!same) { if (container === home && item.parentElement === home) home.insertBefore(g, item); else if (g.nextElementSibling) container.appendChild(g); }
|
|
1579
|
+
placeAt(g, px, py, s);
|
|
1580
|
+
} else if (g.style.left || g.style.top) clearPlacement(g);
|
|
817
1581
|
};
|
|
818
|
-
|
|
819
1582
|
const onMove = (ev) => {
|
|
820
1583
|
px = ev.clientX; py = ev.clientY;
|
|
821
1584
|
if (!active) {
|
|
822
|
-
if (Math.hypot(px - sx, py - sy) < 5) return;
|
|
823
|
-
active = true; lift(ev); paint();
|
|
1585
|
+
if (Math.hypot(px - sx, py - sy) < 5) return;
|
|
1586
|
+
active = true; hideGrip(); lift(ev); paint();
|
|
824
1587
|
}
|
|
825
1588
|
ev.preventDefault();
|
|
826
|
-
const
|
|
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);
|
|
1589
|
+
const drop = dropTarget(px, py, s);
|
|
1590
|
+
if (drop) placeGhost(drop);
|
|
833
1591
|
if (!frame) frame = requestAnimationFrame(paint);
|
|
834
1592
|
};
|
|
835
|
-
|
|
836
1593
|
const settle = (cancelled) => {
|
|
837
1594
|
document.removeEventListener('pointermove', onMove);
|
|
838
1595
|
document.removeEventListener('pointerup', onUp);
|
|
@@ -840,30 +1597,26 @@
|
|
|
840
1597
|
document.removeEventListener('keydown', onKey, true);
|
|
841
1598
|
if (frame) cancelAnimationFrame(frame);
|
|
842
1599
|
if (!active) return;
|
|
843
|
-
if (cancelled) {
|
|
844
|
-
ghost.remove();
|
|
845
|
-
if (homeNext) container.insertBefore(item, homeNext); else container.appendChild(item);
|
|
846
|
-
} else ghost.replaceWith(item);
|
|
847
|
-
if (preStyle == null) item.removeAttribute('style'); else item.setAttribute('style', preStyle);
|
|
848
|
-
item.removeAttribute('data-edit-dragging');
|
|
849
|
-
area.removeAttribute('data-edit-dragging-in');
|
|
850
1600
|
document.querySelectorAll('[data-edit-drop-target]').forEach(a => a.removeAttribute('data-edit-drop-target'));
|
|
851
|
-
|
|
852
|
-
ghost
|
|
853
|
-
if (
|
|
854
|
-
|
|
855
|
-
if (
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1601
|
+
item.removeAttribute('data-edit-dragging');
|
|
1602
|
+
if (cancelled) s.ghost.remove(); else { s.ghost.replaceWith(item); if (lead.length) item.before(...lead); if (rest.length) item.after(...rest); }
|
|
1603
|
+
if (preStyle == null) item.removeAttribute('style'); else item.setAttribute('style', preStyle);
|
|
1604
|
+
s.ghost = null; dragged = null;
|
|
1605
|
+
const back = () => { if (homeNext && homeNext.parentElement === home) home.insertBefore(item, startOf(homeNext)); else home.appendChild(item); if (lead.length) item.before(...lead); if (rest.length) item.after(...rest); };
|
|
1606
|
+
if (cancelled) { back(); announce('Cancelled'); return; }
|
|
1607
|
+
const dest = item.parentElement, toArea = regionOf(dest);
|
|
1608
|
+
if (dest !== home && (s.dataList || !accepts(dest) || !fits(item, dest) || !toArea || (toArea !== area && !(s.crossOK && dropValid(toArea))))) { back(); announce('Cancelled'); return; }
|
|
1609
|
+
if (isPositioned(item)) placeAt(item, px, py, s);
|
|
1610
|
+
else if (item.style.left || item.style.top) clearPlacement(item);
|
|
1611
|
+
const styled = ownStyle(item) !== preOwn;
|
|
1612
|
+
if (s.dataList) { finishReorder(area, home); announce('Dropped'); return; }
|
|
1613
|
+
let did = false;
|
|
1614
|
+
if (dest === home) did = commitOrder(area, home, s.homeBefore);
|
|
1615
|
+
else { commitMove(area, toArea, item, home, s.homeBefore, keysWithout(dest, item)); did = true; }
|
|
1616
|
+
if (styled) commitStyle(regionOf(item), item, { before: preOwn, join: did });
|
|
1617
|
+
announce(toArea !== area ? 'Moved to ' + key(toArea) : 'Dropped');
|
|
862
1618
|
};
|
|
863
1619
|
const onUp = () => settle(false);
|
|
864
|
-
// Escape puts it back, the way every drag is expected to be escapable. A
|
|
865
|
-
// cancelled pointer (OS gesture, alert, pen out of range) also reverts —
|
|
866
|
-
// otherwise the item wedges position:fixed with drop targets still lit.
|
|
867
1620
|
const onCancel = () => settle(true);
|
|
868
1621
|
const onKey = (ev) => { if (ev.key !== 'Escape') return; ev.preventDefault(); ev.stopPropagation(); settle(true); };
|
|
869
1622
|
document.addEventListener('pointermove', onMove);
|
|
@@ -871,148 +1624,134 @@
|
|
|
871
1624
|
document.addEventListener('pointercancel', onCancel);
|
|
872
1625
|
document.addEventListener('keydown', onKey, true);
|
|
873
1626
|
}
|
|
874
|
-
|
|
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;
|
|
1627
|
+
|
|
1628
|
+
function finishReorder(area, container) {
|
|
1629
|
+
if (classify(area) === 'data') { applyDataOrder(area, sortableChildren(container).map(c => itemKey(c, area))); commit(area); return true; }
|
|
1630
|
+
return false;
|
|
916
1631
|
}
|
|
917
|
-
|
|
918
|
-
|
|
1632
|
+
|
|
1633
|
+
/* ---- Keyboard ---- */
|
|
919
1634
|
const ownControl = (e, item) => e.target !== item && !!e.target.closest && item.contains(e.target.closest('a[href], button, summary, [role="button"], [role="link"]'));
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1635
|
+
const siblingsOf = (item) => sortableChildren(item.parentElement).filter(c => c === item || !lockedFor(c, 'move'));
|
|
1636
|
+
function refocus(el) { if (document.activeElement !== el) try { el.focus(); } catch { } }
|
|
1637
|
+
|
|
1638
|
+
function onPieceKeydown(e, area) {
|
|
1639
|
+
if (e._editRow) return;
|
|
1640
|
+
const t = e.target;
|
|
1641
|
+
if (e.key === 'Enter' && t.matches && t.matches('a[href]') && islandLink(t, area)) return;
|
|
1642
|
+
if (t._pieceMode && e.key === 'Enter') { e._editRow = true; e.preventDefault(); exitPieceMode(t, true); return; }
|
|
1643
|
+
let item = null;
|
|
1644
|
+
for (let n = t; n && n !== area; n = n.parentElement) if (n.hasAttribute && n.hasAttribute('data-edit-piece') && movable(n)) { item = n; break; }
|
|
1645
|
+
if (!grabbed) { const b = asPiece(t); if (b && onBlockKey(e, b)) { e._editRow = true; e.preventDefault(); e.stopPropagation(); return; } }
|
|
1646
|
+
if (!item) return;
|
|
1647
|
+
const dir = { ArrowUp: -1, ArrowDown: 1 }[e.key], side = { ArrowLeft: -1, ArrowRight: 1 }[e.key];
|
|
1648
|
+
const plainAlt = e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey;
|
|
1649
|
+
if (plainAlt && dir != null && !(grabbed && grabbed.item === item)) { e._editRow = true; e.preventDefault(); stepRow(item, dir); return; }
|
|
1650
|
+
if (plainAlt && side != null && !inText(e)) { e._editRow = true; e.preventDefault(); if (side > 0) nestInto(item); else unnest(item); return; }
|
|
1651
|
+
if ((e.key === ' ' || e.key === 'Enter') && !inText(e) && !ownControl(e, item) && !e.altKey && !e.metaKey && !e.ctrlKey) {
|
|
1652
|
+
e._editRow = true; e.preventDefault();
|
|
1653
|
+
if (grabbed && grabbed.item === item) { dropGrab(); refocus(item); announce('Dropped'); }
|
|
1654
|
+
else { if (grabbed) cancelGrab(); grabbed = { item, area: blockArea(item), container: item.parentElement, next: groupOf(item).pop().nextElementSibling, before: classify(blockArea(item)) === 'data' ? null : staticKeys(item.parentElement) }; item.setAttribute('data-edit-dragging', 'keyboard'); announce('Grabbed — arrow keys to move, Enter to drop'); }
|
|
1655
|
+
return;
|
|
929
1656
|
}
|
|
930
|
-
if (
|
|
931
|
-
e._editRow = true;
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1657
|
+
if (grabbed && grabbed.item === item) {
|
|
1658
|
+
if (e.key === 'Escape') { e._editRow = true; e.preventDefault(); cancelGrab(); refocus(item); announce('Cancelled'); return; }
|
|
1659
|
+
const step = dir ?? side; if (step == null) return;
|
|
1660
|
+
e._editRow = true; e.preventDefault();
|
|
1661
|
+
const sibs = siblingsOf(item), i = sibs.indexOf(item), j = i + step;
|
|
1662
|
+
if (j < 0 || j >= sibs.length) return;
|
|
1663
|
+
moveWith(item, it => { if (step > 0) groupOf(sibs[j]).pop().after(it); else startOf(sibs[j]).before(it); });
|
|
1664
|
+
refocus(item); announce(`Position ${j + 1} of ${sibs.length}`);
|
|
935
1665
|
return;
|
|
936
1666
|
}
|
|
937
|
-
if (
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1667
|
+
if (t === item && !inText(e) && !e.altKey && !e.metaKey && !e.ctrlKey && (dir != null || side != null) && isPositioned(item)) { e._editRow = true; nudge(e, item); }
|
|
1668
|
+
}
|
|
1669
|
+
function dropGrab() {
|
|
1670
|
+
const g = grabbed; grabbed = null; if (!g) return;
|
|
1671
|
+
g.item.removeAttribute('data-edit-dragging');
|
|
1672
|
+
if (g.before) commitOrder(g.area, g.container, g.before); else finishReorder(g.area, g.container);
|
|
1673
|
+
}
|
|
1674
|
+
function cancelGrab() {
|
|
1675
|
+
const g = grabbed; grabbed = null; if (!g) return;
|
|
1676
|
+
g.item.removeAttribute('data-edit-dragging');
|
|
1677
|
+
moveWith(g.item, it => { if (g.next && g.next.parentElement === g.container) g.container.insertBefore(it, startOf(g.next)); else g.container.appendChild(it); });
|
|
1678
|
+
}
|
|
1679
|
+
// One step, committed at once. Neighbours move round the piece, so focus and caret survive.
|
|
1680
|
+
function stepRow(item, dir) {
|
|
1681
|
+
const area = blockArea(item), container = item.parentElement;
|
|
1682
|
+
const sibs = siblingsOf(item), i = sibs.indexOf(item), j = i + dir;
|
|
951
1683
|
if (i < 0 || j < 0 || j >= sibs.length) { announce(`Position ${i + 1} of ${sibs.length}`); return; }
|
|
1684
|
+
const before = classify(area) === 'data' ? null : staticKeys(container);
|
|
952
1685
|
const other = sibs[j], between = [];
|
|
953
1686
|
for (let n = dir > 0 ? item.nextElementSibling : item.previousElementSibling; n && n !== other; n = dir > 0 ? n.nextElementSibling : n.previousElementSibling) between.push(n);
|
|
954
|
-
|
|
955
|
-
|
|
1687
|
+
// Neighbours move round the piece, each with its titling comment.
|
|
1688
|
+
const block = (n) => [...leadNodes(n), ...groupOf(n)];
|
|
1689
|
+
if (dir > 0) { const at = startOf(item); [other, ...between].filter(n => !extraRoot(n)).forEach(n => at.before(...block(n))); }
|
|
1690
|
+
else { let after = groupOf(item).pop(); [...between.slice().reverse(), other].filter(n => !extraRoot(n)).forEach(n => { after.after(...block(n)); after = groupOf(n).pop(); }); }
|
|
956
1691
|
const focused = document.activeElement, sel = window.getSelection && window.getSelection();
|
|
957
1692
|
const range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
|
958
|
-
finishReorder(area);
|
|
1693
|
+
if (before) commitOrder(area, container, before); else finishReorder(area, container);
|
|
959
1694
|
if (focused && focused.isConnected && document.activeElement !== focused) {
|
|
960
1695
|
focused.focus();
|
|
961
1696
|
if (range) try { sel.removeAllRanges(); sel.addRange(range); } catch { }
|
|
962
1697
|
}
|
|
963
1698
|
announce(`Moved to position ${j + 1} of ${sibs.length}`);
|
|
964
1699
|
}
|
|
965
|
-
|
|
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
|
-
});
|
|
1700
|
+
const describe = (el) => el === regionOf(el) ? key(el) : el.tagName.toLowerCase();
|
|
1701
|
+
function nestInto(item) {
|
|
1702
|
+
const area = blockArea(item);
|
|
1703
|
+
if (classify(area) !== 'static') { announce('Records stay in their list'); return false; }
|
|
1704
|
+
let prev = item.previousElementSibling;
|
|
1705
|
+
while (prev && (pluginNode(prev) || prev.tagName === 'TEMPLATE' || extraRoot(prev))) prev = prev.previousElementSibling;
|
|
1706
|
+
if (!prev || isHostEl(prev) || !accepts(prev) || !fits(item, prev)) { announce('Nothing to nest into'); return false; }
|
|
1707
|
+
const from = item.parentElement, fb = staticKeys(from), tb = staticKeys(prev);
|
|
1708
|
+
moveWith(item, it => prev.appendChild(it));
|
|
1709
|
+
commitMove(area, area, item, from, fb, tb);
|
|
1710
|
+
refocus(item); announce('Nested in ' + describe(prev));
|
|
1711
|
+
return true;
|
|
994
1712
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1713
|
+
function unnest(item) {
|
|
1714
|
+
const area = blockArea(item), from = item.parentElement;
|
|
1715
|
+
if (classify(area) !== 'static') { announce('Records stay in their list'); return false; }
|
|
1716
|
+
const out = from && from !== area ? from.parentElement : null;
|
|
1717
|
+
if (!out || !(out === area || area.contains(out)) || !accepts(out)) { announce('Already at the top level'); return false; }
|
|
1718
|
+
if (!fits(item, out)) { announce('Cannot move out here'); return false; }
|
|
1719
|
+
const fb = staticKeys(from), tb = staticKeys(out);
|
|
1720
|
+
moveWith(item, it => groupOf(from).pop().after(it));
|
|
1721
|
+
commitMove(area, area, item, from, fb, tb);
|
|
1722
|
+
refocus(item); announce('Moved out to ' + describe(out));
|
|
1723
|
+
return true;
|
|
1724
|
+
}
|
|
1725
|
+
// Arrow nudge for a focused, absolutely positioned piece; a key burst commits once.
|
|
1726
|
+
function nudge(e, el) {
|
|
1727
|
+
const d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key]; if (!d) return;
|
|
1728
|
+
e.preventDefault();
|
|
1729
|
+
if (!el._moveTimer) el._preStyle = ownStyle(el);
|
|
1730
|
+
const step = e.shiftKey ? 16 : 4, cs = getComputedStyle(el), parent = el.offsetParent || el.parentElement;
|
|
1731
|
+
const lu = unitOf(el.style.left) || 'px', tu = unitOf(el.style.top) || 'px';
|
|
1732
|
+
const maxL = Math.max(0, parent.clientWidth - el.offsetWidth), maxT = Math.max(0, parent.clientHeight - el.offsetHeight);
|
|
1733
|
+
const nl = Math.max(0, (parseFloat(cs.left) || 0) + d[0] * step), nt = Math.max(0, (parseFloat(cs.top) || 0) + d[1] * step);
|
|
1734
|
+
if (d[0]) el.style.left = toUnit(maxL ? Math.min(maxL, nl) : nl, lu, el, parent, 'w') + lu;
|
|
1735
|
+
if (d[1]) el.style.top = toUnit(maxT ? Math.min(maxT, nt) : nt, tu, el, parent, 'h') + tu;
|
|
1736
|
+
clearTimeout(el._moveTimer); el._moveTimer = setTimeout(() => { el._moveTimer = null; commitStyle(regionOf(el), el); }, 350);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
/* ---- Size: edge handles → unit-preserving width/height ----
|
|
1741
|
+
--edit-size both | x | y | none axes (default both)
|
|
1742
|
+
--edit-size-edges "right bottom-right" handle list (logical start/end ok)
|
|
1743
|
+
--edit-size-handle 1rem handle hit area
|
|
1744
|
+
--edit-size-snap(-x/-y) 12rem 24rem snap stops; unset = no snapping
|
|
1745
|
+
--edit-size-snap-distance(-x/-y) 1rem magnet reach; unset = always the nearest stop
|
|
1746
|
+
--edit-size-close(-x/-y) 4rem below this, an x-show element closes
|
|
1747
|
+
`none` on any --edit-size-* cancels an inherited value. Min/max come from native min-/max-width/height. */
|
|
1008
1748
|
let overlayEl;
|
|
1009
|
-
//
|
|
1010
|
-
// that must re-measure — charts, virtual lists — can track the drag and settle.
|
|
1749
|
+
// edit:size fires while dragging (done:false) and once on commit (done:true).
|
|
1011
1750
|
function sizeEvent(el, done) {
|
|
1012
1751
|
const r = el.getBoundingClientRect();
|
|
1013
1752
|
el.dispatchEvent(new CustomEvent('edit:size', {
|
|
1014
1753
|
bubbles: true,
|
|
1015
|
-
detail: { width: r.width, height: r.height, css: { width: el.style.width, height: el.style.height },
|
|
1754
|
+
detail: { width: r.width, height: r.height, css: { width: el.style.width, height: el.style.height }, done }
|
|
1016
1755
|
}));
|
|
1017
1756
|
}
|
|
1018
1757
|
function showOverlay() { if (!overlayEl) { overlayEl = document.createElement('div'); overlayEl.setAttribute('data-edit-overlay', ''); document.body.appendChild(overlayEl); } overlayEl.style.display = 'block'; return overlayEl; }
|
|
@@ -1024,9 +1763,7 @@
|
|
|
1024
1763
|
const rtl = isRTL(el);
|
|
1025
1764
|
return pos.replace(/start/g, rtl ? 'right' : 'left').replace(/end/g, rtl ? 'left' : 'right');
|
|
1026
1765
|
}
|
|
1027
|
-
// Containing-block size for % limits
|
|
1028
|
-
// offsetParent's padding box when absolute, the viewport when fixed. An
|
|
1029
|
-
// auto-height parent has no definite height, so % height limits drop (null).
|
|
1766
|
+
// Containing-block size for % limits (null when the height is not definite).
|
|
1030
1767
|
function blockSize(el, dim) {
|
|
1031
1768
|
const pos = getComputedStyle(el).position;
|
|
1032
1769
|
if (pos === 'fixed') return dim === 'w' ? document.documentElement.clientWidth : document.documentElement.clientHeight;
|
|
@@ -1069,30 +1806,27 @@
|
|
|
1069
1806
|
const axes = cssVar(el, '--edit-size', 'both');
|
|
1070
1807
|
if (axes === 'none') return;
|
|
1071
1808
|
if (el._sizeBound) return; el._sizeBound = true;
|
|
1072
|
-
|
|
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);
|
|
1809
|
+
// Handles need a containing block; this position is the plugin's, never logged or published.
|
|
1810
|
+
const pos = getComputedStyle(el).position;
|
|
1811
|
+
if (!pos || pos === 'static') { el.style.position = 'relative'; el._editPos = true; }
|
|
1812
|
+
const edges = (cssVar(el, '--edit-size-edges', '') || defaultEdges(axes).join(' ')).split(/\s+/).filter(Boolean);
|
|
1078
1813
|
edges.forEach(pos => {
|
|
1079
|
-
const phys = physical(pos, el);
|
|
1814
|
+
const phys = physical(pos, el);
|
|
1080
1815
|
const h = document.createElement('span');
|
|
1081
|
-
h.setAttribute('data-edit-handle', pos);
|
|
1816
|
+
h.setAttribute('data-edit-handle', pos);
|
|
1082
1817
|
h.tabIndex = 0; h.setAttribute('role', 'slider'); h.setAttribute('aria-label', `Resize ${pos}`);
|
|
1083
1818
|
h.addEventListener('pointerdown', (e) => startSize(e, el, phys));
|
|
1084
1819
|
h.addEventListener('keydown', (e) => keyResize(e, el, phys));
|
|
1085
1820
|
el.appendChild(h);
|
|
1086
1821
|
});
|
|
1087
1822
|
}
|
|
1088
|
-
// Keyboard
|
|
1089
|
-
let _keyTimer;
|
|
1823
|
+
// Keyboard: arrows resize (Shift = larger step); a key burst commits once per element.
|
|
1090
1824
|
function keyResize(e, el, pos) {
|
|
1091
1825
|
const map = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
1092
1826
|
if (!map[e.key]) return;
|
|
1093
|
-
const area = el
|
|
1094
|
-
e.preventDefault();
|
|
1095
|
-
if (!_keyTimer) el._preStyle = el
|
|
1827
|
+
const area = regionOf(el); if (!isActive(area)) return;
|
|
1828
|
+
e.preventDefault(); e.stopPropagation();
|
|
1829
|
+
if (!el._keyTimer) el._preStyle = ownStyle(el);
|
|
1096
1830
|
const [kx, ky] = map[e.key], step = e.shiftKey ? 32 : 8, cs = getComputedStyle(el);
|
|
1097
1831
|
const right = pos.includes('right'), left = pos.includes('left'), top = pos.includes('top'), bottom = pos.includes('bottom');
|
|
1098
1832
|
const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
|
|
@@ -1100,50 +1834,98 @@
|
|
|
1100
1834
|
if ((right || left) && kx) { const w = clamp((parseFloat(cs.width) || 0) + kx * step * (right ? 1 : -1), minW, maxW); el.style.width = toUnit(w, wu, el, el.parentElement, 'w') + wu; }
|
|
1101
1835
|
if ((top || bottom) && ky) { const h = clamp((parseFloat(cs.height) || 0) + ky * step * (bottom ? 1 : -1), minH, maxH); el.style.height = toUnit(h, hu, el, el.parentElement, 'h') + hu; }
|
|
1102
1836
|
sizeEvent(el, false);
|
|
1103
|
-
clearTimeout(_keyTimer); _keyTimer = setTimeout(() => { _keyTimer = null; commitStyle(
|
|
1837
|
+
clearTimeout(el._keyTimer); el._keyTimer = setTimeout(() => { el._keyTimer = null; commitStyle(regionOf(el), el); sizeEvent(el, true); }, 350);
|
|
1838
|
+
}
|
|
1839
|
+
// An axis value wins; an explicit axis `none` cancels the base value too.
|
|
1840
|
+
function axisVar(el, base, axis) {
|
|
1841
|
+
const a = rawVar(el, `${base}-${axis}`);
|
|
1842
|
+
if (a) return a === 'none' ? '' : a;
|
|
1843
|
+
const b = rawVar(el, base);
|
|
1844
|
+
return b === 'none' ? '' : b;
|
|
1845
|
+
}
|
|
1846
|
+
function snapStops(el, axis, dim) {
|
|
1847
|
+
return axisVar(el, '--edit-size-snap', axis).split(/\s+/).filter(Boolean).map(s => toPx(s, el, dim)).filter(v => v != null);
|
|
1848
|
+
}
|
|
1849
|
+
function snapTo(v, stops, reach) {
|
|
1850
|
+
if (!stops.length) return v;
|
|
1851
|
+
let best = v, d = Infinity;
|
|
1852
|
+
for (const p of stops) { const dd = Math.abs(v - p); if (dd < d) { d = dd; best = p; } }
|
|
1853
|
+
return reach == null || d <= reach ? best : v;
|
|
1854
|
+
}
|
|
1855
|
+
const lengthVar = (el, base, axis, dim) => { const v = axisVar(el, base, axis); return v ? toPx(v, el, dim) : null; };
|
|
1856
|
+
// Close rides x-show: only an identifier or member path (no calls) can be set false.
|
|
1857
|
+
const ASSIGNABLE = /^\s*[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*|\s*\[\s*(?:'[^'\\]*'|"[^"\\]*"|\d+)\s*\])*\s*$/;
|
|
1858
|
+
const warnedShow = new WeakSet();
|
|
1859
|
+
function closable(el) {
|
|
1860
|
+
const expr = el.getAttribute('x-show');
|
|
1861
|
+
if (expr == null) return null;
|
|
1862
|
+
if (ASSIGNABLE.test(expr)) return expr;
|
|
1863
|
+
if (!warnedShow.has(el)) { warnedShow.add(el); console.warn('[edit] --edit-size-close needs an assignable x-show expression (e.g. x-show="open"); close is off here:', expr); }
|
|
1864
|
+
return null;
|
|
1104
1865
|
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1866
|
+
const showable = (v) => { try { return JSON.stringify(v) !== undefined ? JSON.parse(JSON.stringify(v)) : true; } catch { return true; } };
|
|
1867
|
+
function setShow(el, expr, value) { window.Alpine.evaluate(el, `${expr} = ${JSON.stringify(value)}`); }
|
|
1868
|
+
// Closing is logged with the size change, so one undo reopens it.
|
|
1869
|
+
function closeVia(area, el, expr, join) {
|
|
1870
|
+
let before;
|
|
1871
|
+
try { before = showable(window.Alpine.evaluate(el, expr)); setShow(el, expr, false); } catch (err) { console.warn('[edit] could not close via x-show:', err); return false; }
|
|
1872
|
+
const id = identity(el, area);
|
|
1873
|
+
if (id != null) pushDelta(Object.assign({ kind: 'show', region: key(area), key: id, expr, before, value: false }, join ? { join: true } : {}));
|
|
1874
|
+
return true;
|
|
1108
1875
|
}
|
|
1109
1876
|
function startSize(e, el, pos) {
|
|
1110
|
-
const area = el
|
|
1111
|
-
if (!isActive(area) || e.button !== 0) return;
|
|
1112
|
-
e.preventDefault(); e.stopPropagation();
|
|
1877
|
+
const area = regionOf(el);
|
|
1878
|
+
if (!isActive(area) || (e.pointerType === 'mouse' && e.button !== 0) || e.button > 0) return;
|
|
1879
|
+
e.preventDefault(); e.stopPropagation();
|
|
1880
|
+
const preStyle = el.getAttribute('style'); el._preStyle = ownStyle(el);
|
|
1113
1881
|
const cs = getComputedStyle(el), rect = el.getBoundingClientRect();
|
|
1114
1882
|
const wu = unitOf(el.style.width) || 'px', hu = unitOf(el.style.height) || 'px';
|
|
1115
1883
|
const baseW = parseFloat(cs.width) || rect.width, baseH = parseFloat(cs.height) || rect.height, sx = e.clientX, sy = e.clientY;
|
|
1116
1884
|
const { minW, maxW, minH, maxH } = limits(el, cs);
|
|
1117
1885
|
const right = pos.includes('right'), left = pos.includes('left'), bottom = pos.includes('bottom'), top = pos.includes('top');
|
|
1118
|
-
const
|
|
1119
|
-
const
|
|
1120
|
-
const
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
let lastW = baseW, lastH = baseH;
|
|
1886
|
+
const reachX = lengthVar(el, '--edit-size-snap-distance', 'x', 'w'), reachY = lengthVar(el, '--edit-size-snap-distance', 'y', 'h');
|
|
1887
|
+
const snapW = snapStops(el, 'x', 'w'), snapH = snapStops(el, 'y', 'h');
|
|
1888
|
+
const closeX = lengthVar(el, '--edit-size-close', 'x', 'w'), closeY = lengthVar(el, '--edit-size-close', 'y', 'h');
|
|
1889
|
+
const showExpr = closeX != null || closeY != null ? closable(el) : null;
|
|
1890
|
+
try { e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); } catch { }
|
|
1891
|
+
showOverlay();
|
|
1892
|
+
let rawW = baseW, rawH = baseH;
|
|
1126
1893
|
const move = (ev) => {
|
|
1127
1894
|
const dx = ev.clientX - sx, dy = ev.clientY - sy;
|
|
1128
|
-
if (right || left) {
|
|
1129
|
-
if (top || bottom) {
|
|
1130
|
-
const collapsed = (collapseX != null && lastW < collapseX) || (collapseY != null && lastH < collapseY);
|
|
1131
|
-
el.toggleAttribute('data-edit-collapsed', collapsed);
|
|
1895
|
+
if (right || left) { rawW = baseW + (right ? dx : -dx); el.style.width = toUnit(snapTo(clamp(rawW, minW, maxW), snapW, reachX), wu, el, el.parentElement, 'w') + wu; }
|
|
1896
|
+
if (top || bottom) { rawH = baseH + (bottom ? dy : -dy); el.style.height = toUnit(snapTo(clamp(rawH, minH, maxH), snapH, reachY), hu, el, el.parentElement, 'h') + hu; }
|
|
1132
1897
|
sizeEvent(el, false);
|
|
1133
1898
|
};
|
|
1899
|
+
const end = () => {
|
|
1900
|
+
document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); document.removeEventListener('pointercancel', cancel);
|
|
1901
|
+
hideOverlay();
|
|
1902
|
+
};
|
|
1903
|
+
const restore = () => { if (preStyle == null) el.removeAttribute('style'); else el.setAttribute('style', preStyle); };
|
|
1904
|
+
const cancel = () => { end(); restore(); sizeEvent(el, true); };
|
|
1134
1905
|
const up = () => {
|
|
1135
|
-
|
|
1136
|
-
|
|
1906
|
+
end();
|
|
1907
|
+
const shutX = (right || left) && closeX != null && rawW < closeX, shutY = (top || bottom) && closeY != null && rawH < closeY;
|
|
1908
|
+
if ((shutX || shutY) && showExpr) {
|
|
1909
|
+
// Closed at the minimum it can have; with none, at the size it had, so it reopens sensibly.
|
|
1910
|
+
const atMin = (shutX ? minW : 0) > 0 || (shutY ? minH : 0) > 0;
|
|
1911
|
+
restore();
|
|
1912
|
+
if (atMin) {
|
|
1913
|
+
if (shutX && minW > 0) el.style.width = toUnit(minW, wu, el, el.parentElement, 'w') + wu;
|
|
1914
|
+
if (shutY && minH > 0) el.style.height = toUnit(minH, hu, el, el.parentElement, 'h') + hu;
|
|
1915
|
+
}
|
|
1916
|
+
const sized = commitStyle(area, el); sizeEvent(el, true);
|
|
1917
|
+
closeVia(area, el, showExpr, sized);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
if (shutX || shutY) { restore(); sizeEvent(el, true); return; } // close configured but not possible here: leave it as it was
|
|
1137
1921
|
commitStyle(area, el); sizeEvent(el, true);
|
|
1138
1922
|
};
|
|
1139
|
-
document.addEventListener('pointermove', move); document.addEventListener('pointerup', up);
|
|
1923
|
+
document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); document.addEventListener('pointercancel', cancel);
|
|
1140
1924
|
}
|
|
1141
1925
|
|
|
1142
1926
|
|
|
1143
1927
|
/* ---- Inline text editing ---- */
|
|
1144
|
-
//
|
|
1145
|
-
// text — is one click and type. Deferred a frame so it wins over the click's
|
|
1146
|
-
// own caret placement; a second click still collapses to a caret for fine edits.
|
|
1928
|
+
// Focus selects the leaf's content; deferred a frame to win over the click's caret.
|
|
1147
1929
|
function selectAllIn(el) {
|
|
1148
1930
|
requestAnimationFrame(() => {
|
|
1149
1931
|
if (dragged || grabbed) return; // reordering, not editing
|
|
@@ -1151,13 +1933,113 @@
|
|
|
1151
1933
|
try { const r = document.createRange(); r.selectNodeContents(el); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); } catch { }
|
|
1152
1934
|
});
|
|
1153
1935
|
}
|
|
1154
|
-
//
|
|
1936
|
+
// A link clicked in an active area is content: neither the router nor the browser follows it,
|
|
1937
|
+
// whatever the contenteditable state (a piece at rest is contenteditable=false). A
|
|
1938
|
+
// contenteditable=false island that is not a resting piece keeps its links. Runs on window,
|
|
1939
|
+
// capture, ahead of the router's document listener.
|
|
1940
|
+
function islandLink(link, area) {
|
|
1941
|
+
const off = link.closest('[contenteditable="false"]');
|
|
1942
|
+
return !!off && area.contains(off) && !off.hasAttribute('data-edit-piece-mode');
|
|
1943
|
+
}
|
|
1944
|
+
function onAreaLinkClick(e) {
|
|
1945
|
+
const link = e.target && e.target.closest && e.target.closest('a[href]');
|
|
1946
|
+
const area = link && regionOf(link);
|
|
1947
|
+
if (!area || !isActive(area) || islandLink(link, area)) return;
|
|
1948
|
+
e.preventDefault();
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/* ---- Rich Text engine: every editable leaf is an inline Rich Text field ---- */
|
|
1952
|
+
const richLeaves = new Set();
|
|
1953
|
+
let rtMissing = false;
|
|
1954
|
+
function rtEngine() {
|
|
1955
|
+
const RT = window.ManifestRichText;
|
|
1956
|
+
if (RT && typeof RT.attach === 'function') return RT;
|
|
1957
|
+
if (!rtMissing) { rtMissing = true; console.warn('[edit] Rich Text is not loaded; text leaves fall back to plain contenteditable'); }
|
|
1958
|
+
return null;
|
|
1959
|
+
}
|
|
1960
|
+
// Edit's own listeners are bound before this, so they run ahead of Rich Text's.
|
|
1961
|
+
function editable(el, format) {
|
|
1962
|
+
el._editRtFormat = format;
|
|
1963
|
+
if (el._editRt || el._pieceMode) return; // a focused piece re-attaches when it leaves piece mode
|
|
1964
|
+
const RT = rtEngine();
|
|
1965
|
+
el._editRtPrior = ['role', 'aria-multiline'].map(a => [a, el.getAttribute(a)]);
|
|
1966
|
+
const h = RT && RT.attach(el, { mode: 'inline', breaks: true, all: false, format, onChange: () => { } });
|
|
1967
|
+
if (!h) { el.setAttribute('contenteditable', 'true'); return; }
|
|
1968
|
+
el._editRt = h;
|
|
1969
|
+
el.setAttribute('data-edit-text', '');
|
|
1970
|
+
richLeaves.add(el);
|
|
1971
|
+
}
|
|
1972
|
+
function unedit(el) {
|
|
1973
|
+
const h = el._editRt; if (!h) return;
|
|
1974
|
+
el._editRt = null; richLeaves.delete(el);
|
|
1975
|
+
el.removeAttribute('data-edit-text');
|
|
1976
|
+
h.detach();
|
|
1977
|
+
}
|
|
1978
|
+
// Leaves inside `area`, and any no longer on the page.
|
|
1979
|
+
const uneditIn = (area) => richLeaves.forEach(el => { if (!el.isConnected || (area && area.contains(el))) unedit(el); });
|
|
1980
|
+
// Rich Text's history spans one edit session: it restarts at focus and when Edit's history moves the text.
|
|
1981
|
+
const restartRich = (el) => { const h = el._editRt; if (h) h.value = h.value; };
|
|
1982
|
+
/* Undo/redo in a leaf, one session at a time. While focused, each Rich Text step
|
|
1983
|
+
(a typing burst, a command, a paste) undoes and redoes on its own. Past the
|
|
1984
|
+
session's start, Edit's history takes over; a session with steps still to redo
|
|
1985
|
+
first becomes one Edit step (start → furthest state), logged and undone, so Edit's
|
|
1986
|
+
redo brings it back whole. Ctrl+Y redoes, as in Edit. */
|
|
1987
|
+
function richHistoryKey(e) {
|
|
1988
|
+
const el = this, h = el._editRt, dir = historyDir(e);
|
|
1989
|
+
if (!h || !dir) return;
|
|
1990
|
+
if (h.can(dir)) { e.preventDefault(); e.stopImmediatePropagation(); h[dir](); return; }
|
|
1991
|
+
const region = regionOf(el);
|
|
1992
|
+
if (dir === 'undo' && region && isActive(region) && !hotkeysOff(el)) handOff(el); // only when Edit's history will act
|
|
1993
|
+
onHistoryKey(e);
|
|
1994
|
+
if (e.defaultPrevented) e.stopImmediatePropagation();
|
|
1995
|
+
}
|
|
1996
|
+
function handOff(el) {
|
|
1997
|
+
const h = el._editRt;
|
|
1998
|
+
if (!h || !h.can('redo') || !el._commitText) return;
|
|
1999
|
+
const top = sessionTop(el);
|
|
2000
|
+
if (el._commitText(top)) undo();
|
|
2001
|
+
}
|
|
2002
|
+
// Drops follow the area's rule (none while active), ahead of Rich Text's own drop handling.
|
|
2003
|
+
function noDrop(el) {
|
|
2004
|
+
el.addEventListener('drop', (e) => { const a = regionOf(el); if (a && isActive(a)) { e.preventDefault(); e.stopImmediatePropagation(); } });
|
|
2005
|
+
}
|
|
2006
|
+
// The session's furthest state: the newest entry in the leaf's Rich Text history.
|
|
2007
|
+
function sessionTop(el) {
|
|
2008
|
+
const html = el._editRt.newest();
|
|
2009
|
+
if (!(el._editField && !el._editRich)) return html.trim();
|
|
2010
|
+
const t = document.createElement('template'); t.innerHTML = html; return t.content.textContent;
|
|
2011
|
+
}
|
|
2012
|
+
// Copies and ghosts of a leaf carry none of the engine's attributes or --rich-text-* state.
|
|
2013
|
+
function unrich(src, copy) {
|
|
2014
|
+
const s = [src, ...src.querySelectorAll('*')], c = [copy, ...copy.querySelectorAll('*')];
|
|
2015
|
+
c.forEach(n => { const st = n.getAttribute('style'); if (st && /--rich-text-/.test(st)) { const v = stripRuntime(st); v ? n.setAttribute('style', v) : n.removeAttribute('style'); } });
|
|
2016
|
+
s.forEach((n, i) => {
|
|
2017
|
+
if (!n._editRt || !c[i]) return;
|
|
2018
|
+
[...c[i].attributes].forEach(a => { if (a.name.startsWith('data-rich-text')) c[i].removeAttribute(a.name); });
|
|
2019
|
+
(n._editRtPrior || []).forEach(([a, v]) => v == null ? c[i].removeAttribute(a) : c[i].setAttribute(a, v));
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
// `commit(value?)` logs the leaf's text against its session start (_preEdit).
|
|
2023
|
+
function bindLeaf(el, commit, input) {
|
|
2024
|
+
if (el._textBound) return; el._textBound = true;
|
|
2025
|
+
el._commitText = commit;
|
|
2026
|
+
el.addEventListener('focus', () => { restartRich(el); el._preEdit = leafText(el); selectAllIn(el); });
|
|
2027
|
+
el.addEventListener('keydown', (e) => { if (e.key === 'Escape' && el.isContentEditable) { e.preventDefault(); e.stopImmediatePropagation(); leaveText(el); } });
|
|
2028
|
+
el.addEventListener('keydown', richHistoryKey);
|
|
2029
|
+
noDrop(el);
|
|
2030
|
+
if (input) el.addEventListener('input', input);
|
|
2031
|
+
el.addEventListener('blur', () => commit());
|
|
2032
|
+
}
|
|
2033
|
+
// A rich editor other than this leaf's own owns `el`.
|
|
2034
|
+
const richOwned = (el) => { const o = el.closest('[data-rich-text]'); return !!o && (o !== el || !el._editRt); };
|
|
2035
|
+
|
|
2036
|
+
// Where x-rich-text is declared, it owns the element: this plugin neither makes
|
|
1155
2037
|
// its leaves contenteditable nor competes for the caret. If the rich editor has
|
|
1156
2038
|
// no expression of its own, we capture what it produces as an ordinary text
|
|
1157
2039
|
// delta, so a rich field inside a page-editing region publishes like any other.
|
|
1158
2040
|
function armRichText(area) {
|
|
1159
|
-
area.querySelectorAll('[data-text
|
|
1160
|
-
if (!capOf(el, 'text') ||
|
|
2041
|
+
area.querySelectorAll('[data-rich-text]:not([data-rich-text-bound])').forEach(el => {
|
|
2042
|
+
if (el._editRt || !capOf(el, 'text') || !/^[@~]/.test(el.getAttribute('data-edit-key') || '')) return;
|
|
1161
2043
|
if (el._richBound) return; el._richBound = true;
|
|
1162
2044
|
el.addEventListener('focusin', () => { el._preEdit = el.innerHTML.trim(); });
|
|
1163
2045
|
el.addEventListener('blur', () => {
|
|
@@ -1168,186 +2050,240 @@
|
|
|
1168
2050
|
});
|
|
1169
2051
|
}
|
|
1170
2052
|
|
|
1171
|
-
//
|
|
2053
|
+
// Static: literal leaves only.
|
|
1172
2054
|
function armText(area) {
|
|
1173
2055
|
armRichText(area);
|
|
1174
|
-
//
|
|
1175
|
-
// descendant leaves; the area itself is the editable leaf.
|
|
2056
|
+
// An area that is itself the text leaf.
|
|
1176
2057
|
const nodes = [...area.querySelectorAll('*')];
|
|
1177
|
-
if (!realChildren(area).length) nodes.unshift(area); // size handles are children too
|
|
2058
|
+
if (!realChildren(area).length || wholeLeaf(area)) nodes.unshift(area); // size handles are children too
|
|
1178
2059
|
nodes.forEach(el => {
|
|
1179
|
-
if (realChildren(el).length || !el.textContent.trim() || el.closest('template') || el.hasAttribute('data-edit-handle')) return;
|
|
1180
|
-
if (el.
|
|
1181
|
-
if (
|
|
1182
|
-
el.
|
|
1183
|
-
if (el
|
|
1184
|
-
el.
|
|
1185
|
-
el
|
|
1186
|
-
|
|
2060
|
+
if ((realChildren(el).length && !wholeLeaf(el)) || !el.textContent.trim() || el.closest('template') || el.hasAttribute('data-edit-handle')) return;
|
|
2061
|
+
if (el._manifestComponent && el !== area) return; // a component host: its markup is another file's
|
|
2062
|
+
if (richOwned(el)) return; // the rich editor owns this subtree
|
|
2063
|
+
if (el.closest('[contenteditable="false"]:not([data-edit-piece-mode])')) return; // an authored non-editable island
|
|
2064
|
+
if (!capOf(el, 'text') || isRendered(el) || RAW_EDIT.has(el.tagName)) return;
|
|
2065
|
+
if (!/^[@~]/.test(el.getAttribute('data-edit-key') || '')) return; // only what will be logged and published
|
|
2066
|
+
bindLeaf(el, (v) => commitStaticNode(regionOf(el) || area, el, 'text', v ?? leafHTML(el))); // live region: the element may have moved since arming
|
|
2067
|
+
editable(el, 'html');
|
|
1187
2068
|
});
|
|
1188
2069
|
}
|
|
1189
2070
|
|
|
1190
|
-
/* ----
|
|
1191
|
-
but ONLY when the binding is a SIMPLE field ref of the loop var (p.name) — computed
|
|
1192
|
-
expressions (e.g. '$' + p.price) stay read-only. Edit → data mutation, not HTML.
|
|
1193
|
-
(Scope today: x-for list item fields. Direct $x.a.b bindings outside a list are a
|
|
1194
|
-
natural extension — different addressing {source,path} + key-value write-back.) ---- */
|
|
2071
|
+
/* ---- Data values (.data): x-for fields bound to a plain `item.field` are editable ---- */
|
|
1195
2072
|
function armDataValues(area) {
|
|
1196
|
-
const tpl = area
|
|
2073
|
+
const tpl = listTpl(area); if (!tpl) return;
|
|
1197
2074
|
const lv = (tpl.getAttribute('x-for').match(/^\s*([\w$]+)\s+in\b/) || [])[1]; if (!lv) return;
|
|
1198
2075
|
const source = dataSourceName(area), fieldRe = new RegExp('^\\s*' + lv + '\\.([\\w$]+)\\s*$');
|
|
1199
2076
|
sortableChildren(area).forEach(clone => {
|
|
1200
|
-
|
|
1201
|
-
clone.
|
|
1202
|
-
|
|
2077
|
+
// The record: :data-key names its id; otherwise the row's own x-for scope gives the record (the writer matches records by id)
|
|
2078
|
+
let id = clone.getAttribute('data-key');
|
|
2079
|
+
if (id == null) { let rec = null; try { rec = window.Alpine.$data(clone)[lv]; } catch { } if (rec && rec.id != null) id = String(rec.id); }
|
|
2080
|
+
if (id == null) return;
|
|
2081
|
+
[clone, ...clone.querySelectorAll('*')].forEach(el => {
|
|
1203
2082
|
const rich = el.hasAttribute('x-html');
|
|
2083
|
+
if ((el.children.length && !(rich && plainFormatted(el))) || el.hasAttribute('data-edit-handle')) return;
|
|
1204
2084
|
const expr = el.getAttribute('x-text') || el.getAttribute('x-html'); if (!expr) return;
|
|
1205
2085
|
const m = expr.match(fieldRe); if (!m) return;
|
|
1206
2086
|
const field = m[1];
|
|
1207
|
-
el.
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
2087
|
+
el._editField = field;
|
|
2088
|
+
// x-html fields hold markup; x-text fields are plain.
|
|
2089
|
+
el._editRich = rich;
|
|
2090
|
+
if (!el._dvBound) {
|
|
2091
|
+
el._dvBound = true;
|
|
2092
|
+
const read = () => rich ? leafHTML(el) : el.textContent;
|
|
2093
|
+
el._commitText = (v) => commitDataValue(area, source, id, field, v ?? read(), el);
|
|
2094
|
+
el.addEventListener('focus', () => { restartRich(el); el._preEdit = leafText(el); if (!authoredRich(el)) selectAllIn(el); }); // the rich editor owns its caret
|
|
2095
|
+
el.addEventListener('focusin', () => { el._preEdit = leafText(el); });
|
|
2096
|
+
el.addEventListener('keydown', richHistoryKey);
|
|
2097
|
+
noDrop(el);
|
|
2098
|
+
el.addEventListener('blur', () => el._commitText(), true);
|
|
2099
|
+
}
|
|
2100
|
+
if (!authoredRich(el)) editable(el, rich ? 'html' : 'plain');
|
|
1221
2101
|
});
|
|
1222
2102
|
});
|
|
1223
2103
|
}
|
|
1224
2104
|
|
|
1225
|
-
/* ----
|
|
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.
|
|
2105
|
+
/* ---- Components: nodes addressed by structural path; scope via $edit.scope ---- */
|
|
2106
|
+
const componentName = (area) => { const r = cmpRoot(area); return (r?.getAttribute('data-component') || '').replace(/-\d+$/, ''); };
|
|
1229
2107
|
function pathOf(node, root) { const idx = []; let n = node; while (n && n !== root && n.parentElement) { idx.unshift(realChildren(n.parentElement).indexOf(n)); n = n.parentElement; } return idx.join('.'); }
|
|
1230
2108
|
function nodeByPath(root, path) { let el = root; for (const i of path.split('.').map(Number)) { el = realChildren(el)[i]; if (!el) return null; } return el; }
|
|
1231
|
-
//
|
|
1232
|
-
// skip the source element (don't fight the caret) and skip any instance that has its
|
|
1233
|
-
// OWN committed override for this node/prop (instance overrides always win).
|
|
2109
|
+
// 'main' scope: mirror live to other instances without their own override.
|
|
1234
2110
|
function liveMainPropagate(area, sourceEl, prop, value) {
|
|
1235
2111
|
if ((area._editScope || 'instance') !== 'main') return;
|
|
1236
|
-
const comp = componentName(area), path = sourceEl.
|
|
1237
|
-
|
|
1238
|
-
if (
|
|
1239
|
-
const root = a
|
|
2112
|
+
const comp = componentName(area), path = sourceEl._editPath, { inst } = componentState();
|
|
2113
|
+
areas().forEach(a => {
|
|
2114
|
+
if (classify(a) !== 'component' || componentName(a) !== comp) return;
|
|
2115
|
+
const root = cmpRoot(a); if (!root) return;
|
|
1240
2116
|
const el = path === '' ? root : nodeByPath(root, path); if (!el || el === sourceEl) return;
|
|
1241
2117
|
const iv = (inst[key(a)] && inst[key(a)][path]) || {}; if (iv[prop] !== undefined) return; // override wins
|
|
1242
|
-
if (prop === 'class')
|
|
2118
|
+
if (prop === 'class') writeClass(el, value);
|
|
1243
2119
|
else if (el.innerHTML !== value) el.innerHTML = value;
|
|
1244
2120
|
});
|
|
1245
2121
|
}
|
|
1246
2122
|
function armComponent(area) {
|
|
1247
|
-
const root = area
|
|
1248
|
-
if (!area._editScope)
|
|
2123
|
+
const root = cmpRoot(area); if (!root) return;
|
|
2124
|
+
if (!area._editScope) area._editScope = 'instance';
|
|
1249
2125
|
area._baseText = area._baseText || {}; area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {};
|
|
1250
|
-
|
|
1251
|
-
const markClass = (el) => { const p = pathOf(el, root); el.setAttribute('data-edit-path', p); if (!(p in area._baseClass)) area._baseClass[p] = el.getAttribute('class') || ''; if (!(p in area._baseStyle)) area._baseStyle[p] = el.getAttribute('style') || ''; };
|
|
2126
|
+
const markClass = (el) => { const p = pathOf(el, root); el._editPath = p; if (!(p in area._baseClass)) area._baseClass[p] = el.getAttribute('class') || ''; if (!(p in area._baseStyle)) area._baseStyle[p] = el.getAttribute('style') || ''; };
|
|
1252
2127
|
markClass(root);
|
|
1253
2128
|
root.querySelectorAll('*').forEach(el => { if (!el.hasAttribute('data-edit-handle')) markClass(el); });
|
|
1254
|
-
// Text leaves also become contenteditable. Capture innerHTML (not textContent) so
|
|
1255
|
-
// nested inline elements like <i>/<br> the user adds are preserved.
|
|
1256
2129
|
armRichText(area);
|
|
1257
|
-
root
|
|
1258
|
-
if (el.children.length || !el.textContent.trim() || !capOf(el, 'text')) return;
|
|
1259
|
-
if (el
|
|
1260
|
-
const p = el.
|
|
2130
|
+
componentNodes(root).forEach(el => {
|
|
2131
|
+
if ((el.children.length && !wholeLeaf(el)) || !el.textContent.trim() || !capOf(el, 'text')) return;
|
|
2132
|
+
if (richOwned(el)) return; // the rich editor owns this subtree
|
|
2133
|
+
const p = el._editPath;
|
|
1261
2134
|
if (!(p in area._baseText)) area._baseText[p] = el.innerHTML.trim();
|
|
1262
|
-
el
|
|
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()));
|
|
2135
|
+
bindLeaf(el, (v) => commitComponentNode(area, el, 'text', v ?? leafHTML(el)), () => liveMainPropagate(area, el, 'text', el.innerHTML));
|
|
2136
|
+
editable(el, 'html');
|
|
1267
2137
|
});
|
|
1268
2138
|
}
|
|
1269
|
-
|
|
1270
|
-
function
|
|
1271
|
-
|
|
1272
|
-
const
|
|
1273
|
-
if (!
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
2139
|
+
// Escape leaves text with the piece focused, so Alt+arrows move it.
|
|
2140
|
+
function leaveText(el) {
|
|
2141
|
+
el.blur();
|
|
2142
|
+
const piece = [el, ...ancestorsIn(el)].find(n => n.hasAttribute && n.hasAttribute('data-edit-piece'));
|
|
2143
|
+
if (!piece) return;
|
|
2144
|
+
if (piece.isContentEditable) {
|
|
2145
|
+
piece._pieceMode = true;
|
|
2146
|
+
piece.setAttribute('data-edit-piece-mode', '');
|
|
2147
|
+
unedit(piece); // a focused piece is not a text field
|
|
2148
|
+
piece.setAttribute('contenteditable', 'false');
|
|
2149
|
+
piece.addEventListener('focusout', () => exitPieceMode(piece), { once: true });
|
|
1278
2150
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
2151
|
+
try { piece.focus(); } catch { }
|
|
2152
|
+
}
|
|
2153
|
+
const ancestorsIn = (el) => { const out = [], area = regionOf(el); for (let n = el.parentElement; n && n !== area; n = n.parentElement) out.push(n); return out; };
|
|
2154
|
+
function exitPieceMode(el, edit) {
|
|
2155
|
+
if (!el._pieceMode) return;
|
|
2156
|
+
el._pieceMode = false;
|
|
2157
|
+
el.removeAttribute('data-edit-piece-mode');
|
|
2158
|
+
if (el.getAttribute('contenteditable') === 'false') { el.removeAttribute('contenteditable'); editable(el, el._editRtFormat || 'html'); }
|
|
2159
|
+
if (edit) { try { el.focus(); } catch { } selectAllIn(el); }
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
/* ---- Attributes and classes (API only; any area) ----
|
|
2164
|
+
Each change is one logged delta. With a :class binding, only the static
|
|
2165
|
+
class attribute is edited; the binding keeps merging its own classes. */
|
|
2166
|
+
const bindingOf = (el, name) => el.getAttribute(':' + name) ?? el.getAttribute('x-bind:' + name);
|
|
2167
|
+
function boundClasses(el) {
|
|
2168
|
+
const expr = bindingOf(el, 'class'); if (expr == null) return [];
|
|
2169
|
+
let v; try { v = window.Alpine.evaluate(el, expr); } catch { return []; }
|
|
2170
|
+
const out = [];
|
|
2171
|
+
const add = (x) => String(x).split(/\s+/).filter(Boolean).forEach(c => out.push(c));
|
|
2172
|
+
if (typeof v === 'string') add(v);
|
|
2173
|
+
else if (Array.isArray(v)) v.flat(Infinity).filter(Boolean).forEach(add);
|
|
2174
|
+
else if (v && typeof v === 'object') Object.entries(v).forEach(([k, on]) => { if (on) add(k); });
|
|
2175
|
+
return out;
|
|
2176
|
+
}
|
|
2177
|
+
const splitClasses = (s) => String(s || '').split(/\s+/).filter(Boolean);
|
|
2178
|
+
function readClass(el) {
|
|
2179
|
+
const bound = new Set(boundClasses(el));
|
|
2180
|
+
return splitClasses(el.getAttribute('class')).filter(c => !bound.has(c)).join(' ');
|
|
2181
|
+
}
|
|
2182
|
+
function writeClass(el, value) {
|
|
2183
|
+
const bound = boundClasses(el);
|
|
2184
|
+
const next = [...new Set([...splitClasses(value), ...bound])].join(' ');
|
|
2185
|
+
if (!next) { if (el.hasAttribute('class')) el.removeAttribute('class'); return; }
|
|
2186
|
+
if (el.getAttribute('class') !== next) el.setAttribute('class', next);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
function attrTarget(el) {
|
|
2190
|
+
if (el == null) el = target;
|
|
2191
|
+
if (!el || el.nodeType !== 1) return null;
|
|
2192
|
+
const area = regionOf(el);
|
|
2193
|
+
if (!area || !isActive(area)) return null;
|
|
2194
|
+
const kind = classify(area);
|
|
2195
|
+
// A component root also carries its placeholder's class and attributes
|
|
2196
|
+
if (kind === 'static' && el._manifestComponent) return null;
|
|
2197
|
+
return { el, area, kind };
|
|
2198
|
+
}
|
|
2199
|
+
function setAttr(name, value, el) {
|
|
2200
|
+
const t = attrTarget(el); if (!t) return false;
|
|
2201
|
+
name = String(name || '').trim().toLowerCase();
|
|
2202
|
+
const v = value === true ? '' : (value === false || value == null) ? null : String(value);
|
|
2203
|
+
const refused = attrRefusal(name, v, t.el.tagName);
|
|
2204
|
+
if (refused) { console.warn(`[edit] attribute refused (${refused}):`, name); return false; }
|
|
2205
|
+
if (lockedFor(t.el, 'attrs')) return false;
|
|
2206
|
+
const host = t.kind !== 'data' && leafHost(t.el);
|
|
2207
|
+
if (host) return setInLeaf(t, host, name, v);
|
|
2208
|
+
if (name === 'class') return setClass(t, v || '');
|
|
2209
|
+
if (t.kind === 'data') { console.warn('[edit] list rows are rendered from data; attributes on them are not editable'); return false; }
|
|
2210
|
+
if (t.kind === 'component') { console.warn('[edit] component nodes take class changes only'); return false; }
|
|
2211
|
+
if (!/^[@~]/.test(t.el.getAttribute('data-edit-key') || '')) return false;
|
|
2212
|
+
if (bindingOf(t.el, name) != null) console.warn(`[edit] ${name} is bound on this element; the binding will override the edited value`);
|
|
2213
|
+
const before = t.el.getAttribute(name);
|
|
2214
|
+
if (before === v) return false;
|
|
2215
|
+
if (v === null) t.el.removeAttribute(name); else t.el.setAttribute(name, v);
|
|
2216
|
+
return commitStaticNode(t.area, t.el, name, v, { before, coalesce: true });
|
|
2217
|
+
}
|
|
2218
|
+
// Inside a Rich Text leaf an attribute is part of the leaf's text: one text delta, kept only if the sanitizer keeps it.
|
|
2219
|
+
function setInLeaf(t, host, name, v) {
|
|
2220
|
+
if (lockedFor(host, 'text') || t.el.getAttribute(name) === v) return false;
|
|
2221
|
+
const focused = document.activeElement === host;
|
|
2222
|
+
const before = focused && host._preEdit !== undefined ? host._preEdit : leafText(host), was = t.el.getAttribute(name), cur = leafText(host);
|
|
2223
|
+
const put = (x) => x === null ? t.el.removeAttribute(name) : t.el.setAttribute(name, x);
|
|
2224
|
+
put(v);
|
|
2225
|
+
if (leafText(host) === cur) { put(was); console.warn(`[edit] ${name} is not kept inside text`); return false; }
|
|
2226
|
+
const value = leafHTML(host);
|
|
2227
|
+
const done = t.kind === 'component' ? commitComponentNode(t.area, host, 'text', value, before) : commitStaticNode(regionOf(host), host, 'text', value, { before });
|
|
2228
|
+
if (focused) { restartRich(host); host._preEdit = leafText(host); }
|
|
2229
|
+
return done;
|
|
2230
|
+
}
|
|
2231
|
+
function setClass(t, value) {
|
|
2232
|
+
if (t.kind === 'data') { console.warn('[edit] list rows are rendered from data; attributes on them are not editable'); return false; }
|
|
2233
|
+
if (leafHost(t.el)) { console.warn('[edit] class is not kept inside text'); return false; }
|
|
2234
|
+
const before = readClass(t.el);
|
|
2235
|
+
value = splitClasses(value).join(' ');
|
|
2236
|
+
if (before === value) return false;
|
|
2237
|
+
writeClass(t.el, value);
|
|
2238
|
+
if (t.kind === 'component') {
|
|
2239
|
+
if (t.el._editPath == null) return false;
|
|
2240
|
+
liveMainPropagate(t.area, t.el, 'class', t.el.getAttribute('class') || '');
|
|
2241
|
+
return commitComponentNode(t.area, t.el, 'class', value, before);
|
|
1293
2242
|
}
|
|
1294
|
-
|
|
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. */
|
|
2243
|
+
if (!/^[@~]/.test(t.el.getAttribute('data-edit-key') || '')) return false;
|
|
2244
|
+
return commitStaticNode(t.area, t.el, 'class', value, { before, coalesce: true });
|
|
2245
|
+
}
|
|
2246
|
+
function editClasses(el, fn) {
|
|
2247
|
+
const t = attrTarget(el); if (!t) return false;
|
|
2248
|
+
if (lockedFor(t.el, 'attrs')) return false;
|
|
2249
|
+
const list = splitClasses(readClass(t.el));
|
|
2250
|
+
const next = fn(list);
|
|
2251
|
+
return next ? setClass(t, next.join(' ')) : false;
|
|
2252
|
+
}
|
|
2253
|
+
const classesApi = {
|
|
2254
|
+
add(cls, el) { const add = splitClasses(cls); return editClasses(el, l => [...l, ...add.filter(c => !l.includes(c))]); },
|
|
2255
|
+
remove(cls, el) { const rm = new Set(splitClasses(cls)); return editClasses(el, l => l.filter(c => !rm.has(c))); },
|
|
2256
|
+
toggle(cls, el, force) {
|
|
2257
|
+
const t = attrTarget(el); if (!t) return false;
|
|
2258
|
+
const names = splitClasses(cls), on = force ?? !names.every(c => t.el.classList.contains(c));
|
|
2259
|
+
return on ? classesApi.add(cls, t.el) : classesApi.remove(cls, t.el);
|
|
2260
|
+
},
|
|
2261
|
+
set(value, el) { const t = attrTarget(el); return !!t && !lockedFor(t.el, 'attrs') && setClass(t, value || ''); },
|
|
2262
|
+
has(cls, el) { void (estore && estore.version); const t = el == null ? target : el; return !!t && t.nodeType === 1 && splitClasses(cls).every(c => t.classList.contains(c)); },
|
|
2263
|
+
list(el) { void (estore && estore.version); const t = el == null ? target : el; return t && t.nodeType === 1 ? splitClasses(readClass(t)) : []; }
|
|
2264
|
+
};
|
|
2265
|
+
|
|
2266
|
+
/* ---- Component scope and revert ---- */
|
|
2267
|
+
const componentAreaOf = (el) => { const a = el && regionOf(el); return a && classify(a) === 'component' ? a : null; };
|
|
2268
|
+
function scopeOf(el) { const a = componentAreaOf(el == null ? target : el); return a ? a._editScope || 'instance' : null; }
|
|
2269
|
+
function setScope(value, el) {
|
|
2270
|
+
const a = componentAreaOf(el == null ? target : el);
|
|
2271
|
+
if (!a || (value !== 'instance' && value !== 'main')) return false;
|
|
2272
|
+
a._editScope = value; bump();
|
|
2273
|
+
return true;
|
|
2274
|
+
}
|
|
2275
|
+
function revert(el) {
|
|
2276
|
+
if (el == null) el = target;
|
|
2277
|
+
const a = componentAreaOf(el); if (!a) return false;
|
|
2278
|
+
if (el === a) revertAll(a);
|
|
2279
|
+
else if (el._editPath != null) revertNode(a, el);
|
|
2280
|
+
else return false;
|
|
2281
|
+
return true;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
/* ---- Theme: CSS custom properties (x-edit.cssvar="--var" or "scope:--var") ----
|
|
2286
|
+
Global on :root, or sandboxed to an x-edit.theme scope's subtree. */
|
|
1351
2287
|
const cssvarControls = new Set();
|
|
1352
2288
|
const themeBaseline = {}; // "scope|var" → original inline value ('' if none) — for revert-to-baseline
|
|
1353
2289
|
const themeTouched = new Set(); // every scope|var ever applied
|
|
@@ -1355,10 +2291,11 @@
|
|
|
1355
2291
|
const themeTargetEl = (scope) => (scope && themeScopes[scope]) || document.documentElement;
|
|
1356
2292
|
const cvValue = (el) => el.value + (el.dataset.unit || ''); // data-unit lets a range/number drive a unit'd var
|
|
1357
2293
|
|
|
1358
|
-
function registerCssVar(el, expression) {
|
|
2294
|
+
function registerCssVar(el, expression, modifiers) {
|
|
1359
2295
|
const spec = (expression || '').trim();
|
|
1360
|
-
const m = spec.match(/^([\w-]+)\s*:\s*(--[\w-]+)$/);
|
|
2296
|
+
const m = spec.match(/^([\w-]+)\s*:\s*(--[\w-]+)$/);
|
|
1361
2297
|
el._cssvar = m ? { scope: m[1], var: m[2] } : { scope: null, var: spec };
|
|
2298
|
+
el._cssvar.persist = !!(modifiers && modifiers.includes('persist'));
|
|
1362
2299
|
cssvarControls.add(el);
|
|
1363
2300
|
}
|
|
1364
2301
|
function syncCssVarInput(el) {
|
|
@@ -1375,7 +2312,7 @@
|
|
|
1375
2312
|
syncCssVarInput(el);
|
|
1376
2313
|
if (el._cvBound) return; el._cvBound = true;
|
|
1377
2314
|
el.addEventListener('input', () => themeTargetEl(cv.scope).style.setProperty(cv.var, cvValue(el))); // live, no log
|
|
1378
|
-
el.addEventListener('change', () => commitTheme(cv.scope, cv.var, cvValue(el)));
|
|
2315
|
+
el.addEventListener('change', () => commitTheme(cv.scope, cv.var, cvValue(el), cv.persist || (s => !!s && !!(s._editPersist || (s._edit && s._edit.persist)))(cv.scope && themeScopes[cv.scope])));
|
|
1379
2316
|
}
|
|
1380
2317
|
function armThemeControls() { cssvarControls.forEach(armCssVar); }
|
|
1381
2318
|
|
|
@@ -1384,8 +2321,7 @@
|
|
|
1384
2321
|
for (let i = 0; i < cursor; i++) { const d = log[i]; if (d.kind === 'theme') s[tkey(d.scope, d.var)] = d; }
|
|
1385
2322
|
return s;
|
|
1386
2323
|
}
|
|
1387
|
-
// Re-derive every touched var from the log
|
|
1388
|
-
// baseline (setProperty to restore an authored inline value; removeProperty if none).
|
|
2324
|
+
// Re-derive every touched var from the log, else its captured baseline.
|
|
1389
2325
|
function applyThemeState() {
|
|
1390
2326
|
const st = themeState();
|
|
1391
2327
|
new Set([...themeTouched, ...Object.keys(st)]).forEach(kk => {
|
|
@@ -1396,14 +2332,58 @@
|
|
|
1396
2332
|
});
|
|
1397
2333
|
cssvarControls.forEach(syncCssVarInput);
|
|
1398
2334
|
}
|
|
1399
|
-
function
|
|
2335
|
+
function rebaseTheme() {
|
|
2336
|
+
Object.keys(themeBaseline).forEach(kk => { const sep = kk.indexOf('|'); themeBaseline[kk] = themeTargetEl(kk.slice(0, sep) || null).style.getPropertyValue(kk.slice(sep + 1)); });
|
|
2337
|
+
themeTouched.clear();
|
|
2338
|
+
}
|
|
2339
|
+
// A custom-property value must stay inside its declaration (same tokenizer as the
|
|
2340
|
+
// source writer): strings and url(...) are opaque; script schemes in url() and
|
|
2341
|
+
// expression() are refused anywhere; data: urls only as images.
|
|
2342
|
+
function themeValueRefusal(v) {
|
|
2343
|
+
const s = String(v == null ? '' : v);
|
|
2344
|
+
if (!s.trim()) return 'empty';
|
|
2345
|
+
if (/expression\s*\(/i.test(s)) return 'expression()';
|
|
2346
|
+
const stack = [];
|
|
2347
|
+
for (let i = 0; i < s.length; i++) {
|
|
2348
|
+
const ch = s[i];
|
|
2349
|
+
if (ch === '"' || ch === "'") {
|
|
2350
|
+
let j = i + 1;
|
|
2351
|
+
for (; j < s.length && s[j] !== ch; j++) { if (s[j] === '\\') j++; else if (s[j] === '\n') return 'unbalanced quotes'; }
|
|
2352
|
+
if (j >= s.length) return 'unbalanced quotes';
|
|
2353
|
+
i = j; continue;
|
|
2354
|
+
}
|
|
2355
|
+
if (/^url\(/i.test(s.slice(i, i + 4)) && !/[\w-]/.test(s[i - 1] || '')) {
|
|
2356
|
+
let j = i + 4, q = null, body = '';
|
|
2357
|
+
for (; j < s.length; j++) {
|
|
2358
|
+
const c = s[j];
|
|
2359
|
+
if (q) { if (c === '\\') { body += s[++j] || ''; continue; } if (c === q) { q = null; continue; } body += c; continue; }
|
|
2360
|
+
if (c === '"' || c === "'") { q = c; continue; }
|
|
2361
|
+
if (c === ')') break;
|
|
2362
|
+
body += c;
|
|
2363
|
+
}
|
|
2364
|
+
if (j >= s.length) return 'unterminated url()';
|
|
2365
|
+
const u = body.replace(/[\u0000-\u0020\u007f]/g, '').toLowerCase();
|
|
2366
|
+
if (/^(javascript|vbscript):/.test(u)) return 'script URL';
|
|
2367
|
+
if (u.startsWith('data:') && !u.startsWith('data:image/')) return 'data URL';
|
|
2368
|
+
i = j; continue;
|
|
2369
|
+
}
|
|
2370
|
+
if (ch === ';' || ch === '{' || ch === '}') return `contains ${ch}`;
|
|
2371
|
+
if (s.startsWith('/*', i) || s.startsWith('<!--', i) || s.startsWith('</', i)) return 'contains a comment or markup';
|
|
2372
|
+
if (ch === '(' || ch === '[') stack.push(ch);
|
|
2373
|
+
else if (ch === ')' || ch === ']') { if (stack.pop() !== (ch === ')' ? '(' : '[')) return 'unbalanced brackets'; }
|
|
2374
|
+
}
|
|
2375
|
+
return stack.length ? 'unbalanced brackets' : null;
|
|
2376
|
+
}
|
|
2377
|
+
function commitTheme(scope, v, value, persist) {
|
|
2378
|
+
if (!/^--[\w-]+$/.test(v || '')) { console.warn('[edit] x-edit.cssvar needs a custom property name (--name):', v); return; }
|
|
2379
|
+
const why = themeValueRefusal(value);
|
|
2380
|
+
if (why) { console.warn(`[edit] theme value refused (${why}):`, value); return; }
|
|
1400
2381
|
const kk = tkey(scope, v); themeTouched.add(kk);
|
|
1401
2382
|
themeTargetEl(scope).style.setProperty(v, value);
|
|
1402
2383
|
const cur = themeState()[kk]; if (cur && cur.value === value) return; // dedupe
|
|
1403
|
-
|
|
2384
|
+
pushDelta({ kind: 'theme', scope: scope || null, var: v, value }, !!persist);
|
|
1404
2385
|
}
|
|
1405
|
-
//
|
|
1406
|
-
// scope element); global vars carry no file → the server falls back to the theme file.
|
|
2386
|
+
// Each var → its scope's data-edit-theme-file, else the server's theme file.
|
|
1407
2387
|
function themePatches() {
|
|
1408
2388
|
return Object.values(themeState()).map(d => {
|
|
1409
2389
|
const el = d.scope ? themeScopes[d.scope] : null;
|
|
@@ -1412,173 +2392,220 @@
|
|
|
1412
2392
|
}
|
|
1413
2393
|
|
|
1414
2394
|
|
|
1415
|
-
/* ---- Arm / disarm
|
|
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.
|
|
2395
|
+
/* ---- Arm / disarm ---- */
|
|
2396
|
+
// Identity is assigned once, on the first mark, from what the source holds: the
|
|
2397
|
+
// origin area plus a content key (ordinal per repeat), and the element's path.
|
|
2398
|
+
// Later arrivals that are not copies get a non-source id and never publish.
|
|
2399
|
+
const originAt = new Map();
|
|
1426
2400
|
function markStatic(area) {
|
|
2401
|
+
const region = key(area), first = !area._marked;
|
|
2402
|
+
area._marked = true;
|
|
1427
2403
|
area._baseClass = area._baseClass || {}; area._baseStyle = area._baseStyle || {}; area._baseText = area._baseText || {};
|
|
1428
2404
|
const seen = Object.create(null);
|
|
1429
|
-
const markEl = (el) => {
|
|
2405
|
+
const markEl = (el, path) => {
|
|
1430
2406
|
let k = el.getAttribute('data-edit-key');
|
|
1431
|
-
if (k) {
|
|
1432
|
-
|
|
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;
|
|
2407
|
+
if (!k) {
|
|
2408
|
+
if (first) {
|
|
2409
|
+
const base = staticKey(el);
|
|
2410
|
+
const n = seen[base] = (seen[base] || 0) + 1;
|
|
2411
|
+
k = canonKey(region, n > 1 ? base + '#' + n : base);
|
|
2412
|
+
originAt.set(k, path);
|
|
2413
|
+
} else k = '!' + mint();
|
|
1442
2414
|
el.setAttribute('data-edit-key', k);
|
|
1443
2415
|
}
|
|
1444
|
-
|
|
1445
|
-
if (!(k in area.
|
|
1446
|
-
if (!(k in area._baseStyle)) area._baseStyle[k] = el.getAttribute('style') || '';
|
|
2416
|
+
if (!(k in area._baseClass)) area._baseClass[k] = readClass(el);
|
|
2417
|
+
if (!(k in area._baseStyle)) area._baseStyle[k] = ownStyle(el);
|
|
1447
2418
|
if (!(k in area._baseText) && !el.children.length) area._baseText[k] = el.innerHTML.trim();
|
|
1448
2419
|
};
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
//
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
2420
|
+
// Inside a Rich Text leaf: counted, as the source writer counts them, never keyed.
|
|
2421
|
+
const count = (el) => { for (const c of el.children) { const b = staticKey(c); seen[b] = (seen[b] || 0) + 1; count(c); } };
|
|
2422
|
+
// The writer never descends into x-rich-text* hosts (fields, menus, controls) or rendered hosts.
|
|
2423
|
+
const opaque = (el) => authoredRich(el) || isRendered(el) || [...el.attributes].some(a => a.name.startsWith('x-rich-text'));
|
|
2424
|
+
const walk = (el, path) => {
|
|
2425
|
+
markEl(el, path);
|
|
2426
|
+
if (el !== area && opaque(el)) return;
|
|
2427
|
+
if (el._editRt || ((first || el._editRtFormat) && wholeLeaf(el) && capOf(el, 'text') && !isRendered(el))) { if (first) count(el); return; }
|
|
2428
|
+
let i = 0;
|
|
2429
|
+
for (const c of el.children) {
|
|
2430
|
+
if (pluginNode(c) || c.closest('[data-edit-ghost]') || isGenerated(c)) continue;
|
|
2431
|
+
const p = path === '' ? String(i) : path + '.' + i;
|
|
2432
|
+
// Another component's markup is one opaque element here, its <x-name> host
|
|
2433
|
+
// (data-edit-host; its first root stands for it, the rest follow it)
|
|
2434
|
+
if (c._manifestComponent) {
|
|
2435
|
+
if (extraRoot(c)) continue;
|
|
2436
|
+
if (!c.hasAttribute('data-edit-host')) {
|
|
2437
|
+
// Marked while still a placeholder: the components plugin carried its key over
|
|
2438
|
+
const carried = c.getAttribute('data-edit-key'), own = canonKey(region, 'X-' + c._manifestComponent.toUpperCase() + ':');
|
|
2439
|
+
let k = carried && (carried === own || carried.startsWith(own + '#')) ? carried : '!' + mint();
|
|
2440
|
+
if (first && k[0] === '!') { const b = 'X-' + c._manifestComponent.toUpperCase() + ':', n = seen[b] = (seen[b] || 0) + 1; k = canonKey(region, n > 1 ? b + '#' + n : b); originAt.set(k, p); }
|
|
2441
|
+
c.setAttribute('data-edit-host', k);
|
|
2442
|
+
}
|
|
2443
|
+
i++; continue;
|
|
2444
|
+
}
|
|
2445
|
+
walk(c, p);
|
|
2446
|
+
i++;
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
walk(area, '');
|
|
1459
2450
|
}
|
|
2451
|
+
const warnedRendered = new Set(), renderedRegions = new Set();
|
|
1460
2452
|
function armArea(area) {
|
|
2453
|
+
if (area._edit.rendered) renderedRegions.add(key(area));
|
|
2454
|
+
if (area._edit.rendered && !warnedRendered.has(key(area))) { warnedRendered.add(key(area)); console.warn(`[edit] x-edit="${key(area)}" is inside content ${area._edit.rendered} renders: it edits on the page (and reaches onPublish), but mnfst-run cannot write it to a source file`); }
|
|
2455
|
+
uneditIn(null);
|
|
1461
2456
|
const kind = classify(area);
|
|
1462
|
-
area.setAttribute('data-edit-armed', '');
|
|
1463
|
-
// Chrome is opt-in: a sortable list should look like a list, not like a page
|
|
1464
|
-
// being edited. The attributes are all CSS asks for — what is shown, and
|
|
1465
|
-
// whether it is shown at all, is decided in the stylesheet.
|
|
1466
|
-
area.toggleAttribute('data-edit-authoring', !!area._edit.authoring);
|
|
1467
|
-
// .authoring regions get the UI scope by default; anything else opts in
|
|
1468
|
-
// by putting data-edit-ui on the region or an ancestor. Only remove what
|
|
1469
|
-
// the plugin itself added, never an author's attribute.
|
|
1470
|
-
if (area._edit.authoring && !area.hasAttribute('data-edit-ui')) { area.setAttribute('data-edit-ui', ''); area._uiByPlugin = true; }
|
|
1471
|
-
area.setAttribute('data-edit-label', kind === 'component' ? `${key(area)} · component · ${area._editScope || 'instance'}` : `${key(area)} · ${kind}`);
|
|
1472
2457
|
if (kind === 'static') markStatic(area);
|
|
1473
|
-
|
|
2458
|
+
markPieces(area);
|
|
1474
2459
|
[area, ...area.querySelectorAll('[data-edit-area]')].forEach(c => { if (ownsCap(c, 'size')) armSize(c); });
|
|
1475
|
-
if (kind === 'component') armComponent(area); else armText(area);
|
|
1476
|
-
if (
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
2460
|
+
if (kind === 'component') armComponent(area); else if (kind === 'static') armText(area);
|
|
2461
|
+
if (kind === 'data' && capOf(area, 'data')) armDataValues(area);
|
|
2462
|
+
if (!area._editBound) {
|
|
2463
|
+
area._editBound = true;
|
|
2464
|
+
area.addEventListener('pointerdown', onAreaPointerDown);
|
|
2465
|
+
area.addEventListener('keydown', (e) => { if (isActive(area)) onPieceKeydown(e, area); });
|
|
2466
|
+
area.addEventListener('pointerover', (e) => { if (isActive(area)) gripOver(e, area); });
|
|
2467
|
+
area.addEventListener('pointerleave', (e) => gripLeave(e.relatedTarget));
|
|
2468
|
+
area.addEventListener('focusin', (e) => { if (isActive(area)) gripFocus(e, area); });
|
|
2469
|
+
area.addEventListener('focusout', (e) => gripBlur(e.relatedTarget));
|
|
2470
|
+
// Native HTML5 drag would drop selected text into neighbouring leaves.
|
|
1482
2471
|
area.addEventListener('dragstart', (e) => { if (isActive(area)) e.preventDefault(); });
|
|
1483
|
-
area.addEventListener('drop', (e) => { if (isActive(area)) e.preventDefault(); });
|
|
2472
|
+
area.addEventListener('drop', (e) => { if (isActive(area)) e.preventDefault(); });
|
|
1484
2473
|
}
|
|
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();
|
|
2474
|
+
if (!area._editObserver) {
|
|
2475
|
+
area._editObserver = new MutationObserver((recs) => {
|
|
2476
|
+
if (dragged || area._rearmQueued || recs.every(r => r.target.isContentEditable)) return;
|
|
2477
|
+
area._rearmQueued = true;
|
|
2478
|
+
queueMicrotask(() => { area._rearmQueued = false; if (area._armed && isActive(area) && !dragged) armArea(area); });
|
|
1496
2479
|
});
|
|
2480
|
+
area._editObserver.observe(area, { childList: true, subtree: true });
|
|
1497
2481
|
}
|
|
1498
|
-
if (kind === 'data' && capOf(area, 'data')) armDataValues(area);
|
|
1499
2482
|
}
|
|
1500
2483
|
function disarmArea(area) {
|
|
2484
|
+
if (gripFor && area.contains(gripFor)) hideGrip();
|
|
2485
|
+
[area, ...area.querySelectorAll('[data-edit-area]')].forEach(e => { if (e._editPos) { e.style.removeProperty('position'); e._editPos = false; } });
|
|
1501
2486
|
area.querySelectorAll('[data-edit-handle]').forEach(h => h.remove());
|
|
1502
|
-
area
|
|
1503
|
-
area.querySelectorAll('[
|
|
1504
|
-
|
|
1505
|
-
area.
|
|
1506
|
-
if (
|
|
2487
|
+
uneditIn(area);
|
|
2488
|
+
[area, ...area.querySelectorAll('[contenteditable]')].forEach(e => { if (e.hasAttribute('contenteditable')) e.removeAttribute('contenteditable'); e._pieceMode = false; e.removeAttribute('data-edit-piece-mode'); });
|
|
2489
|
+
area.querySelectorAll('[data-edit-piece]').forEach(unmarkPiece);
|
|
2490
|
+
[area, ...area.querySelectorAll('[data-edit-area]')].forEach(e => { e._sizeBound = false; });
|
|
2491
|
+
if (grabbed && area.contains(grabbed.item)) cancelGrab();
|
|
1507
2492
|
}
|
|
1508
2493
|
|
|
1509
|
-
/* ---- Activation:
|
|
2494
|
+
/* ---- Activation: always-on by default; .gated waits for $edit.on() ---- */
|
|
1510
2495
|
let estore, booted = false, armQueued = false;
|
|
1511
|
-
const isActive = (a) => !!a && (!a._edit.gated || (estore && estore.active));
|
|
1512
|
-
const
|
|
1513
|
-
function armAll() {
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
2496
|
+
const isActive = (a) => !!a && !!a._edit && (!a._edit.gated || !!(estore && estore.active)) && !sharedKey(a);
|
|
2497
|
+
const warnedShared = new Set();
|
|
2498
|
+
function armAll() {
|
|
2499
|
+
const fresh = [];
|
|
2500
|
+
areas().forEach(a => {
|
|
2501
|
+
if (!sharedKey(a)) return;
|
|
2502
|
+
if (!warnedShared.has(key(a))) { warnedShared.add(key(a)); console.warn(`[edit] x-edit="${key(a)}" is on the page more than once (a component used several times); it is not editable here`); }
|
|
2503
|
+
if (a._armed) { disarmArea(a); a._armed = false; }
|
|
2504
|
+
});
|
|
2505
|
+
areas().forEach(a => { if (isActive(a) && !a._armed) { if (!a._editBound) fresh.push(a); lastSnap[key(a)] = snapshot(a, classify(a)); armArea(a); a._armed = true; } });
|
|
2506
|
+
// A region mounted again after boot (x-if, a deferred route) shows the edits the log still holds for it
|
|
2507
|
+
if (booted && fresh.length) {
|
|
2508
|
+
if (log.some(d => /^st-/.test(d.kind))) { surfaceConflicts(fresh); applyStaticState(); }
|
|
2509
|
+
if (log.some(d => d.kind === 'cmp-main' || d.kind === 'cmp-inst')) applyComponentState();
|
|
2510
|
+
}
|
|
2511
|
+
armThemeControls(); refresh(); booted = true;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// Regions rendered after boot (x-if, routes, lazy components) arm through the directive.
|
|
1521
2515
|
function armLater() {
|
|
1522
|
-
if (!booted || armQueued) return;
|
|
2516
|
+
if (!booted || armQueued) return;
|
|
1523
2517
|
armQueued = true;
|
|
1524
2518
|
queueMicrotask(() => { armQueued = false; armAll(); });
|
|
1525
2519
|
}
|
|
1526
|
-
// Runs from Alpine's directive cleanup (x-if/x-for teardown, or its own mutation
|
|
1527
|
-
// observer), so a removed region drops its observers and its registry slot.
|
|
1528
2520
|
function releaseEdit(el) {
|
|
1529
2521
|
Object.keys(themeScopes).forEach(k => { if (themeScopes[k] === el) delete themeScopes[k]; });
|
|
1530
2522
|
cssvarControls.delete(el);
|
|
1531
2523
|
if (!editEls.has(el)) return;
|
|
1532
2524
|
if (el._armed) { disarmArea(el); el._armed = false; }
|
|
1533
|
-
|
|
1534
|
-
editEls.delete(el); delete el._edit;
|
|
2525
|
+
if (el._editObserver) { el._editObserver.disconnect(); el._editObserver = null; }
|
|
2526
|
+
editEls.delete(el); delete el._edit; resetAreas();
|
|
1535
2527
|
el.removeAttribute('data-edit-area');
|
|
2528
|
+
if (target && (target === el || el.contains(target))) setTarget(null);
|
|
1536
2529
|
refresh();
|
|
1537
2530
|
}
|
|
1538
|
-
|
|
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;
|
|
2531
|
+
|
|
2532
|
+
/* ---- Source patches: every area ----
|
|
2533
|
+
A static area publishes its FINAL structure as a tree of identities. Each node
|
|
2534
|
+
names a source element (key, plus its origin path as a fallback), carries its
|
|
2535
|
+
own edits, and lists children only where structure changed beneath it; a node
|
|
2536
|
+
without children keeps its source markup. Copies name the element they copy.
|
|
2537
|
+
Removed source elements are listed so the writer can tell them from omissions. */
|
|
2538
|
+
const treeKids = (el) => realChildren(el).filter(c => !isGenerated(c));
|
|
2539
|
+
function staticTrees(ss) {
|
|
2540
|
+
const out = [];
|
|
2541
|
+
const editsFor = (id) => {
|
|
2542
|
+
const ops = ss.node[id]; if (!ops) return null;
|
|
2543
|
+
const e = {};
|
|
2544
|
+
Object.entries(ops).forEach(([prop, v]) => { e[prop] = (prop === 'class' || prop === 'style') && v === '' ? null : v; });
|
|
2545
|
+
return Object.keys(e).length ? e : null;
|
|
1564
2546
|
};
|
|
1565
|
-
|
|
2547
|
+
const nontrivial = (n) => n.edits || n.children || n.key[0] === '~';
|
|
2548
|
+
const live = keyMap(staticAreas());
|
|
2549
|
+
staticAreas().forEach(area => {
|
|
2550
|
+
const region = key(area), comp = sourceComponent(area);
|
|
2551
|
+
const ids = new Proxy({}, { get: (_, k) => { const o = originOf(String(k)), a = areaByKey(decodeURIComponent(o.slice(1, o.indexOf('@', 1)))); const m = baseIds && a && baseIds[sourceFile(a)]; return m ? m[o] : undefined; } });
|
|
2552
|
+
// The children a restructured container had when the page loaded (the server refuses
|
|
2553
|
+
// the rewrite if the source no longer has them).
|
|
2554
|
+
const baseOf = (parent) => {
|
|
2555
|
+
const list = parent === '' ? (ss.orderBase[region] || {})[''] : Object.values(ss.orderBase).map(m => m[parent]).find(Boolean);
|
|
2556
|
+
return list ? list.filter(k => k[0] === '@').map(k => { const b = { key: k }, at = originAt.get(k); if (at != null) b.at = at; if (ids && ids[k]) b.fp = ids[k]; return b; }) : null;
|
|
2557
|
+
};
|
|
2558
|
+
const build = (el, isRoot) => {
|
|
2559
|
+
const host = !isRoot && el.getAttribute('data-edit-host');
|
|
2560
|
+
if (host) { if (host[0] === '!') return null; const h = { key: host }, at = originAt.get(host); if (at != null) h.at = at; if (ids && ids[host]) h.fp = ids[host]; return h; }
|
|
2561
|
+
if (!isRoot && el._manifestComponent) return null;
|
|
2562
|
+
const id = el.getAttribute('data-edit-key');
|
|
2563
|
+
if (!id || id[0] === '!') return null;
|
|
2564
|
+
const n = { key: id };
|
|
2565
|
+
const at = originAt.get(originOf(id)); if (at != null) n.at = at;
|
|
2566
|
+
if (ids && ids[originOf(id)]) n.fp = ids[originOf(id)];
|
|
2567
|
+
const e = editsFor(id); if (e) n.edits = e;
|
|
2568
|
+
if (!(e && 'text' in e) && !isRendered(el) && !el.hasAttribute('data-rich-text')) {
|
|
2569
|
+
const kids = treeKids(el).map(c => build(c)).filter(Boolean);
|
|
2570
|
+
const structural = ss.touched.has(isRoot ? '#' + region : id);
|
|
2571
|
+
if (id[0] === '~' || structural) { n.children = kids; const b = structural && id[0] !== '~' && baseOf(isRoot ? '' : id); if (b) n.base = b; }
|
|
2572
|
+
else if (kids.some(nontrivial)) { n.children = kids.filter(nontrivial); n.path = true; } // only a path to edits: the source keeps its own order
|
|
2573
|
+
}
|
|
2574
|
+
return n;
|
|
2575
|
+
};
|
|
2576
|
+
const tree = build(area, true);
|
|
2577
|
+
if (!tree || !(tree.edits || tree.children)) return;
|
|
2578
|
+
delete tree.at; delete tree.fp;
|
|
2579
|
+
out.push(comp ? { kind: 'static-tree', region, component: comp, tree } : { kind: 'static-tree', region, tree });
|
|
2580
|
+
});
|
|
2581
|
+
// Removed: named by a structural delta's `before`, absent from every current order.
|
|
2582
|
+
const placedKeys = new Set();
|
|
2583
|
+
new Set([...Object.keys(ss.orderBase), ...Object.keys(ss.order)]).forEach(r => {
|
|
2584
|
+
Object.values(Object.assign({}, ss.orderBase[r] || {}, ss.order[r] || {})).forEach(ks => ks.forEach(k => placedKeys.add(k)));
|
|
2585
|
+
});
|
|
2586
|
+
const removed = new Set();
|
|
2587
|
+
for (let i = 0; i < cursor; i++) {
|
|
1566
2588
|
const d = log[i];
|
|
1567
|
-
if (d.kind
|
|
1568
|
-
patches.push({ kind: 'static-move', from: d.from, to: d.to, key: d.key, toKey: d.toKey, toOrder: d.toOrder });
|
|
2589
|
+
if (d.kind === 'st-children') d.before.forEach(k => { if (k && k[0] === '@' && !placedKeys.has(k) && !live.has(k)) removed.add(k); });
|
|
1569
2590
|
}
|
|
1570
|
-
const
|
|
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 });
|
|
2591
|
+
const regionOfKey = (k) => decodeURIComponent(k.slice(1, k.indexOf('@', 1)));
|
|
2592
|
+
removed.forEach(k => {
|
|
2593
|
+
const r = regionOfKey(k);
|
|
2594
|
+
let p = out.find(x => x.region === r);
|
|
2595
|
+
if (!p) { const a = areaByKey(r); if (!a || sharedKey(a)) return; const c = sourceComponent(a); p = c ? { kind: 'static-tree', region: r, component: c, tree: { key: '' } } : { kind: 'static-tree', region: r, tree: { key: '' } }; out.push(p); }
|
|
2596
|
+
(p.removed = p.removed || []).push(k);
|
|
1580
2597
|
});
|
|
1581
|
-
|
|
2598
|
+
return out;
|
|
2599
|
+
}
|
|
2600
|
+
function buildPatches() {
|
|
2601
|
+
const patches = Object.entries(fold()).map(([k, v]) => patchFor(k, v.kind, v.snap));
|
|
2602
|
+
staticTrees(staticState()).forEach(p => patches.push(p));
|
|
2603
|
+
const toEdits = (paths) => {
|
|
2604
|
+
const e = [];
|
|
2605
|
+
Object.entries(paths).forEach(([path, props]) => Object.entries(props).forEach(([prop, value]) => e.push({ path, key: path, prop, value })));
|
|
2606
|
+
return e;
|
|
2607
|
+
};
|
|
2608
|
+
const dv = dataValueState();
|
|
1582
2609
|
Object.entries(dv).forEach(([source, recs]) => Object.entries(recs).forEach(([id, fields]) => Object.entries(fields).forEach(([field, value]) => patches.push({ kind: 'data-val', source, id, field, value }))));
|
|
1583
2610
|
const { main, inst, reverts } = componentState();
|
|
1584
2611
|
Object.entries(main).forEach(([component, paths]) => { const edits = toEdits(paths); if (edits.length) patches.push({ kind: 'component', scope: 'main', component, edits }); });
|
|
@@ -1587,85 +2614,287 @@
|
|
|
1587
2614
|
const edits = toEdits(inst[region] || {}), rv = [...(reverts[region] || [])];
|
|
1588
2615
|
if (edits.length || rv.length) patches.push({ kind: 'component', scope: 'instance', component: componentName(area), region, edits, reverts: rv });
|
|
1589
2616
|
});
|
|
1590
|
-
themePatches().forEach(p => patches.push(p));
|
|
2617
|
+
themePatches().forEach(p => patches.push(p));
|
|
1591
2618
|
return patches;
|
|
1592
2619
|
}
|
|
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
|
-
|
|
2620
|
+
// The source now holds every published edit: history starts again from here, and
|
|
2621
|
+
// identities are re-derived from the page as a fresh load of that source would.
|
|
2622
|
+
// Deltas for areas not on the page were not published; they stay, as do those of
|
|
2623
|
+
// `keep` (areas a publish left out), whose identities are left as they are.
|
|
2624
|
+
function rebase(keep) {
|
|
2625
|
+
const mounted = (r) => r == null || (!!areaByKey(r) && !(keep && keep.has(r)));
|
|
2626
|
+
const pending = (d) => (d.kind === 'st-move' ? [d.from, d.to] : [d.region]).some(r => !mounted(r));
|
|
2627
|
+
// Runtime locks are app state, not source: carried over by element.
|
|
2628
|
+
const locks = [];
|
|
2629
|
+
const lockState = lockStateNow();
|
|
2630
|
+
keyMap(staticAreas().filter(a => !(keep && keep.has(key(a))))).forEach((el, k) => { const m = lockState.get(k); if (m && m.size) locks.push([el, new Map(m), k === el.getAttribute('data-edit-host')]); });
|
|
2631
|
+
const nonStaticLocks = log.slice(0, cursor).filter(d => d.kind === 'lock' && !/^[~@]/.test(d.key) && !pending(d));
|
|
2632
|
+
let c = 0;
|
|
2633
|
+
log = log.filter((d, i) => { const keep = pending(d); if (keep && i < cursor) c++; return keep; });
|
|
2634
|
+
cursor = c; ver++;
|
|
2635
|
+
const relock = () => {
|
|
2636
|
+
const add = (d) => { log.splice(cursor, 0, d); cursor++; if (persistOf(d.region)) persisted.add(d); };
|
|
2637
|
+
nonStaticLocks.forEach(add);
|
|
2638
|
+
locks.forEach(([el, m, host]) => {
|
|
2639
|
+
const a = host ? blockArea(el) : regionOf(el), k = el.getAttribute(host ? 'data-edit-host' : 'data-edit-key'); if (!a || !k) return;
|
|
2640
|
+
[true, false].forEach(v => { const acts = [...m].filter(([, x]) => x === v).map(([act]) => act); if (acts.length) add({ kind: 'lock', region: key(a), key: k, actions: acts.length === LOCK_ACTIONS.length ? [] : acts, value: v }); });
|
|
2641
|
+
});
|
|
2642
|
+
ver++;
|
|
2643
|
+
};
|
|
2644
|
+
saveState();
|
|
2645
|
+
const keptAt = keep ? [...originAt].filter(([k]) => keep.has(decodeURIComponent(k.slice(1, k.indexOf('@', 1))))) : [];
|
|
2646
|
+
originAt.clear();
|
|
2647
|
+
keptAt.forEach(([k, p]) => originAt.set(k, p));
|
|
2648
|
+
areas().forEach(a => {
|
|
2649
|
+
if (keep && keep.has(key(a))) return;
|
|
2650
|
+
const kind = classify(a);
|
|
2651
|
+
a._baseText = {}; a._baseClass = {}; a._baseStyle = {};
|
|
2652
|
+
if (kind === 'static') {
|
|
2653
|
+
[a, ...a.querySelectorAll('[data-edit-key]')].forEach(n => { if (regionOf(n) === a) n.removeAttribute('data-edit-key'); });
|
|
2654
|
+
a.querySelectorAll('[data-edit-host]').forEach(n => { if (regionOf(n.parentElement) === a) n.removeAttribute('data-edit-host'); });
|
|
2655
|
+
a._marked = false; markStatic(a);
|
|
1628
2656
|
}
|
|
2657
|
+
if (kind === 'component' && a._armed) armComponent(a);
|
|
2658
|
+
lastSnap[key(a)] = snapshot(a, kind);
|
|
1629
2659
|
});
|
|
1630
|
-
|
|
1631
|
-
|
|
2660
|
+
rebaseTheme();
|
|
2661
|
+
relock();
|
|
2662
|
+
saveState(); refresh();
|
|
2663
|
+
}
|
|
2664
|
+
const published = (r) => !(r && Array.isArray(r.results) && r.results.some(x => x && x.status === 'error'));
|
|
2665
|
+
// Source file hashes as the page loaded them (mnfst-run --edit); a publish carries them
|
|
2666
|
+
// so the server can refuse when a file changed underneath this page.
|
|
2667
|
+
// With them, each source element's fingerprint as loaded: the server refuses an edit
|
|
2668
|
+
// whose element no longer matches (changed, or a twin that moved).
|
|
2669
|
+
let baseFiles = null, baseIds = null;
|
|
2670
|
+
function loadBase(filesOnly) {
|
|
2671
|
+
if (!/^https?:$/.test(location.protocol) || typeof fetch !== 'function') return Promise.resolve(null);
|
|
2672
|
+
return fetch('/__edit/base', { cache: 'no-store' }).then(r => r.ok ? r.json() : null).then(j => {
|
|
2673
|
+
if (j && j.files) baseFiles = j.files;
|
|
2674
|
+
if (j && j.ids && !filesOnly) baseIds = j.ids;
|
|
2675
|
+
return baseFiles;
|
|
2676
|
+
}).catch(() => null);
|
|
2677
|
+
}
|
|
2678
|
+
// Stylesheets and data hot-swap: their new hash applies. HTML waits for the reload
|
|
2679
|
+
// (a cancelled one leaves the page on what it loaded); our own writes advance on publish.
|
|
2680
|
+
// HTML that changes before the base arrives makes a late base untrustworthy.
|
|
2681
|
+
let movedUnbased = false;
|
|
2682
|
+
window.addEventListener('manifest:source-change', (e) => {
|
|
2683
|
+
const d = e.detail || {}, html = /\.html?$/i.test(d.file || '');
|
|
2684
|
+
if (html && !baseIds) movedUnbased = true;
|
|
2685
|
+
if (baseFiles && d.file && d.hash && !html) baseFiles[d.file] = d.hash;
|
|
2686
|
+
});
|
|
2687
|
+
// A refusal for files changed since load keeps the log and reports them ($edit.stale,
|
|
2688
|
+
// `edit:stale`). Reload to take the new source, or publish({ rebase: true }) to write
|
|
2689
|
+
// the edits over it; the history is kept until a publish succeeds.
|
|
2690
|
+
// The live reload a successful publish of ours triggers is not taken: the page is
|
|
2691
|
+
// already rebased, and typing begun since would be lost. Reloads that arrive while a
|
|
2692
|
+
// publish is in flight wait for its answer; anything not written by it still reloads.
|
|
2693
|
+
// After a stale refusal or a rebase write the page shows an older source than the file,
|
|
2694
|
+
// so no reload is ours to skip, and with no live reload coming the page reloads itself.
|
|
2695
|
+
// A reload never discards edits it cannot restore: with unpersisted edits pending, the
|
|
2696
|
+
// page stays and says so (edit:stale), so they can be published first.
|
|
2697
|
+
let ownWrites = null, inFlight = 0, held = [], resync = false, heldReload = null;
|
|
2698
|
+
const ours = (files) => !resync && !!ownWrites && Date.now() < ownWrites.until && files.length > 0 && files.every(f => f && ownWrites.files[f.rel] && ownWrites.files[f.rel] === f.hash);
|
|
2699
|
+
// A reload is safe when every applied edit survives it (persisted), and no focused leaf holds
|
|
2700
|
+
// typing not yet logged (a persisting one is committed first). Undone steps never hold it.
|
|
2701
|
+
// Typing in a focused leaf, not yet logged (it is, on blur): committed now when `force` or its region persists
|
|
2702
|
+
function commitFocused(force) {
|
|
2703
|
+
const f = document.activeElement;
|
|
2704
|
+
if (!f || !f._commitText || f._preEdit === undefined || !f.isContentEditable || leafText(f) === f._preEdit) return true;
|
|
2705
|
+
const a = regionOf(f);
|
|
2706
|
+
if (!a || (!force && !a._edit.persist)) return false;
|
|
2707
|
+
f._commitText(); restartRich(f); f._preEdit = leafText(f);
|
|
2708
|
+
return true;
|
|
2709
|
+
}
|
|
2710
|
+
// Grows a set of held regions by the regions moves tie to them (`via`: held -> the region it is tied to).
|
|
2711
|
+
function tie(held) {
|
|
2712
|
+
const via = new Map();
|
|
2713
|
+
for (let grew = true; grew;) {
|
|
2714
|
+
grew = false;
|
|
2715
|
+
log.slice(0, cursor).forEach(d => {
|
|
2716
|
+
if (d.kind !== 'st-move' || held.has(d.from) === held.has(d.to)) return;
|
|
2717
|
+
const [had, add] = held.has(d.from) ? [d.from, d.to] : [d.to, d.from];
|
|
2718
|
+
held.add(add); via.set(add, via.get(had) || had); grew = true;
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
held.via = via;
|
|
2722
|
+
return held;
|
|
2723
|
+
}
|
|
2724
|
+
// Edits mnfst-run can't write (a generated region's own, a move in or out of one, and a piece
|
|
2725
|
+
// moved from one) never hold a reload: it regenerates that markup (.persist keeps them).
|
|
2726
|
+
// A writable region a move ties to one still holds: it is published once that move is undone.
|
|
2727
|
+
const keyRegion = (k) => { if (typeof k !== 'string') return null; const o = originOf(k); return o[0] === '@' ? o.slice(1, o.indexOf('@', 1)) : null; };
|
|
2728
|
+
const unwritableEdit = (d) => renderedRegions.has(d.region) || renderedRegions.has(d.from) || renderedRegions.has(d.to) || renderedRegions.has(keyRegion(d.path)) || renderedRegions.has(keyRegion(d.key));
|
|
2729
|
+
const regionsOf = (ds) => [...new Set(ds.flatMap(d => [d.region, d.from, d.to]).filter(Boolean))];
|
|
2730
|
+
const unsaved = () => log.slice(0, cursor).filter(d => d.kind !== 'lock' && !persisted.has(d));
|
|
2731
|
+
function focusedRegion() { const f = document.activeElement, a = f && f._commitText && regionOf(f); return a ? key(a) : null; }
|
|
2732
|
+
function reloadSafe() {
|
|
2733
|
+
if (!commitFocused(false) && !renderedRegions.has(focusedRegion())) return false;
|
|
2734
|
+
// Unpersisted edits exist only for regions of this document (mounted or not): they hold it
|
|
2735
|
+
return unsaved().every(unwritableEdit);
|
|
2736
|
+
}
|
|
2737
|
+
function warnDiscarded() {
|
|
2738
|
+
const f = focusedRegion(), lost = regionsOf(unsaved().filter(unwritableEdit));
|
|
2739
|
+
if (f && renderedRegions.has(f) && !commitFocused(false) && !lost.includes(f)) lost.push(f);
|
|
2740
|
+
if (lost.length) console.warn(`[edit] the source changed: reloading discards the unpublished edits of ${lost.map(r => `x-edit="${r}"`).join(', ')} that mnfst-run can't write (generated content, or a piece moved from it); .persist keeps them`);
|
|
2741
|
+
}
|
|
2742
|
+
function reloadNow() { warnDiscarded(); location.reload(); }
|
|
2743
|
+
// A held reload is taken as soon as nothing it would lose remains
|
|
2744
|
+
function takeHeldReload() { if (heldReload && reloadSafe()) { heldReload = null; reloadNow(); } }
|
|
2745
|
+
function reloadIfSafe(files) {
|
|
2746
|
+
if (reloadSafe()) return reloadNow();
|
|
2747
|
+
heldReload = files;
|
|
2748
|
+
const changed = files.map(f => f && f.rel).filter(Boolean);
|
|
2749
|
+
if (estore) estore.stale = [...new Set([...(estore.stale || []), ...changed])];
|
|
2750
|
+
const holding = regionsOf(unsaved().filter(d => !unwritableEdit(d))).filter(r => !renderedRegions.has(r));
|
|
2751
|
+
const f = focusedRegion(); if (f && !renderedRegions.has(f) && !holding.includes(f) && !commitFocused(false)) holding.push(f);
|
|
2752
|
+
const off = tie(new Set(renderedRegions)), tied = holding.filter(r => off.has(r));
|
|
2753
|
+
const name = (rs) => rs.map(r => `x-edit="${r}"`).join(', ');
|
|
2754
|
+
console.warn(`[edit] the source changed, but a reload would lose unpublished edits${holding.length ? ' to ' + name(holding) : ''}: publish (with rebase), then reload`
|
|
2755
|
+
+ (tied.length ? `. ${name(tied)} ${tied.length > 1 ? 'are' : 'is'} tied by a move to ${name([...new Set(tied.map(r => off.via.get(r)))])}, which mnfst-run can't write: undo that move to publish ${tied.length > 1 ? 'them' : 'it'}, or publish the rest` : ''));
|
|
2756
|
+
document.dispatchEvent(new CustomEvent('edit:stale', { detail: { files: changed } }));
|
|
2757
|
+
}
|
|
2758
|
+
window.addEventListener('manifest:live-reload', (e) => {
|
|
2759
|
+
const files = (e.detail && e.detail.files) || [];
|
|
2760
|
+
if (inFlight) { e.preventDefault(); held.push(files); return; }
|
|
2761
|
+
if (ours(files)) { e.preventDefault(); return; }
|
|
2762
|
+
if (!reloadSafe()) { e.preventDefault(); reloadIfSafe(files); }
|
|
2763
|
+
else warnDiscarded();
|
|
2764
|
+
});
|
|
2765
|
+
function settled(r, rebased) {
|
|
2766
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
2767
|
+
if (r && r.base) ownWrites = { files: Object.assign({}, r.base), until: Date.now() + 3000 };
|
|
2768
|
+
if (r && Array.isArray(r.stale) && r.stale.length) resync = true;
|
|
2769
|
+
if (rebased && published(r) && r.base) { resync = true; setTimeout(() => { if (resync) reloadIfSafe(Object.keys(r.base).map(rel => ({ rel }))); }, 1500); }
|
|
2770
|
+
if (inFlight) return;
|
|
2771
|
+
const pending = held; held = [];
|
|
2772
|
+
if (pending.some(f => !ours(f))) reloadIfSafe(pending.flat());
|
|
2773
|
+
}
|
|
2774
|
+
// Publishes run one at a time: a second waits for the first, then builds on its result.
|
|
2775
|
+
let publishing = Promise.resolve();
|
|
2776
|
+
function publish(opts) {
|
|
2777
|
+
const next = publishing.then(() => publishNow(opts));
|
|
2778
|
+
publishing = next.catch(() => { });
|
|
2779
|
+
return next;
|
|
2780
|
+
}
|
|
2781
|
+
// Once a rebase writes over HTML changed elsewhere, the page shows less than the file:
|
|
2782
|
+
// its HTML hashes are not taken (a plain publish is stale until a reload), nor the
|
|
2783
|
+
// fingerprints of keys with twins there (which may not be the elements this page shows).
|
|
2784
|
+
let overNewer = false;
|
|
2785
|
+
function publishNow(opts) {
|
|
2786
|
+
const done = (r, keep) => {
|
|
2787
|
+
if (r && r.base && baseFiles) Object.entries(r.base).forEach(([f, h]) => { if (!overNewer || !/\.html?$/i.test(f)) baseFiles[f] = h; });
|
|
2788
|
+
if (r && r.ids && baseIds) {
|
|
2789
|
+
if (!overNewer) baseIds = r.ids;
|
|
2790
|
+
else Object.entries(r.ids).forEach(([f, m]) => { baseIds[f] = Object.assign({}, baseIds[f]); Object.entries(m || {}).forEach(([k, fp]) => { if (!String(fp).includes('.')) baseIds[f][k] = fp; }); });
|
|
2791
|
+
}
|
|
2792
|
+
const stale = r && Array.isArray(r.stale) ? r.stale : [];
|
|
2793
|
+
if (estore) estore.stale = stale;
|
|
2794
|
+
if (stale.length) document.dispatchEvent(new CustomEvent('edit:stale', { detail: { files: stale } }));
|
|
2795
|
+
if (published(keep ? { results: r.results.filter(x => !(x && keep.has(x.region))) } : r)) rebase(keep);
|
|
2796
|
+
takeHeldReload();
|
|
2797
|
+
return r;
|
|
2798
|
+
};
|
|
2799
|
+
// An area the writer could not resolve does not hold the others back: they are sent
|
|
2800
|
+
// again without it (and areas a move ties to it); its edits stay, its refusal is returned.
|
|
2801
|
+
const leftOut = (r, patches) => {
|
|
2802
|
+
if (!r || !Array.isArray(r.results) || (Array.isArray(r.stale) && r.stale.length)) return null;
|
|
2803
|
+
const failed = new Set(r.results.filter(x => x && x.failed != null).map(x => x.failed));
|
|
2804
|
+
if (!failed.size || r.results.some((x, i) => x && x.status === 'error' && !failed.has(patches[i] && patches[i].region))) return null;
|
|
2805
|
+
tie(failed);
|
|
2806
|
+
return patches.some(p => !failed.has(p.region)) ? failed : null;
|
|
2807
|
+
};
|
|
2808
|
+
// An area inside generated content (x-markdown output and the like) edits on the page,
|
|
2809
|
+
// but its markup is in no file mnfst-run can write: it is refused by name, not sent.
|
|
2810
|
+
const renderedBy = (region) => { const a = areaByKey(region); return a && a._edit.rendered; };
|
|
2811
|
+
const unwritable = (p) => (p.kind === 'static-tree' || (p.kind === 'component' && p.scope === 'instance')) && !!renderedBy(p.region);
|
|
2812
|
+
const send = () => {
|
|
2813
|
+
commitFocused(true); // typing under way publishes with the rest (identities are re-derived from the page after)
|
|
2814
|
+
const patches = buildPatches();
|
|
2815
|
+
if (estore && typeof estore.onPublish === 'function') return Promise.resolve(estore.onPublish(patches, { log, cursor })).then(done);
|
|
2816
|
+
if (!patches.length) { takeHeldReload(); return Promise.resolve({ results: [] }); } // nothing to send: a held reload goes now
|
|
2817
|
+
const post = (ps) => { inFlight++; return fetch('/__edit/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ patches: ps, base: baseFiles }) }).then(r => r.json()).catch(err => { settled(null); throw err; }); };
|
|
2818
|
+
const off = tie(new Set(patches.filter(unwritable).map(p => p.region)));
|
|
2819
|
+
const offResult = (p) => ({ region: p.region, status: 'error', reason: renderedBy(p.region) && unwritable(p)
|
|
2820
|
+
? `x-edit="${p.region}" is inside content ${renderedBy(p.region)} renders, so mnfst-run can't write it to a source file`
|
|
2821
|
+
: `not published: tied by a move to x-edit="${off.via.get(p.region)}", which mnfst-run can't write` });
|
|
2822
|
+
const sendable = patches.filter(p => !off.has(p.region));
|
|
2823
|
+
// The sent part's answer, merged with what was held back, as one result per patch.
|
|
2824
|
+
const finish = (r, held, sent) => {
|
|
2825
|
+
const keep = new Set([...off, ...(held || [])]);
|
|
2826
|
+
const results = patches.map(p => off.has(p.region) ? offResult(p) : r.results[sent.indexOf(p)]);
|
|
2827
|
+
const o = done(Object.assign({}, r, { results }), keep.size ? keep : undefined); settled(r, !!(opts && opts.rebase)); return o;
|
|
2828
|
+
};
|
|
2829
|
+
if (!sendable.length) { const o = done({ results: patches.map(offResult) }, off); return Promise.resolve(o); }
|
|
2830
|
+
return post(sendable).then(r => {
|
|
2831
|
+
const out = leftOut(r, sendable);
|
|
2832
|
+
if (!out) return finish(r, null, sendable);
|
|
2833
|
+
settled(r, false);
|
|
2834
|
+
const rest = sendable.filter(p => !out.has(p.region));
|
|
2835
|
+
return post(rest).then(r2 => {
|
|
2836
|
+
const results = sendable.map((p, i) => out.has(p.region)
|
|
2837
|
+
? (r.results[i] && r.results[i].status === 'error' ? r.results[i] : { region: p.region, status: 'error', reason: `not published: tied by a move to x-edit="${out.via.get(p.region) || [...out].find(x => !out.via.has(x))}", which failed` })
|
|
2838
|
+
: r2.results[rest.indexOf(p)]);
|
|
2839
|
+
return finish(Object.assign({}, r2, { results }), out, sendable);
|
|
2840
|
+
});
|
|
2841
|
+
});
|
|
2842
|
+
};
|
|
2843
|
+
// mnfst-run gets a publish only with the base the page loaded; a missing one is
|
|
2844
|
+
// fetched once more, else the publish is refused and the history kept.
|
|
2845
|
+
const refuse = (reason) => { const patches = buildPatches(); return patches.length ? done({ results: patches.map(p => ({ region: p.region, status: 'error', reason })) }) : { results: [] }; };
|
|
2846
|
+
const custom = estore && typeof estore.onPublish === 'function';
|
|
2847
|
+
const based = custom || (baseFiles && baseIds) ? Promise.resolve(null)
|
|
2848
|
+
: movedUnbased ? Promise.resolve('the source changed before this page could read its base from the dev server; reload to continue')
|
|
2849
|
+
: loadBase().then(() => baseFiles && baseIds ? null : 'the dev server\'s base (/__edit/base) could not be read, so nothing can be checked against the source; nothing was published');
|
|
2850
|
+
const rebaseSend = () => {
|
|
2851
|
+
const was = Object.assign({}, baseFiles);
|
|
2852
|
+
return loadBase(true).then(() => { if (Object.keys(baseFiles || {}).some(f => /\.html?$/i.test(f) && was[f] !== baseFiles[f])) overNewer = true; return send(); });
|
|
2853
|
+
};
|
|
2854
|
+
return based.then(why => why ? refuse(why) : opts && opts.rebase ? rebaseSend() : send());
|
|
1632
2855
|
}
|
|
1633
|
-
|
|
1634
|
-
|
|
2856
|
+
function on() { if (estore) estore.active = true; armAll(); }
|
|
2857
|
+
function off() {
|
|
2858
|
+
if (estore) estore.active = false;
|
|
2859
|
+
areas().forEach(a => { if (a._edit.gated && a._armed) { disarmArea(a); a._armed = false; } });
|
|
2860
|
+
refresh();
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
|
|
1635
2864
|
function refresh() {
|
|
1636
|
-
if (estore)
|
|
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;
|
|
2865
|
+
if (!estore) return;
|
|
2866
|
+
estore.canUndo = cursor > 0;
|
|
2867
|
+
estore.canRedo = cursor < log.length;
|
|
2868
|
+
estore.version = (estore.version || 0) + 1;
|
|
2869
|
+
if (heldReload) setTimeout(takeHeldReload, 0);
|
|
1645
2870
|
}
|
|
1646
2871
|
|
|
1647
2872
|
/* ---- Undo/redo hotkeys: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Ctrl+Y ----
|
|
1648
2873
|
On wherever a region is active; data-edit-hotkeys="off" on a region or any
|
|
1649
2874
|
ancestor opts it out. Text that is still being typed keeps the browser's own
|
|
1650
|
-
undo until it commits on blur; fields and x-text
|
|
1651
|
-
|
|
2875
|
+
undo until it commits on blur; fields and x-rich-text keep theirs always. A leaf
|
|
2876
|
+
on Rich Text uses Rich Text's history while it has steps (richHistoryKey). */
|
|
2877
|
+
const leafText = (el) => el._editField && !el._editRich ? el.textContent : el._editRt ? sanitizeFor(el, el.innerHTML.trim()) : el.innerHTML.trim();
|
|
1652
2878
|
const hotkeysOff = (el) => !!el.closest('[data-edit-hotkeys="off"]');
|
|
1653
|
-
// A leaf
|
|
1654
|
-
// after history rewrites it under the caret, that value must follow (again
|
|
1655
|
-
// once Alpine has re-rendered a data field).
|
|
2879
|
+
// A focused leaf's baseline follows history rewrites under the caret.
|
|
1656
2880
|
function rebaseFocused() {
|
|
1657
2881
|
const el = document.activeElement;
|
|
1658
2882
|
if (!el || el._preEdit === undefined || !el.isContentEditable) return;
|
|
1659
|
-
const sync = () => { if (document.activeElement === el) el._preEdit = leafText(el); };
|
|
2883
|
+
const sync = () => { if (document.activeElement === el) { restartRich(el); el._preEdit = leafText(el); } };
|
|
1660
2884
|
sync(); setTimeout(sync, 0);
|
|
1661
2885
|
}
|
|
1662
|
-
function
|
|
1663
|
-
if (e.
|
|
2886
|
+
function historyDir(e) {
|
|
2887
|
+
if (e.altKey || !(e.metaKey || e.ctrlKey)) return null;
|
|
1664
2888
|
const k = (e.key || '').toLowerCase();
|
|
1665
|
-
|
|
1666
|
-
|
|
2889
|
+
if (k === 'z') return e.shiftKey ? 'redo' : 'undo';
|
|
2890
|
+
return k === 'y' && e.ctrlKey && !e.metaKey && !e.shiftKey ? 'redo' : null;
|
|
2891
|
+
}
|
|
2892
|
+
function onHistoryKey(e) {
|
|
2893
|
+
if (e.defaultPrevented || dragged) return; // never mid-drag
|
|
2894
|
+
const dir = historyDir(e); if (!dir) return;
|
|
2895
|
+
const isRedo = dir === 'redo';
|
|
1667
2896
|
const t = e.target && e.target.nodeType === 1 ? e.target : document.body;
|
|
1668
|
-
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.closest('[data-text
|
|
2897
|
+
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || authoredRich(t.closest('[data-rich-text]'))) return;
|
|
1669
2898
|
const region = t.closest('[data-edit-area]');
|
|
1670
2899
|
if (region) {
|
|
1671
2900
|
const top = regionOf(region);
|
|
@@ -1681,61 +2910,80 @@
|
|
|
1681
2910
|
}
|
|
1682
2911
|
|
|
1683
2912
|
|
|
1684
|
-
/* ---- Restore persisted edits + boot
|
|
2913
|
+
/* ---- Restore persisted edits + boot ---- */
|
|
2914
|
+
// Deltas for areas that no longer persist are dropped before replay.
|
|
2915
|
+
function pruneLoaded() {
|
|
2916
|
+
const stale = (d) => {
|
|
2917
|
+
const regions = d.kind === 'st-move' ? [d.from, d.to] : d.region != null ? [d.region] : [];
|
|
2918
|
+
return regions.some(r => { const a = areaByKey(r); return a && !a._edit.persist; });
|
|
2919
|
+
};
|
|
2920
|
+
if (!log.some(stale)) return;
|
|
2921
|
+
let c = 0;
|
|
2922
|
+
const kept = log.filter((d, i) => { const keep = !stale(d); if (keep && i < cursor) c++; return keep; });
|
|
2923
|
+
log = kept; cursor = c; ver++;
|
|
2924
|
+
saveState();
|
|
2925
|
+
}
|
|
1685
2926
|
function restore() {
|
|
1686
2927
|
if (!log.length) return;
|
|
2928
|
+
pruneLoaded();
|
|
1687
2929
|
for (const [k, v] of Object.entries(fold())) { const area = areaByKey(k); if (area && v.kind === 'data') waitForData(area, () => applySnap(area, 'data', v.snap)); }
|
|
1688
|
-
|
|
1689
|
-
// renames from collisions). Skipped when the source already holds the move
|
|
1690
|
-
// (post-save reload): fresh derivation then matches the recorded order.
|
|
1691
|
-
for (let i = 0; i < cursor; i++) {
|
|
1692
|
-
const d = log[i];
|
|
1693
|
-
if (d.kind !== 'st-move' || !d.remap) continue;
|
|
1694
|
-
const ta = areaByKey(d.to);
|
|
1695
|
-
if (!ta || eq(staticKeys(ta), d.toOrder)) continue;
|
|
1696
|
-
applyMoveKeys(d, false);
|
|
1697
|
-
}
|
|
1698
|
-
if (log.some(d => d.kind === 'st-node' || d.kind === 'st-order' || d.kind === 'st-move')) applyStaticState();
|
|
2930
|
+
if (log.some(d => /^st-/.test(d.kind))) { surfaceConflicts(areas()); applyStaticState(); }
|
|
1699
2931
|
if (log.some(d => d.kind === 'cmp-main' || d.kind === 'cmp-inst')) applyComponentState();
|
|
1700
2932
|
if (log.some(d => d.kind === 'data-val')) { const da = areas().find(a => classify(a) === 'data'); if (da) waitForData(da, applyDataValues); }
|
|
1701
2933
|
if (log.some(d => d.kind === 'theme')) applyThemeState();
|
|
2934
|
+
if (log.some(d => d.kind === 'lock')) areas().forEach(rearm);
|
|
2935
|
+
refresh();
|
|
1702
2936
|
}
|
|
1703
|
-
function waitForData(area, cb) { const expr = dataSourceExpr(area), tpl = area
|
|
2937
|
+
function waitForData(area, cb) { const expr = dataSourceExpr(area), tpl = listTpl(area); let n = 0; const t = setInterval(() => { let r = false; try { r = Array.isArray(window.Alpine.evaluate(tpl, expr)); } catch { } if (r) { clearInterval(t); cb(); } else if (++n > 100) clearInterval(t); }, 50); }
|
|
1704
2938
|
function init() {
|
|
1705
2939
|
if (!window.Alpine || !Alpine.directive) return;
|
|
2940
|
+
// Alpine walks only x-data trees: a page with none still gets its areas
|
|
2941
|
+
if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
|
|
1706
2942
|
Alpine.directive('edit', (el, { modifiers, expression }, { cleanup }) => {
|
|
1707
2943
|
registerEdit(el, modifiers, expression);
|
|
1708
|
-
armLater();
|
|
1709
|
-
cleanup(() => releaseEdit(el));
|
|
2944
|
+
armLater();
|
|
2945
|
+
cleanup(() => releaseEdit(el));
|
|
1710
2946
|
});
|
|
1711
2947
|
Alpine.store('edit', {
|
|
1712
|
-
active: false, canUndo: false, canRedo: false,
|
|
1713
|
-
onPublish: null,
|
|
2948
|
+
active: false, canUndo: false, canRedo: false, version: 0, stale: [], conflicts: [],
|
|
2949
|
+
onPublish: null,
|
|
1714
2950
|
toggle() { this.active ? off() : on(); }, on() { on(); }, off() { off(); },
|
|
1715
|
-
undo() { undo(); }, redo() { redo(); },
|
|
1716
|
-
publish() { return publish(); },
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
get target() { void this.targetVersion; return blockTarget(); },
|
|
2951
|
+
undo() { return undo(); }, redo() { return redo(); },
|
|
2952
|
+
publish(opts) { return publish(opts); },
|
|
2953
|
+
patches() { return buildPatches(); },
|
|
2954
|
+
export() { return JSON.parse(JSON.stringify({ log, cursor })); },
|
|
2955
|
+
get target() { void this.version; return target; },
|
|
1721
2956
|
set target(el) { setBlockTarget(el); },
|
|
1722
|
-
can(
|
|
2957
|
+
can(action, el) { void this.version; return canDo(action, el === undefined ? target : el); },
|
|
2958
|
+
lock(el, action) { return setLock(el === undefined ? target : el, action, true); },
|
|
2959
|
+
unlock(el, action) { return setLock(el === undefined ? target : el, action, false); },
|
|
1723
2960
|
copy(el) { return copyBlock(el); },
|
|
1724
2961
|
cut(el) { return cutBlock(el); },
|
|
1725
2962
|
paste(el) { return pasteBlock(el); },
|
|
1726
2963
|
duplicate(el) { return duplicateBlock(el); },
|
|
1727
2964
|
remove(el) { return removeBlock(el); },
|
|
1728
2965
|
move(el, dest, before) { return moveBlock(el, dest, before); },
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2966
|
+
nest(el) { const p = asPiece(el === undefined ? target : el); return !!p && movable(p) && nestInto(p); },
|
|
2967
|
+
unnest(el) { const p = asPiece(el === undefined ? target : el); return !!p && movable(p) && unnest(p); },
|
|
2968
|
+
block(node) { return pieceOf(node); },
|
|
2969
|
+
attr(name, value, el) { return setAttr(name, value, el); },
|
|
2970
|
+
classes: classesApi,
|
|
2971
|
+
scope(value, el) { void this.version; return value === undefined ? scopeOf(el) : setScope(value, el); },
|
|
2972
|
+
revert(el) { return revert(el); },
|
|
2973
|
+
clearConflicts() { conflicts = []; this.conflicts = []; saveState(); }
|
|
1732
2974
|
});
|
|
1733
2975
|
estore = Alpine.store('edit');
|
|
1734
2976
|
Alpine.magic('edit', () => Alpine.store('edit'));
|
|
1735
2977
|
loadState();
|
|
1736
|
-
|
|
1737
|
-
|
|
2978
|
+
loadBase();
|
|
2979
|
+
setTimeout(() => { armAll(); restore(); }, 450);
|
|
1738
2980
|
document.addEventListener('keydown', onHistoryKey);
|
|
2981
|
+
window.addEventListener('click', onAreaLinkClick, true);
|
|
2982
|
+
window.addEventListener('auxclick', onAreaLinkClick, true);
|
|
2983
|
+
document.addEventListener('contextmenu', onContextCapture, true);
|
|
2984
|
+
document.addEventListener('dropdown:context', onContextCapture, true);
|
|
2985
|
+
// SPA navigation: the state so far is saved under the page it was made on.
|
|
2986
|
+
window.addEventListener('manifest:route-change', () => { if (lsPath !== location.pathname) { lsPath = location.pathname; saveState(); } });
|
|
1739
2987
|
}
|
|
1740
2988
|
document.addEventListener('alpine:init', init);
|
|
1741
2989
|
if (window.Alpine && Alpine.directive) setTimeout(init, 0);
|