@panphora/clayjs 1.2.0 → 1.4.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 +7 -3
- package/THIRD-PARTY-NOTICES.md +10 -0
- package/dist/clay.standalone.js +22539 -14260
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +1 -1
- package/package.json +7 -2
- package/packed-contract.json +17 -0
- package/src/attrs/save-freeze.js +10 -18
- package/src/core/admin-contenteditable.js +8 -6
- package/src/core/admin-inputs.js +23 -13
- package/src/core/admin-onclick.js +5 -0
- package/src/core/is-edit-mode.js +7 -2
- package/src/core/persist.js +5 -10
- package/src/core/save-core.js +35 -0
- package/src/core/save.js +13 -1
- package/src/core/snapshot.js +186 -14
- package/src/core/source-map.js +1025 -0
- package/src/core/unsaved-warning.js +3 -0
- package/src/dom/dom-helpers.js +5 -1
- package/src/lib/content-dom.js +108 -0
- package/src/lib/mutation.js +26 -3
- package/src/lib/region-capabilities.js +69 -0
- package/src/lib/region-policy.js +18 -13
- package/src/loader-logic.js +27 -5
- package/src/loader.js +6 -0
- package/src/plugins/ai-edit.js +625 -0
- package/src/plugins/demo.js +3 -0
- package/src/plugins/sortable.js +6 -1
- package/src/plugins/source.js +410 -0
- package/src/plugins/wire.js +248 -47
- package/src/sync/live-sync.js +106 -42
- package/src/sync/presence.js +303 -0
- package/src/sync/section-notice.js +230 -0
- package/src/sync/splice-merge.js +7 -10
- package/src/sync/stream.js +190 -0
- package/src/vendor/control-serialize.vendor.js +10 -6
- package/src/vendor/hyper-morph.vendor.js +2 -2
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +438 -45
- package/src/vendor/parse5.vendor.js +3 -0
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +22 -15
package/src/sync/live-sync.js
CHANGED
|
@@ -40,12 +40,17 @@ import Mutation from "../lib/mutation.js";
|
|
|
40
40
|
import { isSnapshotRemoved } from "../lib/region-policy.js";
|
|
41
41
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
42
|
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
43
|
-
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from '../core/snapshot.js';
|
|
43
|
+
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode } from '../core/snapshot.js';
|
|
44
44
|
import { isTabLocalRootAttr } from '../lib/root-attrs.js';
|
|
45
45
|
import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
|
|
46
|
+
import { presence } from './presence.js';
|
|
47
|
+
// Side-effect import: the section-changed notice wires itself to
|
|
48
|
+
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
49
|
+
import './section-notice.js';
|
|
46
50
|
import { hostMeta } from '../core/host-meta.js';
|
|
47
51
|
import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
|
|
48
52
|
import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
|
|
53
|
+
import { SyncStream } from './stream.js';
|
|
49
54
|
|
|
50
55
|
// The page just took a frame verified clean against its baseline, so it now IS
|
|
51
56
|
// the file on disk. Both saved baselines move together from one capture: leaving
|
|
@@ -75,6 +80,12 @@ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../
|
|
|
75
80
|
* One profile is chosen once and used for BOTH directions for the life of the page.
|
|
76
81
|
* Letting send and receive decide independently is exactly the bug this replaces: a
|
|
77
82
|
* client that posted to the spec address while streaming from the legacy one.
|
|
83
|
+
*
|
|
84
|
+
* Both stream addresses carry `client-id`, in the same kebab spelling as the rest of
|
|
85
|
+
* the query. It is the tab's own sender id — the one every outbound frame already
|
|
86
|
+
* carries — so a host that reads it at admission knows this connection by the value
|
|
87
|
+
* its frames arrive under, and can tell two tabs of one cookie-less guest apart. A
|
|
88
|
+
* host that does not read it ignores an unknown parameter.
|
|
78
89
|
*/
|
|
79
90
|
const WIRE_PROFILES = {
|
|
80
91
|
spec: {
|
|
@@ -82,8 +93,8 @@ const WIRE_PROFILES = {
|
|
|
82
93
|
relayPath: '/_/sync',
|
|
83
94
|
documentHeader: 'Document-URL',
|
|
84
95
|
snapshotKey: 'snapshot',
|
|
85
|
-
streamPath: (href, lane, resumeId) =>
|
|
86
|
-
`/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
|
|
96
|
+
streamPath: (href, lane, resumeId, clientId) =>
|
|
97
|
+
`/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
|
|
87
98
|
},
|
|
88
99
|
// What every published clayjs speaks, and what hyperclayjs and the inline script in
|
|
89
100
|
// every Collection dashboard hardcode. Hosts keep these addresses permanently.
|
|
@@ -92,8 +103,8 @@ const WIRE_PROFILES = {
|
|
|
92
103
|
relayPath: '/_/live-sync/save',
|
|
93
104
|
documentHeader: 'Page-URL',
|
|
94
105
|
snapshotKey: 'html',
|
|
95
|
-
streamPath: (href, lane, resumeId) =>
|
|
96
|
-
`/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
|
|
106
|
+
streamPath: (href, lane, resumeId, clientId) =>
|
|
107
|
+
`/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
|
|
97
108
|
},
|
|
98
109
|
};
|
|
99
110
|
|
|
@@ -168,6 +179,11 @@ class LiveSync {
|
|
|
168
179
|
// cleanly. It rides in the slot beside the bytes it describes so the two can
|
|
169
180
|
// never be separated, which is the whole rule (§6, and §22 of the plan).
|
|
170
181
|
this._pendingEtag = null;
|
|
182
|
+
// Who the server says sent the pending frame, `{ id, name }`. Rides in the
|
|
183
|
+
// slot for the same reason the stamp does: it describes these bytes and
|
|
184
|
+
// nothing else, so a newer frame replacing them takes its author with it.
|
|
185
|
+
// Reported on `clay:sync-applied` once the frame has actually applied.
|
|
186
|
+
this._pendingBy = null;
|
|
171
187
|
this._morphInFlight = false;
|
|
172
188
|
this._rafHandle = null;
|
|
173
189
|
|
|
@@ -327,6 +343,10 @@ class LiveSync {
|
|
|
327
343
|
this.sse = null;
|
|
328
344
|
}
|
|
329
345
|
|
|
346
|
+
// Nothing feeds the roster once the stream is gone, so leaving the stack up
|
|
347
|
+
// would show a list of people who may all have left.
|
|
348
|
+
presence.clear();
|
|
349
|
+
|
|
330
350
|
if (this._snapshotHandler) {
|
|
331
351
|
document.removeEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
332
352
|
this._snapshotHandler = null;
|
|
@@ -350,6 +370,7 @@ class LiveSync {
|
|
|
350
370
|
this._pendingHtml = null;
|
|
351
371
|
this._pendingSeq = null;
|
|
352
372
|
this._pendingIdentityMap = null;
|
|
373
|
+
this._pendingBy = null;
|
|
353
374
|
this._pendingExternal = null;
|
|
354
375
|
clearTimeout(this._holdRetryPeer);
|
|
355
376
|
clearTimeout(this._holdRetryExt);
|
|
@@ -382,33 +403,25 @@ class LiveSync {
|
|
|
382
403
|
const map = {};
|
|
383
404
|
if (!liveRoot || !cloneRoot) return map;
|
|
384
405
|
|
|
385
|
-
const visit = (
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
id = this.
|
|
389
|
-
|
|
406
|
+
const visit = (clone, path) => {
|
|
407
|
+
const live = originalSnapshotNode(clone);
|
|
408
|
+
if (live) {
|
|
409
|
+
let id = this.liveWeakMap.get(live);
|
|
410
|
+
if (!id) {
|
|
411
|
+
id = this._mintId();
|
|
412
|
+
this.liveWeakMap.set(live, id);
|
|
413
|
+
}
|
|
414
|
+
map[path] = id;
|
|
390
415
|
}
|
|
391
|
-
map[path] = id;
|
|
392
416
|
|
|
393
|
-
const liveKids = [];
|
|
394
|
-
for (const c of live.children) {
|
|
395
|
-
if (!isSnapshotRemoved(c)) liveKids.push(c);
|
|
396
|
-
}
|
|
397
417
|
const cloneKids = clone.children;
|
|
398
418
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
`identity map: subtree skipped at "${path}" (live=${liveKids.length}, clone=${cloneKids.length})`
|
|
402
|
-
);
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
for (let i = 0; i < liveKids.length; i++) {
|
|
407
|
-
visit(liveKids[i], cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
419
|
+
for (let i = 0; i < cloneKids.length; i++) {
|
|
420
|
+
visit(cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
408
421
|
}
|
|
409
422
|
};
|
|
410
423
|
|
|
411
|
-
visit(
|
|
424
|
+
visit(cloneRoot, '');
|
|
412
425
|
return map;
|
|
413
426
|
}
|
|
414
427
|
|
|
@@ -511,7 +524,11 @@ class LiveSync {
|
|
|
511
524
|
_resolveProfile() {
|
|
512
525
|
if (!this._profilePromise) {
|
|
513
526
|
this._profilePromise = hostMeta()
|
|
514
|
-
.then((meta) =>
|
|
527
|
+
.then((meta) => {
|
|
528
|
+
const extensions = meta?.extensions || [];
|
|
529
|
+
this._sharedSync = extensions.includes('sync') && extensions.includes('sync-worker') && !extensions.includes('presence');
|
|
530
|
+
return extensions.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy;
|
|
531
|
+
})
|
|
515
532
|
.catch(() => WIRE_PROFILES.legacy)
|
|
516
533
|
.then((profile) => {
|
|
517
534
|
this._profile = profile;
|
|
@@ -590,10 +607,14 @@ class LiveSync {
|
|
|
590
607
|
// Whichever wire discovery selected. start() does not call connect() until the
|
|
591
608
|
// profile is known, so this is never null on the normal path.
|
|
592
609
|
const profile = this._profile || WIRE_PROFILES.legacy;
|
|
593
|
-
const path = profile.streamPath(window.location.href, this.lane, this.resumeId);
|
|
610
|
+
const path = profile.streamPath(window.location.href, this.lane, this.resumeId, this.clientId);
|
|
594
611
|
// Resolved against the real origin: a <base href> in the authored document
|
|
595
612
|
// would otherwise point the sync stream at an origin the document chose.
|
|
596
|
-
this.sse = new
|
|
613
|
+
this.sse = new SyncStream(new URL(path, window.location.origin).href, {
|
|
614
|
+
shared: this._sharedSync,
|
|
615
|
+
documentURL: window.location.href,
|
|
616
|
+
lane: this.lane,
|
|
617
|
+
});
|
|
597
618
|
|
|
598
619
|
this.sse.onopen = () => {
|
|
599
620
|
console.log('[LiveSync] Connected');
|
|
@@ -628,6 +649,21 @@ class LiveSync {
|
|
|
628
649
|
});
|
|
629
650
|
});
|
|
630
651
|
|
|
652
|
+
// The roster is a NAMED event for the same reason the cursor frame is: a tab
|
|
653
|
+
// with no listener for it never sees a bare data line, so every published
|
|
654
|
+
// client is counted in the roster without being sent one. The frame is
|
|
655
|
+
// handed on untouched — who is named is the host's decision, taken per
|
|
656
|
+
// recipient from that recipient's own access, and nothing here can widen it.
|
|
657
|
+
this.sse.addEventListener('presence', (event) => {
|
|
658
|
+
let data;
|
|
659
|
+
try {
|
|
660
|
+
data = JSON.parse(event.data);
|
|
661
|
+
} catch {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
presence.update(data);
|
|
665
|
+
});
|
|
666
|
+
|
|
631
667
|
this.sse.onmessage = (event) => {
|
|
632
668
|
const data = JSON.parse(event.data);
|
|
633
669
|
|
|
@@ -696,7 +732,7 @@ class LiveSync {
|
|
|
696
732
|
}
|
|
697
733
|
|
|
698
734
|
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
699
|
-
this.applyUpdate(html, seq, identityMap, etag);
|
|
735
|
+
this.applyUpdate(html, seq, identityMap, etag, data.by);
|
|
700
736
|
if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap, etag });
|
|
701
737
|
};
|
|
702
738
|
|
|
@@ -924,7 +960,7 @@ class LiveSync {
|
|
|
924
960
|
const info = data.data;
|
|
925
961
|
if (info && info.kind === 'external-change') {
|
|
926
962
|
if (typeof info.html === 'string') {
|
|
927
|
-
this._enqueueExternal(info.html, data.seq, info.etag);
|
|
963
|
+
this._enqueueExternal(info.html, data.seq, info.etag, info.by);
|
|
928
964
|
} else {
|
|
929
965
|
this._fetchExternalChange(data.seq);
|
|
930
966
|
}
|
|
@@ -937,7 +973,7 @@ class LiveSync {
|
|
|
937
973
|
return false;
|
|
938
974
|
}
|
|
939
975
|
|
|
940
|
-
_enqueueExternal(html, seq, etag) {
|
|
976
|
+
_enqueueExternal(html, seq, etag, by) {
|
|
941
977
|
if (typeof seq === 'number') {
|
|
942
978
|
if (seq <= this._lastExternalSeq) {
|
|
943
979
|
this._log(`Dropping replayed external change: seq=${seq}`);
|
|
@@ -950,6 +986,11 @@ class LiveSync {
|
|
|
950
986
|
seq,
|
|
951
987
|
saveEpoch: this._saveEpoch,
|
|
952
988
|
etag: typeof etag === 'string' && etag ? etag : null,
|
|
989
|
+
// Carried for the same reason the stamp is, and null on every disk frame
|
|
990
|
+
// today: hyperclay stamps an author on the two live relays and nowhere
|
|
991
|
+
// else, so a change that arrived from the filesystem has no author to
|
|
992
|
+
// name. The slot carries it so a host that does stamp one is believed.
|
|
993
|
+
by: by ?? null,
|
|
953
994
|
};
|
|
954
995
|
this._scheduleNextFrame();
|
|
955
996
|
}
|
|
@@ -977,6 +1018,7 @@ class LiveSync {
|
|
|
977
1018
|
*/
|
|
978
1019
|
_fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
|
|
979
1020
|
const epoch = this._saveEpoch;
|
|
1021
|
+
if (repair && typeof seq !== 'number') seq = this._lastExternalSeq;
|
|
980
1022
|
fetch(new URL(window.location.href), { cache: 'no-store' })
|
|
981
1023
|
.then((response) => (response.ok ? response.text() : null))
|
|
982
1024
|
.then((html) => {
|
|
@@ -987,7 +1029,7 @@ class LiveSync {
|
|
|
987
1029
|
// said replay cannot fix this page, so if the newer fetch fails there is
|
|
988
1030
|
// nothing else coming. Refetch rather than drop the only repair.
|
|
989
1031
|
if (repair && attempt < 3) {
|
|
990
|
-
this._fetchServedDocument(
|
|
1032
|
+
this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair });
|
|
991
1033
|
}
|
|
992
1034
|
return;
|
|
993
1035
|
}
|
|
@@ -1003,8 +1045,9 @@ class LiveSync {
|
|
|
1003
1045
|
}
|
|
1004
1046
|
// No stamp: this body came from a GET of the served page, which nobody
|
|
1005
1047
|
// stamped, so the apply leaves the held stamp alone and etag.js asks the
|
|
1006
|
-
// host for a replacement.
|
|
1007
|
-
|
|
1048
|
+
// host for a replacement. No author either, for the same reason — a GET
|
|
1049
|
+
// answers what disk holds, not who put it there.
|
|
1050
|
+
this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null, by: null };
|
|
1008
1051
|
this._scheduleNextFrame();
|
|
1009
1052
|
})
|
|
1010
1053
|
.catch((err) => {
|
|
@@ -1031,8 +1074,10 @@ class LiveSync {
|
|
|
1031
1074
|
* @param {string} html - Full document HTML
|
|
1032
1075
|
* @param {number} [seq] - Optional monotonic seq from the server
|
|
1033
1076
|
* @param {Object} [identityMap] - Optional element-identity map from sender
|
|
1077
|
+
* @param {string} [etag] - Optional version stamp for these bytes
|
|
1078
|
+
* @param {Object} [by] - Optional `{ id, name }` author stamp for these bytes
|
|
1034
1079
|
*/
|
|
1035
|
-
applyUpdate(html, seq, identityMap, etag) {
|
|
1080
|
+
applyUpdate(html, seq, identityMap, etag, by) {
|
|
1036
1081
|
if (this.isDestroyed) return;
|
|
1037
1082
|
this._pendingHtml = html;
|
|
1038
1083
|
this._pendingSeq = seq;
|
|
@@ -1041,6 +1086,7 @@ class LiveSync {
|
|
|
1041
1086
|
// correct: the stamp belongs to bytes that are no longer what will apply.
|
|
1042
1087
|
// Losing it costs one honest 412 later, which is the safe direction.
|
|
1043
1088
|
this._pendingEtag = etag ?? null;
|
|
1089
|
+
this._pendingBy = by ?? null;
|
|
1044
1090
|
this._scheduleNextFrame();
|
|
1045
1091
|
}
|
|
1046
1092
|
|
|
@@ -1098,7 +1144,7 @@ class LiveSync {
|
|
|
1098
1144
|
} else {
|
|
1099
1145
|
this._morphInFlight = true;
|
|
1100
1146
|
try {
|
|
1101
|
-
await this._doApplyExternal(ext.html, ext.seq, ext.etag);
|
|
1147
|
+
await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by);
|
|
1102
1148
|
} catch (err) {
|
|
1103
1149
|
console.error('[LiveSync] applyExternal failed:', err);
|
|
1104
1150
|
} finally {
|
|
@@ -1110,15 +1156,17 @@ class LiveSync {
|
|
|
1110
1156
|
const seq = this._pendingSeq;
|
|
1111
1157
|
const identityMap = this._pendingIdentityMap;
|
|
1112
1158
|
const etag = this._pendingEtag;
|
|
1159
|
+
const by = this._pendingBy;
|
|
1113
1160
|
this._pendingHtml = null;
|
|
1114
1161
|
this._pendingSeq = null;
|
|
1115
1162
|
this._pendingIdentityMap = null;
|
|
1116
1163
|
this._pendingEtag = null;
|
|
1164
|
+
this._pendingBy = null;
|
|
1117
1165
|
if (html == null) return;
|
|
1118
1166
|
|
|
1119
1167
|
this._morphInFlight = true;
|
|
1120
1168
|
try {
|
|
1121
|
-
await this._doApplyUpdate(html, seq, identityMap, etag);
|
|
1169
|
+
await this._doApplyUpdate(html, seq, identityMap, etag, by);
|
|
1122
1170
|
} catch (err) {
|
|
1123
1171
|
console.error('[LiveSync] applyUpdate failed:', err);
|
|
1124
1172
|
} finally {
|
|
@@ -1157,9 +1205,11 @@ class LiveSync {
|
|
|
1157
1205
|
* @param {string} html
|
|
1158
1206
|
* @param {number} [seq]
|
|
1159
1207
|
* @param {Object} [identityMap]
|
|
1208
|
+
* @param {string} [etag]
|
|
1209
|
+
* @param {Object} [by]
|
|
1160
1210
|
* @returns {Promise<void>}
|
|
1161
1211
|
*/
|
|
1162
|
-
async _doApplyUpdate(html, seq, identityMap, etag) {
|
|
1212
|
+
async _doApplyUpdate(html, seq, identityMap, etag, by = null) {
|
|
1163
1213
|
this._log('applyUpdate - pausing mutations and morphing');
|
|
1164
1214
|
this.isPaused = true;
|
|
1165
1215
|
|
|
@@ -1247,6 +1297,7 @@ class LiveSync {
|
|
|
1247
1297
|
this._pendingHtml = html;
|
|
1248
1298
|
this._pendingSeq = seq;
|
|
1249
1299
|
this._pendingIdentityMap = identityMap;
|
|
1300
|
+
this._pendingBy = by;
|
|
1250
1301
|
this._scheduleNextFrame();
|
|
1251
1302
|
}, 3000);
|
|
1252
1303
|
return;
|
|
@@ -1354,8 +1405,13 @@ class LiveSync {
|
|
|
1354
1405
|
// `source` is what lets a listener tell the two apply paths apart. clay.wire
|
|
1355
1406
|
// waits for a DISK frame to call an agent's write landed, and another tab's
|
|
1356
1407
|
// edit arriving first would otherwise report the wrong bytes as delivered.
|
|
1408
|
+
//
|
|
1409
|
+
// `by` is the server's answer about who sent these bytes, and it is on the
|
|
1410
|
+
// event rather than on the frame's arrival for one reason: a frame that held
|
|
1411
|
+
// returns above without reaching here, so nothing can name an author for a
|
|
1412
|
+
// change this tab never took. Null on every frame the host did not stamp.
|
|
1357
1413
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1358
|
-
detail: { seq, source: 'peer' }
|
|
1414
|
+
detail: { seq, source: 'peer', by: by || null }
|
|
1359
1415
|
}));
|
|
1360
1416
|
} finally {
|
|
1361
1417
|
this._log('applyUpdate - morph complete, resuming mutations');
|
|
@@ -1392,7 +1448,7 @@ class LiveSync {
|
|
|
1392
1448
|
* holds or a later dirty peer apply misclassifies this frame's content as
|
|
1393
1449
|
* local edits.
|
|
1394
1450
|
*/
|
|
1395
|
-
async _doApplyExternal(html, seq, etag = null) {
|
|
1451
|
+
async _doApplyExternal(html, seq, etag = null, by = null) {
|
|
1396
1452
|
this._log(`applyExternal - external disk change (seq=${seq})`);
|
|
1397
1453
|
this.isPaused = true;
|
|
1398
1454
|
|
|
@@ -1432,7 +1488,7 @@ class LiveSync {
|
|
|
1432
1488
|
this._holdRetryExt = setTimeout(() => {
|
|
1433
1489
|
this._holdRetryExt = null;
|
|
1434
1490
|
if (this.isDestroyed || this._pendingExternal != null) return;
|
|
1435
|
-
this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag };
|
|
1491
|
+
this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
|
|
1436
1492
|
this._scheduleNextFrame();
|
|
1437
1493
|
}, 3000);
|
|
1438
1494
|
return;
|
|
@@ -1501,7 +1557,15 @@ class LiveSync {
|
|
|
1501
1557
|
}
|
|
1502
1558
|
|
|
1503
1559
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1504
|
-
detail: {
|
|
1560
|
+
detail: {
|
|
1561
|
+
seq,
|
|
1562
|
+
source: 'disk',
|
|
1563
|
+
etag: typeof etag === 'string' && etag ? etag : null,
|
|
1564
|
+
by: by || null,
|
|
1565
|
+
// The bytes now on disk, exactly as they arrived. The source map models them
|
|
1566
|
+
// so this tab's next save copies from the file somebody else just wrote.
|
|
1567
|
+
html,
|
|
1568
|
+
}
|
|
1505
1569
|
}));
|
|
1506
1570
|
} finally {
|
|
1507
1571
|
this._log('applyExternal - morph complete, resuming mutations');
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* presence.js — who else is on this document.
|
|
3
|
+
*
|
|
4
|
+
* A fixed-corner stack of circles, one per participant the host chose to name,
|
|
5
|
+
* plus a count of everyone it did not. The host decides who is named, per
|
|
6
|
+
* recipient, from that recipient's own access; nothing here asks for a name and
|
|
7
|
+
* nothing here can widen what arrived.
|
|
8
|
+
*
|
|
9
|
+
* THE RULE THIS FILE EXISTS TO GET RIGHT.
|
|
10
|
+
*
|
|
11
|
+
* captureForSaveAndComparison() clones the document, hands that clone to peers
|
|
12
|
+
* on `clay:snapshot-ready`, and only THEN strips the save-only regions. So
|
|
13
|
+
* `no-save` alone is not enough: it keeps names out of the file while
|
|
14
|
+
* broadcasting them to every peer on the document, including a visitor the host
|
|
15
|
+
* deliberately answered with a count and no names. `no-snapshot` is the token
|
|
16
|
+
* that runs BEFORE the clone is emitted (captureSnapshot), so every runtime root
|
|
17
|
+
* here carries all three, tooltip included:
|
|
18
|
+
*
|
|
19
|
+
* no-save not written to the saved file
|
|
20
|
+
* no-watch invisible to the mutation system, so a roster change is not an edit
|
|
21
|
+
* no-snapshot absent from every snapshot, including the one peers receive
|
|
22
|
+
*
|
|
23
|
+
* The roster lives in this module's memory. It is never written to a DOM data
|
|
24
|
+
* store, an attribute or anything else a serializer walks — the names on screen
|
|
25
|
+
* are text nodes inside a subtree that no snapshot contains, and the hover label
|
|
26
|
+
* is held in a closure rather than in `title`. Styling is inline and hostile-css
|
|
27
|
+
* !important throughout, so there is no injected <style> tag to mark either.
|
|
28
|
+
*
|
|
29
|
+
* Gated on the host, not on the first frame: a host that does not announce
|
|
30
|
+
* `presence` draws nothing at all rather than an empty stack. That is what
|
|
31
|
+
* hyperclay-local, HTML Clay and makerclay do today and it stays that way until
|
|
32
|
+
* they adopt it.
|
|
33
|
+
*
|
|
34
|
+
* `people[].id` is an opaque keyed pseudonym. It picks the colour and nothing
|
|
35
|
+
* else: it is not a database id, it names no account, and it is never logged.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { hostSupports } from '../core/host-meta.js';
|
|
39
|
+
import { make, set } from '../lib/hostile-css.js';
|
|
40
|
+
|
|
41
|
+
// Runtime-only chrome, in one string so no root can carry two of the three.
|
|
42
|
+
const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
|
|
43
|
+
|
|
44
|
+
// Two initials at 12px need about 15px of the circle, so the overlap has to
|
|
45
|
+
// leave that much clear or the neighbouring circle eats the second letter.
|
|
46
|
+
const SIZE = 30;
|
|
47
|
+
const OVERLAP = 6;
|
|
48
|
+
const FONT = "12px/1 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
49
|
+
const LABEL_FONT = "12px/1.4 system-ui,-apple-system,'Segoe UI',sans-serif";
|
|
50
|
+
|
|
51
|
+
// The chrome around the circles is themeable like every other clayjs surface.
|
|
52
|
+
// The circles themselves are not: their colour is computed per participant, so
|
|
53
|
+
// there is no one value a page could override.
|
|
54
|
+
const INK = 'var(--clay-presence-ink,#1f2023)';
|
|
55
|
+
const CHIP_BG = 'var(--clay-presence-bg,#fff)';
|
|
56
|
+
const CHIP_EDGE = 'var(--clay-presence-edge,rgba(0,0,0,.14))';
|
|
57
|
+
const TIP_BG = 'var(--clay-presence-tip-bg,#222)';
|
|
58
|
+
const TIP_INK = 'var(--clay-presence-tip-ink,#fff)';
|
|
59
|
+
const FACE = '#ffffff';
|
|
60
|
+
|
|
61
|
+
// A fixed set rather than a computed hue: every one of these was looked at
|
|
62
|
+
// against white initials, and a hue wheel puts neighbouring pseudonyms on two
|
|
63
|
+
// blues nobody can tell apart.
|
|
64
|
+
const COLORS = [
|
|
65
|
+
'#b91c1c', '#c2410c', '#a16207', '#15803d', '#0f766e',
|
|
66
|
+
'#0369a1', '#1d4ed8', '#6d28d9', '#a21caf', '#9f1239',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Every element this module creates, marked the same way. */
|
|
70
|
+
function runtimeRoot(el) {
|
|
71
|
+
el.setAttribute('clay', RUNTIME_ONLY);
|
|
72
|
+
return el;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hashOf(id) {
|
|
76
|
+
let h = 5381;
|
|
77
|
+
for (let i = 0; i < id.length; i++) {
|
|
78
|
+
h = (((h << 5) + h) ^ id.charCodeAt(i)) >>> 0;
|
|
79
|
+
}
|
|
80
|
+
return h;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The pseudonym picks the colour and nothing else.
|
|
84
|
+
function colorFor(id) {
|
|
85
|
+
return COLORS[hashOf(id) % COLORS.length];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Array.from, not [0]: a name starting outside the BMP would otherwise show half
|
|
89
|
+
// a character.
|
|
90
|
+
function initialsOf(name) {
|
|
91
|
+
const words = String(name).trim().split(/\s+/).filter(Boolean);
|
|
92
|
+
if (!words.length) return '?';
|
|
93
|
+
const first = (word) => Array.from(word)[0].toUpperCase();
|
|
94
|
+
return words.length === 1 ? first(words[0]) : first(words[0]) + first(words[1]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read the roster frame defensively. Everything here arrived over a wire, and a
|
|
99
|
+
* frame this function cannot understand must draw nothing rather than draw a
|
|
100
|
+
* guess: `[object Object]` in a circle is worse than an empty corner.
|
|
101
|
+
*/
|
|
102
|
+
function normalize(data) {
|
|
103
|
+
const list = data && Array.isArray(data.people) ? data.people : [];
|
|
104
|
+
const people = [];
|
|
105
|
+
for (const person of list) {
|
|
106
|
+
if (!person || typeof person.id !== 'string' || !person.id) continue;
|
|
107
|
+
const name = typeof person.name === 'string' && person.name.trim() ? person.name.trim() : 'Someone';
|
|
108
|
+
people.push({
|
|
109
|
+
id: person.id,
|
|
110
|
+
name,
|
|
111
|
+
canEdit: person.canEdit === true,
|
|
112
|
+
you: person.you === true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const count = data && data.anonymous;
|
|
116
|
+
const anonymous = typeof count === 'number' && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
117
|
+
return { people, anonymous };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class Presence {
|
|
121
|
+
constructor() {
|
|
122
|
+
this.roster = { people: [], anonymous: 0 };
|
|
123
|
+
this.root = null;
|
|
124
|
+
this.stack = null;
|
|
125
|
+
this.count = null;
|
|
126
|
+
this.tip = null;
|
|
127
|
+
this._gate = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Does this host run presence at all? Asked once, of /_/meta, and never
|
|
132
|
+
* inferred from a frame arriving: the answer decides whether the chrome is
|
|
133
|
+
* built, so a host that says nothing draws nothing forever.
|
|
134
|
+
*/
|
|
135
|
+
supported() {
|
|
136
|
+
if (!this._gate) this._gate = hostSupports('presence').then((ok) => ok === true, () => false);
|
|
137
|
+
return this._gate;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Take a roster frame. Held in memory first so a frame that arrives before
|
|
142
|
+
* discovery answers is not lost, and drawn only if the host announced the
|
|
143
|
+
* capability.
|
|
144
|
+
*
|
|
145
|
+
* @param {{people: Array, anonymous: number}} data
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
*/
|
|
148
|
+
async update(data) {
|
|
149
|
+
this.roster = normalize(data);
|
|
150
|
+
if (!(await this.supported())) return;
|
|
151
|
+
this.render();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The stream went away, so the roster is no longer true about anything. */
|
|
155
|
+
clear() {
|
|
156
|
+
this.roster = { people: [], anonymous: 0 };
|
|
157
|
+
if (!this.root) return;
|
|
158
|
+
this.root.remove();
|
|
159
|
+
this.root = null;
|
|
160
|
+
this.stack = null;
|
|
161
|
+
this.count = null;
|
|
162
|
+
this.tip = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
render() {
|
|
166
|
+
const { people, anonymous } = this.roster;
|
|
167
|
+
|
|
168
|
+
// A person alone on a document is told nothing at all. The count is the
|
|
169
|
+
// total either way: a recipient the host would not name gets people: [] and
|
|
170
|
+
// everyone in `anonymous`, so this sum is the same participant count in both
|
|
171
|
+
// directions.
|
|
172
|
+
if (people.length + anonymous <= 1) {
|
|
173
|
+
this.hide();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!this.build()) return;
|
|
177
|
+
|
|
178
|
+
this.hideTip();
|
|
179
|
+
this.stack.textContent = '';
|
|
180
|
+
for (const person of people) {
|
|
181
|
+
this.stack.appendChild(this.avatar(person));
|
|
182
|
+
}
|
|
183
|
+
// The overlap belongs between circles, so the leftmost one does not pull
|
|
184
|
+
// itself into the count beside it.
|
|
185
|
+
if (this.stack.firstElementChild) set(this.stack.firstElementChild, 'margin-left', '0');
|
|
186
|
+
|
|
187
|
+
if (anonymous > 0) {
|
|
188
|
+
this.count.textContent = `+${anonymous} viewing`;
|
|
189
|
+
set(this.count, 'display', 'inline-block');
|
|
190
|
+
} else {
|
|
191
|
+
this.count.textContent = '';
|
|
192
|
+
set(this.count, 'display', 'none');
|
|
193
|
+
}
|
|
194
|
+
set(this.root, 'display', 'flex');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
hide() {
|
|
198
|
+
if (!this.root) return;
|
|
199
|
+
this.hideTip();
|
|
200
|
+
this.stack.textContent = '';
|
|
201
|
+
this.count.textContent = '';
|
|
202
|
+
set(this.root, 'display', 'none');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
build() {
|
|
206
|
+
if (this.root) return true;
|
|
207
|
+
if (typeof document === 'undefined' || !document.body) return false;
|
|
208
|
+
|
|
209
|
+
this.root = runtimeRoot(make('div', [
|
|
210
|
+
'position:fixed',
|
|
211
|
+
'top:calc(12px + env(safe-area-inset-top,0px))',
|
|
212
|
+
'right:calc(12px + env(safe-area-inset-right,0px))',
|
|
213
|
+
'z-index:2147483000',
|
|
214
|
+
'display:flex', 'align-items:center', 'gap:8px',
|
|
215
|
+
'max-width:calc(100vw - 24px)',
|
|
216
|
+
`font:${LABEL_FONT}`, `color:${INK}`,
|
|
217
|
+
// Only the circles take a pointer. A fixed corner that swallowed clicks
|
|
218
|
+
// would take a bite out of whatever the page put underneath it.
|
|
219
|
+
'pointer-events:none',
|
|
220
|
+
]));
|
|
221
|
+
this.root.setAttribute('data-clay-presence', '');
|
|
222
|
+
|
|
223
|
+
this.count = runtimeRoot(make('span', [
|
|
224
|
+
'box-sizing:border-box', 'flex:none', 'white-space:nowrap',
|
|
225
|
+
'padding:4px 8px', 'border-radius:999px',
|
|
226
|
+
`background-color:${CHIP_BG}`, 'background-image:none', `color:${INK}`,
|
|
227
|
+
'border-width:1px', 'border-style:solid', `border-color:${CHIP_EDGE}`,
|
|
228
|
+
`font:${LABEL_FONT}`,
|
|
229
|
+
'box-shadow:0 1px 3px rgba(0,0,0,.2)',
|
|
230
|
+
'display:none',
|
|
231
|
+
]));
|
|
232
|
+
this.count.setAttribute('data-clay-presence-count', '');
|
|
233
|
+
|
|
234
|
+
this.stack = runtimeRoot(make('div', [
|
|
235
|
+
'display:flex', 'align-items:center', 'flex:none',
|
|
236
|
+
]));
|
|
237
|
+
|
|
238
|
+
// Inside the root rather than appended to <body>, so the one element that
|
|
239
|
+
// holds a name at rest cannot outlive the subtree that keeps it out of a
|
|
240
|
+
// snapshot. It carries the marking of its own too.
|
|
241
|
+
this.tip = runtimeRoot(make('span', [
|
|
242
|
+
'position:absolute', 'top:100%', 'right:0', 'margin-top:6px',
|
|
243
|
+
'box-sizing:border-box', 'white-space:nowrap', 'pointer-events:none',
|
|
244
|
+
'padding:4px 8px', 'border-radius:6px',
|
|
245
|
+
`background-color:${TIP_BG}`, 'background-image:none',
|
|
246
|
+
`color:${TIP_INK}`, `font:${LABEL_FONT}`,
|
|
247
|
+
'box-shadow:0 4px 14px rgba(0,0,0,.28)',
|
|
248
|
+
'display:none',
|
|
249
|
+
]));
|
|
250
|
+
this.tip.setAttribute('data-clay-presence-tip', '');
|
|
251
|
+
|
|
252
|
+
this.root.append(this.stack, this.count, this.tip);
|
|
253
|
+
document.body.appendChild(this.root);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
avatar(person) {
|
|
258
|
+
const color = colorFor(person.id);
|
|
259
|
+
// Solid says this person can change the document; hollow says they are
|
|
260
|
+
// reading it. The two are the same three colours inverted, so a stack of
|
|
261
|
+
// both reads as one set rather than as two kinds of thing.
|
|
262
|
+
const skin = person.canEdit
|
|
263
|
+
? [`background-color:${color}`, `color:${FACE}`, `border-color:${FACE}`]
|
|
264
|
+
: [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
|
|
265
|
+
|
|
266
|
+
const el = runtimeRoot(make('div', [
|
|
267
|
+
'box-sizing:border-box', 'flex:none',
|
|
268
|
+
`width:${SIZE}px`, `height:${SIZE}px`, 'border-radius:50%',
|
|
269
|
+
'display:flex', 'align-items:center', 'justify-content:center',
|
|
270
|
+
`font:${FONT}`, 'font-weight:600',
|
|
271
|
+
`margin-left:-${OVERLAP}px`,
|
|
272
|
+
'border-width:2px', 'border-style:solid', 'background-image:none',
|
|
273
|
+
'box-shadow:0 1px 3px rgba(0,0,0,.28)',
|
|
274
|
+
'user-select:none', 'cursor:default', 'pointer-events:auto',
|
|
275
|
+
...skin,
|
|
276
|
+
], initialsOf(person.name)));
|
|
277
|
+
el.setAttribute('data-clay-presence-avatar', '');
|
|
278
|
+
|
|
279
|
+
// The name lives in this closure, never in an attribute on the element, and
|
|
280
|
+
// it reaches the DOM only while a pointer is actually on the circle.
|
|
281
|
+
const label = person.you ? `${person.name} (you)` : person.name;
|
|
282
|
+
el.addEventListener('mouseenter', () => this.showTip(label));
|
|
283
|
+
el.addEventListener('mouseleave', () => this.hideTip());
|
|
284
|
+
return el;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
showTip(text) {
|
|
288
|
+
if (!this.tip) return;
|
|
289
|
+
this.tip.textContent = text;
|
|
290
|
+
set(this.tip, 'display', 'block');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
hideTip() {
|
|
294
|
+
if (!this.tip) return;
|
|
295
|
+
this.tip.textContent = '';
|
|
296
|
+
set(this.tip, 'display', 'none');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const presence = new Presence();
|
|
301
|
+
|
|
302
|
+
export { presence, Presence };
|
|
303
|
+
export default presence;
|