@panphora/clayjs 0.2.1 → 0.4.1
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/README.md +103 -7
- package/clay-data.js +1 -1
- package/clay-internals.js +19 -0
- package/clay.js +19 -3
- package/package.json +5 -2
- package/sap.js +5 -5
- package/src/attrs/onaftersave.js +12 -1
- package/src/attrs/refetch-on-save.js +4 -4
- package/src/attrs/save-freeze.js +47 -19
- package/src/core/admin-contenteditable.js +2 -2
- package/src/core/admin-inputs.js +2 -2
- package/src/core/admin-onclick.js +2 -2
- package/src/core/admin-resources.js +23 -14
- package/src/core/autosave.js +11 -1
- package/src/core/edit-mode.js +2 -2
- package/src/core/host-attrs.js +46 -0
- package/src/core/is-edit-mode.js +14 -6
- package/src/core/persist.js +12 -2
- package/src/core/save-core.js +199 -290
- package/src/core/save.js +121 -82
- package/src/core/snapshot.js +89 -36
- package/src/core/unsaved-warning.js +8 -2
- package/src/dom/all.js +4 -29
- package/src/dom/form-data.js +1 -1
- package/src/dom/nearest.js +11 -9
- package/src/internals/index.js +69 -0
- package/src/lib/cache-bust.js +10 -0
- package/src/lib/cookie.js +50 -15
- package/src/lib/extension-noise.js +5 -1
- package/src/lib/mutation.js +65 -18
- package/src/lib/region-policy.js +17 -2
- package/src/lib/root-attrs.js +45 -0
- package/src/lib/throttle.js +25 -13
- package/src/loader.js +18 -24
- package/src/options/options.js +1 -3
- package/src/plugins/demo.js +7 -1
- package/src/plugins/undo.js +3 -3
- package/src/sync/live-sync.js +77 -76
- package/src/ui/dialogs.js +10 -1
- package/src/ui/index.js +0 -10
- package/src/utils/debounce.js +24 -7
- package/src/vendor/control-serialize.vendor.js +2 -1
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +29 -16
- package/src/vendor/richclay.vendor.js +29 -22
package/src/dom/nearest.js
CHANGED
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
export default function nearest (startElem, selector, elementFoundReturnValue = x => x) {
|
|
32
32
|
const visited = new Set();
|
|
33
33
|
|
|
34
|
-
//
|
|
34
|
+
// Returns the matched ELEMENT, not the transformed value: a transform yielding
|
|
35
|
+
// "" or false used to read as not-found here, so the search walked past a real
|
|
36
|
+
// match (an empty input, an unchecked box) and could report a farther element.
|
|
35
37
|
function checkDeep(root) {
|
|
36
38
|
if (!root || visited.has(root)) return null;
|
|
37
39
|
|
|
@@ -46,7 +48,7 @@ export default function nearest (startElem, selector, elementFoundReturnValue =
|
|
|
46
48
|
localVisited.add(node);
|
|
47
49
|
|
|
48
50
|
if (node.matches(selector)) {
|
|
49
|
-
return
|
|
51
|
+
return node;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
queue.push(...node.children);
|
|
@@ -58,8 +60,8 @@ export default function nearest (startElem, selector, elementFoundReturnValue =
|
|
|
58
60
|
function checkSiblings(start, direction) {
|
|
59
61
|
let sibling = start[direction];
|
|
60
62
|
while (sibling) {
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
+
const found = checkDeep(sibling);
|
|
64
|
+
if (found) return found;
|
|
63
65
|
sibling = sibling[direction];
|
|
64
66
|
}
|
|
65
67
|
return null;
|
|
@@ -80,14 +82,14 @@ export default function nearest (startElem, selector, elementFoundReturnValue =
|
|
|
80
82
|
|
|
81
83
|
// Check children deeply
|
|
82
84
|
for (const child of current.children) {
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
+
const found = checkDeep(child);
|
|
86
|
+
if (found) return elementFoundReturnValue(found);
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
// Check siblings deeply
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
90
|
+
const found = checkSiblings(current, 'previousElementSibling') ||
|
|
91
|
+
checkSiblings(current, 'nextElementSibling');
|
|
92
|
+
if (found) return elementFoundReturnValue(found);
|
|
91
93
|
|
|
92
94
|
// Move up to parent
|
|
93
95
|
current = current.parentElement;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* internals/index.js — the supported low-level surface.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is reachable by direct module import whether we export it or
|
|
5
|
+
* not, because src/ ships to npm and the CDN. What this file adds is the promise:
|
|
6
|
+
* these names keep working, and anything not named here may change in a patch.
|
|
7
|
+
*
|
|
8
|
+
* Lower level than clay.* deliberately. These are the pieces the library builds
|
|
9
|
+
* itself out of, so they assume you know the save lifecycle. The ergonomics are
|
|
10
|
+
* your problem; the stability is ours.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { captureSnapshot, captureForSave, addDocumentTransform } from "../core/snapshot.js";
|
|
14
|
+
import { saveHtml, replacePageWith, isSaveInProgress } from "../core/save-core.js";
|
|
15
|
+
import {
|
|
16
|
+
addRegionToken,
|
|
17
|
+
resolveRegionPolicy,
|
|
18
|
+
isInert,
|
|
19
|
+
isSnapshotRemoved,
|
|
20
|
+
PERSIST,
|
|
21
|
+
REGION_ATTRS,
|
|
22
|
+
STRIP_FROM_SAVE,
|
|
23
|
+
STRIP_FROM_COMPARISON,
|
|
24
|
+
SNAPSHOT_REMOVE_SELECTOR,
|
|
25
|
+
FREEZE_SELECTOR,
|
|
26
|
+
} from "../lib/region-policy.js";
|
|
27
|
+
|
|
28
|
+
const clay = (window.clay = window.clay || {});
|
|
29
|
+
|
|
30
|
+
clay.internals = {
|
|
31
|
+
// The snapshot pipeline, read side. captureSnapshot gives you the clone before
|
|
32
|
+
// any stripping; captureForSave gives you the bytes a save would send.
|
|
33
|
+
captureSnapshot,
|
|
34
|
+
captureForSave,
|
|
35
|
+
|
|
36
|
+
// The same registry clay.addDocumentTransform writes to. Core only publishes that
|
|
37
|
+
// name in edit mode, since core/snapshot.js is editOnly; this one is reachable from
|
|
38
|
+
// the satellite alone, so a view-mode page can still register a transform.
|
|
39
|
+
addDocumentTransform,
|
|
40
|
+
|
|
41
|
+
// Write your own attribute without hardcoding our selectors. Doing it by hand is
|
|
42
|
+
// how a custom attribute quietly stops respecting [no-save] two releases later.
|
|
43
|
+
region: {
|
|
44
|
+
addRegionToken,
|
|
45
|
+
resolveRegionPolicy,
|
|
46
|
+
isInert,
|
|
47
|
+
isSnapshotRemoved,
|
|
48
|
+
PERSIST,
|
|
49
|
+
REGION_ATTRS,
|
|
50
|
+
selectors: {
|
|
51
|
+
stripFromSave: STRIP_FROM_SAVE,
|
|
52
|
+
stripFromComparison: STRIP_FROM_COMPARISON,
|
|
53
|
+
snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
|
|
54
|
+
freeze: FREEZE_SELECTOR,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// The save lane under clay.save. saveHtml sends bytes you supply, so it bypasses
|
|
59
|
+
// the snapshot pipeline entirely: whatever you hand it is what lands in the
|
|
60
|
+
// person's file. Check isSaveInProgress first — firing into an in-flight save is
|
|
61
|
+
// how you lose an edit.
|
|
62
|
+
save: {
|
|
63
|
+
saveHtml,
|
|
64
|
+
replacePageWith,
|
|
65
|
+
isSaveInProgress,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default clay.internals;
|
package/src/lib/cache-bust.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
// cacheBust.js
|
|
2
2
|
// Cache-bust an element's href or src attribute by adding/updating a version query param
|
|
3
3
|
|
|
4
|
+
import { addRegionToken } from './region-policy.js';
|
|
5
|
+
|
|
4
6
|
function cacheBust(el) {
|
|
5
7
|
const attr = el.hasAttribute('href') ? 'href' : 'src';
|
|
6
8
|
const currentValue = el.getAttribute(attr);
|
|
7
9
|
const url = new URL(currentValue, location.href);
|
|
8
10
|
url.searchParams.set('v', Date.now());
|
|
9
11
|
el.setAttribute(attr, url.href);
|
|
12
|
+
|
|
13
|
+
// This runs from [onaftersave], i.e. after the save baseline was taken, so the
|
|
14
|
+
// rewrite would otherwise read as a user edit and leave the page dirty forever.
|
|
15
|
+
// Marking the element instead of re-reading the whole live DOM afterwards is what
|
|
16
|
+
// lets the baseline stay equal to the bytes actually sent: a post-save re-read
|
|
17
|
+
// cannot tell this rewrite apart from something the user typed mid-save, and used
|
|
18
|
+
// to record the latter as saved without sending it.
|
|
19
|
+
addRegionToken(el, 'no-trigger-autosave');
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
export default cacheBust;
|
package/src/lib/cookie.js
CHANGED
|
@@ -1,31 +1,66 @@
|
|
|
1
|
+
// A sandboxed document (an iframe without allow-same-origin) throws a
|
|
2
|
+
// SecurityError on document.cookie. is-edit-mode.js reads a cookie at module
|
|
3
|
+
// scope, so an unguarded throw does not merely lose the cookie: it escapes the
|
|
4
|
+
// dynamic import the loader is awaiting and the document loses its client
|
|
5
|
+
// entirely. No cookies readable means no cookie, which is the honest answer.
|
|
6
|
+
function readCookies() {
|
|
7
|
+
try {
|
|
8
|
+
return document.cookie;
|
|
9
|
+
} catch (err) {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A cookie value that is not valid percent-encoding (a stray '%') makes
|
|
15
|
+
// decodeURIComponent throw URIError. Decode ONCE, outside the JSON try: the catch
|
|
16
|
+
// used to call it a second time on the value that had just thrown, so the second
|
|
17
|
+
// throw escaped — out through is-edit-mode's module-scope read, out through the
|
|
18
|
+
// loader's awaited import, and the document lost its client over a malformed cookie
|
|
19
|
+
// it never even needed.
|
|
20
|
+
function decode(value) {
|
|
21
|
+
try {
|
|
22
|
+
return decodeURIComponent(value);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
1
28
|
// e.g. Cookie.get("isAdminOfCurrentResource")
|
|
2
29
|
function get (cookieName) {
|
|
3
|
-
const cookies =
|
|
30
|
+
const cookies = readCookies().split('; ');
|
|
4
31
|
const cookie = cookies.find(row => row.startsWith(`${cookieName}=`));
|
|
5
32
|
if (!cookie) return null;
|
|
6
|
-
const cookieValue = cookie.slice(cookieName.length + 1);
|
|
33
|
+
const cookieValue = decode(cookie.slice(cookieName.length + 1));
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(cookieValue);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return cookieValue;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Writing document.cookie throws in a sandbox for the same reason reading it
|
|
42
|
+
// does, and there is nothing to clear there anyway.
|
|
43
|
+
function writeCookie(value) {
|
|
7
44
|
try {
|
|
8
|
-
|
|
45
|
+
document.cookie = value;
|
|
9
46
|
} catch (err) {
|
|
10
|
-
|
|
47
|
+
// Sandboxed: no cookie jar to clear.
|
|
11
48
|
}
|
|
12
49
|
}
|
|
13
50
|
|
|
14
51
|
// e.g. Cookie.remove("isAdminOfCurrentResource")
|
|
15
52
|
function remove(name) {
|
|
16
53
|
// Clear from current path
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// Clear from current domain
|
|
20
|
-
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=${window.location.hostname};`
|
|
54
|
+
writeCookie(`${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`)
|
|
21
55
|
|
|
22
|
-
// Clear from
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
56
|
+
// Clear from every parent domain rather than a guessed apex. Joining the last
|
|
57
|
+
// two labels produces `.co.uk` on editor.example.co.uk, which is a public
|
|
58
|
+
// suffix: the browser rejects it and the real cookie survives. Walking every
|
|
59
|
+
// suffix needs no public-suffix list, because the browser rejects exactly the
|
|
60
|
+
// ones that are public suffixes, harmlessly.
|
|
61
|
+
const labels = window.location.hostname.split('.');
|
|
62
|
+
for (let i = 0; i < labels.length; i++) {
|
|
63
|
+
writeCookie(`${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${labels.slice(i).join('.')};`);
|
|
29
64
|
}
|
|
30
65
|
}
|
|
31
66
|
|
|
@@ -52,7 +52,11 @@ export function stripExtensionNoise(root) {
|
|
|
52
52
|
if (!root || !root.querySelectorAll) return
|
|
53
53
|
try {
|
|
54
54
|
for (const el of root.querySelectorAll(EXTENSION_NODE_SELECTOR)) el.remove()
|
|
55
|
-
|
|
55
|
+
// querySelectorAll('*') never returns the root itself, so an extension stamp on
|
|
56
|
+
// <html> used to ride into every saved file. The root is the one element an
|
|
57
|
+
// extension can mark on a page with no other match.
|
|
58
|
+
for (const el of [root, ...root.querySelectorAll('*')]) {
|
|
59
|
+
if (!el.attributes) continue
|
|
56
60
|
for (const attr of [...el.attributes]) {
|
|
57
61
|
if (EXTENSION_ATTR_PATTERN.test(attr.name.toLowerCase())) el.removeAttribute(attr.name)
|
|
58
62
|
}
|
package/src/lib/mutation.js
CHANGED
|
@@ -68,10 +68,34 @@
|
|
|
68
68
|
|
|
69
69
|
import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
|
|
70
70
|
import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
|
|
71
|
+
import { ROOT_LIBRARY_ATTRS } from './root-attrs.js';
|
|
72
|
+
|
|
71
73
|
import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
|
|
72
74
|
|
|
73
75
|
const dummyElem = document.createElement("div");
|
|
74
76
|
|
|
77
|
+
// Attributes clayjs itself writes on <html>: savestatus flips on every save,
|
|
78
|
+
// editmode/pageowner on every mode change. They are library state, not page
|
|
79
|
+
// content, and snapshots already normalize them so they never reach a file.
|
|
80
|
+
//
|
|
81
|
+
// This became load-bearing the moment the hub started observing documentElement
|
|
82
|
+
// instead of body. The raw lane is deliberately unfiltered ("raw means raw"), so
|
|
83
|
+
// without this every save would push a savestatus flip into undo's record stream
|
|
84
|
+
// and Cmd+Z after a save would undo that instead of the user's edit.
|
|
85
|
+
const ROOT_LIBRARY_ATTR_SET = new Set(ROOT_LIBRARY_ATTRS);
|
|
86
|
+
|
|
87
|
+
function isLibraryRootAttr(record) {
|
|
88
|
+
return record.type === 'attributes'
|
|
89
|
+
&& record.target === document.documentElement
|
|
90
|
+
&& ROOT_LIBRARY_ATTR_SET.has(record.attributeName);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function withoutLibraryNoise(records) {
|
|
94
|
+
return records.some(isLibraryRootAttr)
|
|
95
|
+
? records.filter(r => !isLibraryRootAttr(r))
|
|
96
|
+
: records;
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
const localMutation = {
|
|
76
100
|
_callbacks: {
|
|
77
101
|
anyChange: [],
|
|
@@ -108,8 +132,8 @@ const localMutation = {
|
|
|
108
132
|
// Bridge: pause undo recorder too. Live-sync calls Mutation.pause()
|
|
109
133
|
// before morphing remote HTML in, and we want those mutations excluded
|
|
110
134
|
// from the local undo stack as well. undo.pause() is itself reference-counted.
|
|
111
|
-
if (typeof window !== 'undefined' && window.
|
|
112
|
-
window.
|
|
135
|
+
if (typeof window !== 'undefined' && window.clay?.undo?.pause) {
|
|
136
|
+
window.clay.undo.pause();
|
|
113
137
|
}
|
|
114
138
|
this._log('Paused', this._pauseDepth);
|
|
115
139
|
},
|
|
@@ -130,8 +154,8 @@ const localMutation = {
|
|
|
130
154
|
if (this._pauseDepth === 0 && this._observer) {
|
|
131
155
|
this._drainBrowserQueue(this);
|
|
132
156
|
}
|
|
133
|
-
if (typeof window !== 'undefined' && window.
|
|
134
|
-
window.
|
|
157
|
+
if (typeof window !== 'undefined' && window.clay?.undo?.resume) {
|
|
158
|
+
window.clay.undo.resume();
|
|
135
159
|
}
|
|
136
160
|
this._log('Resumed', this._pauseDepth);
|
|
137
161
|
},
|
|
@@ -156,7 +180,7 @@ const localMutation = {
|
|
|
156
180
|
// While paused, only non-pausable callbacks (pure enhancers) run.
|
|
157
181
|
if (onlyNonPausable && callback.pausable !== false) continue;
|
|
158
182
|
|
|
159
|
-
const { fn, debounce = 0, selectorFilter, omitChangeDetails, require, skip } = callback;
|
|
183
|
+
const { fn, debounce = 0, maxWait = 0, selectorFilter, omitChangeDetails, require, skip } = callback;
|
|
160
184
|
|
|
161
185
|
// Per-consumer region policy: drop changes whose region this consumer
|
|
162
186
|
// doesn't participate in (no-save / no-trigger-autosave / no-undo / freeze
|
|
@@ -214,10 +238,21 @@ const localMutation = {
|
|
|
214
238
|
callback.timeout = null;
|
|
215
239
|
}
|
|
216
240
|
|
|
241
|
+
// A plain debounce resets on every mutation, so anything on the page that
|
|
242
|
+
// changes faster than the delay (a clock, a countdown, a polling counter)
|
|
243
|
+
// keeps pushing the callback into the future and it never fires at all.
|
|
244
|
+
// maxWait puts a ceiling on that: however long the churn lasts, the
|
|
245
|
+
// callback runs within maxWait of the FIRST change it was waiting on.
|
|
246
|
+
if (!callback.firstPendingAt) callback.firstPendingAt = Date.now();
|
|
247
|
+
const delay = maxWait > 0
|
|
248
|
+
? Math.max(0, Math.min(debounce, maxWait - (Date.now() - callback.firstPendingAt)))
|
|
249
|
+
: debounce;
|
|
250
|
+
|
|
217
251
|
if (omitChangeDetails) {
|
|
218
252
|
// For omitChangeDetails, just reset the timer
|
|
219
253
|
callback.timeout = setTimeout(() => {
|
|
220
254
|
callback.timeout = null;
|
|
255
|
+
callback.firstPendingAt = null;
|
|
221
256
|
try {
|
|
222
257
|
this._log('Executing debounced callback (no details)');
|
|
223
258
|
fn();
|
|
@@ -225,7 +260,7 @@ const localMutation = {
|
|
|
225
260
|
this._log('Error in callback execution:', e, 'error');
|
|
226
261
|
console.error('Error in Mutation callback:', e);
|
|
227
262
|
}
|
|
228
|
-
},
|
|
263
|
+
}, delay);
|
|
229
264
|
} else {
|
|
230
265
|
// For callbacks with change details, accumulate changes
|
|
231
266
|
if (!callback.pendingChanges) {
|
|
@@ -238,6 +273,7 @@ const localMutation = {
|
|
|
238
273
|
const changes = callback.pendingChanges;
|
|
239
274
|
callback.pendingChanges = null; // Reset to null, not empty array
|
|
240
275
|
callback.timeout = null; // Clear the timeout reference
|
|
276
|
+
callback.firstPendingAt = null;
|
|
241
277
|
try {
|
|
242
278
|
this._log('Executing debounced callback with changes:', { changes });
|
|
243
279
|
if (changes && changes.length > 0) {
|
|
@@ -247,7 +283,7 @@ const localMutation = {
|
|
|
247
283
|
this._log('Error in callback execution:', e, 'error');
|
|
248
284
|
console.error('Error in Mutation callback:', e);
|
|
249
285
|
}
|
|
250
|
-
},
|
|
286
|
+
}, delay);
|
|
251
287
|
}
|
|
252
288
|
}
|
|
253
289
|
}
|
|
@@ -272,7 +308,9 @@ const localMutation = {
|
|
|
272
308
|
// path) so the raw lane's push timing matches a real MutationObserver and
|
|
273
309
|
// undo's own paused-drop keeps working.
|
|
274
310
|
// 2. the change lane (pause-gated, as before).
|
|
275
|
-
_onRecords(
|
|
311
|
+
_onRecords(rawRecords) {
|
|
312
|
+
const records = withoutLibraryNoise(rawRecords);
|
|
313
|
+
if (!records.length) return;
|
|
276
314
|
this._fanOutRaw(records);
|
|
277
315
|
this._handleMutations(records);
|
|
278
316
|
},
|
|
@@ -304,7 +342,9 @@ const localMutation = {
|
|
|
304
342
|
// (c) the requester: returns the pulled records merged with its own buffer,
|
|
305
343
|
// then clears the buffer.
|
|
306
344
|
_drainBrowserQueue(requester) {
|
|
307
|
-
|
|
345
|
+
// Same filter as _onRecords: takeRecords() bypasses the observer callback, so
|
|
346
|
+
// library-owned root attributes would otherwise reach subscribers this way.
|
|
347
|
+
const pulled = withoutLibraryNoise(this._observer ? this._observer.takeRecords() : []);
|
|
308
348
|
const requesterIsRaw = !!requester && this._rawSubscribers.indexOf(requester) !== -1;
|
|
309
349
|
|
|
310
350
|
if (pulled.length) {
|
|
@@ -614,6 +654,8 @@ const localMutation = {
|
|
|
614
654
|
const cb = {
|
|
615
655
|
fn: callback,
|
|
616
656
|
debounce: options.debounce || 0,
|
|
657
|
+
// Ceiling on the debounce: without it, continuous churn defers forever.
|
|
658
|
+
maxWait: options.maxWait || 0,
|
|
617
659
|
selectorFilter: options.selectorFilter,
|
|
618
660
|
omitChangeDetails: options.omitChangeDetails,
|
|
619
661
|
// Region policy: axis this consumer needs ('observed' | 'autosave' | 'undo')
|
|
@@ -624,7 +666,8 @@ const localMutation = {
|
|
|
624
666
|
// enhancers that never save/record/rebroadcast). Default true.
|
|
625
667
|
pausable: options.pausable !== false,
|
|
626
668
|
timeout: null,
|
|
627
|
-
pendingChanges: null
|
|
669
|
+
pendingChanges: null,
|
|
670
|
+
firstPendingAt: null
|
|
628
671
|
};
|
|
629
672
|
|
|
630
673
|
this._callbacks[type].push(cb);
|
|
@@ -664,7 +707,11 @@ const localMutation = {
|
|
|
664
707
|
|
|
665
708
|
this._log('Starting observation');
|
|
666
709
|
this._initializeObserver();
|
|
667
|
-
|
|
710
|
+
// The root, not body: every capture serializes documentElement, so watching
|
|
711
|
+
// only body meant a <head> edit (document.title, a new <style>) could never
|
|
712
|
+
// trigger an autosave while still counting as a change in the dirty check —
|
|
713
|
+
// the page stayed permanently unsaved and only a manual save cleared it.
|
|
714
|
+
this._observer.observe(document.documentElement, {
|
|
668
715
|
childList: true,
|
|
669
716
|
attributes: true,
|
|
670
717
|
subtree: true,
|
|
@@ -718,18 +765,18 @@ const Mutation = existingHub || localMutation;
|
|
|
718
765
|
|
|
719
766
|
if (typeof window !== 'undefined' && !existingHub) {
|
|
720
767
|
window.__clayMutation = Mutation;
|
|
721
|
-
// Publish the
|
|
722
|
-
// bridge
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
768
|
+
// Publish the hub BEFORE the readiness dispatch. Consumers (sap's mutation
|
|
769
|
+
// bridge, hypercms's fast path) read clay.Mutation from inside their
|
|
770
|
+
// mutation-ready listener, and the loader's assembleCore does not run until
|
|
771
|
+
// after this module finishes evaluating, so without this the hub is invisible
|
|
772
|
+
// at the exact moment it announces itself.
|
|
773
|
+
window.clay = window.clay || {};
|
|
774
|
+
window.clay.Mutation = Mutation;
|
|
726
775
|
// Signal consumers (e.g. hypercms ?cms=true auto-open, sap's late-hub listener)
|
|
727
776
|
// that Mutation is ready, so they can react instead of polling. Wrapped so a
|
|
728
777
|
// dispatch failure can never break the install.
|
|
729
778
|
try {
|
|
730
779
|
document.dispatchEvent(new CustomEvent('clay:mutation-ready', { detail: { Mutation } }));
|
|
731
|
-
// vendor-compat: hypercms's readiness fast path listens for the legacy name
|
|
732
|
-
document.dispatchEvent(new CustomEvent('hyperclay:mutation-ready', { detail: { Mutation } }));
|
|
733
780
|
} catch {}
|
|
734
781
|
}
|
|
735
782
|
|
package/src/lib/region-policy.js
CHANGED
|
@@ -52,6 +52,21 @@ function hasRegionToken(el, token) {
|
|
|
52
52
|
return !!el.hasAttribute?.(token); // legacy bare attribute
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Add a canonical region token to an element's `clay` attribute, preserving any
|
|
57
|
+
* tokens already there. The one place that knows how the attribute is spelled, so
|
|
58
|
+
* a caller that needs to mark a region cannot invent a second merge.
|
|
59
|
+
*
|
|
60
|
+
* @param {Element} el
|
|
61
|
+
* @param {string} token - a canonical token, e.g. 'no-trigger-autosave'
|
|
62
|
+
*/
|
|
63
|
+
export function addRegionToken(el, token) {
|
|
64
|
+
const tokens = new Set((el.getAttribute("clay") || "").trim().split(/\s+/).filter(Boolean));
|
|
65
|
+
if (tokens.has(token)) return;
|
|
66
|
+
tokens.add(token);
|
|
67
|
+
el.setAttribute("clay", Array.from(tokens).join(" "));
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
// Serializer selectors (recognize the clay-token spelling FIRST, then new + legacy bare).
|
|
56
71
|
export const STRIP_FROM_SAVE = '[clay~="no-save"], [no-save], [save-remove]';
|
|
57
72
|
export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
|
|
@@ -205,8 +220,8 @@ export function skipForPolicy(policy, require, skip) {
|
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
// The canonical region API the vendored hyper-undo (a separate bundle that can't
|
|
208
|
-
// import this module) delegates "is this undoable?" to via
|
|
209
|
-
//
|
|
223
|
+
// import this module) delegates "is this undoable?" to via clay.region, so the two
|
|
224
|
+
// can no longer drift. The loader assembles this onto clay in assembleCore.
|
|
210
225
|
export const windowRegionShape = {
|
|
211
226
|
resolveRegionPolicy,
|
|
212
227
|
isInert,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* root-attrs.js — the attributes that live on <html>, and who owns them.
|
|
3
|
+
*
|
|
4
|
+
* Three parties write to the root: the host at serve time, this library, and the
|
|
5
|
+
* author. Only the author's belong to the document. The other two belong to one
|
|
6
|
+
* response and one tab, which is why they are named here rather than inside the
|
|
7
|
+
* modules that happen to read them — the observer has to ignore them, and a
|
|
8
|
+
* peer's copy of them must never be applied.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Injected by the host at serve time. Spec §9 bounds a save token to per-file and
|
|
12
|
+
// per-tab and makes the host strip it before writing, so it never reaches disk.
|
|
13
|
+
// But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
|
|
14
|
+
// browsers, which is the hole this module closes.
|
|
15
|
+
export const HOST_TOKEN_ATTRS = ["savetoken", "htmlclaytoken"];
|
|
16
|
+
|
|
17
|
+
// Which save envelope this host's lane takes: a fact about the response, not
|
|
18
|
+
// about the document.
|
|
19
|
+
export const SAVE_TRANSPORT_ATTR = "clay-save-transport";
|
|
20
|
+
|
|
21
|
+
// This library's own root state, and this tab's UI truth.
|
|
22
|
+
export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|
|
23
|
+
|
|
24
|
+
// Never copied between tabs: not written onto this root by an incoming morph, not
|
|
25
|
+
// sent out on a sync broadcast.
|
|
26
|
+
//
|
|
27
|
+
// Both directions matter, and each was found separately. Accepting a peer's token
|
|
28
|
+
// makes every later save go out as that peer, and keeps working after this tab's
|
|
29
|
+
// own access is revoked, because revocation cannot reach a token minted for
|
|
30
|
+
// somebody else. Accepting a peer's ABSENCE of one is the mirror failure logged in
|
|
31
|
+
// documentid.md §5: a token-stripped broadcast removes this tab's own token and it
|
|
32
|
+
// can no longer save at all.
|
|
33
|
+
export const TAB_LOCAL_ROOT_ATTRS = new Set([
|
|
34
|
+
...HOST_TOKEN_ATTRS,
|
|
35
|
+
SAVE_TRANSPORT_ATTR,
|
|
36
|
+
...ROOT_LIBRARY_ATTRS,
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True when an incoming morph must be kept away from this attribute.
|
|
41
|
+
* Scoped to the root: the same names anywhere else are the author's business.
|
|
42
|
+
*/
|
|
43
|
+
export function isTabLocalRootAttr(name, element) {
|
|
44
|
+
return element === document.documentElement && TAB_LOCAL_ROOT_ATTRS.has(name);
|
|
45
|
+
}
|
package/src/lib/throttle.js
CHANGED
|
@@ -1,36 +1,48 @@
|
|
|
1
1
|
function throttle(callback, delay, executeFirst = true) {
|
|
2
2
|
let lastCall = executeFirst ? 0 : Date.now();
|
|
3
3
|
let timeoutId = null;
|
|
4
|
-
let
|
|
4
|
+
let pending = [];
|
|
5
|
+
|
|
6
|
+
// See debounce.js: settle both branches, or a throwing callback strands every
|
|
7
|
+
// caller sharing the window. throttle's caller is the save lane.
|
|
8
|
+
function settle(ctx, args, waiting) {
|
|
9
|
+
let result;
|
|
10
|
+
try {
|
|
11
|
+
result = callback.apply(ctx, args);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
for (const p of waiting) p.reject(error);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
Promise.resolve(result).then(
|
|
17
|
+
value => { for (const p of waiting) p.resolve(value); },
|
|
18
|
+
error => { for (const p of waiting) p.reject(error); },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
5
21
|
|
|
6
22
|
return function (...args) {
|
|
7
23
|
const ctx = this;
|
|
8
24
|
const now = Date.now();
|
|
9
25
|
const remaining = delay - (now - lastCall);
|
|
10
26
|
|
|
11
|
-
return new Promise((resolve) => {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
12
28
|
if (remaining <= 0) {
|
|
13
29
|
clearTimeout(timeoutId);
|
|
14
30
|
timeoutId = null;
|
|
15
31
|
lastCall = now;
|
|
16
32
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Promise.resolve(callback.apply(ctx, args))
|
|
21
|
-
.then(value => { for (const r of resolvers) r(value); });
|
|
33
|
+
const waiting = pending.concat({ resolve, reject });
|
|
34
|
+
pending = [];
|
|
35
|
+
settle(ctx, args, waiting);
|
|
22
36
|
} else {
|
|
23
|
-
|
|
37
|
+
pending.push({ resolve, reject });
|
|
24
38
|
|
|
25
39
|
if (!timeoutId) {
|
|
26
40
|
timeoutId = setTimeout(() => {
|
|
27
41
|
lastCall = Date.now();
|
|
28
42
|
timeoutId = null;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Promise.resolve(callback.apply(ctx, args))
|
|
33
|
-
.then(value => { for (const r of resolvers) r(value); });
|
|
43
|
+
const waiting = pending;
|
|
44
|
+
pending = [];
|
|
45
|
+
settle(ctx, args, waiting);
|
|
34
46
|
}, remaining);
|
|
35
47
|
}
|
|
36
48
|
}
|
package/src/loader.js
CHANGED
|
@@ -28,16 +28,27 @@ export async function boot(base, params, readyResolve) {
|
|
|
28
28
|
loaded[path] = await import(base + "/src/" + path); // sequential: order is load-bearing
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
assembleCore(loaded, { isEditMode, isOwner }, regionPolicy); // window.clay
|
|
31
|
+
assembleCore(loaded, { isEditMode, isOwner }, regionPolicy); // window.clay MUST be assembled
|
|
32
32
|
// before any plugin import
|
|
33
33
|
|
|
34
34
|
for (const path of plan.plugins) {
|
|
35
|
-
|
|
35
|
+
// A plugin that throws on evaluation degrades to absent. Unguarded, its
|
|
36
|
+
// rejection propagated out of boot() and left clay.ready pending forever, so
|
|
37
|
+
// every `await clay.ready` on the page hung: one broken optional module took
|
|
38
|
+
// the whole client with it. (The allSettled below covers the plugins' async
|
|
39
|
+
// `ready` exports, never their evaluation.)
|
|
40
|
+
let mod;
|
|
41
|
+
try {
|
|
42
|
+
mod = await import(base + "/src/" + path);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`clayjs: plugin "${path}" failed to load, continuing without it:`, err);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
36
47
|
loaded[path] = mod;
|
|
37
48
|
attachPluginMember(path, mod); // immediately, not after the loop: hypercms's ?cms=true
|
|
38
49
|
// auto-open runs as a microtask queued during ITS evaluation
|
|
39
|
-
// (before boot resumes) and reads
|
|
40
|
-
//
|
|
50
|
+
// (before boot resumes) and reads clay.RichClay and clay.undo,
|
|
51
|
+
// which earlier plugins must have attached by then
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
// Plugins with async setup (sortable's vendor fetch) export `ready`; hold
|
|
@@ -60,6 +71,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
60
71
|
isEditMode,
|
|
61
72
|
isOwner,
|
|
62
73
|
Mutation: mutation.default,
|
|
74
|
+
region: regionPolicy.windowRegionShape,
|
|
63
75
|
});
|
|
64
76
|
|
|
65
77
|
const snapshot = loaded["core/snapshot.js"];
|
|
@@ -73,28 +85,12 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
73
85
|
}
|
|
74
86
|
if (snapshot) {
|
|
75
87
|
clay.getHTML = snapshot.getPageContents;
|
|
76
|
-
clay.
|
|
88
|
+
clay.addDocumentTransform = snapshot.addDocumentTransform;
|
|
77
89
|
clay.onSnapshot = snapshot.onSnapshot;
|
|
78
90
|
}
|
|
79
91
|
if (cacheBustMod) {
|
|
80
92
|
clay.cacheBust = cacheBustMod.default;
|
|
81
93
|
}
|
|
82
|
-
|
|
83
|
-
// vendor-compat shim: richclay, hypercms, and hyper-undo read window.hyperclay;
|
|
84
|
-
// remove when the vendors migrate to window.clay.
|
|
85
|
-
window.hyperclay = window.hyperclay || {};
|
|
86
|
-
Object.assign(window.hyperclay, {
|
|
87
|
-
Mutation: clay.Mutation,
|
|
88
|
-
isEditMode: clay.isEditMode,
|
|
89
|
-
region: regionPolicy.windowRegionShape,
|
|
90
|
-
});
|
|
91
|
-
if (clay.beforeSave) {
|
|
92
|
-
window.hyperclay.beforeSave = clay.beforeSave; // richclay's save-cleanup hook
|
|
93
|
-
window.hyperclay.onPrepareForSave = clay.beforeSave; // hypercms uses this name
|
|
94
|
-
}
|
|
95
|
-
if (clay.save) {
|
|
96
|
-
window.hyperclay.savePage = clay.save;
|
|
97
|
-
}
|
|
98
94
|
}
|
|
99
95
|
|
|
100
96
|
function attachPluginMember(path, mod) {
|
|
@@ -102,16 +98,14 @@ function attachPluginMember(path, mod) {
|
|
|
102
98
|
|
|
103
99
|
if (path === "plugins/undo.js") {
|
|
104
100
|
clay.undo = mod.undo || mod.default;
|
|
105
|
-
window.hyperclay.undo = clay.undo;
|
|
106
101
|
} else if (path === "sync/live-sync.js") {
|
|
107
102
|
clay.morph = mod.morph;
|
|
108
103
|
} else if (path === "vendor/hypercms.vendor.js") {
|
|
109
104
|
clay.cms = mod.cms || mod.default;
|
|
110
|
-
window.hyperclay.hypercms = clay.cms;
|
|
111
105
|
} else if (path === "plugins/demo.js") {
|
|
112
106
|
clay.demo = mod.demo;
|
|
113
107
|
} else if (path === "vendor/richclay.vendor.js") {
|
|
114
|
-
|
|
108
|
+
clay.RichClay = mod.RichClay || mod.default;
|
|
115
109
|
}
|
|
116
110
|
}
|
|
117
111
|
|