@panphora/clayjs 0.6.1 → 0.7.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/LICENSE +12 -17
- package/README.md +21 -11
- package/THIRD-PARTY-NOTICES.md +40 -0
- package/package.json +4 -4
- 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/save-core.js +26 -27
- package/src/core/save.js +105 -26
- 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 +11 -4
- package/src/plugins/demo.js +14 -8
- package/src/sync/live-sync.js +157 -12
- package/src/sync/splice-merge.js +21 -7
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/NOTICE +0 -19
package/src/sync/live-sync.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* │
|
|
11
11
|
* ▼
|
|
12
12
|
* ┌─────────────────────────────────────────────────────────┐
|
|
13
|
-
* │ 2. SEND
|
|
13
|
+
* │ 2. SEND POST snapshot to the relay address │
|
|
14
14
|
* │ (debounced, skip if unchanged) │
|
|
15
15
|
* └─────────────────────────────────────────────────────────┘
|
|
16
16
|
* │
|
|
@@ -40,11 +40,61 @@ import Mutation from "../lib/mutation.js";
|
|
|
40
40
|
import { isSnapshotRemoved } from "../lib/region-policy.js";
|
|
41
41
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
42
|
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
43
|
-
import { serializeForSync,
|
|
43
|
+
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from '../core/snapshot.js';
|
|
44
44
|
import { isTabLocalRootAttr } from '../lib/root-attrs.js';
|
|
45
45
|
import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
|
|
46
|
+
import { hostMeta } from '../core/host-meta.js';
|
|
46
47
|
import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
|
|
47
|
-
|
|
48
|
+
|
|
49
|
+
// The page just took a frame verified clean against its baseline, so it now IS
|
|
50
|
+
// the file on disk. Both saved baselines move together from one capture: leaving
|
|
51
|
+
// the dirty baseline behind would make the close warning fire on the frame's own
|
|
52
|
+
// content. Never flushes undo (this runs per incoming frame, not per save) and
|
|
53
|
+
// never emits snapshot-ready (it must not feed the send pipeline).
|
|
54
|
+
function pairedBaseline() {
|
|
55
|
+
const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
|
|
56
|
+
return [forComparison, forDirty];
|
|
57
|
+
}
|
|
58
|
+
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../core/save.js';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The two live-sync wires, and the rule for choosing between them.
|
|
62
|
+
*
|
|
63
|
+
* Spec §10 puts both halves on `/_/sync`. Not every host serves that yet, and the
|
|
64
|
+
* ones that do not cannot be upgraded on our schedule: hyperclay-local ships behind
|
|
65
|
+
* Apple notarization, while this library reaches every document within one 10-minute
|
|
66
|
+
* cache TTL. So the client is the half that has to know both.
|
|
67
|
+
*
|
|
68
|
+
* The choice is DISCOVERED, never guessed. Spec §5 makes `/_/meta` the only way to
|
|
69
|
+
* learn what a host can do, and §10 says a client that has not discovered `sync`
|
|
70
|
+
* never opens either half of the route. Probing is also a bad instrument here: an
|
|
71
|
+
* EventSource reports a 404, an auth refusal and an offline browser through one
|
|
72
|
+
* error path, so a failed connection cannot tell you which address was wrong.
|
|
73
|
+
*
|
|
74
|
+
* One profile is chosen once and used for BOTH directions for the life of the page.
|
|
75
|
+
* Letting send and receive decide independently is exactly the bug this replaces: a
|
|
76
|
+
* client that posted to the spec address while streaming from the legacy one.
|
|
77
|
+
*/
|
|
78
|
+
const WIRE_PROFILES = {
|
|
79
|
+
spec: {
|
|
80
|
+
name: 'spec',
|
|
81
|
+
relayPath: '/_/sync',
|
|
82
|
+
documentHeader: 'Document-URL',
|
|
83
|
+
snapshotKey: 'snapshot',
|
|
84
|
+
streamPath: (href, lane, resumeId) =>
|
|
85
|
+
`/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
|
|
86
|
+
},
|
|
87
|
+
// What every published clayjs speaks, and what hyperclayjs and the inline script in
|
|
88
|
+
// every Collection dashboard hardcode. Hosts keep these addresses permanently.
|
|
89
|
+
legacy: {
|
|
90
|
+
name: 'legacy',
|
|
91
|
+
relayPath: '/_/live-sync/save',
|
|
92
|
+
documentHeader: 'Page-URL',
|
|
93
|
+
snapshotKey: 'html',
|
|
94
|
+
streamPath: (href, lane, resumeId) =>
|
|
95
|
+
`/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
48
98
|
|
|
49
99
|
class LiveSync {
|
|
50
100
|
constructor() {
|
|
@@ -65,6 +115,16 @@ class LiveSync {
|
|
|
65
115
|
this.debounceTimer = null;
|
|
66
116
|
this._sendInFlight = false;
|
|
67
117
|
this._queuedSend = null;
|
|
118
|
+
|
|
119
|
+
// The chosen wire, and the in-flight discovery that chooses it. Memoized for the
|
|
120
|
+
// life of the page: a host does not change its capabilities under a loaded
|
|
121
|
+
// document, and hostMeta() memoizes the request itself.
|
|
122
|
+
this._profile = null;
|
|
123
|
+
this._profilePromise = null;
|
|
124
|
+
this._ready = null;
|
|
125
|
+
// Bumped by every start(), so a discovery that resolves after a stop/restart
|
|
126
|
+
// cannot open a stream for the run that has already been torn down.
|
|
127
|
+
this._startGen = 0;
|
|
68
128
|
this.isPaused = false;
|
|
69
129
|
this.isDestroyed = false;
|
|
70
130
|
this.debug = false;
|
|
@@ -129,6 +189,12 @@ class LiveSync {
|
|
|
129
189
|
this._holdRetryPeer = null;
|
|
130
190
|
this._holdRetryExt = null;
|
|
131
191
|
|
|
192
|
+
// Whether each lane is currently holding. A hold is a safe outcome but a
|
|
193
|
+
// silent one: without an event the page simply stops updating and nothing
|
|
194
|
+
// can say why. These make the transitions observable in both directions.
|
|
195
|
+
this._heldLive = false;
|
|
196
|
+
this._heldExt = false;
|
|
197
|
+
|
|
132
198
|
// Identity tracking for content-based morphing across live-sync updates.
|
|
133
199
|
// Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
|
|
134
200
|
// the DOM, never serialized into saved HTML. The WeakMap holds them
|
|
@@ -214,7 +280,16 @@ class LiveSync {
|
|
|
214
280
|
this.resumeId = this.generateResumeId();
|
|
215
281
|
|
|
216
282
|
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
217
|
-
|
|
283
|
+
// One discovery request stands between here and the stream. It is memoized and
|
|
284
|
+
// bounded, and it does not gate ordinary saves — /_/save is a separate lane that
|
|
285
|
+
// never moved. Snapshots produced during the window are queued, not dropped.
|
|
286
|
+
const gen = ++this._startGen;
|
|
287
|
+
// Exposed so a caller (and the tests) can await the point where the stream is
|
|
288
|
+
// actually open, rather than counting microtasks behind the discovery request.
|
|
289
|
+
this._ready = this._resolveProfile().then(() => {
|
|
290
|
+
if (this.isDestroyed || this._startGen !== gen) return;
|
|
291
|
+
this.connect();
|
|
292
|
+
});
|
|
218
293
|
// View-mode tabs are receive-only: saves are edit-gated upstream, so a
|
|
219
294
|
// snapshot listener would never fire — skip registering it.
|
|
220
295
|
if (this.lane === 'live') {
|
|
@@ -405,6 +480,48 @@ class LiveSync {
|
|
|
405
480
|
return pathname;
|
|
406
481
|
}
|
|
407
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Choose the wire profile from what the host advertises.
|
|
485
|
+
*
|
|
486
|
+
* Absence of `sync` selects the legacy wire rather than disabling live sync. That is
|
|
487
|
+
* deliberate transition debt: §10 says an undiscovered capability is not opened at
|
|
488
|
+
* all, but both deployed first-party hosts ran live sync for a long time without
|
|
489
|
+
* advertising anything, and refusing to sync with them would be a regression against
|
|
490
|
+
* every published version. Once the unannounced hosts are gone, absence should mean
|
|
491
|
+
* off. A malformed answer, a 404 or a timeout all land here too, and all mean legacy.
|
|
492
|
+
*/
|
|
493
|
+
_resolveProfile() {
|
|
494
|
+
if (!this._profilePromise) {
|
|
495
|
+
this._profilePromise = hostMeta()
|
|
496
|
+
.then((meta) => (meta?.extensions?.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy))
|
|
497
|
+
.catch(() => WIRE_PROFILES.legacy)
|
|
498
|
+
.then((profile) => {
|
|
499
|
+
this._profile = profile;
|
|
500
|
+
this._log(`Wire profile: ${profile.name}`);
|
|
501
|
+
return profile;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return this._profilePromise;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Report a hold transition on one lane, once per episode in each direction,
|
|
509
|
+
* so a listener can raise a "sync paused until you save" notice and take it
|
|
510
|
+
* back down again. Never fires twice for the same state.
|
|
511
|
+
*
|
|
512
|
+
* @param {string} lane 'live' or 'external'
|
|
513
|
+
* @param {boolean} isHeld
|
|
514
|
+
* @param {Element} [el] the local element that could not be merged
|
|
515
|
+
*/
|
|
516
|
+
_setHeld(lane, isHeld, el) {
|
|
517
|
+
const key = lane === 'live' ? '_heldLive' : '_heldExt';
|
|
518
|
+
if (this[key] === isHeld) return;
|
|
519
|
+
this[key] = isHeld;
|
|
520
|
+
document.dispatchEvent(new CustomEvent(isHeld ? 'clay:sync-held' : 'clay:sync-resumed', {
|
|
521
|
+
detail: { lane, el: el || null },
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
|
|
408
525
|
/**
|
|
409
526
|
* Connect to the SSE endpoint
|
|
410
527
|
* Uses native EventSource reconnection behavior
|
|
@@ -412,8 +529,10 @@ class LiveSync {
|
|
|
412
529
|
connect() {
|
|
413
530
|
if (this.isDestroyed) return;
|
|
414
531
|
|
|
415
|
-
|
|
416
|
-
|
|
532
|
+
// Whichever wire discovery selected. start() does not call connect() until the
|
|
533
|
+
// profile is known, so this is never null on the normal path.
|
|
534
|
+
const profile = this._profile || WIRE_PROFILES.legacy;
|
|
535
|
+
const path = profile.streamPath(window.location.href, this.lane, this.resumeId);
|
|
417
536
|
// Resolved against the real origin: a <base href> in the authored document
|
|
418
537
|
// would otherwise point the sync stream at an origin the document chose.
|
|
419
538
|
this.sse = new EventSource(new URL(path, window.location.origin).href);
|
|
@@ -566,6 +685,14 @@ class LiveSync {
|
|
|
566
685
|
}
|
|
567
686
|
|
|
568
687
|
_enqueueSend(html, identityMap) {
|
|
688
|
+
// Same one-deep queue the in-flight case uses, for the same reason: peers only
|
|
689
|
+
// ever need the newest state, so a fresher snapshot replaces the waiting one.
|
|
690
|
+
// Sending before the profile is known would have to guess an address.
|
|
691
|
+
if (!this._profile) {
|
|
692
|
+
this._queuedSend = { html, identityMap };
|
|
693
|
+
this._resolveProfile().then(() => this._flushQueuedSend());
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
569
696
|
if (this._sendInFlight) {
|
|
570
697
|
this._queuedSend = { html, identityMap };
|
|
571
698
|
return;
|
|
@@ -573,6 +700,14 @@ class LiveSync {
|
|
|
573
700
|
this._postUpdate(html, identityMap);
|
|
574
701
|
}
|
|
575
702
|
|
|
703
|
+
_flushQueuedSend() {
|
|
704
|
+
if (this.isDestroyed || this._sendInFlight) return;
|
|
705
|
+
const queued = this._queuedSend;
|
|
706
|
+
if (!queued) return;
|
|
707
|
+
this._queuedSend = null;
|
|
708
|
+
this._postUpdate(queued.html, queued.identityMap);
|
|
709
|
+
}
|
|
710
|
+
|
|
576
711
|
_postUpdate(html, identityMap) {
|
|
577
712
|
// Skip if unchanged
|
|
578
713
|
if (html === this.lastHtml) {
|
|
@@ -587,11 +722,15 @@ class LiveSync {
|
|
|
587
722
|
|
|
588
723
|
// Absolute against the real origin, so a <base href> in the page cannot
|
|
589
724
|
// redirect the whole document to an origin the author picked.
|
|
590
|
-
|
|
725
|
+
const profile = this._profile || WIRE_PROFILES.legacy;
|
|
726
|
+
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
591
727
|
method: 'POST',
|
|
592
|
-
headers: {
|
|
728
|
+
headers: {
|
|
729
|
+
'Content-Type': 'application/json',
|
|
730
|
+
[profile.documentHeader]: window.location.href,
|
|
731
|
+
},
|
|
593
732
|
body: JSON.stringify({
|
|
594
|
-
|
|
733
|
+
[profile.snapshotKey]: html,
|
|
595
734
|
sender: this.clientId,
|
|
596
735
|
identityMap: identityMap
|
|
597
736
|
})
|
|
@@ -931,6 +1070,7 @@ class LiveSync {
|
|
|
931
1070
|
// blocking edit is undone; the slot-empty check and the drain's
|
|
932
1071
|
// staleness checks drop it once superseded.
|
|
933
1072
|
console.log('[LiveSync] Holding incoming update: unsaved local section cannot be safely merged', protection.held?.el || '');
|
|
1073
|
+
this._setHeld('live', true, protection.held?.el);
|
|
934
1074
|
const epochAtHold = this._saveEpoch;
|
|
935
1075
|
const seenAtHold = this.lastSeenSeq;
|
|
936
1076
|
clearTimeout(this._holdRetryPeer);
|
|
@@ -951,6 +1091,8 @@ class LiveSync {
|
|
|
951
1091
|
retainedRoots = protection.entries.length;
|
|
952
1092
|
}
|
|
953
1093
|
|
|
1094
|
+
this._setHeld('live', false);
|
|
1095
|
+
|
|
954
1096
|
// Morph entire document. We MUST await — HyperMorph.morph returns a
|
|
955
1097
|
// Promise when `scripts: { handle: true }` needs to wait for external
|
|
956
1098
|
// scripts to load. If we don't await, Mutation.resume() fires before
|
|
@@ -1016,7 +1158,7 @@ class LiveSync {
|
|
|
1016
1158
|
// (including typing that arrived during the morph's async wait, which
|
|
1017
1159
|
// must never be recorded as saved).
|
|
1018
1160
|
if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
|
|
1019
|
-
|
|
1161
|
+
setLastSavedBaselines(...pairedBaseline());
|
|
1020
1162
|
}
|
|
1021
1163
|
|
|
1022
1164
|
// Announce that a remote morph just landed, so document-level listeners
|
|
@@ -1102,6 +1244,7 @@ class LiveSync {
|
|
|
1102
1244
|
// disk instead of a stale re-apply, and the seq check drops it
|
|
1103
1245
|
// once a newer external change supersedes it.
|
|
1104
1246
|
console.log('[LiveSync] Holding external change: unsaved local section cannot be safely merged', protection.held?.el || '');
|
|
1247
|
+
this._setHeld('external', true, protection.held?.el);
|
|
1105
1248
|
const epochAtHold = this._saveEpoch;
|
|
1106
1249
|
clearTimeout(this._holdRetryExt);
|
|
1107
1250
|
this._holdRetryExt = setTimeout(() => {
|
|
@@ -1115,6 +1258,8 @@ class LiveSync {
|
|
|
1115
1258
|
retainedRoots = protection.entries.length;
|
|
1116
1259
|
}
|
|
1117
1260
|
|
|
1261
|
+
this._setHeld('external', false);
|
|
1262
|
+
|
|
1118
1263
|
activateIncomingDoc(newDoc.documentElement);
|
|
1119
1264
|
|
|
1120
1265
|
const liveWeakMap = this.liveWeakMap;
|
|
@@ -1148,7 +1293,7 @@ class LiveSync {
|
|
|
1148
1293
|
// beforeunload stays quiet. The dirty re-check matters: typing that
|
|
1149
1294
|
// arrived during the morph's async wait would otherwise be captured
|
|
1150
1295
|
// into the baseline and recorded as saved without reaching disk.
|
|
1151
|
-
|
|
1296
|
+
setLastSavedBaselines(...pairedBaseline());
|
|
1152
1297
|
setUnsavedChanges(false);
|
|
1153
1298
|
|
|
1154
1299
|
// Cross-lane baseline: the PEER diff base (lastHtml) would otherwise
|
|
@@ -1241,5 +1386,5 @@ if (typeof window !== 'undefined') {
|
|
|
1241
1386
|
// Export for the clayjs module system. The class itself is exported so
|
|
1242
1387
|
// tests can create fresh instances without driving the singleton's
|
|
1243
1388
|
// EventSource/snapshot wiring.
|
|
1244
|
-
export { liveSync, LiveSync, morph };
|
|
1389
|
+
export { liveSync, LiveSync, morph, WIRE_PROFILES };
|
|
1245
1390
|
export default liveSync;
|
package/src/sync/splice-merge.js
CHANGED
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
* diff always compares trees from ONE serialization domain.
|
|
8
8
|
*
|
|
9
9
|
* Disk frames (htmlclay external changes; save domain on the wire):
|
|
10
|
-
* diff
|
|
10
|
+
* diff loss-domain clone vs parse(lastSavedDirty) [same domain]
|
|
11
11
|
* splice save-clone subtrees into the incoming disk doc [same domain]
|
|
12
12
|
* The two clones come from ONE snapshot via captureForMerge(), whose
|
|
13
|
-
* pairMap bridges them. Save-domain subtrees still carry the
|
|
14
|
-
* no-
|
|
13
|
+
* pairMap bridges them. Save-domain subtrees still carry the freeze /
|
|
14
|
+
* no-watch children the loss-domain clone strips.
|
|
15
|
+
*
|
|
16
|
+
* The loss domain, not the autosave domain: the question here is "would
|
|
17
|
+
* this frame destroy work?", which is the close warning's question, so it
|
|
18
|
+
* keeps no-trigger-autosave content. Disposable churn is declared out of
|
|
19
|
+
* it with no-dirty rather than inferred from bytes or gesture timing.
|
|
15
20
|
*
|
|
16
21
|
* Peer frames (live-lane relays; snapshot domain on the wire):
|
|
17
22
|
* diff snapshot clone vs parse(lastHtml) [same domain]
|
|
@@ -31,15 +36,16 @@ import { captureSnapshot, captureForMerge } from '../core/snapshot.js';
|
|
|
31
36
|
// save.js is edit-only in the loader waves but safe to reach from here: its
|
|
32
37
|
// module body guards every init on isEditMode, and the disk lane that needs
|
|
33
38
|
// this state only ever runs in edit-mode tabs.
|
|
34
|
-
import {
|
|
39
|
+
import { getLastSavedDirty } from '../core/save.js';
|
|
35
40
|
import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
|
|
36
41
|
import {
|
|
37
42
|
isSnapshotRemoved,
|
|
38
43
|
STRIP_FROM_SAVE,
|
|
39
44
|
FREEZE_SELECTOR,
|
|
40
45
|
SNAPSHOT_REMOVE_SELECTOR,
|
|
46
|
+
NO_DIRTY_SELECTOR,
|
|
41
47
|
} from '../lib/region-policy.js';
|
|
42
|
-
import { probeMarkClean } from '../lib/dirty-gate.js';
|
|
48
|
+
import { probeMarkClean, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
|
|
43
49
|
import { isEditMode } from '../core/is-edit-mode.js';
|
|
44
50
|
import { enableContentEditable } from '../core/admin-contenteditable.js';
|
|
45
51
|
import { enableOnClick } from '../core/admin-onclick.js';
|
|
@@ -53,7 +59,7 @@ const PEER_SKIP_SELECTOR = [
|
|
|
53
59
|
STRIP_FROM_SAVE,
|
|
54
60
|
FREEZE_SELECTOR,
|
|
55
61
|
SNAPSHOT_REMOVE_SELECTOR,
|
|
56
|
-
|
|
62
|
+
NO_DIRTY_SELECTOR,
|
|
57
63
|
].join(', ');
|
|
58
64
|
|
|
59
65
|
function peerSkip(el) {
|
|
@@ -148,6 +154,7 @@ export function protectPeerDoc({ newDoc, parsedWeakMap, baseHtml, baseIdentityMa
|
|
|
148
154
|
return { ok: false, entries: [], held: null };
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
const gateToken = gateCaptureToken();
|
|
151
158
|
const localClone = captureSnapshot({ flushUndo: false });
|
|
152
159
|
const baseDoc = parsePeerBase(baseHtml);
|
|
153
160
|
if (!baseDoc.documentElement) return { ok: false, entries: [], held: null };
|
|
@@ -172,7 +179,12 @@ export function protectPeerDoc({ newDoc, parsedWeakMap, baseHtml, baseIdentityMa
|
|
|
172
179
|
});
|
|
173
180
|
|
|
174
181
|
if (!entries.length) {
|
|
182
|
+
// The oracle just proved the page clean against its baseline. probeMarkClean
|
|
183
|
+
// only caches form signatures; without the generation-checked counter clear
|
|
184
|
+
// an edit that was typed and then undone leaves the gate armed forever, and
|
|
185
|
+
// every later frame pays the full capture-and-diff for nothing.
|
|
175
186
|
probeMarkClean();
|
|
187
|
+
gateClearIfUnchanged(gateToken);
|
|
176
188
|
return { ok: true, entries };
|
|
177
189
|
}
|
|
178
190
|
|
|
@@ -203,9 +215,10 @@ export function protectPeerDoc({ newDoc, parsedWeakMap, baseHtml, baseIdentityMa
|
|
|
203
215
|
* @returns {{ ok: boolean, entries: Array, held?: object }}
|
|
204
216
|
*/
|
|
205
217
|
export function protectDiskDoc({ newDoc }) {
|
|
206
|
-
const base =
|
|
218
|
+
const base = getLastSavedDirty();
|
|
207
219
|
if (!base) return { ok: false, entries: [], held: null };
|
|
208
220
|
|
|
221
|
+
const gateToken = gateCaptureToken();
|
|
209
222
|
const { saveClone, compareClone, pairMap } = captureForMerge();
|
|
210
223
|
const baseDoc = parseDiskBase(base);
|
|
211
224
|
if (!baseDoc.documentElement) return { ok: false, entries: [], held: null };
|
|
@@ -216,6 +229,7 @@ export function protectDiskDoc({ newDoc }) {
|
|
|
216
229
|
|
|
217
230
|
if (!entries.length) {
|
|
218
231
|
probeMarkClean();
|
|
232
|
+
gateClearIfUnchanged(gateToken);
|
|
219
233
|
return { ok: true, entries };
|
|
220
234
|
}
|
|
221
235
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! quickcrop v1.
|
|
1
|
+
/*! quickcrop v1.1.0 | MIT-0 | https://github.com/panphora/quickcrop
|
|
2
2
|
*
|
|
3
3
|
* const result = await quickcrop(file, { aspect: 1 });
|
|
4
4
|
* // result: { blob, dataURL, width, height } on confirm, null on cancel
|
package/NOTICE
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
clayjs
|
|
2
|
-
Copyright (c) 2026 David Miranda
|
|
3
|
-
Licensed under the MIT License (see LICENSE).
|
|
4
|
-
|
|
5
|
-
This product bundles and adapts third-party software:
|
|
6
|
-
|
|
7
|
-
- hyperclayjs — MIT License, Copyright (c) 2025 Hyperclay.
|
|
8
|
-
Portions of clayjs (the save lifecycle, mutation hub, region model, UI,
|
|
9
|
-
event attributes, option visibility, DOM helpers, and utilities under src/)
|
|
10
|
-
are hand-ported from hyperclayjs.
|
|
11
|
-
|
|
12
|
-
- MicroModal — MIT License, Copyright (c) 2017 Indrashish Ghosh.
|
|
13
|
-
Embedded in src/ui/modal.js.
|
|
14
|
-
|
|
15
|
-
- sapjs — MIT License, Copyright (c) 2026 panphora.
|
|
16
|
-
Distributed unmodified as the generated sap.js (see its header).
|
|
17
|
-
|
|
18
|
-
- hyper-html-api — Zero-Clause BSD (0BSD).
|
|
19
|
-
Distributed as the generated clay-data.js (see its header).
|