@panphora/clayjs 1.13.1 → 1.15.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69385 -40992
- package/dist/server/checkpoint.cjs +1 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/core.cjs +72210 -0
- package/dist/server/rooms.cjs +1 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +127 -99
- package/entries/clay.js +22 -0
- package/entries/sap.js +5 -5
- package/package.json +16 -2
- package/packed-contract.json +18 -3
- package/src/core/boot-phase.js +6 -0
- package/src/core/conflict-download.js +12 -3
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +154 -36
- package/src/core/snapshot.js +51 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/derived.js +116 -0
- package/src/lib/effect.js +130 -0
- package/src/lib/mutation.js +3 -1
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +47 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +16 -4
- package/src/loader.js +37 -1
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1170 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +386 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/derive.js +12 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +62 -4
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/conflict-revert.js +2 -0
- package/src/sync/conflicts.js +2 -2
- package/src/sync/live-sync.js +36 -8
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/sap-core.vendor.js +9 -0
- package/src/vendor/sap-ownership.vendor.js +111 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
package/src/core/save.js
CHANGED
|
@@ -13,7 +13,7 @@ import throttle from "../lib/throttle.js";
|
|
|
13
13
|
import Mutation from "../lib/mutation.js";
|
|
14
14
|
import { isDraftMode, isEditMode, isOwner } from "./is-edit-mode.js";
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
saveCapturedHtml,
|
|
17
17
|
getPageContents,
|
|
18
18
|
replacePageWith as replacePageWithCore,
|
|
19
19
|
addDocumentTransform,
|
|
@@ -21,12 +21,15 @@ import {
|
|
|
21
21
|
saveFateIsUnknown,
|
|
22
22
|
withKeepalive
|
|
23
23
|
} from "./save-core.js";
|
|
24
|
-
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck } from "./snapshot.js";
|
|
24
|
+
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck, captureSaveClone } from "./snapshot.js";
|
|
25
|
+
import { paintSignature } from "../lib/derived.js";
|
|
25
26
|
import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
|
|
26
27
|
import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
27
28
|
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
28
29
|
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
29
30
|
import { autosaveActive } from "../lib/autosave-state.js";
|
|
31
|
+
import { registerStyleAdoption } from "../lib/style-adoption.js";
|
|
32
|
+
import { styleComparisonStripper } from "./style-comparisons.js";
|
|
30
33
|
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
|
|
31
34
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
32
35
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
@@ -35,6 +38,7 @@ import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/use
|
|
|
35
38
|
// Both read across it at call time, never while a module is evaluating, so neither
|
|
36
39
|
// side sees a half-built module.
|
|
37
40
|
import { bytesUnsaved } from "./unsaved-warning.js";
|
|
41
|
+
import { bootPhase } from "./boot-phase.js";
|
|
38
42
|
|
|
39
43
|
// Keep this library's own root state out of the saved bytes.
|
|
40
44
|
//
|
|
@@ -188,6 +192,8 @@ let unsavedChanges = false;
|
|
|
188
192
|
// the split is observable there.
|
|
189
193
|
let lastSavedContents = '';
|
|
190
194
|
let lastSavedDirty = '';
|
|
195
|
+
let baselineRevision = 0;
|
|
196
|
+
const inFlightComparisons = new Set();
|
|
191
197
|
// A save was requested while one was on the wire; run one more when it settles.
|
|
192
198
|
let pendingSave = false;
|
|
193
199
|
// An autosave arrived while one of our saves was on the wire. When that save lands
|
|
@@ -374,12 +380,11 @@ function skipped_(msg) {
|
|
|
374
380
|
* where the write may or may not have landed, so it is not treated as success
|
|
375
381
|
* either.
|
|
376
382
|
*/
|
|
377
|
-
function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null) {
|
|
383
|
+
function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null, quietConflict = false) {
|
|
378
384
|
if (result.ok) {
|
|
379
385
|
// Both baselines advance from the SAME pre-request capture, never from the
|
|
380
386
|
// live DOM, so an edit made while the request was on the wire stays unsaved.
|
|
381
|
-
|
|
382
|
-
lastSavedDirty = forDirty;
|
|
387
|
+
setLastSavedBaselines(forComparison, forDirty);
|
|
383
388
|
lastSavedBytes = forSave;
|
|
384
389
|
unsavedChanges = false;
|
|
385
390
|
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
@@ -391,15 +396,39 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
|
|
|
391
396
|
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
392
397
|
releaseConflictHold();
|
|
393
398
|
} else if (result.msgType === 'conflict') {
|
|
399
|
+
// A refused heal leaves the tab as it was: still clean, no save event, no
|
|
400
|
+
// hold. The save that beat it reaches this tab through sync.
|
|
401
|
+
if (quietConflict) {
|
|
402
|
+
if (savingTimeout) {
|
|
403
|
+
clearTimeout(savingTimeout);
|
|
404
|
+
savingTimeout = null;
|
|
405
|
+
}
|
|
406
|
+
document.documentElement.setAttribute('savestatus', 'saved');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
394
409
|
holdForConflict(result.conflictEtag);
|
|
395
410
|
setSaveState('conflict', result.msg, result.msgType, {
|
|
396
411
|
hadTimeout: result.hadTimeout === true,
|
|
397
412
|
changedBy: result.changedBy ?? null,
|
|
398
413
|
afterTimeout: result.afterTimeout === true,
|
|
399
414
|
etag: result.conflictEtag ?? null,
|
|
415
|
+
code: result.code ?? null,
|
|
416
|
+
reason: result.reason ?? null,
|
|
417
|
+
retryAfter: result.retryAfter ?? null,
|
|
418
|
+
captureId,
|
|
400
419
|
});
|
|
401
420
|
} else if (result.msgType !== 'skipped') {
|
|
402
|
-
const extra = {
|
|
421
|
+
const extra = {
|
|
422
|
+
status: result.status ?? null,
|
|
423
|
+
hadTimeout: result.hadTimeout === true,
|
|
424
|
+
// The host's typed refusal, so a listener can react to the specific code
|
|
425
|
+
// rather than reading the message. `retryAfter` is only set where the host
|
|
426
|
+
// asked for a delay.
|
|
427
|
+
code: result.code ?? null,
|
|
428
|
+
reason: result.reason ?? null,
|
|
429
|
+
retryAfter: result.retryAfter ?? null,
|
|
430
|
+
captureId,
|
|
431
|
+
};
|
|
403
432
|
if (!navigator.onLine) {
|
|
404
433
|
setSaveState('offline', result.msg, '', extra);
|
|
405
434
|
} else {
|
|
@@ -455,14 +484,14 @@ export function getLastSavedBytes() { return lastSavedBytes; }
|
|
|
455
484
|
export function setLastSavedBaselines(forComparison, forDirty) {
|
|
456
485
|
lastSavedContents = forComparison;
|
|
457
486
|
lastSavedDirty = forDirty;
|
|
487
|
+
baselineRevision++;
|
|
458
488
|
}
|
|
459
489
|
|
|
460
490
|
// Kept for back-compat: a caller with one string means the page has no batching
|
|
461
491
|
// region, or it does not know about the split. Setting both from it is the safe
|
|
462
492
|
// reading, since the domains coincide exactly when there is no such region.
|
|
463
493
|
export function setLastSavedContents(val) {
|
|
464
|
-
|
|
465
|
-
lastSavedDirty = val;
|
|
494
|
+
setLastSavedBaselines(val, val);
|
|
466
495
|
}
|
|
467
496
|
|
|
468
497
|
/**
|
|
@@ -509,15 +538,15 @@ export function savePage(callback = () => {}) {
|
|
|
509
538
|
// Single capture: clone once, get both versions
|
|
510
539
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
511
540
|
// forComparison additionally strips every autosave-off region
|
|
512
|
-
let forSave, forComparison, forDirty;
|
|
541
|
+
let forSave, forComparison, forDirty, captureId;
|
|
513
542
|
const gateToken = gateCaptureToken();
|
|
514
543
|
try {
|
|
515
|
-
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
544
|
+
({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
|
|
516
545
|
} catch (err) {
|
|
517
546
|
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
518
547
|
clearExplicitSave();
|
|
519
|
-
setSaveState('error', err.message);
|
|
520
|
-
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
548
|
+
setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
|
|
549
|
+
const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
|
|
521
550
|
if (typeof callback === 'function') {
|
|
522
551
|
callback(result);
|
|
523
552
|
}
|
|
@@ -545,11 +574,14 @@ export function savePage(callback = () => {}) {
|
|
|
545
574
|
// Start debounced 'saving' state (only shows if save takes >500ms)
|
|
546
575
|
setSavingState();
|
|
547
576
|
|
|
548
|
-
// Use
|
|
577
|
+
// Use the captured save path with our pre-captured content (avoids double capture)
|
|
578
|
+
const comparison = { forComparison, forDirty };
|
|
579
|
+
inFlightComparisons.add(comparison);
|
|
549
580
|
ownSaveOnWire = true;
|
|
550
|
-
|
|
581
|
+
saveCapturedHtml(forSave, captureId, (result) => {
|
|
551
582
|
ownSaveOnWire = false;
|
|
552
|
-
|
|
583
|
+
inFlightComparisons.delete(comparison);
|
|
584
|
+
applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after save', gateToken, forSave, captureId);
|
|
553
585
|
if (typeof callback === 'function') {
|
|
554
586
|
callback(result);
|
|
555
587
|
}
|
|
@@ -565,7 +597,7 @@ export function savePage(callback = () => {}) {
|
|
|
565
597
|
* @param {Function} callback - Optional callback for custom handling
|
|
566
598
|
* @returns {Promise<{msg: string, msgType: string}>}
|
|
567
599
|
*/
|
|
568
|
-
export function savePageForce(callback = () => {}) {
|
|
600
|
+
export function savePageForce(callback = () => {}, { quietConflict = false } = {}) {
|
|
569
601
|
return new Promise((resolve) => {
|
|
570
602
|
if (!isEditMode && !window.clay?.testMode) {
|
|
571
603
|
const skipped = skipped_('Not in edit mode');
|
|
@@ -587,14 +619,14 @@ export function savePageForce(callback = () => {}) {
|
|
|
587
619
|
setOfflineStateQuiet();
|
|
588
620
|
}
|
|
589
621
|
|
|
590
|
-
let forSave, forComparison, forDirty;
|
|
622
|
+
let forSave, forComparison, forDirty, captureId;
|
|
591
623
|
const gateToken = gateCaptureToken();
|
|
592
624
|
try {
|
|
593
|
-
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
625
|
+
({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
|
|
594
626
|
} catch (err) {
|
|
595
627
|
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
596
|
-
setSaveState('error', err.message);
|
|
597
|
-
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
628
|
+
setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
|
|
629
|
+
const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
|
|
598
630
|
if (typeof callback === 'function') {
|
|
599
631
|
callback(result);
|
|
600
632
|
}
|
|
@@ -603,10 +635,13 @@ export function savePageForce(callback = () => {}) {
|
|
|
603
635
|
|
|
604
636
|
setSavingState();
|
|
605
637
|
|
|
638
|
+
const comparison = { forComparison, forDirty };
|
|
639
|
+
inFlightComparisons.add(comparison);
|
|
606
640
|
ownSaveOnWire = true;
|
|
607
|
-
|
|
641
|
+
saveCapturedHtml(forSave, captureId, (result) => {
|
|
608
642
|
ownSaveOnWire = false;
|
|
609
|
-
|
|
643
|
+
inFlightComparisons.delete(comparison);
|
|
644
|
+
applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after force save', gateToken, forSave, captureId, quietConflict);
|
|
610
645
|
if (typeof callback === 'function') {
|
|
611
646
|
callback(result);
|
|
612
647
|
}
|
|
@@ -774,6 +809,67 @@ let baselineActive = true;
|
|
|
774
809
|
let baselineSettledAt = false;
|
|
775
810
|
export function baselineSettled() { return baselineSettledAt; }
|
|
776
811
|
|
|
812
|
+
// A repaint that leaves the page's painted values different from the file's means
|
|
813
|
+
// the file is stale: an agent wrote a record without painting it. One clean,
|
|
814
|
+
// autosaving tab saves the painted page so the bytes on disk heal. The delay is
|
|
815
|
+
// jittered and the check reruns when it fires, so when several tabs repaint the
|
|
816
|
+
// same frame, the first save reaches the others and they skip.
|
|
817
|
+
const PAINT_HEAL_MIN_MS = 250;
|
|
818
|
+
const PAINT_HEAL_SPREAD_MS = 1250;
|
|
819
|
+
let paintHealTimer = null;
|
|
820
|
+
|
|
821
|
+
const HEAL_KEY = 'clay:paint-heal:' + location.pathname;
|
|
822
|
+
|
|
823
|
+
function hashSignature(s) {
|
|
824
|
+
let h = 5381;
|
|
825
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
826
|
+
return String(h >>> 0);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function healedFrom(signature) {
|
|
830
|
+
try { return localStorage.getItem(HEAL_KEY) === hashSignature(signature); } catch { return false; }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function noteHealedFrom(signature) {
|
|
834
|
+
try { localStorage.setItem(HEAL_KEY, hashSignature(signature)); } catch {}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** The painted values of the page as it would be saved. */
|
|
838
|
+
export function currentPaintSignature() {
|
|
839
|
+
return paintSignature(captureSaveClone({ flushUndo: false }));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Save the page if its painted values differ from the file's. `fileSignature` is
|
|
844
|
+
* the file's paintSignature, or a function returning it (or null when unknown)
|
|
845
|
+
* when the timer fires.
|
|
846
|
+
*/
|
|
847
|
+
export function schedulePaintHeal(fileSignature) {
|
|
848
|
+
if (!isEditMode || isDraftMode) return;
|
|
849
|
+
clearTimeout(paintHealTimer);
|
|
850
|
+
paintHealTimer = setTimeout(() => {
|
|
851
|
+
paintHealTimer = null;
|
|
852
|
+
const onDisk = typeof fileSignature === 'function' ? fileSignature() : fileSignature;
|
|
853
|
+
if (onDisk == null || !autosaveActive() || !baselineSettled() || isSaveConflicted() || autosaveIsSuspended()) return;
|
|
854
|
+
if (captureForDirtyCheck({ flushUndo: false }) !== lastSavedDirty) return;
|
|
855
|
+
if (onDisk === currentPaintSignature()) return;
|
|
856
|
+
if (healedFrom(onDisk)) return;
|
|
857
|
+
noteHealedFrom(onDisk);
|
|
858
|
+
savePageForce(() => {}, { quietConflict: true });
|
|
859
|
+
}, PAINT_HEAL_MIN_MS + Math.random() * PAINT_HEAL_SPREAD_MS);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
registerStyleAdoption(identity => {
|
|
863
|
+
const strip = styleComparisonStripper(identity);
|
|
864
|
+
lastSavedContents = strip(lastSavedContents);
|
|
865
|
+
lastSavedDirty = strip(lastSavedDirty);
|
|
866
|
+
baselineContents = strip(baselineContents);
|
|
867
|
+
for (const comparison of inFlightComparisons) {
|
|
868
|
+
comparison.forComparison = strip(comparison.forComparison);
|
|
869
|
+
comparison.forDirty = strip(comparison.forDirty);
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
777
873
|
// ============================================
|
|
778
874
|
// BASELINE CAPTURE (Settled Signal)
|
|
779
875
|
// ============================================
|
|
@@ -787,10 +883,23 @@ export function baselineSettled() { return baselineSettledAt; }
|
|
|
787
883
|
// WHY IMMEDIATE + CONDITIONAL UPDATE:
|
|
788
884
|
// We set baseline immediately as a safety net. If the user edits or saves
|
|
789
885
|
// before settle completes, we don't overwrite their work. The settled snapshot
|
|
790
|
-
// only replaces baseline if
|
|
886
|
+
// only replaces baseline if no user input or baseline installation intervened.
|
|
791
887
|
|
|
792
888
|
const SETTLE_MS = 500; // Wait for no mutations for this long
|
|
793
889
|
const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
|
|
890
|
+
const MAX_HOLD_MS = 10000; // A held settle releases itself after this
|
|
891
|
+
|
|
892
|
+
let settleNow = null;
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* The loader's mount phase calls this once the derive engine has taken its first
|
|
896
|
+
* pass. The settle runs at once on the painted page, unless a person already
|
|
897
|
+
* edited or a save already installed a baseline (captureBaseline's guard).
|
|
898
|
+
*/
|
|
899
|
+
export function releaseBootSettle() {
|
|
900
|
+
bootPhase.holdSettle = false;
|
|
901
|
+
if (settleNow) settleNow();
|
|
902
|
+
}
|
|
794
903
|
|
|
795
904
|
function initBaselineCapture() {
|
|
796
905
|
if (!isEditMode) return;
|
|
@@ -803,8 +912,8 @@ function initBaselineCapture() {
|
|
|
803
912
|
// This ensures saves during settle window work correctly
|
|
804
913
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
805
914
|
const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
|
|
806
|
-
|
|
807
|
-
|
|
915
|
+
setLastSavedBaselines(immediateContents, immediateDirty);
|
|
916
|
+
const immediateRevision = baselineRevision;
|
|
808
917
|
baselineContents = immediateContents;
|
|
809
918
|
logBaseline('immediate capture', `${immediateContents.length} chars`);
|
|
810
919
|
|
|
@@ -829,18 +938,14 @@ function initBaselineCapture() {
|
|
|
829
938
|
if (unsubscribeMutation) unsubscribeMutation();
|
|
830
939
|
userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
|
|
831
940
|
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
// Checking one would let a second, unsent edit in that region be captured
|
|
837
|
-
// here as though it had been saved.
|
|
838
|
-
if (!userEdited && lastSavedContents === immediateContents && lastSavedDirty === immediateDirty) {
|
|
941
|
+
// Adoption only removes owned styles from comparison state. It does not
|
|
942
|
+
// advance this revision. A save or sync installation does, even when its
|
|
943
|
+
// comparison bytes match, so settle cannot absorb a later unsent edit.
|
|
944
|
+
if (!userEdited && baselineRevision === immediateRevision) {
|
|
839
945
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
840
946
|
const gateToken = gateCaptureToken();
|
|
841
947
|
const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
|
|
842
|
-
|
|
843
|
-
lastSavedDirty = contentsDirty;
|
|
948
|
+
setLastSavedBaselines(contents, contentsDirty);
|
|
844
949
|
baselineContents = contents;
|
|
845
950
|
// Boot churn (modules rewriting attributes at DOM-ready) counted toward
|
|
846
951
|
// the scoped-sync gate; the settled baseline is the proof it wasn't edits.
|
|
@@ -862,19 +967,32 @@ function initBaselineCapture() {
|
|
|
862
967
|
baselineSettledAt = true;
|
|
863
968
|
document.dispatchEvent(new CustomEvent('clay:baseline-settled'));
|
|
864
969
|
};
|
|
970
|
+
settleNow = captureBaseline;
|
|
971
|
+
|
|
972
|
+
// While the loader holds the settle for a derive engine's first pass, neither
|
|
973
|
+
// the quiet timer nor the max timeout may settle; releaseBootSettle does.
|
|
974
|
+
const settle = () => {
|
|
975
|
+
if (!bootPhase.holdSettle) captureBaseline();
|
|
976
|
+
};
|
|
865
977
|
|
|
866
978
|
// Start settle observer - fires when no mutations for SETTLE_MS.
|
|
867
979
|
// require:'autosave' so churn in no-save / save-* / no-watch regions doesn't
|
|
868
980
|
// keep resetting the settle timer or count toward the baseline.
|
|
869
981
|
unsubscribeMutation = Mutation.onAnyChange(
|
|
870
982
|
{ debounce: SETTLE_MS, omitChangeDetails: true, require: 'autosave' },
|
|
871
|
-
|
|
983
|
+
settle
|
|
872
984
|
);
|
|
873
985
|
|
|
874
986
|
// Max timeout fallback
|
|
875
987
|
setTimeout(() => {
|
|
876
|
-
if (!settled)
|
|
988
|
+
if (!settled) settle();
|
|
877
989
|
}, MAX_SETTLE_MS);
|
|
990
|
+
|
|
991
|
+
// The loader releases a held settle after the derive engine mounts. If boot
|
|
992
|
+
// never gets there (a plugin's ready stalls, boot throws), release it anyway.
|
|
993
|
+
setTimeout(() => {
|
|
994
|
+
if (!settled && bootPhase.holdSettle) releaseBootSettle();
|
|
995
|
+
}, MAX_HOLD_MS);
|
|
878
996
|
}
|
|
879
997
|
|
|
880
998
|
// Run when DOM is ready
|
package/src/core/snapshot.js
CHANGED
|
@@ -43,6 +43,7 @@ import { restoreAuthoredUrls } from '../lib/authored-url.js';
|
|
|
43
43
|
import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
|
|
44
44
|
import { TAB_LOCAL_ROOT_ATTRS, stripRouteAttrs } from '../lib/root-attrs.js';
|
|
45
45
|
import { stripEphemeral } from '../lib/ephemeral.js';
|
|
46
|
+
import { stripDerived } from '../lib/derived.js';
|
|
46
47
|
import { createContentView } from '../lib/content-dom.js';
|
|
47
48
|
|
|
48
49
|
// =============================================================================
|
|
@@ -58,6 +59,17 @@ const snapshotProvenance = new WeakMap();
|
|
|
58
59
|
// second renderer would be two answers to "what bytes does this save send".
|
|
59
60
|
let saveRenderer = null;
|
|
60
61
|
|
|
62
|
+
// The capture identity for save captures. A refused save that may retry reads it
|
|
63
|
+
// before sending and again before each retry: a capture newer than the one those
|
|
64
|
+
// bytes describe means the attempt is already superseded and must not be re-sent.
|
|
65
|
+
// It is an opaque local count, monotone per loaded module, and never goes on the
|
|
66
|
+
// wire; it is what binds one capture to the exact requests that carried its body.
|
|
67
|
+
let captureCounter = 0;
|
|
68
|
+
|
|
69
|
+
export function currentCaptureId() {
|
|
70
|
+
return captureCounter;
|
|
71
|
+
}
|
|
72
|
+
|
|
61
73
|
export function originalSnapshotNode(cloneNode) {
|
|
62
74
|
return snapshotProvenance.get(cloneNode) || null;
|
|
63
75
|
}
|
|
@@ -224,7 +236,7 @@ function clonePreventingOnclone(node, deep = true) {
|
|
|
224
236
|
finally { window.__preventOnclone = prev; }
|
|
225
237
|
}
|
|
226
238
|
|
|
227
|
-
export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
239
|
+
export function captureSnapshot({ flushUndo = true, authored = true, forSave = false, requestRefresh = false } = {}) {
|
|
228
240
|
// Force-close any pending undo idle batch BEFORE cloning the DOM, so the
|
|
229
241
|
// snapshot reflects a clean undo boundary. Without this, a save that fires
|
|
230
242
|
// mid-typing would leave the idle batch open across the save boundary, and
|
|
@@ -245,7 +257,7 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
|
245
257
|
|
|
246
258
|
for (const hook of snapshotHooks) {
|
|
247
259
|
stripSnapshotRegions(clone);
|
|
248
|
-
hook(clone, { original: originalSnapshotNode });
|
|
260
|
+
hook(clone, { original: originalSnapshotNode, forSave, requestRefresh });
|
|
249
261
|
}
|
|
250
262
|
|
|
251
263
|
// Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
|
|
@@ -302,9 +314,17 @@ function prepareCloneForSave(clone, { authored = true } = {}) {
|
|
|
302
314
|
el.remove();
|
|
303
315
|
}
|
|
304
316
|
|
|
317
|
+
stripDiagnosticBeacons(clone);
|
|
318
|
+
|
|
305
319
|
return clone;
|
|
306
320
|
}
|
|
307
321
|
|
|
322
|
+
// sapjs marks an element it cannot run with a sap-error beacon. It describes this
|
|
323
|
+
// tab's engine, never the document, so no save carries it.
|
|
324
|
+
function stripDiagnosticBeacons(clone) {
|
|
325
|
+
for (const el of clone.querySelectorAll("[sap-error]")) el.removeAttribute("sap-error");
|
|
326
|
+
}
|
|
327
|
+
|
|
308
328
|
/**
|
|
309
329
|
* The save-domain clone, for a caller that needs the tree rather than the bytes.
|
|
310
330
|
*
|
|
@@ -327,6 +347,20 @@ export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
|
|
|
327
347
|
return prepareCloneForSave(captureSnapshot({ flushUndo, authored }), { authored });
|
|
328
348
|
}
|
|
329
349
|
|
|
350
|
+
// Attributes that render exactly like their absence. A scroll lock or a browser
|
|
351
|
+
// extension that resets an inline style leaves `style=""` behind, and the dirty
|
|
352
|
+
// check must not read that as an edit the person would lose on close.
|
|
353
|
+
const EMPTY_INERT_ATTRS = ['style', 'class'];
|
|
354
|
+
|
|
355
|
+
function serializeForCompare(clone) {
|
|
356
|
+
stripDerived(clone);
|
|
357
|
+
for (const name of EMPTY_INERT_ATTRS) {
|
|
358
|
+
if (clone.getAttribute(name) === '') clone.removeAttribute(name);
|
|
359
|
+
for (const el of clone.querySelectorAll(`[${name}=""]`)) el.removeAttribute(name);
|
|
360
|
+
}
|
|
361
|
+
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
362
|
+
}
|
|
363
|
+
|
|
330
364
|
/**
|
|
331
365
|
* Capture snapshot prepared for dirty/change comparison.
|
|
332
366
|
*
|
|
@@ -355,7 +389,7 @@ export function captureForComparison({ flushUndo = true } = {}) {
|
|
|
355
389
|
|
|
356
390
|
stripSnapshotRegions(clone);
|
|
357
391
|
|
|
358
|
-
return
|
|
392
|
+
return serializeForCompare(clone);
|
|
359
393
|
}
|
|
360
394
|
|
|
361
395
|
/**
|
|
@@ -388,7 +422,7 @@ export function captureForDirtyCheck({ flushUndo = true } = {}) {
|
|
|
388
422
|
|
|
389
423
|
stripSnapshotRegions(clone);
|
|
390
424
|
|
|
391
|
-
return
|
|
425
|
+
return serializeForCompare(clone);
|
|
392
426
|
}
|
|
393
427
|
|
|
394
428
|
/**
|
|
@@ -419,7 +453,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
419
453
|
hook(clone);
|
|
420
454
|
}
|
|
421
455
|
stripSnapshotRegions(clone);
|
|
422
|
-
const forComparison =
|
|
456
|
+
const forComparison = serializeForCompare(clone);
|
|
423
457
|
|
|
424
458
|
let forDirty = forComparison;
|
|
425
459
|
if (dirtyClone) {
|
|
@@ -431,7 +465,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
431
465
|
hook(dirtyClone);
|
|
432
466
|
}
|
|
433
467
|
stripSnapshotRegions(dirtyClone);
|
|
434
|
-
forDirty =
|
|
468
|
+
forDirty = serializeForCompare(dirtyClone);
|
|
435
469
|
}
|
|
436
470
|
|
|
437
471
|
return { forComparison, forDirty };
|
|
@@ -445,10 +479,12 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
445
479
|
*
|
|
446
480
|
* @param {Object} options
|
|
447
481
|
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
448
|
-
* @returns {{ forSave: string, forComparison: string, forDirty: string }}
|
|
482
|
+
* @returns {{ forSave: string, forComparison: string, forDirty: string, captureId: number }}
|
|
449
483
|
*/
|
|
450
|
-
export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true } = {}) {
|
|
451
|
-
|
|
484
|
+
export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true, requestRefresh = false } = {}) {
|
|
485
|
+
if (!Number.isSafeInteger(captureCounter + 1)) throw new Error('Save capture identity exhausted');
|
|
486
|
+
const captureId = ++captureCounter;
|
|
487
|
+
const clone = captureSnapshot({ flushUndo, forSave: emitForSync, requestRefresh });
|
|
452
488
|
|
|
453
489
|
// Emit for live-sync before any stripping.
|
|
454
490
|
//
|
|
@@ -459,7 +495,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
459
495
|
// and a baseline the dirty check cannot reproduce warns on close forever.
|
|
460
496
|
if (emitForSync) {
|
|
461
497
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
462
|
-
detail: { documentElement: clone, forSave: true }
|
|
498
|
+
detail: { documentElement: clone, forSave: true, captureId }
|
|
463
499
|
}));
|
|
464
500
|
}
|
|
465
501
|
|
|
@@ -488,6 +524,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
488
524
|
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
489
525
|
el.remove();
|
|
490
526
|
}
|
|
527
|
+
stripDiagnosticBeacons(clone);
|
|
491
528
|
const forSave = serializeSaveClone(clone);
|
|
492
529
|
|
|
493
530
|
// Compare clone: strip every autosave-off region, then run hooks
|
|
@@ -499,7 +536,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
499
536
|
hook(compareClone);
|
|
500
537
|
}
|
|
501
538
|
stripSnapshotRegions(compareClone);
|
|
502
|
-
const forComparison =
|
|
539
|
+
const forComparison = serializeForCompare(compareClone);
|
|
503
540
|
|
|
504
541
|
// Dirty clone: same shape as the compare clone, one selector weaker.
|
|
505
542
|
let forDirty = forComparison;
|
|
@@ -512,10 +549,10 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
512
549
|
hook(dirtyClone);
|
|
513
550
|
}
|
|
514
551
|
stripSnapshotRegions(dirtyClone);
|
|
515
|
-
forDirty =
|
|
552
|
+
forDirty = serializeForCompare(dirtyClone);
|
|
516
553
|
}
|
|
517
554
|
|
|
518
|
-
return { forSave, forComparison, forDirty };
|
|
555
|
+
return { forSave, forComparison, forDirty, captureId };
|
|
519
556
|
}
|
|
520
557
|
|
|
521
558
|
/**
|
|
@@ -570,6 +607,7 @@ export function captureForMerge() {
|
|
|
570
607
|
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
571
608
|
el.remove();
|
|
572
609
|
}
|
|
610
|
+
stripDiagnosticBeacons(clone);
|
|
573
611
|
|
|
574
612
|
for (const el of compareClone.querySelectorAll(STRIP_FROM_DIRTY_CHECK)) {
|
|
575
613
|
el.remove();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { parse } from '../vendor/parse5.vendor.js';
|
|
2
|
+
import { getStyleBaseUrl } from '../dom/insert-style-tag.js';
|
|
3
|
+
|
|
4
|
+
export function styleComparisonStripper(identity) {
|
|
5
|
+
const cache = new Map();
|
|
6
|
+
return (html) => {
|
|
7
|
+
if (!html || !html.includes(identity.name !== undefined ? '<style' : '<link')) return html;
|
|
8
|
+
if (cache.has(html)) return cache.get(html);
|
|
9
|
+
const spans = [];
|
|
10
|
+
const tree = parse(html, { sourceCodeLocationInfo: true, scriptingEnabled: true });
|
|
11
|
+
const visit = (node) => {
|
|
12
|
+
const attrs = new Map((node.attrs || []).map(({ name, value }) => [name, value]));
|
|
13
|
+
const owned = identity.name !== undefined
|
|
14
|
+
? node.tagName === 'style' && (attrs.get('data-name') === identity.name ||
|
|
15
|
+
(identity.legacyId !== undefined && attrs.get('id') === identity.legacyId && !attrs.has('data-name')))
|
|
16
|
+
: node.tagName === 'link' && attrs.get('rel')?.toLowerCase() === 'stylesheet' &&
|
|
17
|
+
getStyleBaseUrl(attrs.get('href') ?? null) === identity.href;
|
|
18
|
+
const loc = node.sourceCodeLocation;
|
|
19
|
+
if (owned && loc?.startTag) {
|
|
20
|
+
spans.push([loc.startTag.startOffset, loc.endTag?.endOffset ?? loc.endOffset]);
|
|
21
|
+
} else {
|
|
22
|
+
for (const child of node.childNodes || []) visit(child);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
visit(tree);
|
|
26
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
27
|
+
let result = '';
|
|
28
|
+
let offset = 0;
|
|
29
|
+
for (const [from, to] of spans) {
|
|
30
|
+
result += html.slice(offset, from);
|
|
31
|
+
offset = to;
|
|
32
|
+
}
|
|
33
|
+
result += html.slice(offset);
|
|
34
|
+
cache.set(html, result);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
export function getStyleBaseUrl(url) {
|
|
2
|
+
try {
|
|
3
|
+
const parsed = new URL(url, window.location.href);
|
|
4
|
+
return parsed.origin + parsed.pathname;
|
|
5
|
+
} catch {
|
|
6
|
+
return url;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
1
10
|
/**
|
|
2
11
|
* Insert styles into the document (inline CSS or external stylesheet).
|
|
3
12
|
*
|
|
@@ -62,24 +71,14 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
|
|
|
62
71
|
const href = nameOrHref;
|
|
63
72
|
const cb = typeof cssOrCallback === 'function' ? cssOrCallback : undefined;
|
|
64
73
|
|
|
65
|
-
// Helper to get base URL without query params (for comparison)
|
|
66
|
-
const getBaseUrl = (url) => {
|
|
67
|
-
try {
|
|
68
|
-
const parsed = new URL(url, window.location.href);
|
|
69
|
-
return parsed.origin + parsed.pathname;
|
|
70
|
-
} catch {
|
|
71
|
-
return url;
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
74
|
// Normalize href to full URL path (without query params) for comparison
|
|
76
|
-
const normalizedHref =
|
|
75
|
+
const normalizedHref = getStyleBaseUrl(href);
|
|
77
76
|
|
|
78
77
|
// Find all links with matching normalized path (ignoring query params like ?v=)
|
|
79
78
|
const existingLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]
|
|
80
79
|
.filter(el => {
|
|
81
80
|
try {
|
|
82
|
-
return
|
|
81
|
+
return getStyleBaseUrl(el.getAttribute('href')) === normalizedHref;
|
|
83
82
|
} catch {
|
|
84
83
|
return false;
|
|
85
84
|
}
|