@panphora/clayjs 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/clay.standalone.js +8715 -5949
- package/entries/clay-data.js +8 -6
- package/package.json +1 -1
- package/src/core/conflict-notice.js +2 -4
- package/src/core/page-data.js +321 -0
- package/src/core/save-core.js +35 -2
- package/src/core/save.js +123 -13
- package/src/core/unsaved-warning.js +43 -20
- package/src/lib/content-dom.js +3 -3
- package/src/lib/dirty-gate.js +28 -1
- package/src/loader-logic.js +2 -0
- package/src/loader.js +13 -2
- package/src/plugins/wire.js +4 -4
- package/src/sync/conflict-footprints.js +41 -0
- package/src/sync/conflict-revert.js +3 -0
- package/src/sync/conflicts.js +28 -1
- package/src/sync/live-sync.js +5 -3
- package/src/ui/bevel-controls.js +8 -2
- package/src/ui/bevel-dialog.js +2 -4
- package/src/ui/bevel.js +22 -2
- package/src/ui/dialogs.js +1 -1
- package/src/ui/skins/cms.js +1 -1
- package/src/ui/skins/quickcrop.js +1 -1
- package/src/ui/skins/richclay.js +2 -2
- package/src/ui/toast.js +7 -15
- package/src/vendor/hyper-morph.vendor.js +4 -4
- package/src/vendor/hypercms.vendor.js +12 -12
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +27 -19
package/src/core/save.js
CHANGED
|
@@ -18,15 +18,22 @@ import {
|
|
|
18
18
|
replacePageWith as replacePageWithCore,
|
|
19
19
|
addDocumentTransform,
|
|
20
20
|
isSaveInProgress,
|
|
21
|
-
saveFateIsUnknown
|
|
21
|
+
saveFateIsUnknown,
|
|
22
|
+
withKeepalive
|
|
22
23
|
} from "./save-core.js";
|
|
23
24
|
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
24
|
-
import { seedEtag } from "./etag.js";
|
|
25
|
+
import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
25
26
|
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
27
|
+
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
26
28
|
import { autosaveActive } from "../lib/autosave-state.js";
|
|
27
29
|
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
28
30
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
29
31
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
32
|
+
// A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
|
|
33
|
+
// flush asks the SAME bytes question the close warning asks, through `bytesUnsaved`.
|
|
34
|
+
// Both read across it at call time, never while a module is evaluating, so neither
|
|
35
|
+
// side sees a half-built module.
|
|
36
|
+
import { bytesUnsaved } from "./unsaved-warning.js";
|
|
30
37
|
|
|
31
38
|
// Keep this library's own root state out of the saved bytes.
|
|
32
39
|
//
|
|
@@ -207,23 +214,26 @@ export function resumeAutosave() {
|
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
// ============================================
|
|
210
|
-
// THE
|
|
217
|
+
// THE ALL-SAVE HOLD
|
|
211
218
|
// ============================================
|
|
212
219
|
//
|
|
213
|
-
// While an
|
|
214
|
-
//
|
|
215
|
-
// so for that window no save goes out at all. A save asked for meanwhile is
|
|
216
|
-
// remembered and runs on release; Keep releases without the replay because it saves
|
|
217
|
-
// right after.
|
|
220
|
+
// While an operation owns the page's persisted state, no save goes out at all. A
|
|
221
|
+
// save asked for meanwhile is remembered and runs on release.
|
|
218
222
|
let previewHold = 0;
|
|
219
223
|
let previewMissed = false;
|
|
224
|
+
const PREVIEW_HOLD_REASON = 'Waiting for the AI edit to be kept or reverted';
|
|
225
|
+
const holdReasons = new Map();
|
|
220
226
|
|
|
221
|
-
export function holdAllSaves() {
|
|
227
|
+
export function holdAllSaves(reason = PREVIEW_HOLD_REASON) {
|
|
222
228
|
previewHold++;
|
|
229
|
+
holdReasons.set(reason, (holdReasons.get(reason) || 0) + 1);
|
|
223
230
|
}
|
|
224
231
|
|
|
225
|
-
export function releaseAllSaves({ replay = true } = {}) {
|
|
226
|
-
|
|
232
|
+
export function releaseAllSaves({ replay = true, reason = PREVIEW_HOLD_REASON } = {}) {
|
|
233
|
+
const count = holdReasons.get(reason) || 0;
|
|
234
|
+
if (count === 0) return;
|
|
235
|
+
if (count === 1) holdReasons.delete(reason);
|
|
236
|
+
else holdReasons.set(reason, count - 1);
|
|
227
237
|
previewHold--;
|
|
228
238
|
if (previewHold > 0) return;
|
|
229
239
|
const missed = previewMissed;
|
|
@@ -235,11 +245,16 @@ export function savesHeld() {
|
|
|
235
245
|
return previewHold > 0;
|
|
236
246
|
}
|
|
237
247
|
|
|
248
|
+
export function saveHoldReason() {
|
|
249
|
+
if (previewHold === 0) return null;
|
|
250
|
+
return holdReasons.keys().next().value || PREVIEW_HOLD_REASON;
|
|
251
|
+
}
|
|
252
|
+
|
|
238
253
|
function heldForPreview(callback, resolve) {
|
|
239
254
|
if (previewHold === 0) return false;
|
|
240
255
|
previewMissed = true;
|
|
241
256
|
clearExplicitSave();
|
|
242
|
-
const skipped = skipped_(
|
|
257
|
+
const skipped = skipped_(saveHoldReason());
|
|
243
258
|
callback(skipped);
|
|
244
259
|
resolve(skipped);
|
|
245
260
|
return true;
|
|
@@ -560,6 +575,101 @@ export function savePageForce(callback = () => {}) {
|
|
|
560
575
|
});
|
|
561
576
|
}
|
|
562
577
|
|
|
578
|
+
// Resolve on the next save that settles, or reject once the deadline has passed.
|
|
579
|
+
// Every settled state is one, not just success: a save that failed is an answer about
|
|
580
|
+
// the page's bytes too, and the caller decides what to do with it.
|
|
581
|
+
function settled(deadline) {
|
|
582
|
+
const events = ['clay:save-saved', 'clay:save-error', 'clay:save-offline', 'clay:save-conflict'];
|
|
583
|
+
return new Promise((resolve, reject) => {
|
|
584
|
+
let timer = null;
|
|
585
|
+
const cleanup = () => {
|
|
586
|
+
clearTimeout(timer);
|
|
587
|
+
for (const name of events) document.removeEventListener(name, onSettle);
|
|
588
|
+
};
|
|
589
|
+
const onSettle = () => {
|
|
590
|
+
cleanup();
|
|
591
|
+
resolve();
|
|
592
|
+
};
|
|
593
|
+
const remaining = deadline - Date.now();
|
|
594
|
+
if (remaining <= 0) {
|
|
595
|
+
reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
timer = setTimeout(() => {
|
|
599
|
+
cleanup();
|
|
600
|
+
reject(Object.assign(new Error('Save timed out.'), { state: 'failed' }));
|
|
601
|
+
}, remaining);
|
|
602
|
+
for (const name of events) document.addEventListener(name, onSettle);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function flushError(message, state) {
|
|
607
|
+
return Object.assign(new Error(message), { state });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Resolve when `name` fires on document, or reject at the deadline.
|
|
611
|
+
function nextEvent(name, deadline) {
|
|
612
|
+
return new Promise((resolve, reject) => {
|
|
613
|
+
const remaining = deadline - Date.now();
|
|
614
|
+
if (remaining <= 0) return reject(flushError('Save timed out.', 'failed'));
|
|
615
|
+
const timer = setTimeout(() => {
|
|
616
|
+
document.removeEventListener(name, onEvent);
|
|
617
|
+
reject(flushError('Save timed out.', 'failed'));
|
|
618
|
+
}, remaining);
|
|
619
|
+
const onEvent = () => { clearTimeout(timer); resolve(); };
|
|
620
|
+
document.addEventListener(name, onEvent, { once: true });
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Save now; answer only when the host has accepted the bytes the page holds now.
|
|
625
|
+
// Resolves { state: 'view' | 'clean' | 'saved', etag }. Rejects with an Error carrying
|
|
626
|
+
// state 'failed' (including a timeout), 'conflict', or 'blocked' (the bytes are on the
|
|
627
|
+
// host but the page holds work a save cannot write, registered through
|
|
628
|
+
// clay.registerUnsavedState; only a person can resolve that). Never overwrites.
|
|
629
|
+
// `keepalive` is a transport hint honoured under 60 KB of UTF-8.
|
|
630
|
+
export async function flushSave({ keepalive = false, timeoutMs = 15000 } = {}) {
|
|
631
|
+
if (!isEditMode) return { state: 'view', etag: null };
|
|
632
|
+
const deadline = Date.now() + timeoutMs;
|
|
633
|
+
let timer = null;
|
|
634
|
+
const timeout = new Promise((_, reject) => {
|
|
635
|
+
timer = setTimeout(() => reject(flushError('Save timed out.', 'failed')), Math.max(0, timeoutMs));
|
|
636
|
+
});
|
|
637
|
+
try {
|
|
638
|
+
return await Promise.race([flushBytes(keepalive, deadline), timeout]);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
if (err && typeof err.state === 'string') throw err;
|
|
641
|
+
throw flushError(err?.message || 'Save failed', 'failed');
|
|
642
|
+
} finally {
|
|
643
|
+
clearTimeout(timer);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function flushBytes(keepalive, deadline) {
|
|
648
|
+
// Load-time module churn is not the person's work: autosave never saves it, and
|
|
649
|
+
// neither does a flush. Wait for the baseline to settle (at most MAX_SETTLE_MS).
|
|
650
|
+
if (!baselineSettled()) await nextEvent('clay:baseline-settled', deadline);
|
|
651
|
+
|
|
652
|
+
let saved = false;
|
|
653
|
+
let etag = null;
|
|
654
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
655
|
+
if (isSaveInProgress()) await settled(deadline);
|
|
656
|
+
if (isSaveConflicted()) throw flushError('This document has a conflict to resolve.', 'conflict');
|
|
657
|
+
const token = gateCaptureToken();
|
|
658
|
+
if (!bytesUnsaved()) {
|
|
659
|
+
gateClearIfUnchanged(token);
|
|
660
|
+
if (hasUnsavedState()) throw flushError('This document has unsaved work a save cannot write.', 'blocked');
|
|
661
|
+
return { state: saved ? 'saved' : 'clean', etag: etag ?? lastSeenEtag() };
|
|
662
|
+
}
|
|
663
|
+
const result = await withKeepalive(keepalive, () => savePage());
|
|
664
|
+
// The lane was busy (a drained pending save is on the wire): wait it out next turn.
|
|
665
|
+
if (result.msgType === 'skipped') continue;
|
|
666
|
+
if (!result.ok) throw flushError(result.msg || 'Save failed', result.msgType === 'conflict' || isSaveConflicted() ? 'conflict' : 'failed');
|
|
667
|
+
saved = true;
|
|
668
|
+
etag = result.etag ?? null;
|
|
669
|
+
}
|
|
670
|
+
throw flushError('The document kept changing while saving.', 'failed');
|
|
671
|
+
}
|
|
672
|
+
|
|
563
673
|
/**
|
|
564
674
|
* Keep this tab's version, over the one the host is holding.
|
|
565
675
|
*
|
|
@@ -752,7 +862,7 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
752
862
|
|
|
753
863
|
if (previewHold > 0) {
|
|
754
864
|
previewMissed = true;
|
|
755
|
-
const skipped = skipped_(
|
|
865
|
+
const skipped = skipped_(saveHoldReason());
|
|
756
866
|
callback(skipped);
|
|
757
867
|
return Promise.resolve(skipped);
|
|
758
868
|
}
|
|
@@ -43,6 +43,39 @@ preloadIfEnabled();
|
|
|
43
43
|
// sandboxed document, which cannot read cookies at all. Those are exactly the
|
|
44
44
|
// documents where an unsaved edit is easiest to lose. If the page is editable,
|
|
45
45
|
// the person editing it deserves the warning.
|
|
46
|
+
//
|
|
47
|
+
// The same predicate is exposed as `clay.hasUnsavedChanges`, which a host asks
|
|
48
|
+
// synchronously before deciding whether a save is worth asking for.
|
|
49
|
+
export function hasUnsavedChanges() {
|
|
50
|
+
if (!isEditMode) return false;
|
|
51
|
+
|
|
52
|
+
// Work outside the DOM first: it needs no capture, and a capture that throws
|
|
53
|
+
// must not hide it. The demo plugin does not save it either, so it warns there too.
|
|
54
|
+
if (hasUnsavedState()) return true;
|
|
55
|
+
|
|
56
|
+
// The demo plugin saves the page's bytes into this browser's own storage, so
|
|
57
|
+
// leaving loses none of them.
|
|
58
|
+
if (window.clay?.demo) return false;
|
|
59
|
+
|
|
60
|
+
return bytesUnsaved();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The page's own bytes against the last bytes the host accepted. A capture that throws
|
|
64
|
+
// counts as unsaved: something on the page is broken, and "nothing to lose" is the one
|
|
65
|
+
// answer that can lose work.
|
|
66
|
+
//
|
|
67
|
+
// The DIRTY domain, not the autosave domain. An edit inside a no-trigger-autosave
|
|
68
|
+
// region never starts a save by itself, which is exactly why closing the tab on one has
|
|
69
|
+
// to warn: nothing else is going to write it.
|
|
70
|
+
export function bytesUnsaved() {
|
|
71
|
+
if (!isEditMode) return false;
|
|
72
|
+
try {
|
|
73
|
+
return captureForDirtyCheck() !== getLastSavedDirty();
|
|
74
|
+
} catch {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
46
79
|
window.addEventListener('beforeunload', (event) => {
|
|
47
80
|
if (!isEditMode) return;
|
|
48
81
|
|
|
@@ -51,26 +84,16 @@ window.addEventListener('beforeunload', (event) => {
|
|
|
51
84
|
return;
|
|
52
85
|
}
|
|
53
86
|
|
|
54
|
-
|
|
55
|
-
// must not hide it. The demo plugin does not save it either, so it warns there too.
|
|
56
|
-
const held = hasUnsavedState();
|
|
57
|
-
|
|
58
|
-
// The demo plugin saves the page's bytes into this browser's own storage, so
|
|
59
|
-
// leaving loses none of them.
|
|
60
|
-
let bytesDiffer = false;
|
|
61
|
-
if (!held && !window.clay?.demo) {
|
|
62
|
-
// The DIRTY domain, not the autosave domain. An edit inside a
|
|
63
|
-
// no-trigger-autosave region never starts a save by itself, which is exactly
|
|
64
|
-
// why closing the tab on one has to warn: nothing else is going to write it.
|
|
65
|
-
const currentForCompare = captureForDirtyCheck();
|
|
66
|
-
const lastSaved = getLastSavedDirty();
|
|
67
|
-
bytesDiffer = currentForCompare !== lastSaved;
|
|
68
|
-
// Debug: log what's different before showing the warning
|
|
69
|
-
if (bytesDiffer) logUnloadDiffSync(currentForCompare, lastSaved);
|
|
70
|
-
}
|
|
87
|
+
if (!hasUnsavedChanges()) return;
|
|
71
88
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
event.returnValue = '';
|
|
91
|
+
// Debug only, after the decision: a capture that throws here must not cancel the warning.
|
|
92
|
+
if (!window.clay?.demo && !hasUnsavedState()) {
|
|
93
|
+
try {
|
|
94
|
+
const currentForCompare = captureForDirtyCheck();
|
|
95
|
+
const lastSaved = getLastSavedDirty();
|
|
96
|
+
if (currentForCompare !== lastSaved) logUnloadDiffSync(currentForCompare, lastSaved);
|
|
97
|
+
} catch {}
|
|
75
98
|
}
|
|
76
99
|
});
|
package/src/lib/content-dom.js
CHANGED
|
@@ -5,9 +5,9 @@ const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visite
|
|
|
5
5
|
function copyControlState(source, copy) {
|
|
6
6
|
if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
|
|
7
7
|
const type = (source.getAttribute?.('type') || '').toLowerCase()
|
|
8
|
-
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
|
|
9
|
-
if ('
|
|
10
|
-
if (
|
|
8
|
+
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
|
|
9
|
+
if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
|
|
10
|
+
if (source.tagName === 'OPTION') copy.selected = source.selected
|
|
11
11
|
if (source.tagName === 'SELECT') {
|
|
12
12
|
for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
|
|
13
13
|
}
|
package/src/lib/dirty-gate.js
CHANGED
|
@@ -35,6 +35,26 @@ let clearedAt = 0;
|
|
|
35
35
|
let paused = false;
|
|
36
36
|
let started = false;
|
|
37
37
|
|
|
38
|
+
// The state change, announced on the document: `clay:dirty` when work nothing has
|
|
39
|
+
// written appears, `clay:clean` when a save (or the oracle) has accounted for it. A
|
|
40
|
+
// burst of edits is ONE announcement, since what a listener wants is the state, not
|
|
41
|
+
// the edit. Only a transition is dispatched.
|
|
42
|
+
let announcedDirty = false;
|
|
43
|
+
let queued = false;
|
|
44
|
+
|
|
45
|
+
function scheduleAnnounce() {
|
|
46
|
+
if (!isEditMode || queued) return;
|
|
47
|
+
queued = true;
|
|
48
|
+
queueMicrotask(() => {
|
|
49
|
+
queued = false;
|
|
50
|
+
if (typeof document === 'undefined') return;
|
|
51
|
+
const dirty = pageMaybeDirty();
|
|
52
|
+
if (dirty === announcedDirty) return;
|
|
53
|
+
announcedDirty = dirty;
|
|
54
|
+
document.dispatchEvent(new CustomEvent(dirty ? 'clay:dirty' : 'clay:clean'));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
const PERSIST_CONTROLS = `input${PERSIST}, textarea${PERSIST}, select${PERSIST}`;
|
|
39
59
|
|
|
40
60
|
// Regions the loss oracle never sees. The hub feed already skips them, through
|
|
@@ -81,6 +101,7 @@ function onUserInput(event) {
|
|
|
81
101
|
if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
|
|
82
102
|
if (el.closest(GATE_IGNORE)) return;
|
|
83
103
|
changes++;
|
|
104
|
+
scheduleAnnounce();
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
export function startDirtyGate() {
|
|
@@ -89,7 +110,10 @@ export function startDirtyGate() {
|
|
|
89
110
|
Mutation.onAnyChange(
|
|
90
111
|
{ omitChangeDetails: true, require: 'dirty' },
|
|
91
112
|
() => {
|
|
92
|
-
if (!paused)
|
|
113
|
+
if (!paused) {
|
|
114
|
+
changes++;
|
|
115
|
+
scheduleAnnounce();
|
|
116
|
+
}
|
|
93
117
|
}
|
|
94
118
|
);
|
|
95
119
|
document.addEventListener('input', onUserInput, true);
|
|
@@ -151,6 +175,7 @@ export function probeMarkClean() {
|
|
|
151
175
|
for (const el of gatedPersistControls()) {
|
|
152
176
|
probeCache.set(el, controlSignature(el));
|
|
153
177
|
}
|
|
178
|
+
scheduleAnnounce();
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
export function pageMaybeDirty() {
|
|
@@ -173,6 +198,7 @@ export function gateCaptureToken() {
|
|
|
173
198
|
/** A change nothing in the DOM recorded: the page holds text a save has not written. */
|
|
174
199
|
export function gateMarkDirty() {
|
|
175
200
|
changes++;
|
|
201
|
+
scheduleAnnounce();
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
export function gateClearIfUnchanged(token) {
|
|
@@ -186,6 +212,7 @@ export function gateClearIfUnchanged(token) {
|
|
|
186
212
|
for (const [el, sig] of token.probe) {
|
|
187
213
|
if (controlSignature(el) === sig) probeCache.set(el, sig);
|
|
188
214
|
}
|
|
215
|
+
scheduleAnnounce();
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
if (typeof document !== 'undefined' && isEditMode) {
|
package/src/loader-logic.js
CHANGED
|
@@ -8,6 +8,7 @@ export const CORE_WAVES = {
|
|
|
8
8
|
// is exactly the case where edit mode is off, so the editOnly wave would never
|
|
9
9
|
// reach it. It draws nothing on any other page.
|
|
10
10
|
"core/stale-host-notice.js",
|
|
11
|
+
"core/page-data.js",
|
|
11
12
|
],
|
|
12
13
|
editOnly: [
|
|
13
14
|
"vendor/hyper-morph.vendor.js",
|
|
@@ -66,6 +67,7 @@ export const MODULES = {
|
|
|
66
67
|
"lib/mutation.js": () => import("./lib/mutation.js"),
|
|
67
68
|
"core/edit-mode.js": () => import("./core/edit-mode.js"),
|
|
68
69
|
"core/stale-host-notice.js": () => import("./core/stale-host-notice.js"),
|
|
70
|
+
"core/page-data.js": () => import("./core/page-data.js"),
|
|
69
71
|
"core/snapshot.js": () => import("./core/snapshot.js"),
|
|
70
72
|
"core/save-core.js": () => import("./core/save-core.js"),
|
|
71
73
|
"core/save.js": () => import("./core/save.js"),
|
package/src/loader.js
CHANGED
|
@@ -50,7 +50,7 @@ export async function boot(base, params, readyResolve) {
|
|
|
50
50
|
continue;
|
|
51
51
|
}
|
|
52
52
|
loaded[path] = mod;
|
|
53
|
-
attachPluginMember(path, mod);
|
|
53
|
+
attachPluginMember(path, mod, loaded); // immediately, not after the loop: hypercms's ?cms=true
|
|
54
54
|
// auto-open runs as a microtask queued during ITS evaluation
|
|
55
55
|
// (before boot resumes) and reads clay.RichClay and clay.undo,
|
|
56
56
|
// which earlier plugins must have attached by then
|
|
@@ -83,13 +83,23 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
83
83
|
const save = loaded["core/save.js"];
|
|
84
84
|
const cacheBustMod = loaded["lib/cache-bust.js"];
|
|
85
85
|
const hyperMorph = loaded["vendor/hyper-morph.vendor.js"];
|
|
86
|
+
const pageData = loaded["core/page-data.js"];
|
|
87
|
+
|
|
88
|
+
clay.readData = pageData.readData;
|
|
89
|
+
clay.writeData = pageData.writeData;
|
|
86
90
|
|
|
87
91
|
if (save) {
|
|
88
92
|
const saveFn = save.savePage || save.default;
|
|
89
93
|
saveFn.force = save.savePageForce;
|
|
90
94
|
saveFn.overwrite = save.saveOverwritingConflict;
|
|
95
|
+
saveFn.flush = save.flushSave;
|
|
91
96
|
clay.save = saveFn;
|
|
92
97
|
}
|
|
98
|
+
// The close warning's own predicate, reachable without a capture. It falls back to
|
|
99
|
+
// false rather than to absent: the module is edit-only, and a document that cannot
|
|
100
|
+
// save has nothing unsaved. Older clayjs has no such member, and that is the answer
|
|
101
|
+
// a host feature-detects.
|
|
102
|
+
clay.hasUnsavedChanges = loaded["core/unsaved-warning.js"]?.hasUnsavedChanges ?? (() => false);
|
|
93
103
|
if (snapshot) {
|
|
94
104
|
clay.getHTML = snapshot.getPageContents;
|
|
95
105
|
clay.addDocumentTransform = snapshot.addDocumentTransform;
|
|
@@ -101,7 +111,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
|
|
|
101
111
|
if (hyperMorph) clay.morph = hyperMorph.morph;
|
|
102
112
|
}
|
|
103
113
|
|
|
104
|
-
function attachPluginMember(path, mod) {
|
|
114
|
+
function attachPluginMember(path, mod, loaded) {
|
|
105
115
|
const clay = window.clay;
|
|
106
116
|
|
|
107
117
|
if (path === "plugins/undo.js") {
|
|
@@ -109,6 +119,7 @@ function attachPluginMember(path, mod) {
|
|
|
109
119
|
} else if (path === "sync/live-sync.js") {
|
|
110
120
|
clay.morph = mod.morph;
|
|
111
121
|
clay.conflicts = mod.conflicts;
|
|
122
|
+
loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
|
|
112
123
|
} else if (path === "plugins/cms.js") {
|
|
113
124
|
clay.cms = mod.cms || mod.default;
|
|
114
125
|
} else if (path === "plugins/upload.js") {
|
package/src/plugins/wire.js
CHANGED
|
@@ -506,10 +506,10 @@ async function flushSave() {
|
|
|
506
506
|
// surface is an opt-in satellite script, absent on every page the loader
|
|
507
507
|
// builds, so reading it here made "is a save on the wire" permanently false.
|
|
508
508
|
const { isSaveInProgress } = await import("../core/save-core.js");
|
|
509
|
-
// A held save
|
|
510
|
-
// nothing to save: posting now
|
|
511
|
-
const { savesHeld } = await import("../core/save.js");
|
|
512
|
-
if (savesHeld()) throw new Error(
|
|
509
|
+
// A held save means another operation owns the page's persisted state, not a
|
|
510
|
+
// page with nothing to save: posting now could hand the agent stale bytes.
|
|
511
|
+
const { savesHeld, saveHoldReason } = await import("../core/save.js");
|
|
512
|
+
if (savesHeld()) throw new Error(saveHoldReason());
|
|
513
513
|
const deadline = Date.now() + SAVE_FLUSH_TIMEOUT_MS;
|
|
514
514
|
|
|
515
515
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { conflicts } from './conflicts.js';
|
|
2
|
+
|
|
3
|
+
const replacements = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export const replacementFootprint = (rec) => replacements.get(rec) || null;
|
|
6
|
+
|
|
7
|
+
export function trackConflictFootprints(merge) {
|
|
8
|
+
const watched = new Map();
|
|
9
|
+
for (const rec of conflicts.list()) {
|
|
10
|
+
if (!rec.recovery?.text) continue;
|
|
11
|
+
const live = rec.recovery.subject.live;
|
|
12
|
+
const node = replacements.get(rec) || (live.length === 1 ? live[0] : null);
|
|
13
|
+
if (node?.nodeType === 1 && node.ownerDocument === document && node.isConnected) watched.set(rec, node);
|
|
14
|
+
}
|
|
15
|
+
if (!watched.size) return merge();
|
|
16
|
+
const observer = new MutationObserver(() => {});
|
|
17
|
+
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
18
|
+
try {
|
|
19
|
+
const pending = merge();
|
|
20
|
+
const changes = observer.takeRecords();
|
|
21
|
+
if (changes.length !== 2) return pending;
|
|
22
|
+
const [insert, remove] = changes;
|
|
23
|
+
if (insert.type !== 'childList' || remove.type !== 'childList' || insert.target !== remove.target ||
|
|
24
|
+
insert.addedNodes.length !== 1 || insert.removedNodes.length ||
|
|
25
|
+
remove.removedNodes.length !== 1 || remove.addedNodes.length) return pending;
|
|
26
|
+
const old = remove.removedNodes[0];
|
|
27
|
+
const fresh = insert.addedNodes[0];
|
|
28
|
+
if (old.nodeType !== 1 || fresh.nodeType !== 1 || old.isConnected || !fresh.isConnected ||
|
|
29
|
+
old.namespaceURI !== fresh.namespaceURI || old.tagName === fresh.tagName ||
|
|
30
|
+
insert.nextSibling !== old || remove.previousSibling !== fresh ||
|
|
31
|
+
fresh.parentNode !== insert.target || fresh.previousSibling !== insert.previousSibling ||
|
|
32
|
+
fresh.nextSibling !== remove.nextSibling) return pending;
|
|
33
|
+
if (old.childNodes.length !== 1 || fresh.childNodes.length !== 1 || old.firstChild.nodeType !== 3 ||
|
|
34
|
+
!old.firstChild.isEqualNode(fresh.firstChild) || old.attributes.length !== fresh.attributes.length ||
|
|
35
|
+
[...old.attributes].some((a) => fresh.getAttributeNS(a.namespaceURI, a.localName) !== a.value)) return pending;
|
|
36
|
+
for (const [rec, node] of watched) if (node === old) replacements.set(rec, fresh);
|
|
37
|
+
return pending;
|
|
38
|
+
} finally {
|
|
39
|
+
observer.disconnect();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
2
2
|
import { conflicts } from "./conflicts.js";
|
|
3
|
+
import { replacementFootprint } from "./conflict-footprints.js";
|
|
3
4
|
import { SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR } from "./live-sync.js";
|
|
4
5
|
import { savePage } from "../core/save.js";
|
|
5
6
|
import { gateMarkDirty } from "../lib/dirty-gate.js";
|
|
@@ -802,6 +803,8 @@ function footprintOf(rec, apply) {
|
|
|
802
803
|
}
|
|
803
804
|
const live = subject ? liveOf(subject, apply, subject.nodeType) : null;
|
|
804
805
|
if (live) return live;
|
|
806
|
+
const replacement = replacementFootprint(rec);
|
|
807
|
+
if (replacement && onPage(replacement)) return replacement;
|
|
805
808
|
const parent = r.structure && r.structure.localPlacement ? liveOf(r.structure.localPlacement.parent, apply, 1) : null;
|
|
806
809
|
if (parent) return parent;
|
|
807
810
|
const path = subject && subject.local.length ? subject.local[0] : [];
|
package/src/sync/conflicts.js
CHANGED
|
@@ -63,6 +63,33 @@ export function beginApply({ source, seq = null, etag = null, domain, root }) {
|
|
|
63
63
|
return id;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function insertionProof(raw) {
|
|
67
|
+
if (!raw.every((c) => c.kind === 'structure' && c.detail === 'insert-collision' && c.recovery?.applied === true && !c.recovery.unavailable && c.recovery.structure?.localAction === 'inserted' && c.recovery.structure.fragmentKind === 'element')) return null;
|
|
68
|
+
const subject = raw[0].recovery.subject;
|
|
69
|
+
if (subject?.local?.length !== 1 || subject.merged?.length !== 1 || subject.live?.length !== 1) return null;
|
|
70
|
+
return { local: subject.local[0], merged: subject.merged[0], live: subject.live[0] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function foldInsertions(groups) {
|
|
74
|
+
const proofs = new Map(groups.map((raw) => [raw, insertionProof(raw)]));
|
|
75
|
+
const below = (parent, child) => parent.length < child.length && parent.every((step, i) => step === child[i]);
|
|
76
|
+
const at = (root, path) => path.reduce((node, step) => step === 'content' ? node?.content : node?.childNodes[step], root);
|
|
77
|
+
const folded = new Map();
|
|
78
|
+
for (const raw of groups) {
|
|
79
|
+
const child = proofs.get(raw);
|
|
80
|
+
let owner = raw;
|
|
81
|
+
if (child) {
|
|
82
|
+
for (const candidate of groups) {
|
|
83
|
+
const parent = proofs.get(candidate);
|
|
84
|
+
if (parent && parent.local.length < proofs.get(owner).local.length && below(parent.local, child.local) && below(parent.merged, child.merged) && (parent.live.contains(child.live) || at(parent.live, child.merged.slice(parent.merged.length)) === child.live)) owner = candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!folded.has(owner)) folded.set(owner, [...owner]);
|
|
88
|
+
if (raw !== owner) folded.get(owner).push(...raw);
|
|
89
|
+
}
|
|
90
|
+
return [...folded.values()];
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
export function completeApply(applyId, conflicts, { ticket }) {
|
|
67
94
|
const apply = applies.get(applyId);
|
|
68
95
|
if (!apply) return [];
|
|
@@ -74,7 +101,7 @@ export function completeApply(applyId, conflicts, { ticket }) {
|
|
|
74
101
|
if (!groups.has(key)) groups.set(key, []);
|
|
75
102
|
groups.get(key).push(c);
|
|
76
103
|
}
|
|
77
|
-
const ids = [...groups.values()].map((raw) => install(apply, raw));
|
|
104
|
+
const ids = foldInsertions([...groups.values()]).map((raw) => install(apply, raw));
|
|
78
105
|
pending.delete(applyId);
|
|
79
106
|
if (!ids.length) applies.delete(applyId);
|
|
80
107
|
if (ids.length) changed({ added: ids.map((id) => records.get(id)) });
|
package/src/sync/live-sync.js
CHANGED
|
@@ -60,6 +60,7 @@ import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnc
|
|
|
60
60
|
import { gestureSeen } from '../lib/user-gesture.js';
|
|
61
61
|
import { SyncStream } from './stream.js';
|
|
62
62
|
import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
|
|
63
|
+
import { trackConflictFootprints } from './conflict-footprints.js';
|
|
63
64
|
|
|
64
65
|
// What a live-sync merge never reads or touches on any side: editor chrome,
|
|
65
66
|
// content kept out of the save or the snapshot, frozen regions, and nodes
|
|
@@ -1339,6 +1340,7 @@ class LiveSync {
|
|
|
1339
1340
|
* @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
|
|
1340
1341
|
*/
|
|
1341
1342
|
async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, source = 'peer', seq = null, etag = null, extra = {} }) {
|
|
1343
|
+
const mergeDocument = (options) => trackConflictFootprints(() => HyperMorph.mergeDocument(options));
|
|
1342
1344
|
const store = this.identity;
|
|
1343
1345
|
// A frame may neither write these onto our root nor, by not carrying them,
|
|
1344
1346
|
// take ours away. Returning false is hyper-morph's veto for both directions.
|
|
@@ -1396,7 +1398,7 @@ class LiveSync {
|
|
|
1396
1398
|
let localRoot = null;
|
|
1397
1399
|
let applyId = null;
|
|
1398
1400
|
if (base == null) {
|
|
1399
|
-
pending =
|
|
1401
|
+
pending = mergeDocument({
|
|
1400
1402
|
...common,
|
|
1401
1403
|
base: null,
|
|
1402
1404
|
local: { root: captureLocal(), toLive: originalSnapshotNode },
|
|
@@ -1414,7 +1416,7 @@ class LiveSync {
|
|
|
1414
1416
|
const localRoot = captureLocal();
|
|
1415
1417
|
const baseDoc = localRoot.ownerDocument;
|
|
1416
1418
|
if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
|
|
1417
|
-
pending =
|
|
1419
|
+
pending = mergeDocument({
|
|
1418
1420
|
...common,
|
|
1419
1421
|
base: baseDoc,
|
|
1420
1422
|
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
@@ -1432,7 +1434,7 @@ class LiveSync {
|
|
|
1432
1434
|
domain: source === 'disk' ? 'save' : 'sync',
|
|
1433
1435
|
});
|
|
1434
1436
|
try {
|
|
1435
|
-
pending =
|
|
1437
|
+
pending = mergeDocument({
|
|
1436
1438
|
...common,
|
|
1437
1439
|
base,
|
|
1438
1440
|
local: { root: localRoot, toLive: originalSnapshotNode },
|
package/src/ui/bevel-controls.js
CHANGED
|
@@ -72,8 +72,10 @@ function protectIcon(svg, size) {
|
|
|
72
72
|
`fill:${attr('fill', svg.getAttribute('fill') ?? 'currentColor')}`,
|
|
73
73
|
`stroke:${attr('stroke', svg.getAttribute('stroke') ?? 'none')}`,
|
|
74
74
|
];
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
for (const prop of ['stroke-width', 'stroke-linecap', 'stroke-linejoin']) {
|
|
76
|
+
const value = el.getAttribute(prop) ?? svg.getAttribute(prop);
|
|
77
|
+
if (value !== null) rules.push(`${prop}:${value}`);
|
|
78
|
+
}
|
|
77
79
|
if (el.localName === 'path' && el.hasAttribute('d')) rules.push(`d:path("${el.getAttribute('d')}")`);
|
|
78
80
|
style(el, rules);
|
|
79
81
|
}
|
|
@@ -84,6 +86,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
|
|
|
84
86
|
const size = small ? SIZE.small : SIZE.normal;
|
|
85
87
|
const reducedMotion = media('(prefers-reduced-motion: reduce)');
|
|
86
88
|
const forced = media('(forced-colors: active)');
|
|
89
|
+
const coarse = media('(pointer: coarse)');
|
|
87
90
|
const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
|
|
88
91
|
const pinned = new Map();
|
|
89
92
|
|
|
@@ -103,6 +106,7 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
|
|
|
103
106
|
}
|
|
104
107
|
if (flags.focusVisible) rules.push(...RULES.focus);
|
|
105
108
|
if (forced) rules.push(...MEDIA.forcedColors);
|
|
109
|
+
if (coarse) rules.push(...MEDIA.coarseButton);
|
|
106
110
|
rules.push(...extra);
|
|
107
111
|
for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
|
|
108
112
|
restyle(b, rules);
|
|
@@ -234,6 +238,7 @@ export function bevelText(tag, rules = [], text) {
|
|
|
234
238
|
export function paintInput(el, { rules = [] } = {}) {
|
|
235
239
|
const flags = { hovered: false, focused: false };
|
|
236
240
|
const forced = media('(forced-colors: active)');
|
|
241
|
+
const coarse = media('(pointer: coarse)');
|
|
237
242
|
runtime(el);
|
|
238
243
|
function applyState() {
|
|
239
244
|
const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
|
|
@@ -242,6 +247,7 @@ export function paintInput(el, { rules = [] } = {}) {
|
|
|
242
247
|
if (flags.focused) list.push(...RULES.inputFocus);
|
|
243
248
|
if (forced) list.push(...MEDIA.forcedColors);
|
|
244
249
|
list.push(...rules);
|
|
250
|
+
if (coarse) list.push(...MEDIA.coarseInput);
|
|
245
251
|
if (height) list.push(`height:${height}`);
|
|
246
252
|
restyle(el, list);
|
|
247
253
|
}
|
package/src/ui/bevel-dialog.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// backdrop clicks and settling, so each keeps the rules it already had.
|
|
4
4
|
import { set } from "../lib/hostile-css.js";
|
|
5
5
|
import { bevelBox, bevelSurface, bevelText, pageScheme, protectIcon } from "./bevel-controls.js";
|
|
6
|
-
import { TOKENS, FONT_SANS, RULES } from "./bevel.js";
|
|
6
|
+
import { TOKENS, FONT_SANS, RULES, GLYPHS } from "./bevel.js";
|
|
7
7
|
|
|
8
8
|
// A frame's own way to settle itself. The modal is a singleton and clears every frame
|
|
9
9
|
// when it opens; a frame it does not own (the crop dialog) registers here, so it is
|
|
@@ -32,8 +32,6 @@ export function keepFocusIn(container, event) {
|
|
|
32
32
|
// The dashboard's dialog (Bevel's React dialog with the dashboard's overrides), drawn
|
|
33
33
|
// inline. ClayJS loads no webfonts, so the title's Newsreader falls back to Georgia.
|
|
34
34
|
const LINE = `1px solid ${TOKENS["line-2"]}`;
|
|
35
|
-
const CLOSE_X =
|
|
36
|
-
'<svg viewBox="0 0 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>';
|
|
37
35
|
|
|
38
36
|
// The close: a column at the header's right edge in the button face, or a 62px square
|
|
39
37
|
// at the panel's corner when there is no header.
|
|
@@ -48,7 +46,7 @@ function dialogClose(label, inHeader) {
|
|
|
48
46
|
b.type = "button";
|
|
49
47
|
b.setAttribute("aria-label", label);
|
|
50
48
|
b.title = label;
|
|
51
|
-
b.innerHTML =
|
|
49
|
+
b.innerHTML = GLYPHS.dialogClose;
|
|
52
50
|
protectIcon(b.firstElementChild, 16);
|
|
53
51
|
b.addEventListener("pointerenter", () => set(b, "background", hover));
|
|
54
52
|
b.addEventListener("pointerleave", () => set(b, "background", TOKENS.face));
|