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