@panphora/clayjs 1.5.3 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/THIRD-PARTY-NOTICES.md +6 -0
- package/dist/clay.standalone.js +15041 -10657
- 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-core.js +35 -2
- package/src/core/save-feedback.js +6 -0
- package/src/core/save.js +157 -3
- package/src/core/snapshot.js +21 -0
- package/src/core/stale-host-notice.js +21 -24
- package/src/core/unsaved-warning.js +64 -16
- package/src/lib/content-dom.js +4 -2
- package/src/lib/dirty-gate.js +37 -1
- package/src/lib/unsaved-state.js +55 -0
- package/src/loader-logic.js +26 -15
- package/src/loader.js +10 -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 +130 -109
- 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,9 +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
60
|
import { gestureSeen } from '../lib/user-gesture.js';
|
|
61
61
|
import { SyncStream } from './stream.js';
|
|
62
|
+
import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
|
|
62
63
|
|
|
63
64
|
// What a live-sync merge never reads or touches on any side: editor chrome,
|
|
64
65
|
// content kept out of the save or the snapshot, frozen regions, and nodes
|
|
@@ -209,15 +210,16 @@ class LiveSync {
|
|
|
209
210
|
this._agreeSeq = 0;
|
|
210
211
|
// The ticket of the save currently in flight, taken when it captured.
|
|
211
212
|
this._saveTicket = 0;
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
this.
|
|
219
|
-
//
|
|
220
|
-
|
|
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;
|
|
221
223
|
this.clientId = this.generateClientId();
|
|
222
224
|
|
|
223
225
|
// Per-stream resume id for the htmlclay replay server's wire contract.
|
|
@@ -232,9 +234,9 @@ class LiveSync {
|
|
|
232
234
|
// held so the commit relay can pair the host's stamp with the content that
|
|
233
235
|
// stamp actually describes. Set at snapshot-ready, which fires before the
|
|
234
236
|
// save POST goes out, and consumed by _relayCommit when the response lands.
|
|
235
|
-
// Never reconstructed from lastHtml: that is the
|
|
236
|
-
//
|
|
237
|
-
//
|
|
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.
|
|
238
240
|
this._savedSnapshot = null;
|
|
239
241
|
this._sendInFlight = false;
|
|
240
242
|
this._queuedSend = null;
|
|
@@ -310,10 +312,9 @@ class LiveSync {
|
|
|
310
312
|
// can resolve synthetic identities on its base tree.
|
|
311
313
|
this._lastIdentityMap = null;
|
|
312
314
|
|
|
313
|
-
// Bumped whenever an APPLY (or a reset) rewrites lastHtml.
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// 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.
|
|
317
318
|
this._applyGen = 0;
|
|
318
319
|
|
|
319
320
|
// Hold-retry timers, one per pending slot. A held frame's slot and seq
|
|
@@ -345,6 +346,12 @@ class LiveSync {
|
|
|
345
346
|
this.onNotification = null;
|
|
346
347
|
}
|
|
347
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
|
+
|
|
348
355
|
_log(message, data = null) {
|
|
349
356
|
if (!this.debug) return;
|
|
350
357
|
const prefix = `[LiveSync ${new Date().toISOString()}]`;
|
|
@@ -390,8 +397,7 @@ class LiveSync {
|
|
|
390
397
|
_seedBases() {
|
|
391
398
|
if (pageMaybeDirty()) return;
|
|
392
399
|
const clone = captureSnapshot({ flushUndo: false });
|
|
393
|
-
this.
|
|
394
|
-
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
400
|
+
this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
|
|
395
401
|
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
396
402
|
}
|
|
397
403
|
|
|
@@ -419,12 +425,14 @@ class LiveSync {
|
|
|
419
425
|
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
420
426
|
this.lastHtml = null;
|
|
421
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();
|
|
422
431
|
// The reset takes a ticket of its own, so a writer from before this start
|
|
423
432
|
// (a save response still on the wire) cannot land in the new run.
|
|
424
433
|
this._diskBase = null;
|
|
425
434
|
this._diskBaseTicket = this._ticket();
|
|
426
435
|
this._saveTicket = 0;
|
|
427
|
-
this.unresolvedConflicts = [];
|
|
428
436
|
this._savedSnapshot = null;
|
|
429
437
|
this._applyGen++;
|
|
430
438
|
this.lastSeenSeq = 0;
|
|
@@ -445,6 +453,7 @@ class LiveSync {
|
|
|
445
453
|
const gen = ++this._startGen;
|
|
446
454
|
if (this.lane === 'live' && !baselineSettled()) {
|
|
447
455
|
const applyGen = this._applyGen;
|
|
456
|
+
const morphTicket = this._morphTicket;
|
|
448
457
|
const saveEpoch = this._saveEpoch;
|
|
449
458
|
const startHtml = this.lastHtml;
|
|
450
459
|
const startDiskTicket = this._diskBaseTicket;
|
|
@@ -462,6 +471,9 @@ class LiveSync {
|
|
|
462
471
|
// A save this tab sent is still unconfirmed: what it carries is not yet
|
|
463
472
|
// common history, and a refused save must merge against the older base.
|
|
464
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;
|
|
465
477
|
// Refresh each lane only while it still holds what start left. A frame, a
|
|
466
478
|
// landed relay, an own save or a disk frame since then gave it a newer base
|
|
467
479
|
// (an own save counts even when its relay failed and lastHtml never moved).
|
|
@@ -470,8 +482,7 @@ class LiveSync {
|
|
|
470
482
|
// rewriting content the merge cannot reach.
|
|
471
483
|
if (startHtml !== null && this._applyGen === applyGen && this._saveEpoch === saveEpoch && this.lastHtml === startHtml) {
|
|
472
484
|
const clone = captureSnapshot({ flushUndo: false });
|
|
473
|
-
this.
|
|
474
|
-
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
485
|
+
this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), this._ticket());
|
|
475
486
|
}
|
|
476
487
|
if (startDiskSeeded && this._diskBaseTicket === startDiskTicket) {
|
|
477
488
|
this._setDiskBase(captureForSaveAndComparison({ emitForSync: false }).forSave, this._ticket());
|
|
@@ -498,16 +509,9 @@ class LiveSync {
|
|
|
498
509
|
// today) counts as now.
|
|
499
510
|
const ticket = this._saveTicket || this._ticket();
|
|
500
511
|
this._saveTicket = 0;
|
|
512
|
+
if (ticket > this._acceptedSaveTicket) this._acceptedSaveTicket = ticket;
|
|
501
513
|
const bytes = getLastSavedBytes();
|
|
502
514
|
if (bytes != null) this._setDiskBase(bytes, ticket);
|
|
503
|
-
// A save this tab made is the one act that acknowledges a lost
|
|
504
|
-
// conflict: the person wrote the page as it stands. Only the page as
|
|
505
|
-
// it stood at the capture: a conflict from a frame that applied after
|
|
506
|
-
// it is not in these bytes, and it keeps the page dirty.
|
|
507
|
-
this.unresolvedConflicts = this.unresolvedConflicts.filter(
|
|
508
|
-
(c) => this._conflictTickets.get(c) > ticket
|
|
509
|
-
);
|
|
510
|
-
if (this.unresolvedConflicts.length) gateMarkDirty();
|
|
511
515
|
this._relayCommit();
|
|
512
516
|
};
|
|
513
517
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
@@ -522,7 +526,6 @@ class LiveSync {
|
|
|
522
526
|
queueMicrotask(() => {
|
|
523
527
|
if (this.isDestroyed) return;
|
|
524
528
|
if (refusedBy !== lastSeenEtag()) return;
|
|
525
|
-
if (this.unresolvedConflicts.length) return;
|
|
526
529
|
conflictResolvedBySync(refusedBy);
|
|
527
530
|
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
528
531
|
});
|
|
@@ -836,7 +839,7 @@ class LiveSync {
|
|
|
836
839
|
if (etag && html === this.lastHtml) {
|
|
837
840
|
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
838
841
|
recordEtag(etag);
|
|
839
|
-
if (isSaveConflicted()
|
|
842
|
+
if (isSaveConflicted()) {
|
|
840
843
|
conflictResolvedBySync(etag);
|
|
841
844
|
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
842
845
|
}
|
|
@@ -891,7 +894,7 @@ class LiveSync {
|
|
|
891
894
|
// The save that follows this capture stores these bytes, so this is the
|
|
892
895
|
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
893
896
|
// by the next capture, so it always names the save currently in flight.
|
|
894
|
-
this._savedSnapshot = { html, identityMap };
|
|
897
|
+
this._savedSnapshot = { html, identityMap, ticket: this._saveTicket };
|
|
895
898
|
};
|
|
896
899
|
|
|
897
900
|
document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
@@ -953,22 +956,29 @@ class LiveSync {
|
|
|
953
956
|
// stored, and §10 is explicit that a stamp must never travel on its own.
|
|
954
957
|
if (!pending || typeof pending.html !== 'string') return;
|
|
955
958
|
|
|
956
|
-
|
|
957
|
-
|
|
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;
|
|
958
964
|
|
|
959
|
-
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
|
|
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) {
|
|
964
972
|
this._log('Skipping send - HTML unchanged');
|
|
965
973
|
return;
|
|
966
974
|
}
|
|
975
|
+
this._enqueueSend(pending.html, pending.identityMap, etag || null);
|
|
976
|
+
}
|
|
967
977
|
|
|
968
|
-
|
|
978
|
+
_postUpdate(html, identityMap, etag = null, attempt = 0) {
|
|
979
|
+
this._log(`Sending update (HTML length: ${html.length})`);
|
|
969
980
|
|
|
970
981
|
this._sendInFlight = true;
|
|
971
|
-
const gen = this._applyGen;
|
|
972
982
|
let retry = false;
|
|
973
983
|
|
|
974
984
|
// Absolute against the real origin, so a <base href> in the page cannot
|
|
@@ -987,18 +997,7 @@ class LiveSync {
|
|
|
987
997
|
...(etag ? { etag } : {})
|
|
988
998
|
})
|
|
989
999
|
}).then(response => {
|
|
990
|
-
if (response.ok) {
|
|
991
|
-
// A frame that applied while this POST was on the wire has already
|
|
992
|
-
// advanced lastHtml past this snapshot; assigning would rewind the
|
|
993
|
-
// diff base to pre-frame state and misclassify the frame's content
|
|
994
|
-
// as local edits on the next dirty apply.
|
|
995
|
-
if (this._applyGen === gen) {
|
|
996
|
-
this.lastHtml = html;
|
|
997
|
-
this._lastIdentityMap = identityMap || null;
|
|
998
|
-
} else {
|
|
999
|
-
this._log('Skipping lastHtml advance: a frame applied during the POST');
|
|
1000
|
-
}
|
|
1001
|
-
} else {
|
|
1000
|
+
if (!response.ok) {
|
|
1002
1001
|
console.warn('[LiveSync] Save returned status:', response.status);
|
|
1003
1002
|
retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
1004
1003
|
}
|
|
@@ -1284,6 +1283,22 @@ class LiveSync {
|
|
|
1284
1283
|
this._diskBaseTicket = ticket;
|
|
1285
1284
|
}
|
|
1286
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
|
+
|
|
1287
1302
|
_requestFrame(cb) {
|
|
1288
1303
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
1289
1304
|
return window.requestAnimationFrame(cb);
|
|
@@ -1323,7 +1338,7 @@ class LiveSync {
|
|
|
1323
1338
|
* @param {boolean} lane.synthetic - whether this tab's synthetic ids apply
|
|
1324
1339
|
* @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
|
|
1325
1340
|
*/
|
|
1326
|
-
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 = {} }) {
|
|
1327
1342
|
const store = this.identity;
|
|
1328
1343
|
// A frame may neither write these onto our root nor, by not carrying them,
|
|
1329
1344
|
// take ours away. Returning false is hyper-morph's veto for both directions.
|
|
@@ -1363,6 +1378,7 @@ class LiveSync {
|
|
|
1363
1378
|
remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
|
|
1364
1379
|
ignoreAttribute: isRootLocalAttr,
|
|
1365
1380
|
protectFocusedValue: true,
|
|
1381
|
+
keepLiveOnly: source === 'peer',
|
|
1366
1382
|
scripts: { mergeTags: mergeTagRecognizers },
|
|
1367
1383
|
hooks: { beforeAttributeUpdated },
|
|
1368
1384
|
...extra,
|
|
@@ -1377,10 +1393,13 @@ class LiveSync {
|
|
|
1377
1393
|
// loaded and run. Resuming mutations earlier would let late scripts' DOM
|
|
1378
1394
|
// changes look like user edits and echo back out.
|
|
1379
1395
|
let pending;
|
|
1396
|
+
let localRoot = null;
|
|
1397
|
+
let applyId = null;
|
|
1380
1398
|
if (base == null) {
|
|
1381
1399
|
pending = HyperMorph.mergeDocument({
|
|
1382
1400
|
...common,
|
|
1383
1401
|
base: null,
|
|
1402
|
+
local: { root: captureLocal(), toLive: originalSnapshotNode },
|
|
1384
1403
|
identity: { local: localIdentity, remote: sideIdentity(identityMap) },
|
|
1385
1404
|
});
|
|
1386
1405
|
} else if (!dirty) {
|
|
@@ -1399,6 +1418,7 @@ class LiveSync {
|
|
|
1399
1418
|
...common,
|
|
1400
1419
|
base: baseDoc,
|
|
1401
1420
|
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
1421
|
+
fastPath: true,
|
|
1402
1422
|
identity: {
|
|
1403
1423
|
base: localIdentity,
|
|
1404
1424
|
local: localIdentity,
|
|
@@ -1406,41 +1426,47 @@ class LiveSync {
|
|
|
1406
1426
|
},
|
|
1407
1427
|
});
|
|
1408
1428
|
} else {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
identity: {
|
|
1414
|
-
base: sideIdentity(baseIdentityMap),
|
|
1415
|
-
local: localIdentity,
|
|
1416
|
-
remote: sideIdentity(identityMap),
|
|
1417
|
-
},
|
|
1429
|
+
localRoot = captureLocal();
|
|
1430
|
+
applyId = beginApply({
|
|
1431
|
+
source, seq, etag, root: localRoot,
|
|
1432
|
+
domain: source === 'disk' ? 'save' : 'sync',
|
|
1418
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
|
+
}
|
|
1419
1449
|
}
|
|
1420
1450
|
// The apply itself ran synchronously inside mergeDocument; only resource
|
|
1421
1451
|
// loads are still pending. This is the moment the frame's bytes were true
|
|
1422
1452
|
// of the document, so the lane dates its base here, not after the await:
|
|
1423
1453
|
// a save that captures during the wait is newer than this frame.
|
|
1424
1454
|
const ticket = this._ticket();
|
|
1425
|
-
|
|
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
|
+
}
|
|
1426
1463
|
const typedDuringWait = gateCaptureToken().gen !== token.gen;
|
|
1427
1464
|
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
|
|
1432
|
-
if (dirty && report.
|
|
1433
|
-
for (const c of report.conflicts) this._conflictTickets.set(c, ticket);
|
|
1434
|
-
this.unresolvedConflicts.push(...report.conflicts);
|
|
1435
|
-
}
|
|
1436
|
-
// Clean only when nothing of this tab's survived, nothing of it was lost,
|
|
1437
|
-
// and nothing lost earlier is still waiting on a save.
|
|
1438
|
-
if (
|
|
1439
|
-
dirty &&
|
|
1440
|
-
!report.localDiverged &&
|
|
1441
|
-
report.conflicts.length === 0 &&
|
|
1442
|
-
this.unresolvedConflicts.length === 0
|
|
1443
|
-
) {
|
|
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) {
|
|
1444
1470
|
gateClearIfUnchanged(token);
|
|
1445
1471
|
}
|
|
1446
1472
|
// Synthetic ids converge: every live element the frame named takes the
|
|
@@ -1451,20 +1477,18 @@ class LiveSync {
|
|
|
1451
1477
|
const synthetic = mapIds.has(id) ? id : syntheticByAuthored.get(id);
|
|
1452
1478
|
if (synthetic) store.adopt(el, synthetic);
|
|
1453
1479
|
}
|
|
1454
|
-
return { report, ticket, typedDuringWait };
|
|
1480
|
+
return { report, ticket, typedDuringWait, conflictIds };
|
|
1455
1481
|
}
|
|
1456
1482
|
|
|
1457
1483
|
/**
|
|
1458
1484
|
* The automatic save a merge may owe: the merge kept this tab's edits
|
|
1459
1485
|
* (convergence), or the person typed during the awaited apply (the mutation
|
|
1460
1486
|
* feed was paused, so nothing else will schedule it). Only where autosave is
|
|
1461
|
-
* on
|
|
1462
|
-
* with the lost text gone and clear the one signal that says so.
|
|
1487
|
+
* on.
|
|
1463
1488
|
*/
|
|
1464
1489
|
_saveAfterMerge(diverged, typedDuringWait) {
|
|
1465
1490
|
if (!(diverged || typedDuringWait)) return;
|
|
1466
1491
|
if (!autosaveActive()) return;
|
|
1467
|
-
if (this.unresolvedConflicts.length) return;
|
|
1468
1492
|
savePageThrottled();
|
|
1469
1493
|
}
|
|
1470
1494
|
|
|
@@ -1538,11 +1562,14 @@ class LiveSync {
|
|
|
1538
1562
|
// knowingly missing what disk holds, and a stamp there would let its next
|
|
1539
1563
|
// save replace that change with nobody told.
|
|
1540
1564
|
|
|
1541
|
-
const { report, typedDuringWait: typed } = await this._mergeIncoming(html, identityMap, {
|
|
1565
|
+
const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, identityMap, {
|
|
1542
1566
|
base: this.lastHtml,
|
|
1543
1567
|
baseIdentityMap: this._lastIdentityMap,
|
|
1544
1568
|
captureLocal: () => captureSnapshot({ flushUndo: false }),
|
|
1545
1569
|
synthetic: true,
|
|
1570
|
+
source: 'peer',
|
|
1571
|
+
seq,
|
|
1572
|
+
etag,
|
|
1546
1573
|
});
|
|
1547
1574
|
diverged = report.localDiverged;
|
|
1548
1575
|
typedDuringWait = typed;
|
|
@@ -1561,11 +1588,9 @@ class LiveSync {
|
|
|
1561
1588
|
// two of a burst would read the protected section as clean and clobber
|
|
1562
1589
|
// it) and could dedupe away the convergence send. Convergence is driven
|
|
1563
1590
|
// by the explicit save below instead.
|
|
1564
|
-
this
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
? identityMap
|
|
1568
|
-
: 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);
|
|
1569
1594
|
this._applyGen++;
|
|
1570
1595
|
|
|
1571
1596
|
// The stamp of section 6, taken now that the bytes it describes are the
|
|
@@ -1573,7 +1598,7 @@ class LiveSync {
|
|
|
1573
1598
|
// above, and deliberately the same moment: the two claims a page makes
|
|
1574
1599
|
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1575
1600
|
// it", and only the second one is this tab's to make.
|
|
1576
|
-
if (typeof etag === 'string' && etag) {
|
|
1601
|
+
if (!(this._acceptedSaveTicket > ticket) && typeof etag === 'string' && etag) {
|
|
1577
1602
|
recordEtag(etag);
|
|
1578
1603
|
stamped = true;
|
|
1579
1604
|
}
|
|
@@ -1598,13 +1623,10 @@ class LiveSync {
|
|
|
1598
1623
|
// gate or a diverged merge leaves the base where it was, and the
|
|
1599
1624
|
// convergence save moves it once the merge is on disk.
|
|
1600
1625
|
//
|
|
1601
|
-
// Unresolved conflicts keep every saved baseline where it is too: the
|
|
1602
|
-
// page is dirty by definition until this tab saves.
|
|
1603
1626
|
if (
|
|
1604
1627
|
this.lane === 'live' &&
|
|
1605
1628
|
!diverged &&
|
|
1606
|
-
!pageMaybeDirty()
|
|
1607
|
-
this.unresolvedConflicts.length === 0
|
|
1629
|
+
!pageMaybeDirty()
|
|
1608
1630
|
) {
|
|
1609
1631
|
const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
|
|
1610
1632
|
emitForSync: false,
|
|
@@ -1631,7 +1653,7 @@ class LiveSync {
|
|
|
1631
1653
|
// returns above without reaching here, so nothing can name an author for a
|
|
1632
1654
|
// change this tab never took. Null on every frame the host did not stamp.
|
|
1633
1655
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1634
|
-
detail: { seq, source: 'peer', by: by || null, report }
|
|
1656
|
+
detail: { seq, source: 'peer', by: by || null, report, unresolved: this.unresolvedConflicts, conflictIds }
|
|
1635
1657
|
}));
|
|
1636
1658
|
} finally {
|
|
1637
1659
|
this._log('applyUpdate - morph complete, resuming mutations');
|
|
@@ -1650,9 +1672,7 @@ class LiveSync {
|
|
|
1650
1672
|
// isPaused is lifted so the convergence save's relay reaches peers.
|
|
1651
1673
|
// Only where autosave is on: a manual-save page must never auto-write, so
|
|
1652
1674
|
// there the merge stays local, still dirty, until the person saves.
|
|
1653
|
-
|
|
1654
|
-
// this tab lost text, until the person saves.
|
|
1655
|
-
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1675
|
+
if (stamped) conflictResolvedBySync(etag);
|
|
1656
1676
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1657
1677
|
}
|
|
1658
1678
|
|
|
@@ -1717,10 +1737,13 @@ class LiveSync {
|
|
|
1717
1737
|
// file. The merged document is activated for edit mode before it is
|
|
1718
1738
|
// applied, exactly as boot would have, so every node, local or incoming,
|
|
1719
1739
|
// goes live from its own saved form.
|
|
1720
|
-
const { report, ticket, typedDuringWait: typed } = await this._mergeIncoming(html, null, {
|
|
1740
|
+
const { report, ticket, typedDuringWait: typed, conflictIds } = await this._mergeIncoming(html, null, {
|
|
1721
1741
|
base: this._diskBase,
|
|
1722
1742
|
captureLocal: () => captureForMerge().saveClone,
|
|
1723
1743
|
synthetic: false,
|
|
1744
|
+
source: 'disk',
|
|
1745
|
+
seq,
|
|
1746
|
+
etag,
|
|
1724
1747
|
extra: { beforeApply: (doc) => activateIncomingDoc(doc.documentElement) },
|
|
1725
1748
|
});
|
|
1726
1749
|
diverged = report.localDiverged;
|
|
@@ -1748,8 +1771,7 @@ class LiveSync {
|
|
|
1748
1771
|
if (
|
|
1749
1772
|
this.lane === 'live' &&
|
|
1750
1773
|
!diverged &&
|
|
1751
|
-
!pageMaybeDirty()
|
|
1752
|
-
this.unresolvedConflicts.length === 0
|
|
1774
|
+
!pageMaybeDirty()
|
|
1753
1775
|
) {
|
|
1754
1776
|
// Clean apply: the DOM now IS the disk state, so a local comparison
|
|
1755
1777
|
// capture of it is the truthful baseline. The next no-op save skips,
|
|
@@ -1765,8 +1787,7 @@ class LiveSync {
|
|
|
1765
1787
|
// newer peer frame. Rebuild it exactly the way the send pipeline
|
|
1766
1788
|
// does, so it stays in the snapshot domain.
|
|
1767
1789
|
const clone = captureSnapshot({ flushUndo: false });
|
|
1768
|
-
this.
|
|
1769
|
-
this._lastIdentityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
1790
|
+
this._setPeerBase(serializeForSync(clone), this.identity.exportMap(clone, originalSnapshotNode), ticket);
|
|
1770
1791
|
this._applyGen++;
|
|
1771
1792
|
}
|
|
1772
1793
|
|
|
@@ -1780,6 +1801,8 @@ class LiveSync {
|
|
|
1780
1801
|
// so this tab's next save copies from the file somebody else just wrote.
|
|
1781
1802
|
html,
|
|
1782
1803
|
report,
|
|
1804
|
+
conflictIds,
|
|
1805
|
+
unresolved: this.unresolvedConflicts,
|
|
1783
1806
|
}
|
|
1784
1807
|
}));
|
|
1785
1808
|
} finally {
|
|
@@ -1794,9 +1817,7 @@ class LiveSync {
|
|
|
1794
1817
|
// (our edits + their bytes). The baseline was left pre-external, so the
|
|
1795
1818
|
// save sees both as changes and writes the merge back. Only where
|
|
1796
1819
|
// autosave is on, as in the peer lane.
|
|
1797
|
-
|
|
1798
|
-
// this tab lost text, until the person saves.
|
|
1799
|
-
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1820
|
+
if (stamped) conflictResolvedBySync(etag);
|
|
1800
1821
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1801
1822
|
}
|
|
1802
1823
|
|
|
@@ -1860,5 +1881,5 @@ if (typeof window !== 'undefined') {
|
|
|
1860
1881
|
// Export for the clayjs module system. The class itself is exported so
|
|
1861
1882
|
// tests can create fresh instances without driving the singleton's
|
|
1862
1883
|
// EventSource/snapshot wiring.
|
|
1863
|
-
export { liveSync, LiveSync, morph, WIRE_PROFILES };
|
|
1884
|
+
export { liveSync, LiveSync, morph, WIRE_PROFILES, conflicts, SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR };
|
|
1864
1885
|
export default liveSync;
|