@panphora/clayjs 0.6.0 → 0.7.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 +12 -17
- package/README.md +33 -15
- package/THIRD-PARTY-NOTICES.md +40 -0
- package/_headers +12 -0
- package/clay-events.js +9 -0
- package/package.json +3 -3
- package/src/attrs/onaftersave.js +23 -9
- package/src/attrs/refetch-on-save.js +38 -18
- package/src/core/autosave.js +11 -4
- package/src/core/host-attrs.js +1 -16
- package/src/core/host-meta.js +107 -0
- package/src/core/save-core.js +26 -27
- package/src/core/save.js +119 -28
- package/src/core/snapshot.js +121 -26
- package/src/core/unsaved-warning.js +11 -7
- package/src/internals/index.js +3 -26
- package/src/lib/authored-url.js +96 -0
- package/src/lib/cache-bust.js +16 -12
- package/src/lib/dirty-gate.js +28 -10
- package/src/lib/mutation.js +5 -3
- package/src/lib/region-policy.js +98 -18
- package/src/lib/root-attrs.js +0 -5
- package/src/lib/user-gesture.js +33 -1
- package/src/loader-logic.js +29 -1
- package/src/loader.js +4 -0
- package/src/plugins/demo.js +14 -8
- package/src/plugins/upload.js +185 -0
- package/src/sync/live-sync.js +157 -12
- package/src/sync/splice-merge.js +21 -7
- package/src/vendor/hypercms.vendor.js +53 -11
- package/src/vendor/quickcrop.vendor.js +401 -0
- package/NOTICE +0 -19
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authored-url.js — keeping a runtime URL rewrite out of the saved file.
|
|
3
|
+
*
|
|
4
|
+
* cacheBust and refetch-on-save both rewrite an href/src after a save, so the
|
|
5
|
+
* browser re-fetches the asset. That write lands AFTER the save baseline was
|
|
6
|
+
* taken, so without help it reads as an edit the person made: the page goes
|
|
7
|
+
* dirty the instant a save succeeds, warns on close about work that was already
|
|
8
|
+
* written, and on an autosave page saves again immediately.
|
|
9
|
+
*
|
|
10
|
+
* The old answer stamped the element `no-trigger-autosave` on the way past. It
|
|
11
|
+
* worked only from the SECOND save on — the baseline already held the element
|
|
12
|
+
* unmarked, so the first comparison stripped something the baseline contained
|
|
13
|
+
* and the page read dirty anyway. It also spent a region marker, which changes
|
|
14
|
+
* how undo, autosave and live-sync treat that subtree forever, to paper over a
|
|
15
|
+
* serialization problem.
|
|
16
|
+
*
|
|
17
|
+
* This fixes the bytes instead. Remember what the URL was authored as, restore
|
|
18
|
+
* it on every snapshot clone, and leave the region model alone. The live DOM
|
|
19
|
+
* keeps the busted URL, the file keeps the authored one, and no comparison ever
|
|
20
|
+
* sees a difference.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const AUTHORED = 'clay-authored-url';
|
|
24
|
+
const RUNTIME = 'clay-runtime-url';
|
|
25
|
+
const ATTR = 'clay-url-attr';
|
|
26
|
+
const SUPERSEDED = 'clay-superseded';
|
|
27
|
+
|
|
28
|
+
// Every attribute this module owns. They live on the live element and are
|
|
29
|
+
// stripped from every clone, so none of them ever reaches the file.
|
|
30
|
+
const OWNED = [AUTHORED, RUNTIME, ATTR, SUPERSEDED];
|
|
31
|
+
|
|
32
|
+
/** Which attribute carries this element's URL, matching both call sites' rule. */
|
|
33
|
+
export function urlAttrFor(el) {
|
|
34
|
+
return el.hasAttribute('href') ? 'href' : el.hasAttribute('src') ? 'src' : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write a clay-generated URL to the live DOM, remembering the authored one.
|
|
39
|
+
*
|
|
40
|
+
* Record-if-absent is the important part. A second cache-bust must not record
|
|
41
|
+
* the first bust's output as the authored value, or the file accumulates `?v=`
|
|
42
|
+
* stamps and the restore puts back a stale URL. It also makes the two helpers
|
|
43
|
+
* compose: when refetch clones an element cacheBust already touched, the clone
|
|
44
|
+
* carries the original authored value and keeps it.
|
|
45
|
+
*/
|
|
46
|
+
export function writeRuntimeUrl(el, attr, value) {
|
|
47
|
+
if (!el.hasAttribute(AUTHORED)) {
|
|
48
|
+
el.setAttribute(AUTHORED, el.getAttribute(attr) ?? '');
|
|
49
|
+
el.setAttribute(ATTR, attr);
|
|
50
|
+
}
|
|
51
|
+
el.setAttribute(attr, value);
|
|
52
|
+
el.setAttribute(RUNTIME, value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mark an element that a replacement has been inserted next to.
|
|
57
|
+
*
|
|
58
|
+
* refetch-on-save leaves the old element in the DOM until the new one loads, so
|
|
59
|
+
* for up to two seconds the page holds both. Every snapshot taken in that window
|
|
60
|
+
* must serialize exactly one, or a capture differs from the baseline by a whole
|
|
61
|
+
* duplicated element.
|
|
62
|
+
*/
|
|
63
|
+
export function markSuperseded(el) {
|
|
64
|
+
el.setAttribute(SUPERSEDED, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isSuperseded(el) {
|
|
68
|
+
return el.hasAttribute(SUPERSEDED);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Restore authored URLs on a snapshot clone and drop every attribute this
|
|
73
|
+
* module owns. Runs inside captureSnapshot, so it reaches the save clone, both
|
|
74
|
+
* comparison clones, and the live-sync broadcast alike.
|
|
75
|
+
*/
|
|
76
|
+
export function restoreAuthoredUrls(clone) {
|
|
77
|
+
for (const el of clone.querySelectorAll(`[${SUPERSEDED}]`)) {
|
|
78
|
+
el.remove();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const el of clone.querySelectorAll(`[${AUTHORED}]`)) {
|
|
82
|
+
const attr = el.getAttribute(ATTR);
|
|
83
|
+
const authored = el.getAttribute(AUTHORED);
|
|
84
|
+
const runtime = el.getAttribute(RUNTIME);
|
|
85
|
+
|
|
86
|
+
// Restore only while the live value is still the one clay wrote. If the page
|
|
87
|
+
// has changed it since, that is a real authored edit: restoring would
|
|
88
|
+
// discard it silently, and keep discarding it on every save afterwards.
|
|
89
|
+
if (attr && el.getAttribute(attr) === runtime) {
|
|
90
|
+
if (authored) el.setAttribute(attr, authored);
|
|
91
|
+
else el.removeAttribute(attr);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const name of OWNED) el.removeAttribute(name);
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/lib/cache-bust.js
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
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
|
|
4
|
+
import Mutation from './mutation.js';
|
|
5
|
+
import { urlAttrFor, writeRuntimeUrl } from './authored-url.js';
|
|
5
6
|
|
|
6
7
|
function cacheBust(el) {
|
|
7
|
-
const attr = el
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const attr = urlAttrFor(el);
|
|
9
|
+
if (!attr) return;
|
|
10
|
+
|
|
11
|
+
const url = new URL(el.getAttribute(attr), location.href);
|
|
10
12
|
url.searchParams.set('v', Date.now());
|
|
11
|
-
el.setAttribute(attr, url.href);
|
|
12
13
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
// Paused because this is clay writing, not the person editing: it must not
|
|
15
|
+
// schedule an autosave. Keeping it out of the SAVED FILE is a separate job,
|
|
16
|
+
// done by remembering the authored URL and restoring it on every snapshot
|
|
17
|
+
// clone (see authored-url.js) rather than by marking the region.
|
|
18
|
+
Mutation.pause();
|
|
19
|
+
try {
|
|
20
|
+
writeRuntimeUrl(el, attr, url.href);
|
|
21
|
+
} finally {
|
|
22
|
+
Mutation.resume();
|
|
23
|
+
}
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export default cacheBust;
|
package/src/lib/dirty-gate.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* a false "clean" full-morphs over a real unsaved edit.
|
|
9
9
|
*
|
|
10
10
|
* Two feeds, because neither alone sees everything:
|
|
11
|
-
* - the mutation hub (require: '
|
|
11
|
+
* - the mutation hub (require: 'dirty'), which sees DOM changes but not
|
|
12
12
|
* value-property writes on form controls (typing fires no MutationRecord
|
|
13
13
|
* for .value), and
|
|
14
14
|
* - capture-phase input/change listeners on ALL form controls, not just
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import Mutation from './mutation.js';
|
|
29
29
|
import { isEditMode } from '../core/is-edit-mode.js';
|
|
30
|
-
import {
|
|
30
|
+
import { STRIP_FROM_DIRTY_CHECK, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
|
|
31
31
|
|
|
32
32
|
let changes = 0;
|
|
33
33
|
let clearedAt = 0;
|
|
@@ -36,13 +36,18 @@ let started = false;
|
|
|
36
36
|
|
|
37
37
|
const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
|
|
38
38
|
|
|
39
|
-
// Regions the
|
|
40
|
-
// `require: '
|
|
41
|
-
//
|
|
39
|
+
// Regions the loss oracle never sees. The hub feed already skips them, through
|
|
40
|
+
// `require: 'dirty'`, and the input feed has to skip them for the same reason:
|
|
41
|
+
// their content is stripped from the merge's compare clone, so an edit inside
|
|
42
42
|
// one can never produce a dirty root, and counting it marks the page dirty with
|
|
43
43
|
// nothing for the oracle to find — permanently, since only a save clears the
|
|
44
44
|
// counter and churn in these regions triggers none.
|
|
45
45
|
//
|
|
46
|
+
// The gate and the oracle must key off the SAME domain. This is the dirty
|
|
47
|
+
// domain, so no-trigger-autosave is deliberately absent: the oracle can now
|
|
48
|
+
// report an unsaved batching edit, so the gate has to arm for one. Disposable
|
|
49
|
+
// churn leaves via no-dirty, which is in both.
|
|
50
|
+
//
|
|
46
51
|
// This is not a relaxation of "never under-report". A control here is absent
|
|
47
52
|
// from the clone by definition, so there is nothing about it to under-report.
|
|
48
53
|
// It matters because a mounted tool (redpen's answer field, any panel that
|
|
@@ -50,9 +55,22 @@ const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
|
|
|
50
55
|
// it used to freeze the live-sync save baseline for the rest of the session,
|
|
51
56
|
// after which every incoming disk change was diffed against a stale base, and
|
|
52
57
|
// the previous change was spliced back over the newer one and written to disk.
|
|
53
|
-
const GATE_IGNORE = `${
|
|
58
|
+
const GATE_IGNORE = `${STRIP_FROM_DIRTY_CHECK}, ${SNAPSHOT_REMOVE_SELECTOR}`;
|
|
54
59
|
const probeCache = new WeakMap();
|
|
55
60
|
|
|
61
|
+
// The [persist] probe has to honour GATE_IGNORE too. It scans by attribute, not
|
|
62
|
+
// through the mutation hub, so without this a programmatic .value write inside a
|
|
63
|
+
// no-dirty region pins the gate dirty forever even though every DOM mutation
|
|
64
|
+
// there is ignored — the one hole that would survive the domain switch.
|
|
65
|
+
function gatedPersistControls() {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
|
|
68
|
+
if (el.closest(GATE_IGNORE)) continue;
|
|
69
|
+
out.push(el);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
function onUserInput(event) {
|
|
57
75
|
// Deliberately NOT gated on `paused`: a morph never dispatches input or
|
|
58
76
|
// change events, so anything arriving here is the user — including typing
|
|
@@ -68,7 +86,7 @@ export function startDirtyGate() {
|
|
|
68
86
|
if (started) return;
|
|
69
87
|
started = true;
|
|
70
88
|
Mutation.onAnyChange(
|
|
71
|
-
{ omitChangeDetails: true, require: '
|
|
89
|
+
{ omitChangeDetails: true, require: 'dirty' },
|
|
72
90
|
() => {
|
|
73
91
|
if (!paused) changes++;
|
|
74
92
|
}
|
|
@@ -115,7 +133,7 @@ function serializedStateMatches(el) {
|
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
export function persistProbeDirty() {
|
|
118
|
-
for (const el of
|
|
136
|
+
for (const el of gatedPersistControls()) {
|
|
119
137
|
const sig = controlSignature(el);
|
|
120
138
|
if (probeCache.get(el) === sig) continue;
|
|
121
139
|
if (serializedStateMatches(el)) {
|
|
@@ -129,7 +147,7 @@ export function persistProbeDirty() {
|
|
|
129
147
|
|
|
130
148
|
/** Call after the oracle verified the whole page clean against its baseline. */
|
|
131
149
|
export function probeMarkClean() {
|
|
132
|
-
for (const el of
|
|
150
|
+
for (const el of gatedPersistControls()) {
|
|
133
151
|
probeCache.set(el, controlSignature(el));
|
|
134
152
|
}
|
|
135
153
|
}
|
|
@@ -145,7 +163,7 @@ export function pageMaybeDirty() {
|
|
|
145
163
|
*/
|
|
146
164
|
export function gateCaptureToken() {
|
|
147
165
|
const probe = [];
|
|
148
|
-
for (const el of
|
|
166
|
+
for (const el of gatedPersistControls()) {
|
|
149
167
|
probe.push([el, controlSignature(el)]);
|
|
150
168
|
}
|
|
151
169
|
return { gen: changes, probe };
|
package/src/lib/mutation.js
CHANGED
|
@@ -604,12 +604,14 @@ const localMutation = {
|
|
|
604
604
|
});
|
|
605
605
|
|
|
606
606
|
// Data-guard provenance: if a trusted gesture drove this turn (or one
|
|
607
|
-
// happened within the recency window) and any change is
|
|
608
|
-
// (
|
|
607
|
+
// happened within the recency window) and any change is dirty-relevant
|
|
608
|
+
// (i.e. it reaches the saved bytes), mark the pending save user-driven.
|
|
609
|
+
// The DIRTY domain, not the autosave one: an edit inside a batching region
|
|
610
|
+
// is still the person's work, and the save that carries it is still human.
|
|
609
611
|
// Runs synchronously in the MO callback; skipped during paused-morph drains.
|
|
610
612
|
if (!onlyNonPausable && isUserDrivenNow()) {
|
|
611
613
|
for (const change of changes) {
|
|
612
|
-
if (!skipForPolicy(this._policyForChange(change), '
|
|
614
|
+
if (!skipForPolicy(this._policyForChange(change), 'dirty')) {
|
|
613
615
|
markUserDriven();
|
|
614
616
|
break;
|
|
615
617
|
}
|
package/src/lib/region-policy.js
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* resolved for back-compat. They apply to an element and its descendants:
|
|
8
8
|
*
|
|
9
9
|
* no-save — not written to the saved file (stripped). Live at runtime.
|
|
10
|
-
* no-trigger-autosave — saved,
|
|
10
|
+
* no-trigger-autosave — saved, and still counts as unsaved work, but editing it does
|
|
11
|
+
* not start an autosave. Durable work the person batches by hand.
|
|
12
|
+
* no-dirty — saved, but its content is disposable: no autosave, no close
|
|
13
|
+
* warning, and an incoming sync frame may replace it.
|
|
11
14
|
* no-undo — edits here are not recorded in the undo stack.
|
|
12
15
|
* no-watch — invisible to the whole mutation system (high-churn regions). Still saved.
|
|
13
16
|
* freeze — saved as authored (runtime changes not persisted). Live at runtime.
|
|
@@ -17,9 +20,15 @@
|
|
|
17
20
|
*
|
|
18
21
|
* mutations-ignore -> no-watch
|
|
19
22
|
* save-remove -> no-save no-undo
|
|
20
|
-
* save-ignore -> no-
|
|
23
|
+
* save-ignore -> no-dirty no-undo
|
|
21
24
|
* save-freeze -> freeze no-undo
|
|
22
25
|
*
|
|
26
|
+
* save-ignore maps to no-dirty, NOT to no-trigger-autosave. hyper-morph has always
|
|
27
|
+
* defined it as local-instance chrome ("explicit leave me alone") and sync-ignores
|
|
28
|
+
* it, and every real use of it in the wild is a generated stylesheet <link>. Calling
|
|
29
|
+
* it a spelling of no-trigger-autosave gave clayjs a durable region that hyper-morph
|
|
30
|
+
* refuses to sync, which is a contradiction no merge domain can express.
|
|
31
|
+
*
|
|
23
32
|
* Separately, a snapshot-layer marker controls whether an element appears in any
|
|
24
33
|
* snapshot at all (save file, live-sync broadcast, and dirty-comparison):
|
|
25
34
|
*
|
|
@@ -31,7 +40,7 @@
|
|
|
31
40
|
*
|
|
32
41
|
* resolveRegionPolicy() walks an element's self-or-ancestor chain once and
|
|
33
42
|
* returns the four independent axes the rest of the framework keys off:
|
|
34
|
-
* { watched, autosaveTriggered, undoable, persist, extension }
|
|
43
|
+
* { watched, autosaveTriggered, dirtyTracked, undoable, persist, extension }
|
|
35
44
|
*/
|
|
36
45
|
|
|
37
46
|
import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
|
|
@@ -39,10 +48,12 @@ import { EXTENSION_NODE_SELECTOR } from './extension-noise.js';
|
|
|
39
48
|
export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
|
|
40
49
|
|
|
41
50
|
// 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'];
|
|
51
|
+
export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'freeze'];
|
|
43
52
|
|
|
44
|
-
//
|
|
45
|
-
|
|
53
|
+
// Every canonical token spellable inside the space-separated `clay` attribute.
|
|
54
|
+
// Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
|
|
55
|
+
// while callers hardcoded their own copies.
|
|
56
|
+
export const TOKENS = ["no-save", "no-snapshot", "no-trigger-autosave", "no-dirty", "no-watch", "no-undo", "freeze"];
|
|
46
57
|
|
|
47
58
|
// True when a region marker is present, whether spelled as a `clay` token
|
|
48
59
|
// (whitespace-token semantics, matching [clay~=token]) or a legacy bare attribute.
|
|
@@ -75,7 +86,32 @@ export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
|
|
|
75
86
|
// mutations-ignore footgun (their content stays in the saved file, but is no
|
|
76
87
|
// longer counted as a change).
|
|
77
88
|
export const STRIP_FROM_COMPARISON =
|
|
78
|
-
'[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]';
|
|
89
|
+
'[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
|
|
90
|
+
|
|
91
|
+
// Every spelling of no-trigger-autosave: the ONE difference between the autosave
|
|
92
|
+
// domain and the dirty domain. A document containing none of these has identical
|
|
93
|
+
// domains, which is what lets captureForSaveAndComparison short-circuit.
|
|
94
|
+
// Derived here rather than written out at the call site so a page using the
|
|
95
|
+
// legacy `save-ignore` spelling cannot get a dirty domain the policy disagrees
|
|
96
|
+
// with — the split-brain where savePage() skips while the close warning fires.
|
|
97
|
+
export const NO_TRIGGER_AUTOSAVE_SELECTOR =
|
|
98
|
+
'[clay~="no-trigger-autosave"], [no-trigger-autosave]';
|
|
99
|
+
|
|
100
|
+
// Disposable regions: saved, but their content is explicitly not work. Stripped
|
|
101
|
+
// from BOTH comparison domains, skipped by the merge on both sides, and never
|
|
102
|
+
// counted by the close warning. This is the declared signal that lets the merge
|
|
103
|
+
// stop guessing whether churn inside a batching region was a person or a renderer.
|
|
104
|
+
export const NO_DIRTY_SELECTOR =
|
|
105
|
+
'[clay~="no-dirty"], [no-dirty], [save-ignore]';
|
|
106
|
+
|
|
107
|
+
// forDirtyCheck strips everything forComparison does EXCEPT no-trigger-autosave.
|
|
108
|
+
// An edit inside such a region is a real edit: the person can save it by hand and
|
|
109
|
+
// must be warned about it on close. It just doesn't start an autosave by itself.
|
|
110
|
+
// no-watch stays stripped — a region invisible to the mutation system cannot be
|
|
111
|
+
// dirty-tracked either, and counting it would re-freeze the live-sync baseline
|
|
112
|
+
// the way the mounted-tool bug did (see dirty-gate.js).
|
|
113
|
+
export const STRIP_FROM_DIRTY_CHECK =
|
|
114
|
+
'[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
|
|
79
115
|
|
|
80
116
|
// Snapshot-layer marker: removed from EVERY snapshot (save, live-sync broadcast,
|
|
81
117
|
// dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
|
|
@@ -83,8 +119,13 @@ export const STRIP_FROM_COMPARISON =
|
|
|
83
119
|
// live-sync receiver keeps its own local copy instead of deleting it.
|
|
84
120
|
export const SNAPSHOT_REMOVE_SELECTOR = '[clay~="no-snapshot"], [snapshot-remove], [no-snapshot]';
|
|
85
121
|
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
// Ancestor-aware, because the strip removes a marked element together with its
|
|
123
|
+
// whole subtree: a child of a no-snapshot region is just as absent from every
|
|
124
|
+
// snapshot as the region itself, and answering false for it was a lie callers
|
|
125
|
+
// acted on.
|
|
126
|
+
export function isSnapshotRemoved(node) {
|
|
127
|
+
const element = startElement(node);
|
|
128
|
+
return !!(element && element.closest && element.closest(SNAPSHOT_REMOVE_SELECTOR));
|
|
88
129
|
}
|
|
89
130
|
|
|
90
131
|
const PERSIST_RANK = { full: 0, frozen: 1, none: 2 };
|
|
@@ -98,19 +139,20 @@ function startElement(node) {
|
|
|
98
139
|
* Walk an element's self-or-ancestor chain once and resolve its region axes.
|
|
99
140
|
*
|
|
100
141
|
* @param {Node} node
|
|
101
|
-
* @returns {{watched:boolean, autosaveTriggered:boolean, undoable:boolean, persist:string, extension:boolean}}
|
|
142
|
+
* @returns {{watched:boolean, autosaveTriggered:boolean, dirtyTracked:boolean, undoable:boolean, persist:string, extension:boolean}}
|
|
102
143
|
*/
|
|
103
144
|
export function resolveRegionPolicy(node) {
|
|
104
145
|
let element = startElement(node);
|
|
105
146
|
|
|
106
147
|
// Browser-extension injected content is never page content, for any consumer.
|
|
107
148
|
if (element && element.closest && element.closest(EXTENSION_NODE_SELECTOR)) {
|
|
108
|
-
return { watched: false, autosaveTriggered: false, undoable: false, persist: PERSIST.FULL, extension: true };
|
|
149
|
+
return { watched: false, autosaveTriggered: false, dirtyTracked: false, undoable: false, persist: PERSIST.FULL, extension: true };
|
|
109
150
|
}
|
|
110
151
|
|
|
111
152
|
let watched = true;
|
|
112
153
|
let undoable = true;
|
|
113
154
|
let autosaveOff = false;
|
|
155
|
+
let dirtyOff = false;
|
|
114
156
|
let persistRank = 0;
|
|
115
157
|
|
|
116
158
|
while (element && element.nodeType === 1) {
|
|
@@ -118,13 +160,14 @@ export function resolveRegionPolicy(node) {
|
|
|
118
160
|
// new naked attributes
|
|
119
161
|
if (hasRegionToken(element, 'no-watch')) watched = false;
|
|
120
162
|
if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
|
|
163
|
+
if (hasRegionToken(element, 'no-dirty')) { autosaveOff = true; dirtyOff = true; }
|
|
121
164
|
if (hasRegionToken(element, 'no-undo')) undoable = false;
|
|
122
165
|
if (hasRegionToken(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
|
|
123
166
|
if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
|
|
124
167
|
// legacy markers -> bundles
|
|
125
168
|
if (hasRegionToken(element, 'mutations-ignore')) watched = false;
|
|
126
169
|
if (hasRegionToken(element, 'save-remove')) { persistRank = Math.max(persistRank, PERSIST_RANK.none); undoable = false; }
|
|
127
|
-
if (hasRegionToken(element, 'save-ignore')) { autosaveOff = true; undoable = false; }
|
|
170
|
+
if (hasRegionToken(element, 'save-ignore')) { autosaveOff = true; dirtyOff = true; undoable = false; }
|
|
128
171
|
if (hasRegionToken(element, 'save-freeze')) { persistRank = Math.max(persistRank, PERSIST_RANK.frozen); undoable = false; }
|
|
129
172
|
}
|
|
130
173
|
element = element.parentElement;
|
|
@@ -133,12 +176,18 @@ export function resolveRegionPolicy(node) {
|
|
|
133
176
|
// Implication rules (no-save wins over freeze automatically via Math.max above):
|
|
134
177
|
// no-watch ⟹ no autosave + no undo (can't track what isn't watched)
|
|
135
178
|
// no-save / freeze ⟹ no autosave (nothing live to persist)
|
|
136
|
-
|
|
137
|
-
|
|
179
|
+
// no-watch ⟹ not dirty-tracked either (can't track what isn't watched)
|
|
180
|
+
// no-save / freeze ⟹ not dirty-tracked (nothing live to persist)
|
|
181
|
+
// no-trigger-autosave deliberately does NOT clear dirtyTracked: that is the
|
|
182
|
+
// whole distinction between the two axes. no-dirty is the token that clears
|
|
183
|
+
// both, for content that is saved but is not work.
|
|
184
|
+
if (!watched) { autosaveOff = true; undoable = false; dirtyOff = true; }
|
|
185
|
+
if (persistRank > 0) { autosaveOff = true; dirtyOff = true; }
|
|
138
186
|
|
|
139
187
|
return {
|
|
140
188
|
watched,
|
|
141
189
|
autosaveTriggered: !autosaveOff,
|
|
190
|
+
dirtyTracked: !dirtyOff,
|
|
142
191
|
undoable,
|
|
143
192
|
persist: RANK_PERSIST[persistRank],
|
|
144
193
|
extension: false,
|
|
@@ -176,6 +225,7 @@ export function strictestPolicy(a, b) {
|
|
|
176
225
|
return {
|
|
177
226
|
watched: a.watched && b.watched,
|
|
178
227
|
autosaveTriggered: a.autosaveTriggered && b.autosaveTriggered,
|
|
228
|
+
dirtyTracked: a.dirtyTracked && b.dirtyTracked,
|
|
179
229
|
undoable: a.undoable && b.undoable,
|
|
180
230
|
persist: PERSIST_RANK[a.persist] >= PERSIST_RANK[b.persist] ? a.persist : b.persist,
|
|
181
231
|
extension: a.extension || b.extension,
|
|
@@ -191,7 +241,8 @@ const SKIP_TOKEN_PREDICATES = {
|
|
|
191
241
|
'freeze': p => p.persist === PERSIST.FROZEN,
|
|
192
242
|
'save-freeze': p => p.persist === PERSIST.FROZEN,
|
|
193
243
|
'no-trigger-autosave': p => !p.autosaveTriggered,
|
|
194
|
-
'
|
|
244
|
+
'no-dirty': p => !p.dirtyTracked,
|
|
245
|
+
'save-ignore': p => !p.dirtyTracked,
|
|
195
246
|
'no-undo': p => !p.undoable,
|
|
196
247
|
};
|
|
197
248
|
|
|
@@ -199,7 +250,7 @@ const SKIP_TOKEN_PREDICATES = {
|
|
|
199
250
|
* Should a consumer skip a change in this region?
|
|
200
251
|
*
|
|
201
252
|
* @param {object} policy resolved region policy
|
|
202
|
-
* @param {string} [require] axis the consumer needs: 'observed' | 'autosave' | 'undo'
|
|
253
|
+
* @param {string} [require] axis the consumer needs: 'observed' | 'autosave' | 'dirty' | 'undo'
|
|
203
254
|
* @param {string[]} [skip] literal attribute escape-hatch (any match -> skip)
|
|
204
255
|
* @returns {boolean}
|
|
205
256
|
*/
|
|
@@ -211,6 +262,7 @@ export function skipForPolicy(policy, require, skip) {
|
|
|
211
262
|
switch (require) {
|
|
212
263
|
case 'observed': return !policy.watched;
|
|
213
264
|
case 'autosave': return !policy.autosaveTriggered;
|
|
265
|
+
case 'dirty': return !policy.dirtyTracked;
|
|
214
266
|
case 'undo': return !policy.undoable;
|
|
215
267
|
default:
|
|
216
268
|
// No require declared: preserve the legacy four-marker skip so unmodified
|
|
@@ -222,14 +274,42 @@ export function skipForPolicy(policy, require, skip) {
|
|
|
222
274
|
// The canonical region API the vendored hyper-undo (a separate bundle that can't
|
|
223
275
|
// import this module) delegates "is this undoable?" to via clay.region, so the two
|
|
224
276
|
// can no longer drift. The loader assembles this onto clay in assembleCore.
|
|
225
|
-
|
|
277
|
+
// ONE object, served to both clay.region and clay.internals.region.
|
|
278
|
+
//
|
|
279
|
+
// They had drifted into two shapes with different key spellings for the same
|
|
280
|
+
// selectors, and both are documented, so this is an additive union rather than a
|
|
281
|
+
// choice between them: every name either surface published still resolves.
|
|
282
|
+
export const regionShape = {
|
|
226
283
|
resolveRegionPolicy,
|
|
227
284
|
isInert,
|
|
285
|
+
isSnapshotRemoved,
|
|
228
286
|
skipForPolicy,
|
|
229
287
|
strictestPolicy,
|
|
288
|
+
addRegionToken,
|
|
230
289
|
PERSIST,
|
|
290
|
+
TOKENS,
|
|
231
291
|
REGION_ATTRS,
|
|
292
|
+
|
|
293
|
+
// Flat UPPERCASE names: what clay.region has always published.
|
|
232
294
|
STRIP_FROM_SAVE,
|
|
233
|
-
FREEZE_SELECTOR,
|
|
234
295
|
STRIP_FROM_COMPARISON,
|
|
296
|
+
STRIP_FROM_DIRTY_CHECK,
|
|
297
|
+
NO_TRIGGER_AUTOSAVE_SELECTOR,
|
|
298
|
+
NO_DIRTY_SELECTOR,
|
|
299
|
+
SNAPSHOT_REMOVE_SELECTOR,
|
|
300
|
+
FREEZE_SELECTOR,
|
|
301
|
+
|
|
302
|
+
// Nested camelCase: what clay.internals.region has always published.
|
|
303
|
+
selectors: {
|
|
304
|
+
stripFromSave: STRIP_FROM_SAVE,
|
|
305
|
+
stripFromComparison: STRIP_FROM_COMPARISON,
|
|
306
|
+
stripFromDirtyCheck: STRIP_FROM_DIRTY_CHECK,
|
|
307
|
+
noTriggerAutosave: NO_TRIGGER_AUTOSAVE_SELECTOR,
|
|
308
|
+
noDirty: NO_DIRTY_SELECTOR,
|
|
309
|
+
snapshotRemove: SNAPSHOT_REMOVE_SELECTOR,
|
|
310
|
+
freeze: FREEZE_SELECTOR,
|
|
311
|
+
},
|
|
235
312
|
};
|
|
313
|
+
|
|
314
|
+
// The name the loader imported before the two shapes merged.
|
|
315
|
+
export const windowRegionShape = regionShape;
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -18,10 +18,6 @@
|
|
|
18
18
|
// content cannot strip this tab's copy, and a peer's copy is never applied.
|
|
19
19
|
export const HOST_TOKEN_ATTRS = ["savetoken", "htmlclaytoken", "htmlclayid"];
|
|
20
20
|
|
|
21
|
-
// Which save envelope this host's lane takes: a fact about the response, not
|
|
22
|
-
// about the document.
|
|
23
|
-
export const SAVE_TRANSPORT_ATTR = "clay-save-transport";
|
|
24
|
-
|
|
25
21
|
// This library's own root state, and this tab's UI truth.
|
|
26
22
|
export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|
|
27
23
|
|
|
@@ -36,7 +32,6 @@ export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
|
|
|
36
32
|
// can no longer save at all.
|
|
37
33
|
export const TAB_LOCAL_ROOT_ATTRS = new Set([
|
|
38
34
|
...HOST_TOKEN_ATTRS,
|
|
39
|
-
SAVE_TRANSPORT_ATTR,
|
|
40
35
|
...ROOT_LIBRARY_ATTRS,
|
|
41
36
|
]);
|
|
42
37
|
|
package/src/lib/user-gesture.js
CHANGED
|
@@ -45,6 +45,7 @@ const RECENT_GESTURE_MS = 500;
|
|
|
45
45
|
let gestureTaskActive = false;
|
|
46
46
|
let lastTrustedGestureTs = -Infinity;
|
|
47
47
|
let userDrivenSinceLastSave = false;
|
|
48
|
+
let explicitSaveIntent = false;
|
|
48
49
|
let installed = false;
|
|
49
50
|
|
|
50
51
|
// Monotonic clock for the recency window — never moves backward, so a system
|
|
@@ -69,7 +70,9 @@ function onGesture(e) {
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Install the capture-phase gesture listeners (idempotent). Call once in edit
|
|
72
|
-
* mode
|
|
73
|
+
* mode. save.js does this for every editable page: gesture provenance is not an
|
|
74
|
+
* autosave feature, and gating it on <html autosave> left every manual-save page
|
|
75
|
+
* reporting its human edits as background writes.
|
|
73
76
|
*/
|
|
74
77
|
export function initUserGesture() {
|
|
75
78
|
if (installed || typeof document === 'undefined') return;
|
|
@@ -108,11 +111,40 @@ export function consumeUserDriven() {
|
|
|
108
111
|
return v;
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Record that a person asked for THIS save (Cmd+S, a [trigger-save] button).
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT markUserDriven(). That bit accumulates until a save actually
|
|
118
|
+
* ships, which is right for "an edit happened in a human turn" but wrong here: a
|
|
119
|
+
* person pressing Save on a clean page produces no save at all, so the bit stays
|
|
120
|
+
* armed and rides whatever BACKGROUND write happens next, reporting it as human.
|
|
121
|
+
* That is precisely the write the recovery guard exists to catch.
|
|
122
|
+
*
|
|
123
|
+
* This one is scoped to a single save attempt. The send consumes it; an attempt
|
|
124
|
+
* that ends without sending clears it.
|
|
125
|
+
*/
|
|
126
|
+
export function markExplicitSave() {
|
|
127
|
+
explicitSaveIntent = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read-and-reset, at the actual send. */
|
|
131
|
+
export function consumeExplicitSave() {
|
|
132
|
+
const v = explicitSaveIntent;
|
|
133
|
+
explicitSaveIntent = false;
|
|
134
|
+
return v;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The save attempt ended without sending anything. */
|
|
138
|
+
export function clearExplicitSave() {
|
|
139
|
+
explicitSaveIntent = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
111
142
|
/** Test seam: force-reset state. */
|
|
112
143
|
export function _resetUserGesture() {
|
|
113
144
|
gestureTaskActive = false;
|
|
114
145
|
lastTrustedGestureTs = -Infinity;
|
|
115
146
|
userDrivenSinceLastSave = false;
|
|
147
|
+
explicitSaveIntent = false;
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
/**
|
package/src/loader-logic.js
CHANGED
|
@@ -21,11 +21,34 @@ export const PLUGIN_PATHS = {
|
|
|
21
21
|
sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
|
|
22
22
|
undo: { path: "plugins/undo.js", editOnly: true, default: false },
|
|
23
23
|
cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
|
|
24
|
+
quickcrop: { path: "vendor/quickcrop.vendor.js", editOnly: false, default: false },
|
|
25
|
+
// editOnly, because a file picker only ever appears in edit mode: the cms
|
|
26
|
+
// injects its own editing toggle there and clayjs's edit-mode signal is a
|
|
27
|
+
// superset of the cms's, so the plugin is present exactly when it can be used.
|
|
28
|
+
upload: { path: "plugins/upload.js", editOnly: true, default: false },
|
|
24
29
|
wire: { path: "plugins/wire.js", editOnly: false, default: false },
|
|
25
30
|
demo: { path: "plugins/demo.js", editOnly: false, default: false },
|
|
26
31
|
};
|
|
27
32
|
|
|
28
|
-
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "wire", "demo"];
|
|
33
|
+
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "demo"];
|
|
34
|
+
|
|
35
|
+
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|
|
36
|
+
// a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
|
|
37
|
+
// finds nothing, so `plugins=cms` has to bring quickcrop with it or image crop is
|
|
38
|
+
// dead with no error and no log. quickcrop loads BEFORE cms in the order above,
|
|
39
|
+
// because the loader attaches each plugin's member as it lands and cms reads what
|
|
40
|
+
// earlier plugins attached during its own evaluation.
|
|
41
|
+
// `upload` rides the same reasoning one step further. Without it the cms has no
|
|
42
|
+
// uploader to look up, so it embeds every picked image in the document as a data:
|
|
43
|
+
// URL: a two megabyte photo costs 2.7 MB of base64 on that save, on every future
|
|
44
|
+
// save, and in every stored version. With it the cms asks the host first and
|
|
45
|
+
// embeds only when the host does not store files, which is still the right answer
|
|
46
|
+
// on a plain file server.
|
|
47
|
+
//
|
|
48
|
+
// This is the only line in the capability that changes how an already-published
|
|
49
|
+
// page behaves, which is why it shipped alone, one release after the plugin it
|
|
50
|
+
// enables. Reverting it is reverting this line.
|
|
51
|
+
const IMPLIES = { cms: ["quickcrop", "upload"] };
|
|
29
52
|
|
|
30
53
|
function parseCsv(params, key, enabled, apply) {
|
|
31
54
|
const raw = params.get(key);
|
|
@@ -50,6 +73,11 @@ export function resolveModules(params, isEditMode) {
|
|
|
50
73
|
if (spec.default) enabled.add(name);
|
|
51
74
|
}
|
|
52
75
|
parseCsv(params, "plugins", enabled, (set, name) => set.add(name));
|
|
76
|
+
// Between the two: exclude still wins, so `plugins=cms&exclude=quickcrop` opts
|
|
77
|
+
// back out of the cropper.
|
|
78
|
+
for (const name of [...enabled]) {
|
|
79
|
+
for (const implied of IMPLIES[name] || []) enabled.add(implied);
|
|
80
|
+
}
|
|
53
81
|
parseCsv(params, "exclude", enabled, (set, name) => set.delete(name));
|
|
54
82
|
|
|
55
83
|
const plugins = [];
|
package/src/loader.js
CHANGED
|
@@ -102,12 +102,16 @@ function attachPluginMember(path, mod) {
|
|
|
102
102
|
clay.morph = mod.morph;
|
|
103
103
|
} else if (path === "vendor/hypercms.vendor.js") {
|
|
104
104
|
clay.cms = mod.cms || mod.default;
|
|
105
|
+
} else if (path === "plugins/upload.js") {
|
|
106
|
+
clay.upload = mod.upload || mod.default;
|
|
105
107
|
} else if (path === "plugins/wire.js") {
|
|
106
108
|
clay.wire = mod.wire || mod.default;
|
|
107
109
|
} else if (path === "plugins/demo.js") {
|
|
108
110
|
clay.demo = mod.demo;
|
|
109
111
|
} else if (path === "vendor/richclay.vendor.js") {
|
|
110
112
|
clay.RichClay = mod.RichClay || mod.default;
|
|
113
|
+
} else if (path === "vendor/quickcrop.vendor.js") {
|
|
114
|
+
clay.quickcrop = mod.quickcrop || mod.default;
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
117
|
|