@panphora/clayjs 0.1.0
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/LICENSE +21 -0
- package/README.md +57 -0
- package/clay.js +21 -0
- package/package.json +27 -0
- package/src/attrs/onaftersave.js +38 -0
- package/src/attrs/refetch-on-save.js +36 -0
- package/src/attrs/save-freeze.js +102 -0
- package/src/core/admin-attrs.js +22 -0
- package/src/core/admin-contenteditable.js +47 -0
- package/src/core/admin-inputs.js +48 -0
- package/src/core/admin-onclick.js +50 -0
- package/src/core/admin-resources.js +49 -0
- package/src/core/autosave.js +61 -0
- package/src/core/edit-mode.js +38 -0
- package/src/core/is-edit-mode.js +30 -0
- package/src/core/persist.js +103 -0
- package/src/core/save-core.js +385 -0
- package/src/core/save.js +475 -0
- package/src/core/snapshot.js +282 -0
- package/src/core/unsaved-warning.js +38 -0
- package/src/lib/autosave-debug.js +223 -0
- package/src/lib/cache-bust.js +12 -0
- package/src/lib/cookie.js +37 -0
- package/src/lib/dom-ready.js +9 -0
- package/src/lib/extension-noise.js +63 -0
- package/src/lib/load-vendor-script.js +57 -0
- package/src/lib/mutation.js +719 -0
- package/src/lib/query.js +3 -0
- package/src/lib/region-policy.js +220 -0
- package/src/lib/throttle.js +41 -0
- package/src/lib/user-gesture.js +126 -0
- package/src/loader-logic.js +62 -0
- package/src/loader.js +123 -0
- package/src/plugins/indicator.js +51 -0
- package/src/plugins/sortable.js +119 -0
- package/src/plugins/undo.js +23 -0
- package/src/sync/live-sync.js +752 -0
- package/src/vendor/Sortable.vendor.js +2 -0
- package/src/vendor/control-serialize.vendor.js +88 -0
- package/src/vendor/hyper-morph.vendor.js +22 -0
- package/src/vendor/hyper-undo.vendor.js +11 -0
- package/src/vendor/hypercms.vendor.js +1751 -0
- package/src/vendor/richclay.vendor.js +456 -0
package/src/lib/query.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* region-policy.js — the region capability model.
|
|
3
|
+
*
|
|
4
|
+
* A region declares how it participates in the framework via a small set of
|
|
5
|
+
* orthogonal tokens. The canonical spelling is the space-separated `clay`
|
|
6
|
+
* attribute (`clay="no-save no-undo"`); bare naked attributes are still
|
|
7
|
+
* resolved for back-compat. They apply to an element and its descendants:
|
|
8
|
+
*
|
|
9
|
+
* no-save — not written to the saved file (stripped). Live at runtime.
|
|
10
|
+
* no-trigger-autosave — saved, but editing it doesn't trigger an autosave / mark dirty.
|
|
11
|
+
* no-undo — edits here are not recorded in the undo stack.
|
|
12
|
+
* no-watch — invisible to the whole mutation system (high-churn regions). Still saved.
|
|
13
|
+
* freeze — saved as authored (runtime changes not persisted). Live at runtime.
|
|
14
|
+
*
|
|
15
|
+
* Four legacy markers map onto bundles of the above for back-compat. They all
|
|
16
|
+
* additionally gain "watched" (behaviors now run inside them):
|
|
17
|
+
*
|
|
18
|
+
* mutations-ignore -> no-watch
|
|
19
|
+
* save-remove -> no-save no-undo
|
|
20
|
+
* save-ignore -> no-trigger-autosave no-undo
|
|
21
|
+
* save-freeze -> freeze no-undo
|
|
22
|
+
*
|
|
23
|
+
* Separately, a snapshot-layer marker controls whether an element appears in any
|
|
24
|
+
* snapshot at all (save file, live-sync broadcast, and dirty-comparison):
|
|
25
|
+
*
|
|
26
|
+
* snapshot-remove / no-snapshot — removed from every snapshot. Runtime-only
|
|
27
|
+
* local chrome. hyper-morph also treats it as sync-ignored, so a live-sync
|
|
28
|
+
* receiver preserves its own copy instead of deleting it. Handled in
|
|
29
|
+
* snapshot.js, not by the policy axes below; no-snapshot is the consistent
|
|
30
|
+
* alias for the original snapshot-remove.
|
|
31
|
+
*
|
|
32
|
+
* resolveRegionPolicy() walks an element's self-or-ancestor chain once and
|
|
33
|
+
* returns the four independent axes the rest of the framework keys off:
|
|
34
|
+
* { watched, autosaveTriggered, undoable, persist, extension }
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
|
|
38
|
+
|
|
39
|
+
export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
|
|
40
|
+
|
|
41
|
+
// The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
|
|
42
|
+
export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-undo', 'no-watch', 'freeze'];
|
|
43
|
+
|
|
44
|
+
// Canonical tokens spellable inside the space-separated `clay` attribute.
|
|
45
|
+
const CLAY_TOKENS = ["no-save", "no-snapshot", "no-trigger-autosave", "no-watch", "no-undo", "freeze"];
|
|
46
|
+
|
|
47
|
+
// True when a region marker is present, whether spelled as a `clay` token
|
|
48
|
+
// (whitespace-token semantics, matching [clay~=token]) or a legacy bare attribute.
|
|
49
|
+
function hasRegionToken(el, token) {
|
|
50
|
+
const clay = el.getAttribute?.("clay");
|
|
51
|
+
if (clay && clay.split(/\s+/).includes(token)) return true; // whitespace-token semantics, matches [clay~=…]
|
|
52
|
+
return !!el.hasAttribute?.(token); // legacy bare attribute
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Serializer selectors (recognize the clay-token spelling FIRST, then new + legacy bare).
|
|
56
|
+
export const STRIP_FROM_SAVE = '[clay~="no-save"], [no-save], [save-remove]';
|
|
57
|
+
export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
|
|
58
|
+
// forComparison additionally strips every region whose autosave-trigger is off,
|
|
59
|
+
// so their churn never marks the page dirty — including the no-watch /
|
|
60
|
+
// mutations-ignore footgun (their content stays in the saved file, but is no
|
|
61
|
+
// longer counted as a change).
|
|
62
|
+
export const STRIP_FROM_COMPARISON =
|
|
63
|
+
'[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
|
|
64
|
+
|
|
65
|
+
// Snapshot-layer marker: removed from EVERY snapshot (save, live-sync broadcast,
|
|
66
|
+
// dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
|
|
67
|
+
// original `snapshot-remove`; hyper-morph treats both as sync-ignored so a
|
|
68
|
+
// live-sync receiver keeps its own local copy instead of deleting it.
|
|
69
|
+
export const SNAPSHOT_REMOVE_SELECTOR = '[clay~="no-snapshot"], [snapshot-remove], [no-snapshot]';
|
|
70
|
+
|
|
71
|
+
export function isSnapshotRemoved(el) {
|
|
72
|
+
return hasRegionToken(el, 'snapshot-remove') || hasRegionToken(el, 'no-snapshot');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PERSIST_RANK = { full: 0, frozen: 1, none: 2 };
|
|
76
|
+
const RANK_PERSIST = ['full', 'frozen', 'none'];
|
|
77
|
+
|
|
78
|
+
function startElement(node) {
|
|
79
|
+
return node && node.nodeType !== 1 ? node.parentElement : node;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Walk an element's self-or-ancestor chain once and resolve its region axes.
|
|
84
|
+
*
|
|
85
|
+
* @param {Node} node
|
|
86
|
+
* @returns {{watched:boolean, autosaveTriggered:boolean, undoable:boolean, persist:string, extension:boolean}}
|
|
87
|
+
*/
|
|
88
|
+
export function resolveRegionPolicy(node) {
|
|
89
|
+
let element = startElement(node);
|
|
90
|
+
|
|
91
|
+
// Browser-extension injected content is never page content, for any consumer.
|
|
92
|
+
if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) {
|
|
93
|
+
return { watched: false, autosaveTriggered: false, undoable: false, persist: PERSIST.FULL, extension: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let watched = true;
|
|
97
|
+
let undoable = true;
|
|
98
|
+
let autosaveOff = false;
|
|
99
|
+
let persistRank = 0;
|
|
100
|
+
|
|
101
|
+
while (element && element.nodeType === 1) {
|
|
102
|
+
if (element.hasAttribute) {
|
|
103
|
+
// new naked attributes
|
|
104
|
+
if (hasRegionToken(element, 'no-watch')) watched = false;
|
|
105
|
+
if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
|
|
106
|
+
if (hasRegionToken(element, 'no-undo')) undoable = false;
|
|
107
|
+
if (hasRegionToken(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
|
|
108
|
+
if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
|
|
109
|
+
// legacy markers -> bundles
|
|
110
|
+
if (hasRegionToken(element, 'mutations-ignore')) watched = false;
|
|
111
|
+
if (hasRegionToken(element, 'save-remove')) { persistRank = Math.max(persistRank, PERSIST_RANK.none); undoable = false; }
|
|
112
|
+
if (hasRegionToken(element, 'save-ignore')) { autosaveOff = true; undoable = false; }
|
|
113
|
+
if (hasRegionToken(element, 'save-freeze')) { persistRank = Math.max(persistRank, PERSIST_RANK.frozen); undoable = false; }
|
|
114
|
+
}
|
|
115
|
+
element = element.parentElement;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Implication rules (no-save wins over freeze automatically via Math.max above):
|
|
119
|
+
// no-watch ⟹ no autosave + no undo (can't track what isn't watched)
|
|
120
|
+
// no-save / freeze ⟹ no autosave (nothing live to persist)
|
|
121
|
+
if (!watched) { autosaveOff = true; undoable = false; }
|
|
122
|
+
if (persistRank > 0) autosaveOff = true;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
watched,
|
|
126
|
+
autosaveTriggered: !autosaveOff,
|
|
127
|
+
undoable,
|
|
128
|
+
persist: RANK_PERSIST[persistRank],
|
|
129
|
+
extension: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Cheap intake-level check: is a node invisible to EVERY consumer?
|
|
135
|
+
*
|
|
136
|
+
* Only no-watch / mutations-ignore (and extension noise) qualify — they're the
|
|
137
|
+
* one universal drop, so the observer can skip walking those subtrees entirely.
|
|
138
|
+
* All other region attributes are resolved per-consumer in Mutation._notify.
|
|
139
|
+
*
|
|
140
|
+
* @param {Node} node
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
export function isInert(node) {
|
|
144
|
+
let element = startElement(node);
|
|
145
|
+
if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) return true;
|
|
146
|
+
while (element && element.nodeType === 1) {
|
|
147
|
+
if (element.hasAttribute &&
|
|
148
|
+
(hasRegionToken(element, 'no-watch') || hasRegionToken(element, 'mutations-ignore'))) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
element = element.parentElement;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Combine two resolved policies into the stricter of each axis. Used to merge a
|
|
158
|
+
* removed (detached) element's own markers with its still-attached parent's.
|
|
159
|
+
*/
|
|
160
|
+
export function strictestPolicy(a, b) {
|
|
161
|
+
return {
|
|
162
|
+
watched: a.watched && b.watched,
|
|
163
|
+
autosaveTriggered: a.autosaveTriggered && b.autosaveTriggered,
|
|
164
|
+
undoable: a.undoable && b.undoable,
|
|
165
|
+
persist: PERSIST_RANK[a.persist] >= PERSIST_RANK[b.persist] ? a.persist : b.persist,
|
|
166
|
+
extension: a.extension || b.extension,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Literal `skip:[...]` escape-hatch tokens -> axis predicate.
|
|
171
|
+
const SKIP_TOKEN_PREDICATES = {
|
|
172
|
+
'no-watch': p => !p.watched,
|
|
173
|
+
'mutations-ignore': p => !p.watched,
|
|
174
|
+
'no-save': p => p.persist === PERSIST.NONE,
|
|
175
|
+
'save-remove': p => p.persist === PERSIST.NONE,
|
|
176
|
+
'freeze': p => p.persist === PERSIST.FROZEN,
|
|
177
|
+
'save-freeze': p => p.persist === PERSIST.FROZEN,
|
|
178
|
+
'no-trigger-autosave': p => !p.autosaveTriggered,
|
|
179
|
+
'save-ignore': p => !p.autosaveTriggered,
|
|
180
|
+
'no-undo': p => !p.undoable,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Should a consumer skip a change in this region?
|
|
185
|
+
*
|
|
186
|
+
* @param {object} policy resolved region policy
|
|
187
|
+
* @param {string} [require] axis the consumer needs: 'observed' | 'autosave' | 'undo'
|
|
188
|
+
* @param {string[]} [skip] literal attribute escape-hatch (any match -> skip)
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
export function skipForPolicy(policy, require, skip) {
|
|
192
|
+
if (policy.extension) return true;
|
|
193
|
+
if (skip && skip.length) {
|
|
194
|
+
return skip.some(tok => SKIP_TOKEN_PREDICATES[tok]?.(policy) || false);
|
|
195
|
+
}
|
|
196
|
+
switch (require) {
|
|
197
|
+
case 'observed': return !policy.watched;
|
|
198
|
+
case 'autosave': return !policy.autosaveTriggered;
|
|
199
|
+
case 'undo': return !policy.undoable;
|
|
200
|
+
default:
|
|
201
|
+
// No require declared: preserve the legacy four-marker skip so unmodified
|
|
202
|
+
// consumers behave exactly as before. (Undo-only opt-outs still pass.)
|
|
203
|
+
return !policy.watched || !policy.autosaveTriggered || policy.persist !== PERSIST.FULL;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The canonical region API the vendored hyper-undo (a separate bundle that can't
|
|
208
|
+
// import this module) delegates "is this undoable?" to via window.hyperclay.region,
|
|
209
|
+
// so the two can no longer drift. The loader assembles this onto the compat shim.
|
|
210
|
+
export const windowRegionShape = {
|
|
211
|
+
resolveRegionPolicy,
|
|
212
|
+
isInert,
|
|
213
|
+
skipForPolicy,
|
|
214
|
+
strictestPolicy,
|
|
215
|
+
PERSIST,
|
|
216
|
+
REGION_ATTRS,
|
|
217
|
+
STRIP_FROM_SAVE,
|
|
218
|
+
FREEZE_SELECTOR,
|
|
219
|
+
STRIP_FROM_COMPARISON,
|
|
220
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
function throttle(callback, delay, executeFirst = true) {
|
|
2
|
+
let lastCall = executeFirst ? 0 : Date.now();
|
|
3
|
+
let timeoutId = null;
|
|
4
|
+
let pendingResolvers = [];
|
|
5
|
+
|
|
6
|
+
return function (...args) {
|
|
7
|
+
const ctx = this;
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const remaining = delay - (now - lastCall);
|
|
10
|
+
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
if (remaining <= 0) {
|
|
13
|
+
clearTimeout(timeoutId);
|
|
14
|
+
timeoutId = null;
|
|
15
|
+
lastCall = now;
|
|
16
|
+
|
|
17
|
+
const resolvers = pendingResolvers.concat(resolve);
|
|
18
|
+
pendingResolvers = [];
|
|
19
|
+
|
|
20
|
+
Promise.resolve(callback.apply(ctx, args))
|
|
21
|
+
.then(value => { for (const r of resolvers) r(value); });
|
|
22
|
+
} else {
|
|
23
|
+
pendingResolvers.push(resolve);
|
|
24
|
+
|
|
25
|
+
if (!timeoutId) {
|
|
26
|
+
timeoutId = setTimeout(() => {
|
|
27
|
+
lastCall = Date.now();
|
|
28
|
+
timeoutId = null;
|
|
29
|
+
const resolvers = pendingResolvers;
|
|
30
|
+
pendingResolvers = [];
|
|
31
|
+
|
|
32
|
+
Promise.resolve(callback.apply(ctx, args))
|
|
33
|
+
.then(value => { for (const r of resolvers) r(value); });
|
|
34
|
+
}, remaining);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default throttle;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* user-gesture — the "was a human driving this write?" signal for the
|
|
3
|
+
* data-clobber guard. Small and dedicated (NOT behaviorCollector, which is the
|
|
4
|
+
* analytics collector).
|
|
5
|
+
*
|
|
6
|
+
* How it works: capture-phase listeners for trusted user-intent events stamp
|
|
7
|
+
* `lastTrustedGestureTs` (and set a same-turn `gestureTaskActive` flag, cleared
|
|
8
|
+
* on the next macrotask). The Mutation hub calls `isUserDrivenNow()`
|
|
9
|
+
* synchronously when it processes a DOM change. A change reads as user-driven if
|
|
10
|
+
* it lands in the same turn as a gesture OR within `RECENT_GESTURE_MS` of the
|
|
11
|
+
* last trusted gesture. The recency window is what attributes CROSS-TURN
|
|
12
|
+
* first-party edits: a button's `click` handler runs a turn after its
|
|
13
|
+
* `pointerdown`, and a confirm-modal delete runs a macrotask after the Confirm
|
|
14
|
+
* gesture (theModal defers its resolve via setTimeout) — both mutate a few ms
|
|
15
|
+
* after the gesture, inside the window. A background setTimeout / fetch().then()
|
|
16
|
+
* mutation with no recent gesture reads background. Synthetic dispatchEvent /
|
|
17
|
+
* el.click() are isTrusted=false, so a script can't fake a gesture.
|
|
18
|
+
*
|
|
19
|
+
* Why a bounded recency window and not `navigator.userActivation`: its transient
|
|
20
|
+
* window is ~5s and consumable — too loose for a recovery tool, since a
|
|
21
|
+
* background clobber within 5s of any click would read user-driven and suppress
|
|
22
|
+
* the chip (silent data loss). `RECENT_GESTURE_MS` is an owned, tight window
|
|
23
|
+
* stamped only by trusted gestures, so it's unspoofable and an order of
|
|
24
|
+
* magnitude less exposed.
|
|
25
|
+
*
|
|
26
|
+
* The accumulated bit rides every save the page makes; the server splits a
|
|
27
|
+
* UI-gestured save from a background-script save with it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// `change` and `submit` are included intentionally. Both are reachable with
|
|
31
|
+
// isTrusted=true from script (checkbox.click(), form.requestSubmit(), a
|
|
32
|
+
// submit-button .click()), so a nefarious script could fake a gesture and suppress
|
|
33
|
+
// the chip. That is out of scope by design: this panel catches ACCIDENTAL
|
|
34
|
+
// deletion, not malicious intent, and we assume scripts are helpful. Listening for
|
|
35
|
+
// them attributes legitimate script- or control-driven form edits as driven, which
|
|
36
|
+
// avoids false-positive chips. (A synthetic dispatchEvent stays isTrusted=false.)
|
|
37
|
+
const GESTURE_EVENTS = ['pointerdown', 'pointerup', 'click', 'keydown', 'beforeinput', 'change', 'submit', 'paste', 'drop', 'cut'];
|
|
38
|
+
|
|
39
|
+
// A cross-turn first-party edit (click handler, confirm-modal delete) mutates
|
|
40
|
+
// within a few ms of its trusted gesture; 500ms is ample margin while keeping
|
|
41
|
+
// the false-negative window (a background clobber near a gesture) an order of
|
|
42
|
+
// magnitude tighter than navigator.userActivation's ~5s.
|
|
43
|
+
const RECENT_GESTURE_MS = 500;
|
|
44
|
+
|
|
45
|
+
let gestureTaskActive = false;
|
|
46
|
+
let lastTrustedGestureTs = -Infinity;
|
|
47
|
+
let userDrivenSinceLastSave = false;
|
|
48
|
+
let installed = false;
|
|
49
|
+
|
|
50
|
+
// Monotonic clock for the recency window — never moves backward, so a system
|
|
51
|
+
// clock change can't widen or break the window (Date.now() can jump).
|
|
52
|
+
function now() {
|
|
53
|
+
return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function markGestureTurn() {
|
|
57
|
+
gestureTaskActive = true;
|
|
58
|
+
lastTrustedGestureTs = now();
|
|
59
|
+
// Clear the same-turn flag on the next macrotask. The MutationObserver
|
|
60
|
+
// microtask for a change made synchronously in this turn drains BEFORE this
|
|
61
|
+
// fires; cross-turn changes fall back to the recency window above.
|
|
62
|
+
setTimeout(() => { gestureTaskActive = false; }, 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onGesture(e) {
|
|
66
|
+
if (!e || !e.isTrusted) return;
|
|
67
|
+
markGestureTurn();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Install the capture-phase gesture listeners (idempotent). Call once in edit
|
|
72
|
+
* mode (autosave.js and data-loss-panel both do this).
|
|
73
|
+
*/
|
|
74
|
+
export function initUserGesture() {
|
|
75
|
+
if (installed || typeof document === 'undefined') return;
|
|
76
|
+
installed = true;
|
|
77
|
+
for (const type of GESTURE_EVENTS) {
|
|
78
|
+
document.addEventListener(type, onGesture, true);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True iff a trusted gesture drove this exact turn, OR one happened within
|
|
84
|
+
* RECENT_GESTURE_MS. The Mutation hub calls this synchronously per change batch.
|
|
85
|
+
*/
|
|
86
|
+
export function isUserDrivenNow() {
|
|
87
|
+
if (gestureTaskActive) return true;
|
|
88
|
+
return (now() - lastTrustedGestureTs) < RECENT_GESTURE_MS;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Record that an autosave-relevant change happened in a user-driven turn. Called
|
|
93
|
+
* by the Mutation hub (per autosave-filtered batch) and by the [persist] input
|
|
94
|
+
* path (a trusted input is itself the gesture+change).
|
|
95
|
+
*/
|
|
96
|
+
export function markUserDriven() {
|
|
97
|
+
userDrivenSinceLastSave = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read-and-reset the accumulated bit. Called at the ACTUAL save send (not on a
|
|
102
|
+
* save that never ships), so it survives the autosave debounce and coalescing.
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function consumeUserDriven() {
|
|
106
|
+
const v = userDrivenSinceLastSave;
|
|
107
|
+
userDrivenSinceLastSave = false;
|
|
108
|
+
return v;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Test seam: force-reset state. */
|
|
112
|
+
export function _resetUserGesture() {
|
|
113
|
+
gestureTaskActive = false;
|
|
114
|
+
lastTrustedGestureTs = -Infinity;
|
|
115
|
+
userDrivenSinceLastSave = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Test seam: open a gesture turn as a trusted event would, without a real
|
|
120
|
+
* trusted event (jsdom can't forge isTrusted). Same semantics as onGesture: the
|
|
121
|
+
* turn is active synchronously and cleared on the next macrotask, and the
|
|
122
|
+
* recency timestamp is stamped.
|
|
123
|
+
*/
|
|
124
|
+
export function _simulateGestureTurn() {
|
|
125
|
+
markGestureTurn();
|
|
126
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const CORE_WAVES = {
|
|
2
|
+
always: [
|
|
3
|
+
"lib/mutation.js", // first: everything hangs off the hub; cms/sync need it in view mode too
|
|
4
|
+
"core/edit-mode.js", // both modes — matches today: 'edit-mode' is NOT in EDIT_MODE_ONLY
|
|
5
|
+
// (hyperclay.js:251-273 lists 'edit-mode-helpers', not 'edit-mode');
|
|
6
|
+
// toggleEditMode must exist in view mode, it's the way IN
|
|
7
|
+
],
|
|
8
|
+
editOnly: [
|
|
9
|
+
"core/snapshot.js", "core/save-core.js", "core/save.js",
|
|
10
|
+
"core/unsaved-warning.js", "core/persist.js",
|
|
11
|
+
"core/admin-attrs.js", "core/autosave.js",
|
|
12
|
+
"attrs/save-freeze.js", "attrs/onaftersave.js", "attrs/refetch-on-save.js",
|
|
13
|
+
"lib/cache-bust.js",
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const PLUGIN_PATHS = {
|
|
18
|
+
richclay: { path: "vendor/richclay.vendor.js", editOnly: true, default: true },
|
|
19
|
+
indicator: { path: "plugins/indicator.js", editOnly: true, default: false },
|
|
20
|
+
sync: { path: "sync/live-sync.js", editOnly: false, default: false },
|
|
21
|
+
sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
|
|
22
|
+
undo: { path: "plugins/undo.js", editOnly: true, default: false },
|
|
23
|
+
cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync"];
|
|
27
|
+
|
|
28
|
+
function parseCsv(params, key, enabled, apply) {
|
|
29
|
+
const raw = params.get(key);
|
|
30
|
+
if (!raw) return;
|
|
31
|
+
for (const token of raw.split(",")) {
|
|
32
|
+
const name = token.trim();
|
|
33
|
+
if (!name) continue;
|
|
34
|
+
if (!PLUGIN_PATHS[name]) {
|
|
35
|
+
console.warn(`clayjs: unknown plugin "${name}"`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
apply(enabled, name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveModules(params, isEditMode) {
|
|
43
|
+
const core = [...CORE_WAVES.always];
|
|
44
|
+
if (isEditMode) core.push(...CORE_WAVES.editOnly);
|
|
45
|
+
|
|
46
|
+
const enabled = new Set();
|
|
47
|
+
for (const [name, spec] of Object.entries(PLUGIN_PATHS)) {
|
|
48
|
+
if (spec.default) enabled.add(name);
|
|
49
|
+
}
|
|
50
|
+
parseCsv(params, "plugins", enabled, (set, name) => set.add(name));
|
|
51
|
+
parseCsv(params, "exclude", enabled, (set, name) => set.delete(name));
|
|
52
|
+
|
|
53
|
+
const plugins = [];
|
|
54
|
+
for (const name of PLUGIN_ORDER) {
|
|
55
|
+
if (!enabled.has(name)) continue;
|
|
56
|
+
const spec = PLUGIN_PATHS[name];
|
|
57
|
+
if (spec.editOnly && !isEditMode) continue;
|
|
58
|
+
plugins.push(spec.path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { core, plugins };
|
|
62
|
+
}
|
package/src/loader.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { resolveModules } from "./loader-logic.js";
|
|
2
|
+
import onDomReady from "./lib/dom-ready.js";
|
|
3
|
+
|
|
4
|
+
function domReady() {
|
|
5
|
+
return new Promise((resolve) => onDomReady(resolve));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function boot(base, params, readyResolve) {
|
|
9
|
+
await domReady(); // Mutation observes document.body
|
|
10
|
+
// unconditionally, and a <head> placement
|
|
11
|
+
// would otherwise observe null
|
|
12
|
+
|
|
13
|
+
const editMode = await import(base + "/src/core/is-edit-mode.js");
|
|
14
|
+
const { isEditMode, isOwner } = editMode;
|
|
15
|
+
|
|
16
|
+
// richclay's vendor build detects edit mode via this legacy global; set it
|
|
17
|
+
// before any plugin import so its autoInit sees the right value. Overwrite
|
|
18
|
+
// even a pre-set value: resolution already consumed it (lowest precedence),
|
|
19
|
+
// and a conflicting leftover would make richclay disable itself.
|
|
20
|
+
window.__hyperclayEditMode = isEditMode;
|
|
21
|
+
|
|
22
|
+
const regionPolicy = await import(base + "/src/lib/region-policy.js");
|
|
23
|
+
|
|
24
|
+
const plan = resolveModules(params, isEditMode);
|
|
25
|
+
const loaded = {};
|
|
26
|
+
|
|
27
|
+
for (const path of plan.core) {
|
|
28
|
+
loaded[path] = await import(base + "/src/" + path); // sequential: order is load-bearing
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
assembleCore(loaded, { isEditMode, isOwner }, regionPolicy); // window.clay + shim MUST exist
|
|
32
|
+
// before any plugin import
|
|
33
|
+
|
|
34
|
+
for (const path of plan.plugins) {
|
|
35
|
+
const mod = await import(base + "/src/" + path);
|
|
36
|
+
loaded[path] = mod;
|
|
37
|
+
attachPluginMember(path, mod); // immediately, not after the loop: hypercms's ?cms=true
|
|
38
|
+
// auto-open runs as a microtask queued during ITS evaluation
|
|
39
|
+
// (before boot resumes) and reads window.hyperclay.RichClay
|
|
40
|
+
// and .undo, which earlier plugins must have mirrored by then
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Plugins with async setup (sortable's vendor fetch) export `ready`; hold
|
|
44
|
+
// clay.ready until they finish. allSettled: a failed plugin degrades, never blocks boot.
|
|
45
|
+
await Promise.allSettled(plan.plugins.map((path) => loaded[path]?.ready).filter(Boolean));
|
|
46
|
+
|
|
47
|
+
installViewModeNotice(isEditMode);
|
|
48
|
+
readyResolve(window.clay);
|
|
49
|
+
document.dispatchEvent(new CustomEvent("clay:ready", { detail: { clay: window.clay } }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
53
|
+
const clay = window.clay;
|
|
54
|
+
|
|
55
|
+
const mutation = loaded["lib/mutation.js"];
|
|
56
|
+
const editModeMod = loaded["core/edit-mode.js"];
|
|
57
|
+
|
|
58
|
+
Object.assign(clay, {
|
|
59
|
+
toggleEditMode: editModeMod.toggleEditMode,
|
|
60
|
+
isEditMode,
|
|
61
|
+
isOwner,
|
|
62
|
+
Mutation: mutation.default,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const snapshot = loaded["core/snapshot.js"];
|
|
66
|
+
const save = loaded["core/save.js"];
|
|
67
|
+
const cacheBustMod = loaded["lib/cache-bust.js"];
|
|
68
|
+
|
|
69
|
+
if (save) {
|
|
70
|
+
const saveFn = save.savePage || save.default;
|
|
71
|
+
saveFn.force = save.savePageForce;
|
|
72
|
+
clay.save = saveFn;
|
|
73
|
+
}
|
|
74
|
+
if (snapshot) {
|
|
75
|
+
clay.getHTML = snapshot.getPageContents;
|
|
76
|
+
clay.beforeSave = snapshot.beforeSave;
|
|
77
|
+
clay.onSnapshot = snapshot.onSnapshot;
|
|
78
|
+
}
|
|
79
|
+
if (cacheBustMod) {
|
|
80
|
+
clay.cacheBust = cacheBustMod.default;
|
|
81
|
+
}
|
|
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
|
+
}
|
|
99
|
+
|
|
100
|
+
function attachPluginMember(path, mod) {
|
|
101
|
+
const clay = window.clay;
|
|
102
|
+
|
|
103
|
+
if (path === "plugins/undo.js") {
|
|
104
|
+
clay.undo = mod.undo || mod.default;
|
|
105
|
+
window.hyperclay.undo = clay.undo;
|
|
106
|
+
} else if (path === "sync/live-sync.js") {
|
|
107
|
+
clay.morph = mod.morph;
|
|
108
|
+
} else if (path === "vendor/hypercms.vendor.js") {
|
|
109
|
+
clay.cms = mod.cms || mod.default;
|
|
110
|
+
window.hyperclay.hypercms = clay.cms;
|
|
111
|
+
} else if (path === "vendor/richclay.vendor.js") {
|
|
112
|
+
window.hyperclay.RichClay = mod.RichClay || mod.default;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function installViewModeNotice(isEditMode) {
|
|
117
|
+
if (isEditMode) return;
|
|
118
|
+
document.addEventListener("click", (event) => {
|
|
119
|
+
if (!event.target.closest?.("[trigger-save]")) return;
|
|
120
|
+
console.info("clayjs: you're not the owner of this page; changes stay local");
|
|
121
|
+
document.dispatchEvent(new CustomEvent("clay:view-save-attempt"));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isEditMode } from "../core/is-edit-mode.js";
|
|
2
|
+
import onDomReady from "../lib/dom-ready.js";
|
|
3
|
+
|
|
4
|
+
const LABELS = {
|
|
5
|
+
saving: "Saving…",
|
|
6
|
+
saved: "Saved",
|
|
7
|
+
error: "Couldn't save",
|
|
8
|
+
offline: "Offline, not saved",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
let el = null;
|
|
12
|
+
let hideTimer = null;
|
|
13
|
+
|
|
14
|
+
function ensure() {
|
|
15
|
+
if (el) return el;
|
|
16
|
+
el = document.createElement("div");
|
|
17
|
+
el.setAttribute("clay", "no-save no-watch no-snapshot");
|
|
18
|
+
el.setAttribute("data-clay-indicator", "");
|
|
19
|
+
el.setAttribute("role", "status");
|
|
20
|
+
el.style.cssText = [
|
|
21
|
+
"position:fixed", "right:16px", "bottom:16px", "z-index:2147483000",
|
|
22
|
+
"padding:4px 12px", "border-radius:999px",
|
|
23
|
+
"font:13px/1.6 system-ui,-apple-system,sans-serif",
|
|
24
|
+
"background:var(--clay-indicator-bg,#2e2b27)",
|
|
25
|
+
"color:var(--clay-indicator-ink,#f2f0eb)",
|
|
26
|
+
"opacity:0", "transition:opacity .25s", "pointer-events:none",
|
|
27
|
+
].join(";");
|
|
28
|
+
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) el.style.transition = "none";
|
|
29
|
+
document.body.appendChild(el);
|
|
30
|
+
return el;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function show(state) {
|
|
34
|
+
const node = ensure();
|
|
35
|
+
node.textContent = LABELS[state];
|
|
36
|
+
node.dataset.state = state;
|
|
37
|
+
node.style.background = state === "error" || state === "offline"
|
|
38
|
+
? "var(--clay-indicator-error-bg,#7a3b28)" : "var(--clay-indicator-bg,#2e2b27)";
|
|
39
|
+
node.style.opacity = "1";
|
|
40
|
+
clearTimeout(hideTimer);
|
|
41
|
+
if (state !== "saving") hideTimer = setTimeout(() => { node.style.opacity = "0"; }, 2200);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function init() {
|
|
45
|
+
if (!isEditMode) return;
|
|
46
|
+
for (const state of Object.keys(LABELS)) {
|
|
47
|
+
document.addEventListener("clay:save-" + state, () => show(state));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onDomReady(init);
|