@panphora/clayjs 1.5.2 → 1.6.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/README.md +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +14984 -10685
- package/package.json +9 -4
- package/src/core/conflict-download.js +91 -0
- package/src/core/conflict-notice.js +720 -0
- package/src/core/conflict-presentation.js +301 -0
- package/src/core/save-conflict-notice.js +2 -192
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +60 -1
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +35 -10
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +9 -0
- package/src/lib/unsaved-state.js +55 -0
- package/src/lib/user-gesture.js +10 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +4 -3
- package/src/plugins/ai-edit.js +489 -179
- package/src/plugins/cms.js +12 -0
- package/src/plugins/indicator.js +35 -16
- package/src/plugins/quickcrop.js +103 -0
- package/src/plugins/richclay.js +21 -0
- package/src/plugins/source.js +13 -6
- package/src/plugins/wire.js +4 -0
- package/src/sync/conflict-revert.js +1138 -0
- package/src/sync/conflicts.js +159 -0
- package/src/sync/live-sync.js +188 -116
- package/src/sync/presence.js +42 -36
- package/src/sync/section-notice.js +53 -28
- package/src/ui/bevel-controls.js +277 -0
- package/src/ui/bevel-dialog.js +112 -0
- package/src/ui/bevel.js +247 -0
- package/src/ui/dialogs.js +63 -86
- package/src/ui/index.js +22 -11
- package/src/ui/modal.js +80 -387
- package/src/ui/skins/cms-toggle.js +15 -0
- package/src/ui/skins/cms.js +4 -0
- package/src/ui/skins/quickcrop.js +4 -0
- package/src/ui/skins/richclay.js +4 -0
- package/src/ui/toast.js +116 -401
- package/src/ui/vendor-skin.js +65 -0
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/quickcrop.vendor.js +14 -8
- package/src/vendor/richclay.vendor.js +22 -41
package/src/sync/live-sync.js
CHANGED
|
@@ -56,8 +56,10 @@ import { presence } from './presence.js';
|
|
|
56
56
|
import './section-notice.js';
|
|
57
57
|
import { hostMeta } from '../core/host-meta.js';
|
|
58
58
|
import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves } from '../core/etag.js';
|
|
59
|
-
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged
|
|
59
|
+
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
|
|
60
|
+
import { gestureSeen } from '../lib/user-gesture.js';
|
|
60
61
|
import { SyncStream } from './stream.js';
|
|
62
|
+
import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
|
|
61
63
|
|
|
62
64
|
// What a live-sync merge never reads or touches on any side: editor chrome,
|
|
63
65
|
// content kept out of the save or the snapshot, frozen regions, and nodes
|
|
@@ -144,7 +146,7 @@ function pairedBaseline() {
|
|
|
144
146
|
const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
|
|
145
147
|
return [forComparison, forDirty];
|
|
146
148
|
}
|
|
147
|
-
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted } from '../core/save.js';
|
|
149
|
+
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted, baselineSettled } from '../core/save.js';
|
|
148
150
|
|
|
149
151
|
/**
|
|
150
152
|
* The two live-sync wires, and the rule for choosing between them.
|
|
@@ -208,15 +210,16 @@ class LiveSync {
|
|
|
208
210
|
this._agreeSeq = 0;
|
|
209
211
|
// The ticket of the save currently in flight, taken when it captured.
|
|
210
212
|
this._saveTicket = 0;
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
this.
|
|
218
|
-
//
|
|
219
|
-
|
|
213
|
+
// The peer lane's base carries a ticket from the same counter as the disk
|
|
214
|
+
// lane, so an accepted save and a frame install in the order the document
|
|
215
|
+
// held their bytes, not the order their promises settled.
|
|
216
|
+
this._peerBaseTicket = 0;
|
|
217
|
+
// The newest accepted save's capture ticket. A frame older than it does not
|
|
218
|
+
// adopt its own stamp: the save's response already recorded a newer one.
|
|
219
|
+
this._acceptedSaveTicket = 0;
|
|
220
|
+
// The ticket of the latest frame of either lane whose morph has run. The
|
|
221
|
+
// boot settle must not replace a base a morph already moved past.
|
|
222
|
+
this._morphTicket = 0;
|
|
220
223
|
this.clientId = this.generateClientId();
|
|
221
224
|
|
|
222
225
|
// Per-stream resume id for the htmlclay replay server's wire contract.
|
|
@@ -231,9 +234,9 @@ class LiveSync {
|
|
|
231
234
|
// held so the commit relay can pair the host's stamp with the content that
|
|
232
235
|
// stamp actually describes. Set at snapshot-ready, which fires before the
|
|
233
236
|
// save POST goes out, and consumed by _relayCommit when the response lands.
|
|
234
|
-
// Never reconstructed from lastHtml: that is the
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
+
// Never reconstructed from lastHtml: that is the newest accepted save or
|
|
238
|
+
// applied frame, which can still lag the save in flight, and pairing a fresh
|
|
239
|
+
// stamp with older bytes is the one thing spec §10 forbids.
|
|
237
240
|
this._savedSnapshot = null;
|
|
238
241
|
this._sendInFlight = false;
|
|
239
242
|
this._queuedSend = null;
|
|
@@ -303,15 +306,15 @@ class LiveSync {
|
|
|
303
306
|
this._saveEpoch = 0;
|
|
304
307
|
this._saveSavedHandler = null;
|
|
305
308
|
this._saveConflictHandler = null;
|
|
309
|
+
this._settledHandler = null;
|
|
306
310
|
|
|
307
311
|
// The identityMap that came with lastHtml, so the peer-lane dirty diff
|
|
308
312
|
// can resolve synthetic identities on its base tree.
|
|
309
313
|
this._lastIdentityMap = null;
|
|
310
314
|
|
|
311
|
-
// Bumped whenever an APPLY (or a reset) rewrites lastHtml.
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
// applied while it was on the wire.
|
|
315
|
+
// Bumped whenever an APPLY (or a reset) rewrites lastHtml. The boot settle
|
|
316
|
+
// reads it: a lane a frame already replaced since start is not refreshed
|
|
317
|
+
// from whatever DOM that frame left behind.
|
|
315
318
|
this._applyGen = 0;
|
|
316
319
|
|
|
317
320
|
// Hold-retry timers, one per pending slot. A held frame's slot and seq
|
|
@@ -343,6 +346,12 @@ class LiveSync {
|
|
|
343
346
|
this.onNotification = null;
|
|
344
347
|
}
|
|
345
348
|
|
|
349
|
+
// Compatibility: the raw engine reports of every open loss. The ledger
|
|
350
|
+
// (clay.conflicts) is the real owner; nothing here removes a record.
|
|
351
|
+
get unresolvedConflicts() {
|
|
352
|
+
return conflicts.list().filter((c) => c.kind !== 'apply-incomplete');
|
|
353
|
+
}
|
|
354
|
+
|
|
346
355
|
_log(message, data = null) {
|
|
347
356
|
if (!this.debug) return;
|
|
348
357
|
const prefix = `[LiveSync ${new Date().toISOString()}]`;
|
|
@@ -380,6 +389,18 @@ class LiveSync {
|
|
|
380
389
|
}
|
|
381
390
|
}
|
|
382
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Seed both merge bases (the peer lane's and the disk lane's) with the page as
|
|
394
|
+
* it stands, so a first frame that arrives after local edits merges instead of
|
|
395
|
+
* holding. A page already dirty has no trustworthy base; it keeps null and holds.
|
|
396
|
+
*/
|
|
397
|
+
_seedBases() {
|
|
398
|
+
if (pageMaybeDirty()) return;
|
|
399
|
+
const clone = captureSnapshot({ flushUndo: false });
|
|
400
|
+
this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
|
|
401
|
+
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
402
|
+
}
|
|
403
|
+
|
|
383
404
|
/**
|
|
384
405
|
* Start the LiveSync system
|
|
385
406
|
* Can be called after stop() to restart with a new file
|
|
@@ -404,12 +425,14 @@ class LiveSync {
|
|
|
404
425
|
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
405
426
|
this.lastHtml = null;
|
|
406
427
|
this._lastIdentityMap = null;
|
|
428
|
+
// A ticket of its own, as the disk lane's reset takes: a save accepted or
|
|
429
|
+
// a frame settling from before this start cannot land in the new run.
|
|
430
|
+
this._peerBaseTicket = this._ticket();
|
|
407
431
|
// The reset takes a ticket of its own, so a writer from before this start
|
|
408
432
|
// (a save response still on the wire) cannot land in the new run.
|
|
409
433
|
this._diskBase = null;
|
|
410
434
|
this._diskBaseTicket = this._ticket();
|
|
411
435
|
this._saveTicket = 0;
|
|
412
|
-
this.unresolvedConflicts = [];
|
|
413
436
|
this._savedSnapshot = null;
|
|
414
437
|
this._applyGen++;
|
|
415
438
|
this.lastSeenSeq = 0;
|
|
@@ -417,22 +440,56 @@ class LiveSync {
|
|
|
417
440
|
this._pendingExternal = null;
|
|
418
441
|
this.resumeId = this.generateResumeId();
|
|
419
442
|
|
|
420
|
-
// Seed both merge bases
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
if (this.lane === 'live'
|
|
425
|
-
const clone = captureSnapshot({ flushUndo: false });
|
|
426
|
-
this.lastHtml = serializeForSync(clone);
|
|
427
|
-
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
428
|
-
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
429
|
-
}
|
|
443
|
+
// Seed both merge bases now, so a first frame that arrives after local edits
|
|
444
|
+
// merges instead of holding. A page still booting is refreshed at the settle
|
|
445
|
+
// below: a base taken before a module built its DOM makes the first dirty merge
|
|
446
|
+
// see both sides add that DOM, and keep it twice.
|
|
447
|
+
if (this.lane === 'live') this._seedBases();
|
|
430
448
|
|
|
431
449
|
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
432
450
|
// One discovery request stands between here and the stream. It is memoized and
|
|
433
451
|
// bounded, and it does not gate ordinary saves — /_/save is a separate lane that
|
|
434
452
|
// never moved. Snapshots produced during the window are queued, not dropped.
|
|
435
453
|
const gen = ++this._startGen;
|
|
454
|
+
if (this.lane === 'live' && !baselineSettled()) {
|
|
455
|
+
const applyGen = this._applyGen;
|
|
456
|
+
const morphTicket = this._morphTicket;
|
|
457
|
+
const saveEpoch = this._saveEpoch;
|
|
458
|
+
const startHtml = this.lastHtml;
|
|
459
|
+
const startDiskTicket = this._diskBaseTicket;
|
|
460
|
+
const startDiskSeeded = this._diskBase !== null;
|
|
461
|
+
this._settledHandler = () => {
|
|
462
|
+
this._settledHandler = null;
|
|
463
|
+
if (this.isDestroyed || this._startGen !== gen) return;
|
|
464
|
+
// A person has touched the page: whatever they did (a drag, a button whose
|
|
465
|
+
// handler edits a turn later) may be in it and not yet saved. Keep the start
|
|
466
|
+
// seed, as 1.5.2 did; only an untouched tab gets the settled page as its base.
|
|
467
|
+
if (gestureSeen()) return;
|
|
468
|
+
// The settle clears the gate only when it saw no user edit, so a dirty page
|
|
469
|
+
// here holds work the base must not absorb: keep the start seed.
|
|
470
|
+
if (pageMaybeDirty()) return;
|
|
471
|
+
// A save this tab sent is still unconfirmed: what it carries is not yet
|
|
472
|
+
// common history, and a refused save must merge against the older base.
|
|
473
|
+
if (this._saveTicket !== 0) return;
|
|
474
|
+
// A frame morphed the page since start: its base is newer than
|
|
475
|
+
// anything the settle would capture.
|
|
476
|
+
if (this._morphTicket !== morphTicket) return;
|
|
477
|
+
// Refresh each lane only while it still holds what start left. A frame, a
|
|
478
|
+
// landed relay, an own save or a disk frame since then gave it a newer base
|
|
479
|
+
// (an own save counts even when its relay failed and lastHtml never moved).
|
|
480
|
+
// Only replace a seed start took, never fill a base start left empty: a page
|
|
481
|
+
// dirty at start held its frames in 1.5.2, and its own scripts may still be
|
|
482
|
+
// rewriting content the merge cannot reach.
|
|
483
|
+
if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
|
|
484
|
+
const clone = captureSnapshot({ flushUndo: false });
|
|
485
|
+
this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
|
|
486
|
+
}
|
|
487
|
+
if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
|
|
488
|
+
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
document.addEventListener('clay:baseline-settled', this._settledHandler, { once: true });
|
|
492
|
+
}
|
|
436
493
|
// Exposed so a caller (and the tests) can await the point where the stream is
|
|
437
494
|
// actually open, rather than counting microtasks behind the discovery request.
|
|
438
495
|
this._ready = this._resolveProfile().then(() => {
|
|
@@ -452,16 +509,9 @@ class LiveSync {
|
|
|
452
509
|
// today) counts as now.
|
|
453
510
|
const ticket = this._saveTicket || this._ticket();
|
|
454
511
|
this._saveTicket = 0;
|
|
512
|
+
if (ticket > this._acceptedSaveTicket) this._acceptedSaveTicket = ticket;
|
|
455
513
|
const bytes = getLastSavedBytes();
|
|
456
514
|
if (bytes != null) this._setDiskBase(bytes, ticket);
|
|
457
|
-
// A save this tab made is the one act that acknowledges a lost
|
|
458
|
-
// conflict: the person wrote the page as it stands. Only the page as
|
|
459
|
-
// it stood at the capture: a conflict from a frame that applied after
|
|
460
|
-
// it is not in these bytes, and it keeps the page dirty.
|
|
461
|
-
this.unresolvedConflicts = this.unresolvedConflicts.filter(
|
|
462
|
-
(c) => this._conflictTickets.get(c) > ticket
|
|
463
|
-
);
|
|
464
|
-
if (this.unresolvedConflicts.length) gateMarkDirty();
|
|
465
515
|
this._relayCommit();
|
|
466
516
|
};
|
|
467
517
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
@@ -476,7 +526,6 @@ class LiveSync {
|
|
|
476
526
|
queueMicrotask(() => {
|
|
477
527
|
if (this.isDestroyed) return;
|
|
478
528
|
if (refusedBy !== lastSeenEtag()) return;
|
|
479
|
-
if (this.unresolvedConflicts.length) return;
|
|
480
529
|
conflictResolvedBySync(refusedBy);
|
|
481
530
|
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
482
531
|
});
|
|
@@ -514,6 +563,11 @@ class LiveSync {
|
|
|
514
563
|
this._saveConflictHandler = null;
|
|
515
564
|
}
|
|
516
565
|
|
|
566
|
+
if (this._settledHandler) {
|
|
567
|
+
document.removeEventListener('clay:baseline-settled', this._settledHandler);
|
|
568
|
+
this._settledHandler = null;
|
|
569
|
+
}
|
|
570
|
+
|
|
517
571
|
this._queuedSend = null;
|
|
518
572
|
|
|
519
573
|
// Cancel any pending frame and clear the queue. A morph already in
|
|
@@ -785,7 +839,7 @@ class LiveSync {
|
|
|
785
839
|
if (etag && html === this.lastHtml) {
|
|
786
840
|
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
787
841
|
recordEtag(etag);
|
|
788
|
-
if (isSaveConflicted()
|
|
842
|
+
if (isSaveConflicted()) {
|
|
789
843
|
conflictResolvedBySync(etag);
|
|
790
844
|
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
791
845
|
}
|
|
@@ -840,7 +894,7 @@ class LiveSync {
|
|
|
840
894
|
// The save that follows this capture stores these bytes, so this is the
|
|
841
895
|
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
842
896
|
// by the next capture, so it always names the save currently in flight.
|
|
843
|
-
this._savedSnapshot = { html, identityMap };
|
|
897
|
+
this._savedSnapshot = { html, identityMap, ticket: this._saveTicket };
|
|
844
898
|
};
|
|
845
899
|
|
|
846
900
|
document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
@@ -902,22 +956,29 @@ class LiveSync {
|
|
|
902
956
|
// stored, and §10 is explicit that a stamp must never travel on its own.
|
|
903
957
|
if (!pending || typeof pending.html !== 'string') return;
|
|
904
958
|
|
|
905
|
-
|
|
906
|
-
|
|
959
|
+
// Skip only an unstamped repeat, judged against the base as it stood. A
|
|
960
|
+
// landed save is news even when its bytes equal the last relay: "Keep mine"
|
|
961
|
+
// can restore exactly those bytes after the peers moved on, and they must
|
|
962
|
+
// hear it.
|
|
963
|
+
const repeat = !etag && pending.html === this.lastHtml;
|
|
907
964
|
|
|
908
|
-
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
|
|
965
|
+
// The host accepted these bytes, so every later accepted save descends from
|
|
966
|
+
// them: they are the peer lane's base from this moment, not once the relay
|
|
967
|
+
// returns. A frame whose morph ran after this capture installs its own,
|
|
968
|
+
// newer base when its resources settle, and the ticket keeps it there.
|
|
969
|
+
this._setPeerBase(pending.html, pending.identityMap, pending.ticket);
|
|
970
|
+
|
|
971
|
+
if (repeat) {
|
|
913
972
|
this._log('Skipping send - HTML unchanged');
|
|
914
973
|
return;
|
|
915
974
|
}
|
|
975
|
+
this._enqueueSend(pending.html, pending.identityMap, etag || null);
|
|
976
|
+
}
|
|
916
977
|
|
|
917
|
-
|
|
978
|
+
_postUpdate(html, identityMap, etag = null, attempt = 0) {
|
|
979
|
+
this._log(`Sending update (HTML length: ${html.length})`);
|
|
918
980
|
|
|
919
981
|
this._sendInFlight = true;
|
|
920
|
-
const gen = this._applyGen;
|
|
921
982
|
let retry = false;
|
|
922
983
|
|
|
923
984
|
// Absolute against the real origin, so a <base href> in the page cannot
|
|
@@ -936,18 +997,7 @@ class LiveSync {
|
|
|
936
997
|
...(etag ? { etag } : {})
|
|
937
998
|
})
|
|
938
999
|
}).then(response => {
|
|
939
|
-
if (response.ok) {
|
|
940
|
-
// A frame that applied while this POST was on the wire has already
|
|
941
|
-
// advanced lastHtml past this snapshot; assigning would rewind the
|
|
942
|
-
// diff base to pre-frame state and misclassify the frame's content
|
|
943
|
-
// as local edits on the next dirty apply.
|
|
944
|
-
if (this._applyGen === gen) {
|
|
945
|
-
this.lastHtml = html;
|
|
946
|
-
this._lastIdentityMap = identityMap || null;
|
|
947
|
-
} else {
|
|
948
|
-
this._log('Skipping lastHtml advance: a frame applied during the POST');
|
|
949
|
-
}
|
|
950
|
-
} else {
|
|
1000
|
+
if (!response.ok) {
|
|
951
1001
|
console.warn('[LiveSync] Save returned status:', response.status);
|
|
952
1002
|
retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
953
1003
|
}
|
|
@@ -1233,6 +1283,22 @@ class LiveSync {
|
|
|
1233
1283
|
this._diskBaseTicket = ticket;
|
|
1234
1284
|
}
|
|
1235
1285
|
|
|
1286
|
+
/**
|
|
1287
|
+
* The one writer of the peer lane's base. Keeps the newest moment: a save
|
|
1288
|
+
* accepted after a later frame's morph, or a frame whose resource wait
|
|
1289
|
+
* outlasts a later save, can no longer rewind it.
|
|
1290
|
+
*/
|
|
1291
|
+
_setPeerBase(html, identityMap, ticket) {
|
|
1292
|
+
if (ticket < this._peerBaseTicket) return false;
|
|
1293
|
+
this._peerBaseTicket = ticket;
|
|
1294
|
+
this.lastHtml = html;
|
|
1295
|
+
this._lastIdentityMap =
|
|
1296
|
+
identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)
|
|
1297
|
+
? identityMap
|
|
1298
|
+
: null;
|
|
1299
|
+
return true;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1236
1302
|
_requestFrame(cb) {
|
|
1237
1303
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
1238
1304
|
return window.requestAnimationFrame(cb);
|
|
@@ -1272,7 +1338,7 @@ class LiveSync {
|
|
|
1272
1338
|
* @param {boolean} lane.synthetic - whether this tab's synthetic ids apply
|
|
1273
1339
|
* @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
|
|
1274
1340
|
*/
|
|
1275
|
-
async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, extra = {} }) {
|
|
1341
|
+
async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, source = 'peer', seq = null, etag = null, extra = {} }) {
|
|
1276
1342
|
const store = this.identity;
|
|
1277
1343
|
// A frame may neither write these onto our root nor, by not carrying them,
|
|
1278
1344
|
// take ours away. Returning false is hyper-morph's veto for both directions.
|
|
@@ -1312,6 +1378,7 @@ class LiveSync {
|
|
|
1312
1378
|
remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
|
|
1313
1379
|
ignoreAttribute: isRootLocalAttr,
|
|
1314
1380
|
protectFocusedValue: true,
|
|
1381
|
+
keepLiveOnly: source === 'peer',
|
|
1315
1382
|
scripts: { mergeTags: mergeTagRecognizers },
|
|
1316
1383
|
hooks: { beforeAttributeUpdated },
|
|
1317
1384
|
...extra,
|
|
@@ -1326,10 +1393,13 @@ class LiveSync {
|
|
|
1326
1393
|
// loaded and run. Resuming mutations earlier would let late scripts' DOM
|
|
1327
1394
|
// changes look like user edits and echo back out.
|
|
1328
1395
|
let pending;
|
|
1396
|
+
let localRoot = null;
|
|
1397
|
+
let applyId = null;
|
|
1329
1398
|
if (base == null) {
|
|
1330
1399
|
pending = HyperMorph.mergeDocument({
|
|
1331
1400
|
...common,
|
|
1332
1401
|
base: null,
|
|
1402
|
+
local: { root: captureLocal(), toLive: originalSnapshotNode },
|
|
1333
1403
|
identity: { local: localIdentity, remote: sideIdentity(identityMap) },
|
|
1334
1404
|
});
|
|
1335
1405
|
} else if (!dirty) {
|
|
@@ -1348,6 +1418,7 @@ class LiveSync {
|
|
|
1348
1418
|
...common,
|
|
1349
1419
|
base: baseDoc,
|
|
1350
1420
|
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
1421
|
+
fastPath: true,
|
|
1351
1422
|
identity: {
|
|
1352
1423
|
base: localIdentity,
|
|
1353
1424
|
local: localIdentity,
|
|
@@ -1355,41 +1426,47 @@ class LiveSync {
|
|
|
1355
1426
|
},
|
|
1356
1427
|
});
|
|
1357
1428
|
} else {
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
identity: {
|
|
1363
|
-
base: sideIdentity(baseIdentityMap),
|
|
1364
|
-
local: localIdentity,
|
|
1365
|
-
remote: sideIdentity(identityMap),
|
|
1366
|
-
},
|
|
1429
|
+
localRoot = captureLocal();
|
|
1430
|
+
applyId = beginApply({
|
|
1431
|
+
source, seq, etag, root: localRoot,
|
|
1432
|
+
domain: source === 'disk' ? 'save' : 'sync',
|
|
1367
1433
|
});
|
|
1434
|
+
try {
|
|
1435
|
+
pending = HyperMorph.mergeDocument({
|
|
1436
|
+
...common,
|
|
1437
|
+
base,
|
|
1438
|
+
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
1439
|
+
identity: {
|
|
1440
|
+
base: sideIdentity(baseIdentityMap),
|
|
1441
|
+
local: localIdentity,
|
|
1442
|
+
remote: sideIdentity(identityMap),
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
failApply(applyId, err);
|
|
1447
|
+
throw err;
|
|
1448
|
+
}
|
|
1368
1449
|
}
|
|
1369
1450
|
// The apply itself ran synchronously inside mergeDocument; only resource
|
|
1370
1451
|
// loads are still pending. This is the moment the frame's bytes were true
|
|
1371
1452
|
// of the document, so the lane dates its base here, not after the await:
|
|
1372
1453
|
// a save that captures during the wait is newer than this frame.
|
|
1373
1454
|
const ticket = this._ticket();
|
|
1374
|
-
|
|
1455
|
+
this._morphTicket = ticket;
|
|
1456
|
+
let report;
|
|
1457
|
+
try {
|
|
1458
|
+
report = await pending;
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
if (applyId) failApply(applyId, err, { ticket });
|
|
1461
|
+
throw err;
|
|
1462
|
+
}
|
|
1375
1463
|
const typedDuringWait = gateCaptureToken().gen !== token.gen;
|
|
1376
1464
|
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
|
|
1381
|
-
if (dirty && report.
|
|
1382
|
-
for (const c of report.conflicts) this._conflictTickets.set(c, ticket);
|
|
1383
|
-
this.unresolvedConflicts.push(...report.conflicts);
|
|
1384
|
-
}
|
|
1385
|
-
// Clean only when nothing of this tab's survived, nothing of it was lost,
|
|
1386
|
-
// and nothing lost earlier is still waiting on a save.
|
|
1387
|
-
if (
|
|
1388
|
-
dirty &&
|
|
1389
|
-
!report.localDiverged &&
|
|
1390
|
-
report.conflicts.length === 0 &&
|
|
1391
|
-
this.unresolvedConflicts.length === 0
|
|
1392
|
-
) {
|
|
1465
|
+
// What the frame won over this tab's unsaved edits goes to the ledger with
|
|
1466
|
+
// the clone the merge read as "mine". Earlier losses live in the ledger,
|
|
1467
|
+
// not in the page's dirty state.
|
|
1468
|
+
const conflictIds = applyId ? completeApply(applyId, report.conflicts, { ticket }) : [];
|
|
1469
|
+
if (dirty && !report.localDiverged) {
|
|
1393
1470
|
gateClearIfUnchanged(token);
|
|
1394
1471
|
}
|
|
1395
1472
|
// Synthetic ids converge: every live element the frame named takes the
|
|
@@ -1400,20 +1477,18 @@ class LiveSync {
|
|
|
1400
1477
|
const synthetic = mapIds.has(id) ? id : syntheticByAuthored.get(id);
|
|
1401
1478
|
if (synthetic) store.adopt(el, synthetic);
|
|
1402
1479
|
}
|
|
1403
|
-
return { report, ticket, typedDuringWait };
|
|
1480
|
+
return { report, ticket, typedDuringWait, conflictIds };
|
|
1404
1481
|
}
|
|
1405
1482
|
|
|
1406
1483
|
/**
|
|
1407
1484
|
* The automatic save a merge may owe: the merge kept this tab's edits
|
|
1408
1485
|
* (convergence), or the person typed during the awaited apply (the mutation
|
|
1409
1486
|
* feed was paused, so nothing else will schedule it). Only where autosave is
|
|
1410
|
-
* on
|
|
1411
|
-
* with the lost text gone and clear the one signal that says so.
|
|
1487
|
+
* on.
|
|
1412
1488
|
*/
|
|
1413
1489
|
_saveAfterMerge(diverged, typedDuringWait) {
|
|
1414
1490
|
if (!(diverged || typedDuringWait)) return;
|
|
1415
1491
|
if (!autosaveActive()) return;
|
|
1416
|
-
if (this.unresolvedConflicts.length) return;
|
|
1417
1492
|
savePageThrottled();
|
|
1418
1493
|
}
|
|
1419
1494
|
|
|
@@ -1487,11 +1562,14 @@ class LiveSync {
|
|
|
1487
1562
|
// knowingly missing what disk holds, and a stamp there would let its next
|
|
1488
1563
|
// save replace that change with nobody told.
|
|
1489
1564
|
|
|
1490
|
-
const { report, typedDuringWait: typed } = await this._mergeIncoming(html, identityMap, {
|
|
1565
|
+
const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, identityMap, {
|
|
1491
1566
|
base: this.lastHtml,
|
|
1492
1567
|
baseIdentityMap: this._lastIdentityMap,
|
|
1493
1568
|
captureLocal: () => captureSnapshot({ flushUndo: false }),
|
|
1494
1569
|
synthetic: true,
|
|
1570
|
+
source: 'peer',
|
|
1571
|
+
seq,
|
|
1572
|
+
etag,
|
|
1495
1573
|
});
|
|
1496
1574
|
diverged = report.localDiverged;
|
|
1497
1575
|
typedDuringWait = typed;
|
|
@@ -1510,11 +1588,9 @@ class LiveSync {
|
|
|
1510
1588
|
// two of a burst would read the protected section as clean and clobber
|
|
1511
1589
|
// it) and could dedupe away the convergence send. Convergence is driven
|
|
1512
1590
|
// by the explicit save below instead.
|
|
1513
|
-
this
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
? identityMap
|
|
1517
|
-
: null;
|
|
1591
|
+
// A save captured during this frame's resource wait and already accepted
|
|
1592
|
+
// is newer than the frame: it keeps the base, and its stamp too.
|
|
1593
|
+
this._setPeerBase(html, identityMap, ticket);
|
|
1518
1594
|
this._applyGen++;
|
|
1519
1595
|
|
|
1520
1596
|
// The stamp of section 6, taken now that the bytes it describes are the
|
|
@@ -1522,7 +1598,7 @@ class LiveSync {
|
|
|
1522
1598
|
// above, and deliberately the same moment: the two claims a page makes
|
|
1523
1599
|
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1524
1600
|
// it", and only the second one is this tab's to make.
|
|
1525
|
-
if (typeof etag === 'string' && etag) {
|
|
1601
|
+
if (!(this._acceptedSaveTicket > ticket) && typeof etag === 'string' && etag) {
|
|
1526
1602
|
recordEtag(etag);
|
|
1527
1603
|
stamped = true;
|
|
1528
1604
|
}
|
|
@@ -1547,13 +1623,10 @@ class LiveSync {
|
|
|
1547
1623
|
// gate or a diverged merge leaves the base where it was, and the
|
|
1548
1624
|
// convergence save moves it once the merge is on disk.
|
|
1549
1625
|
//
|
|
1550
|
-
// Unresolved conflicts keep every saved baseline where it is too: the
|
|
1551
|
-
// page is dirty by definition until this tab saves.
|
|
1552
1626
|
if (
|
|
1553
1627
|
this.lane === 'live' &&
|
|
1554
1628
|
!diverged &&
|
|
1555
|
-
!pageMaybeDirty()
|
|
1556
|
-
this.unresolvedConflicts.length === 0
|
|
1629
|
+
!pageMaybeDirty()
|
|
1557
1630
|
) {
|
|
1558
1631
|
const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
|
|
1559
1632
|
emitForSync: false,
|
|
@@ -1580,7 +1653,7 @@ class LiveSync {
|
|
|
1580
1653
|
// returns above without reaching here, so nothing can name an author for a
|
|
1581
1654
|
// change this tab never took. Null on every frame the host did not stamp.
|
|
1582
1655
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1583
|
-
detail: { seq, source: 'peer', by: by || null, report }
|
|
1656
|
+
detail: { seq, source: 'peer', by: by || null, report, unresolved: this.unresolvedConflicts, conflictIds }
|
|
1584
1657
|
}));
|
|
1585
1658
|
} finally {
|
|
1586
1659
|
this._log('applyUpdate - morph complete, resuming mutations');
|
|
@@ -1599,9 +1672,7 @@ class LiveSync {
|
|
|
1599
1672
|
// isPaused is lifted so the convergence save's relay reaches peers.
|
|
1600
1673
|
// Only where autosave is on: a manual-save page must never auto-write, so
|
|
1601
1674
|
// there the merge stays local, still dirty, until the person saves.
|
|
1602
|
-
|
|
1603
|
-
// this tab lost text, until the person saves.
|
|
1604
|
-
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1675
|
+
if (stamped) conflictResolvedBySync(etag);
|
|
1605
1676
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1606
1677
|
}
|
|
1607
1678
|
|
|
@@ -1666,10 +1737,13 @@ class LiveSync {
|
|
|
1666
1737
|
// file. The merged document is activated for edit mode before it is
|
|
1667
1738
|
// applied, exactly as boot would have, so every node, local or incoming,
|
|
1668
1739
|
// goes live from its own saved form.
|
|
1669
|
-
const { report, ticket, typedDuringWait: typed } = await this._mergeIncoming(html, null, {
|
|
1740
|
+
const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, null, {
|
|
1670
1741
|
base: this._diskBase,
|
|
1671
1742
|
captureLocal: () => captureForMerge().saveClone,
|
|
1672
1743
|
synthetic: false,
|
|
1744
|
+
source: 'disk',
|
|
1745
|
+
seq,
|
|
1746
|
+
etag,
|
|
1673
1747
|
extra: { beforeApply: (doc) => activateIncomingDoc(doc.documentElement) },
|
|
1674
1748
|
});
|
|
1675
1749
|
diverged = report.localDiverged;
|
|
@@ -1697,8 +1771,7 @@ class LiveSync {
|
|
|
1697
1771
|
if (
|
|
1698
1772
|
this.lane === 'live' &&
|
|
1699
1773
|
!diverged &&
|
|
1700
|
-
!pageMaybeDirty()
|
|
1701
|
-
this.unresolvedConflicts.length === 0
|
|
1774
|
+
!pageMaybeDirty()
|
|
1702
1775
|
) {
|
|
1703
1776
|
// Clean apply: the DOM now IS the disk state, so a local comparison
|
|
1704
1777
|
// capture of it is the truthful baseline. The next no-op save skips,
|
|
@@ -1714,8 +1787,7 @@ class LiveSync {
|
|
|
1714
1787
|
// newer peer frame. Rebuild it exactly the way the send pipeline
|
|
1715
1788
|
// does, so it stays in the snapshot domain.
|
|
1716
1789
|
const clone = captureSnapshot({ flushUndo: false });
|
|
1717
|
-
this.
|
|
1718
|
-
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
1790
|
+
this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), ticket);
|
|
1719
1791
|
this._applyGen++;
|
|
1720
1792
|
}
|
|
1721
1793
|
|
|
@@ -1729,6 +1801,8 @@ class LiveSync {
|
|
|
1729
1801
|
// so this tab's next save copies from the file somebody else just wrote.
|
|
1730
1802
|
html,
|
|
1731
1803
|
report,
|
|
1804
|
+
conflictIds,
|
|
1805
|
+
unresolved: this.unresolvedConflicts,
|
|
1732
1806
|
}
|
|
1733
1807
|
}));
|
|
1734
1808
|
} finally {
|
|
@@ -1743,9 +1817,7 @@ class LiveSync {
|
|
|
1743
1817
|
// (our edits + their bytes). The baseline was left pre-external, so the
|
|
1744
1818
|
// save sees both as changes and writes the merge back. Only where
|
|
1745
1819
|
// autosave is on, as in the peer lane.
|
|
1746
|
-
|
|
1747
|
-
// this tab lost text, until the person saves.
|
|
1748
|
-
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1820
|
+
if (stamped) conflictResolvedBySync(etag);
|
|
1749
1821
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1750
1822
|
}
|
|
1751
1823
|
|
|
@@ -1809,5 +1881,5 @@ if (typeof window !== 'undefined') {
|
|
|
1809
1881
|
// Export for the clayjs module system. The class itself is exported so
|
|
1810
1882
|
// tests can create fresh instances without driving the singleton's
|
|
1811
1883
|
// EventSource/snapshot wiring.
|
|
1812
|
-
export { liveSync, LiveSync, morph, WIRE_PROFILES };
|
|
1884
|
+
export { liveSync, LiveSync, morph, WIRE_PROFILES, conflicts, SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR };
|
|
1813
1885
|
export default liveSync;
|