@panphora/clayjs 1.13.1 → 1.14.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 +69667 -41708
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +94 -93
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- 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 +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -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 +7 -3
- package/src/loader.js +6 -0
- 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 +1121 -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 +384 -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/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- 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/live-sync.js +13 -2
- 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/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,
|
|
@@ -27,6 +27,8 @@ import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
|
27
27
|
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
28
28
|
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
29
29
|
import { autosaveActive } from "../lib/autosave-state.js";
|
|
30
|
+
import { registerStyleAdoption } from "../lib/style-adoption.js";
|
|
31
|
+
import { styleComparisonStripper } from "./style-comparisons.js";
|
|
30
32
|
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
|
|
31
33
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
32
34
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
@@ -188,6 +190,8 @@ let unsavedChanges = false;
|
|
|
188
190
|
// the split is observable there.
|
|
189
191
|
let lastSavedContents = '';
|
|
190
192
|
let lastSavedDirty = '';
|
|
193
|
+
let baselineRevision = 0;
|
|
194
|
+
const inFlightComparisons = new Set();
|
|
191
195
|
// A save was requested while one was on the wire; run one more when it settles.
|
|
192
196
|
let pendingSave = false;
|
|
193
197
|
// An autosave arrived while one of our saves was on the wire. When that save lands
|
|
@@ -374,12 +378,11 @@ function skipped_(msg) {
|
|
|
374
378
|
* where the write may or may not have landed, so it is not treated as success
|
|
375
379
|
* either.
|
|
376
380
|
*/
|
|
377
|
-
function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null) {
|
|
381
|
+
function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null, captureId = null) {
|
|
378
382
|
if (result.ok) {
|
|
379
383
|
// Both baselines advance from the SAME pre-request capture, never from the
|
|
380
384
|
// live DOM, so an edit made while the request was on the wire stays unsaved.
|
|
381
|
-
|
|
382
|
-
lastSavedDirty = forDirty;
|
|
385
|
+
setLastSavedBaselines(forComparison, forDirty);
|
|
383
386
|
lastSavedBytes = forSave;
|
|
384
387
|
unsavedChanges = false;
|
|
385
388
|
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
@@ -397,9 +400,23 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
|
|
|
397
400
|
changedBy: result.changedBy ?? null,
|
|
398
401
|
afterTimeout: result.afterTimeout === true,
|
|
399
402
|
etag: result.conflictEtag ?? null,
|
|
403
|
+
code: result.code ?? null,
|
|
404
|
+
reason: result.reason ?? null,
|
|
405
|
+
retryAfter: result.retryAfter ?? null,
|
|
406
|
+
captureId,
|
|
400
407
|
});
|
|
401
408
|
} else if (result.msgType !== 'skipped') {
|
|
402
|
-
const extra = {
|
|
409
|
+
const extra = {
|
|
410
|
+
status: result.status ?? null,
|
|
411
|
+
hadTimeout: result.hadTimeout === true,
|
|
412
|
+
// The host's typed refusal, so a listener can react to the specific code
|
|
413
|
+
// rather than reading the message. `retryAfter` is only set where the host
|
|
414
|
+
// asked for a delay.
|
|
415
|
+
code: result.code ?? null,
|
|
416
|
+
reason: result.reason ?? null,
|
|
417
|
+
retryAfter: result.retryAfter ?? null,
|
|
418
|
+
captureId,
|
|
419
|
+
};
|
|
403
420
|
if (!navigator.onLine) {
|
|
404
421
|
setSaveState('offline', result.msg, '', extra);
|
|
405
422
|
} else {
|
|
@@ -455,14 +472,14 @@ export function getLastSavedBytes() { return lastSavedBytes; }
|
|
|
455
472
|
export function setLastSavedBaselines(forComparison, forDirty) {
|
|
456
473
|
lastSavedContents = forComparison;
|
|
457
474
|
lastSavedDirty = forDirty;
|
|
475
|
+
baselineRevision++;
|
|
458
476
|
}
|
|
459
477
|
|
|
460
478
|
// Kept for back-compat: a caller with one string means the page has no batching
|
|
461
479
|
// region, or it does not know about the split. Setting both from it is the safe
|
|
462
480
|
// reading, since the domains coincide exactly when there is no such region.
|
|
463
481
|
export function setLastSavedContents(val) {
|
|
464
|
-
|
|
465
|
-
lastSavedDirty = val;
|
|
482
|
+
setLastSavedBaselines(val, val);
|
|
466
483
|
}
|
|
467
484
|
|
|
468
485
|
/**
|
|
@@ -509,15 +526,15 @@ export function savePage(callback = () => {}) {
|
|
|
509
526
|
// Single capture: clone once, get both versions
|
|
510
527
|
// forSave strips non-persisted regions ([no-save]/[save-remove])
|
|
511
528
|
// forComparison additionally strips every autosave-off region
|
|
512
|
-
let forSave, forComparison, forDirty;
|
|
529
|
+
let forSave, forComparison, forDirty, captureId;
|
|
513
530
|
const gateToken = gateCaptureToken();
|
|
514
531
|
try {
|
|
515
|
-
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
532
|
+
({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
|
|
516
533
|
} catch (err) {
|
|
517
534
|
console.error('savePage: captureForSaveAndComparison failed', err);
|
|
518
535
|
clearExplicitSave();
|
|
519
|
-
setSaveState('error', err.message);
|
|
520
|
-
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
536
|
+
setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
|
|
537
|
+
const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
|
|
521
538
|
if (typeof callback === 'function') {
|
|
522
539
|
callback(result);
|
|
523
540
|
}
|
|
@@ -545,11 +562,14 @@ export function savePage(callback = () => {}) {
|
|
|
545
562
|
// Start debounced 'saving' state (only shows if save takes >500ms)
|
|
546
563
|
setSavingState();
|
|
547
564
|
|
|
548
|
-
// Use
|
|
565
|
+
// Use the captured save path with our pre-captured content (avoids double capture)
|
|
566
|
+
const comparison = { forComparison, forDirty };
|
|
567
|
+
inFlightComparisons.add(comparison);
|
|
549
568
|
ownSaveOnWire = true;
|
|
550
|
-
|
|
569
|
+
saveCapturedHtml(forSave, captureId, (result) => {
|
|
551
570
|
ownSaveOnWire = false;
|
|
552
|
-
|
|
571
|
+
inFlightComparisons.delete(comparison);
|
|
572
|
+
applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after save', gateToken, forSave, captureId);
|
|
553
573
|
if (typeof callback === 'function') {
|
|
554
574
|
callback(result);
|
|
555
575
|
}
|
|
@@ -587,14 +607,14 @@ export function savePageForce(callback = () => {}) {
|
|
|
587
607
|
setOfflineStateQuiet();
|
|
588
608
|
}
|
|
589
609
|
|
|
590
|
-
let forSave, forComparison, forDirty;
|
|
610
|
+
let forSave, forComparison, forDirty, captureId;
|
|
591
611
|
const gateToken = gateCaptureToken();
|
|
592
612
|
try {
|
|
593
|
-
({ forSave, forComparison, forDirty } = captureForSaveAndComparison());
|
|
613
|
+
({ forSave, forComparison, forDirty, captureId } = captureForSaveAndComparison({ requestRefresh: true }));
|
|
594
614
|
} catch (err) {
|
|
595
615
|
console.error('savePageForce: captureForSaveAndComparison failed', err);
|
|
596
|
-
setSaveState('error', err.message);
|
|
597
|
-
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
616
|
+
setSaveState('error', err.message, '', { sent: false, code: err.code ?? null });
|
|
617
|
+
const result = { msg: err.message, msgType: 'error', code: err.code ?? null, etag: null };
|
|
598
618
|
if (typeof callback === 'function') {
|
|
599
619
|
callback(result);
|
|
600
620
|
}
|
|
@@ -603,10 +623,13 @@ export function savePageForce(callback = () => {}) {
|
|
|
603
623
|
|
|
604
624
|
setSavingState();
|
|
605
625
|
|
|
626
|
+
const comparison = { forComparison, forDirty };
|
|
627
|
+
inFlightComparisons.add(comparison);
|
|
606
628
|
ownSaveOnWire = true;
|
|
607
|
-
|
|
629
|
+
saveCapturedHtml(forSave, captureId, (result) => {
|
|
608
630
|
ownSaveOnWire = false;
|
|
609
|
-
|
|
631
|
+
inFlightComparisons.delete(comparison);
|
|
632
|
+
applySaveResult(result, comparison.forComparison, comparison.forDirty, 'updated after force save', gateToken, forSave, captureId);
|
|
610
633
|
if (typeof callback === 'function') {
|
|
611
634
|
callback(result);
|
|
612
635
|
}
|
|
@@ -774,6 +797,17 @@ let baselineActive = true;
|
|
|
774
797
|
let baselineSettledAt = false;
|
|
775
798
|
export function baselineSettled() { return baselineSettledAt; }
|
|
776
799
|
|
|
800
|
+
registerStyleAdoption(identity => {
|
|
801
|
+
const strip = styleComparisonStripper(identity);
|
|
802
|
+
lastSavedContents = strip(lastSavedContents);
|
|
803
|
+
lastSavedDirty = strip(lastSavedDirty);
|
|
804
|
+
baselineContents = strip(baselineContents);
|
|
805
|
+
for (const comparison of inFlightComparisons) {
|
|
806
|
+
comparison.forComparison = strip(comparison.forComparison);
|
|
807
|
+
comparison.forDirty = strip(comparison.forDirty);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
777
811
|
// ============================================
|
|
778
812
|
// BASELINE CAPTURE (Settled Signal)
|
|
779
813
|
// ============================================
|
|
@@ -787,7 +821,7 @@ export function baselineSettled() { return baselineSettledAt; }
|
|
|
787
821
|
// WHY IMMEDIATE + CONDITIONAL UPDATE:
|
|
788
822
|
// We set baseline immediately as a safety net. If the user edits or saves
|
|
789
823
|
// before settle completes, we don't overwrite their work. The settled snapshot
|
|
790
|
-
// only replaces baseline if
|
|
824
|
+
// only replaces baseline if no user input or baseline installation intervened.
|
|
791
825
|
|
|
792
826
|
const SETTLE_MS = 500; // Wait for no mutations for this long
|
|
793
827
|
const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
|
|
@@ -803,8 +837,8 @@ function initBaselineCapture() {
|
|
|
803
837
|
// This ensures saves during settle window work correctly
|
|
804
838
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
805
839
|
const { forComparison: immediateContents, forDirty: immediateDirty } = captureForComparisonAndDirty();
|
|
806
|
-
|
|
807
|
-
|
|
840
|
+
setLastSavedBaselines(immediateContents, immediateDirty);
|
|
841
|
+
const immediateRevision = baselineRevision;
|
|
808
842
|
baselineContents = immediateContents;
|
|
809
843
|
logBaseline('immediate capture', `${immediateContents.length} chars`);
|
|
810
844
|
|
|
@@ -829,18 +863,14 @@ function initBaselineCapture() {
|
|
|
829
863
|
if (unsubscribeMutation) unsubscribeMutation();
|
|
830
864
|
userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
|
|
831
865
|
|
|
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) {
|
|
866
|
+
// Adoption only removes owned styles from comparison state. It does not
|
|
867
|
+
// advance this revision. A save or sync installation does, even when its
|
|
868
|
+
// comparison bytes match, so settle cannot absorb a later unsent edit.
|
|
869
|
+
if (!userEdited && baselineRevision === immediateRevision) {
|
|
839
870
|
// Store stripped version so comparisons are direct (no parsing needed)
|
|
840
871
|
const gateToken = gateCaptureToken();
|
|
841
872
|
const { forComparison: contents, forDirty: contentsDirty } = captureForComparisonAndDirty();
|
|
842
|
-
|
|
843
|
-
lastSavedDirty = contentsDirty;
|
|
873
|
+
setLastSavedBaselines(contents, contentsDirty);
|
|
844
874
|
baselineContents = contents;
|
|
845
875
|
// Boot churn (modules rewriting attributes at DOM-ready) counted toward
|
|
846
876
|
// the scoped-sync gate; the settled baseline is the proof it wasn't edits.
|
package/src/core/snapshot.js
CHANGED
|
@@ -58,6 +58,17 @@ const snapshotProvenance = new WeakMap();
|
|
|
58
58
|
// second renderer would be two answers to "what bytes does this save send".
|
|
59
59
|
let saveRenderer = null;
|
|
60
60
|
|
|
61
|
+
// The capture identity for save captures. A refused save that may retry reads it
|
|
62
|
+
// before sending and again before each retry: a capture newer than the one those
|
|
63
|
+
// bytes describe means the attempt is already superseded and must not be re-sent.
|
|
64
|
+
// It is an opaque local count, monotone per loaded module, and never goes on the
|
|
65
|
+
// wire; it is what binds one capture to the exact requests that carried its body.
|
|
66
|
+
let captureCounter = 0;
|
|
67
|
+
|
|
68
|
+
export function currentCaptureId() {
|
|
69
|
+
return captureCounter;
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
export function originalSnapshotNode(cloneNode) {
|
|
62
73
|
return snapshotProvenance.get(cloneNode) || null;
|
|
63
74
|
}
|
|
@@ -224,7 +235,7 @@ function clonePreventingOnclone(node, deep = true) {
|
|
|
224
235
|
finally { window.__preventOnclone = prev; }
|
|
225
236
|
}
|
|
226
237
|
|
|
227
|
-
export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
238
|
+
export function captureSnapshot({ flushUndo = true, authored = true, forSave = false, requestRefresh = false } = {}) {
|
|
228
239
|
// Force-close any pending undo idle batch BEFORE cloning the DOM, so the
|
|
229
240
|
// snapshot reflects a clean undo boundary. Without this, a save that fires
|
|
230
241
|
// mid-typing would leave the idle batch open across the save boundary, and
|
|
@@ -245,7 +256,7 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
|
245
256
|
|
|
246
257
|
for (const hook of snapshotHooks) {
|
|
247
258
|
stripSnapshotRegions(clone);
|
|
248
|
-
hook(clone, { original: originalSnapshotNode });
|
|
259
|
+
hook(clone, { original: originalSnapshotNode, forSave, requestRefresh });
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
// Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
|
|
@@ -327,6 +338,19 @@ export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
|
|
|
327
338
|
return prepareCloneForSave(captureSnapshot({ flushUndo, authored }), { authored });
|
|
328
339
|
}
|
|
329
340
|
|
|
341
|
+
// Attributes that render exactly like their absence. A scroll lock or a browser
|
|
342
|
+
// extension that resets an inline style leaves `style=""` behind, and the dirty
|
|
343
|
+
// check must not read that as an edit the person would lose on close.
|
|
344
|
+
const EMPTY_INERT_ATTRS = ['style', 'class'];
|
|
345
|
+
|
|
346
|
+
function serializeForCompare(clone) {
|
|
347
|
+
for (const name of EMPTY_INERT_ATTRS) {
|
|
348
|
+
if (clone.getAttribute(name) === '') clone.removeAttribute(name);
|
|
349
|
+
for (const el of clone.querySelectorAll(`[${name}=""]`)) el.removeAttribute(name);
|
|
350
|
+
}
|
|
351
|
+
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
352
|
+
}
|
|
353
|
+
|
|
330
354
|
/**
|
|
331
355
|
* Capture snapshot prepared for dirty/change comparison.
|
|
332
356
|
*
|
|
@@ -355,7 +379,7 @@ export function captureForComparison({ flushUndo = true } = {}) {
|
|
|
355
379
|
|
|
356
380
|
stripSnapshotRegions(clone);
|
|
357
381
|
|
|
358
|
-
return
|
|
382
|
+
return serializeForCompare(clone);
|
|
359
383
|
}
|
|
360
384
|
|
|
361
385
|
/**
|
|
@@ -388,7 +412,7 @@ export function captureForDirtyCheck({ flushUndo = true } = {}) {
|
|
|
388
412
|
|
|
389
413
|
stripSnapshotRegions(clone);
|
|
390
414
|
|
|
391
|
-
return
|
|
415
|
+
return serializeForCompare(clone);
|
|
392
416
|
}
|
|
393
417
|
|
|
394
418
|
/**
|
|
@@ -419,7 +443,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
419
443
|
hook(clone);
|
|
420
444
|
}
|
|
421
445
|
stripSnapshotRegions(clone);
|
|
422
|
-
const forComparison =
|
|
446
|
+
const forComparison = serializeForCompare(clone);
|
|
423
447
|
|
|
424
448
|
let forDirty = forComparison;
|
|
425
449
|
if (dirtyClone) {
|
|
@@ -431,7 +455,7 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
431
455
|
hook(dirtyClone);
|
|
432
456
|
}
|
|
433
457
|
stripSnapshotRegions(dirtyClone);
|
|
434
|
-
forDirty =
|
|
458
|
+
forDirty = serializeForCompare(dirtyClone);
|
|
435
459
|
}
|
|
436
460
|
|
|
437
461
|
return { forComparison, forDirty };
|
|
@@ -445,10 +469,12 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
445
469
|
*
|
|
446
470
|
* @param {Object} options
|
|
447
471
|
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
448
|
-
* @returns {{ forSave: string, forComparison: string, forDirty: string }}
|
|
472
|
+
* @returns {{ forSave: string, forComparison: string, forDirty: string, captureId: number }}
|
|
449
473
|
*/
|
|
450
|
-
export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true } = {}) {
|
|
451
|
-
|
|
474
|
+
export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true, requestRefresh = false } = {}) {
|
|
475
|
+
if (!Number.isSafeInteger(captureCounter + 1)) throw new Error('Save capture identity exhausted');
|
|
476
|
+
const captureId = ++captureCounter;
|
|
477
|
+
const clone = captureSnapshot({ flushUndo, forSave: emitForSync, requestRefresh });
|
|
452
478
|
|
|
453
479
|
// Emit for live-sync before any stripping.
|
|
454
480
|
//
|
|
@@ -459,7 +485,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
459
485
|
// and a baseline the dirty check cannot reproduce warns on close forever.
|
|
460
486
|
if (emitForSync) {
|
|
461
487
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
462
|
-
detail: { documentElement: clone, forSave: true }
|
|
488
|
+
detail: { documentElement: clone, forSave: true, captureId }
|
|
463
489
|
}));
|
|
464
490
|
}
|
|
465
491
|
|
|
@@ -499,7 +525,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
499
525
|
hook(compareClone);
|
|
500
526
|
}
|
|
501
527
|
stripSnapshotRegions(compareClone);
|
|
502
|
-
const forComparison =
|
|
528
|
+
const forComparison = serializeForCompare(compareClone);
|
|
503
529
|
|
|
504
530
|
// Dirty clone: same shape as the compare clone, one selector weaker.
|
|
505
531
|
let forDirty = forComparison;
|
|
@@ -512,10 +538,10 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
512
538
|
hook(dirtyClone);
|
|
513
539
|
}
|
|
514
540
|
stripSnapshotRegions(dirtyClone);
|
|
515
|
-
forDirty =
|
|
541
|
+
forDirty = serializeForCompare(dirtyClone);
|
|
516
542
|
}
|
|
517
543
|
|
|
518
|
-
return { forSave, forComparison, forDirty };
|
|
544
|
+
return { forSave, forComparison, forDirty, captureId };
|
|
519
545
|
}
|
|
520
546
|
|
|
521
547
|
/**
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// clay.effect(fn): run fn soon and again after anything that can change what it
|
|
2
|
+
// reads. Local edits arrive through the mutation hub; a live-sync merge runs with
|
|
3
|
+
// the hub paused, so clay:sync-applied covers it; undo and redo write through the
|
|
4
|
+
// hub as well, and their events are a backstop. fn runs with the hub paused, so
|
|
5
|
+
// its own writes (derived counts, totals) never re-trigger it, never start an
|
|
6
|
+
// autosave and never enter undo history. The first run is on a microtask too:
|
|
7
|
+
// pausing the hub mid-way through an undo.commit would split the command. fn must
|
|
8
|
+
// be synchronous: writes after an await land outside the pause and would loop.
|
|
9
|
+
//
|
|
10
|
+
// In edit mode each effect warns once about the misuses it can see: a write to
|
|
11
|
+
// saved, dirty-tracked content (saved with the file, but never marks it unsaved),
|
|
12
|
+
// an effect created inside an effect, undo.commit inside an effect (checked by the
|
|
13
|
+
// undo plugin through effect.running), and an effect that keeps re-running.
|
|
14
|
+
import { isTabLocalAttr } from "./root-attrs.js";
|
|
15
|
+
|
|
16
|
+
const LOOP_RUNS = 50;
|
|
17
|
+
const LOOP_WINDOW_MS = 1000;
|
|
18
|
+
const NO_SNAPSHOT = '[clay~="no-snapshot"], [no-snapshot], [snapshot-remove]';
|
|
19
|
+
|
|
20
|
+
let running = 0;
|
|
21
|
+
|
|
22
|
+
const describe = (node) => {
|
|
23
|
+
const el = node.nodeType === 1 ? node : node.parentElement;
|
|
24
|
+
if (!el) return "a detached node";
|
|
25
|
+
let s = el.localName;
|
|
26
|
+
if (el.id) s += "#" + el.id;
|
|
27
|
+
else if (el.classList.length) s += "." + [...el.classList].slice(0, 2).join(".");
|
|
28
|
+
return "<" + s + ">";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function savedWrite(records) {
|
|
32
|
+
const policy = window.clay?.region?.resolveRegionPolicy;
|
|
33
|
+
if (!policy) return null;
|
|
34
|
+
const tracked = (el) => !!el && !el.closest(NO_SNAPSHOT) && policy(el).dirtyTracked;
|
|
35
|
+
for (const r of records) {
|
|
36
|
+
if (r.type === "attributes" && isTabLocalAttr(r.attributeName, r.target)) continue;
|
|
37
|
+
const el = r.target.nodeType === 1 ? r.target : r.target.parentElement;
|
|
38
|
+
if (!tracked(el)) continue;
|
|
39
|
+
// A child added or removed counts only when it is saved itself: chrome
|
|
40
|
+
// inserted into a saved parent (clay="editor-ui") is stripped on save.
|
|
41
|
+
if (r.type === "childList" && ![...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType !== 1 || tracked(n))) continue;
|
|
42
|
+
return r.target;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function effect(fn) {
|
|
48
|
+
const clay = window.clay;
|
|
49
|
+
const Mutation = clay.Mutation;
|
|
50
|
+
const warned = new Set();
|
|
51
|
+
const warn = (key, message) => {
|
|
52
|
+
if (warned.has(key)) return;
|
|
53
|
+
warned.add(key);
|
|
54
|
+
console.warn("clay.effect: " + message);
|
|
55
|
+
};
|
|
56
|
+
let queued = false;
|
|
57
|
+
let stopped = false;
|
|
58
|
+
let windowStart = 0;
|
|
59
|
+
let runs = 0;
|
|
60
|
+
|
|
61
|
+
if (running && clay.isEditMode) {
|
|
62
|
+
console.warn("clay.effect: an effect was created inside another effect, so a new one starts on every run. Create effects once, in clay.start.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const watchLoop = () => {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (now - windowStart > LOOP_WINDOW_MS) { windowStart = now; runs = 0; }
|
|
68
|
+
if (++runs > LOOP_RUNS) {
|
|
69
|
+
warn("loop", `this effect ran more than ${LOOP_RUNS} times in a second. Something keeps changing the document, often a timer or async callback the effect started.`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const run = () => {
|
|
74
|
+
queued = false;
|
|
75
|
+
if (stopped) return;
|
|
76
|
+
let observer = null;
|
|
77
|
+
Mutation.pause();
|
|
78
|
+
running++;
|
|
79
|
+
try {
|
|
80
|
+
if (clay.isEditMode === true) {
|
|
81
|
+
watchLoop();
|
|
82
|
+
if (!warned.has("saved")) {
|
|
83
|
+
observer = new MutationObserver(() => {});
|
|
84
|
+
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const result = fn();
|
|
88
|
+
if (result && typeof result.then === "function") {
|
|
89
|
+
stop();
|
|
90
|
+
console.error("clay.effect: fn returned a promise. It must be synchronous, so this effect was stopped.");
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error("clay.effect:", err);
|
|
94
|
+
} finally {
|
|
95
|
+
running--;
|
|
96
|
+
Mutation.resume();
|
|
97
|
+
const records = observer ? observer.takeRecords() : [];
|
|
98
|
+
observer?.disconnect();
|
|
99
|
+
if (records.length) {
|
|
100
|
+
const target = savedWrite(records);
|
|
101
|
+
if (target) {
|
|
102
|
+
warn("saved", `this effect changed ${describe(target)}, which is saved with the file. Effect writes never mark the page unsaved, so a merge can leave it dirty with no save scheduled. Draw derived values inside clay="no-dirty" or clay="editor-ui".`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const schedule = () => {
|
|
108
|
+
if (queued || stopped) return;
|
|
109
|
+
queued = true;
|
|
110
|
+
queueMicrotask(run);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const offMutation = Mutation.onAnyChange({ debounce: 0, require: "observed" }, schedule);
|
|
114
|
+
document.addEventListener("clay:sync-applied", schedule);
|
|
115
|
+
clay.undo?.on?.("undo", schedule);
|
|
116
|
+
clay.undo?.on?.("redo", schedule);
|
|
117
|
+
|
|
118
|
+
const stop = () => {
|
|
119
|
+
stopped = true;
|
|
120
|
+
offMutation();
|
|
121
|
+
document.removeEventListener("clay:sync-applied", schedule);
|
|
122
|
+
clay.undo?.off?.("undo", schedule);
|
|
123
|
+
clay.undo?.off?.("redo", schedule);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
schedule();
|
|
127
|
+
return stop;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Object.defineProperty(effect, "running", { get: () => running > 0 });
|
package/src/lib/style.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { insertStyles, getStyleBaseUrl } from "../dom/insert-style-tag.js";
|
|
2
|
+
import Mutation from "./mutation.js";
|
|
3
|
+
import { adoptStyleComparisons } from "./style-adoption.js";
|
|
4
|
+
|
|
5
|
+
// A style a script owns. The tag saves with the page, so the next load paints
|
|
6
|
+
// styled before any script runs. Each call writes the current CSS into the saved
|
|
7
|
+
// tag in place (or points the saved <link> at the current href), so a page saved
|
|
8
|
+
// by an older script picks up newer styles. The tag is no-dirty: an update is
|
|
9
|
+
// carried by the next real save and never counts as an edit, starts an autosave
|
|
10
|
+
// or raises the close warning.
|
|
11
|
+
const markNoDirty = (el) => {
|
|
12
|
+
const tokens = (el.getAttribute("clay") ?? "").split(/\s+/).filter(Boolean);
|
|
13
|
+
if (!tokens.includes("no-dirty")) el.setAttribute("clay", [...tokens, "no-dirty"].join(" "));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function style(nameOrHref, css) {
|
|
17
|
+
return writeStyle(nameOrHref, css);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function styleWithLegacyId(name, css, legacyId) {
|
|
21
|
+
return writeStyle(name, css, legacyId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeStyle(nameOrHref, css, legacyId) {
|
|
25
|
+
const inline = typeof css === "string";
|
|
26
|
+
const identity = inline ? { name: nameOrHref, legacyId } : { href: getStyleBaseUrl(nameOrHref) };
|
|
27
|
+
const legacy = legacyId === undefined ? [] : document.querySelectorAll(`style[id="${legacyId}"]:not([data-name])`);
|
|
28
|
+
Mutation.pause();
|
|
29
|
+
try {
|
|
30
|
+
adoptStyleComparisons(identity);
|
|
31
|
+
for (const el of legacy) el.dataset.name = nameOrHref;
|
|
32
|
+
if (inline) {
|
|
33
|
+
const el = insertStyles(nameOrHref, css, markNoDirty);
|
|
34
|
+
if (legacyId !== undefined) el.id = legacyId;
|
|
35
|
+
return el;
|
|
36
|
+
}
|
|
37
|
+
return insertStyles(nameOrHref, (link) => {
|
|
38
|
+
markNoDirty(link);
|
|
39
|
+
if (link.getAttribute("href") !== nameOrHref) link.setAttribute("href", nameOrHref);
|
|
40
|
+
});
|
|
41
|
+
} finally {
|
|
42
|
+
Mutation.resume();
|
|
43
|
+
}
|
|
44
|
+
}
|