@panphora/clayjs 1.9.2 → 1.10.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 +1 -1
- package/dist/clay.standalone.js +7304 -6594
- package/entries/clay-router.js +38 -0
- package/entries/sap.js +6 -6
- package/package.json +2 -2
- package/packed-contract.json +1 -0
- package/src/attrs/save-freeze.js +12 -2
- package/src/core/snapshot.js +4 -1
- package/src/core/source-map.js +1 -1
- package/src/lib/mutation.js +28 -4
- package/src/lib/person-face.js +16 -0
- package/src/lib/root-attrs.js +28 -0
- package/src/plugins/people.js +3 -14
- package/src/plugins/sortable.js +3 -1
- package/src/route/route.js +188 -0
- package/src/standalone.js +1 -0
- package/src/sync/conflict-revert.js +1 -2
- package/src/sync/editing-marker.js +172 -0
- package/src/sync/editing-path.js +46 -0
- package/src/sync/editing-sender.js +100 -0
- package/src/sync/live-sync.js +64 -14
- package/src/sync/presence.js +14 -36
- package/src/sync/stream.js +14 -4
- package/src/vendor/hyper-morph.vendor.js +4 -4
package/src/sync/live-sync.js
CHANGED
|
@@ -44,17 +44,19 @@ import { EXTENSION_NODE_SELECTOR } from '../lib/extension-noise.js';
|
|
|
44
44
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
45
45
|
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
46
46
|
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode, captureForMerge, captureForSaveAndComparison } from '../core/snapshot.js';
|
|
47
|
-
import {
|
|
47
|
+
import { isTabLocalAttr } from '../lib/root-attrs.js';
|
|
48
48
|
import { autosaveActive } from '../lib/autosave-state.js';
|
|
49
49
|
import { enableContentEditable } from '../core/admin-contenteditable.js';
|
|
50
50
|
import { enableOnClick } from '../core/admin-onclick.js';
|
|
51
51
|
import { enableAdminInputs } from '../core/admin-inputs.js';
|
|
52
52
|
import { enableAdminResources } from '../core/admin-resources.js';
|
|
53
53
|
import { presence } from './presence.js';
|
|
54
|
+
import { editingMarker } from './editing-marker.js';
|
|
54
55
|
// Side-effect import: the section-changed notice wires itself to
|
|
55
56
|
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
56
57
|
import './section-notice.js';
|
|
57
|
-
import { hostMeta } from '../core/host-meta.js';
|
|
58
|
+
import { hostMeta, hostSupports } from '../core/host-meta.js';
|
|
59
|
+
import { EditingSender } from './editing-sender.js';
|
|
58
60
|
import { servedDocumentEtag } from '../core/host-attrs.js';
|
|
59
61
|
import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves, representedEtag, forgetRepresentedEtag } from '../core/etag.js';
|
|
60
62
|
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
|
|
@@ -64,15 +66,34 @@ import { conflicts, beginApply, completeApply, failApply } from './conflicts.js'
|
|
|
64
66
|
import { beginLineageCapture } from './conflict-footprints.js';
|
|
65
67
|
|
|
66
68
|
// What a live-sync merge never reads or touches on any side: editor chrome,
|
|
67
|
-
// content kept out of the save or the snapshot, frozen regions,
|
|
68
|
-
// browser extensions inject. The same set the 0.5 morph's sync policy
|
|
69
|
+
// content kept out of the save or the snapshot, the contents of frozen regions,
|
|
70
|
+
// and nodes browser extensions inject. The same set the 0.5 morph's sync policy
|
|
71
|
+
// ignored.
|
|
69
72
|
const SYNC_IGNORE_SELECTOR = [
|
|
70
73
|
'[editor-ui]', '[clay~="editor-ui"]', '[save-ignore]', '[snapshot-remove]',
|
|
71
|
-
'[no-snapshot]', '[no-save]', '[save-remove]', '[freeze]', '[save-freeze]',
|
|
72
|
-
'[clay~="no-save"]', '[clay~="no-snapshot"]', '[clay~="freeze"]',
|
|
74
|
+
'[no-snapshot]', '[no-save]', '[save-remove]', '[freeze] *', '[save-freeze] *',
|
|
75
|
+
'[clay~="no-save"]', '[clay~="no-snapshot"]', '[clay~="freeze"] *',
|
|
73
76
|
EXTENSION_NODE_SELECTOR,
|
|
74
77
|
].join(',');
|
|
75
78
|
|
|
79
|
+
// A frozen element is a scratchpad: the element is part of the document and
|
|
80
|
+
// merges like any other, but its contents belong to this tab. Every merge input
|
|
81
|
+
// has its frozen elements emptied, and hyper-morph's opaque option leaves the
|
|
82
|
+
// live contents alone.
|
|
83
|
+
const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
|
|
84
|
+
|
|
85
|
+
function emptyScratchpads(root) {
|
|
86
|
+
for (const el of root.querySelectorAll(FREEZE_SELECTOR)) el.replaceChildren();
|
|
87
|
+
return root;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scratchpadsEmptied(input) {
|
|
91
|
+
if (input == null) return input;
|
|
92
|
+
const doc = typeof input === 'string' ? new DOMParser().parseFromString(input, 'text/html') : input;
|
|
93
|
+
emptyScratchpads(doc);
|
|
94
|
+
return doc;
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
// Regions whose local state is never work to keep: disposable content
|
|
77
98
|
// (no-dirty) and regions the mutation system does not watch, whose churn the
|
|
78
99
|
// dirty gate already excludes. The incoming frame's version wins there.
|
|
@@ -81,10 +102,9 @@ const REMOTE_WINS_SELECTOR = [
|
|
|
81
102
|
'[clay~="no-watch"]', '[no-watch]', '[mutations-ignore]',
|
|
82
103
|
].join(',');
|
|
83
104
|
|
|
84
|
-
// Host tokens
|
|
105
|
+
// Host tokens, library state and router state, on every side
|
|
85
106
|
// of a merge: the live root, a snapshot clone's root, a parsed frame's root.
|
|
86
|
-
const isRootLocalAttr = (el, name) =>
|
|
87
|
-
!el.parentElement && el.localName === 'html' && TAB_LOCAL_ROOT_ATTRS.has(name);
|
|
107
|
+
const isRootLocalAttr = (el, name) => isTabLocalAttr(name, el);
|
|
88
108
|
|
|
89
109
|
// Identity an author wrote into the page, the fallback behind synthetic ids.
|
|
90
110
|
//
|
|
@@ -265,6 +285,7 @@ class LiveSync {
|
|
|
265
285
|
// document, and hostMeta() memoizes the request itself.
|
|
266
286
|
this._profile = null;
|
|
267
287
|
this._profilePromise = null;
|
|
288
|
+
this._editing = null;
|
|
268
289
|
this._ready = null;
|
|
269
290
|
// Bumped by every start(), so a discovery that resolves after a stop/restart
|
|
270
291
|
// cannot open a stream for the run that has already been torn down.
|
|
@@ -529,6 +550,12 @@ class LiveSync {
|
|
|
529
550
|
// snapshot listener would never fire — skip registering it.
|
|
530
551
|
if (this.lane === 'live') {
|
|
531
552
|
this.listenForSnapshots();
|
|
553
|
+
hostSupports('presence-editing').then((supported) => {
|
|
554
|
+
if (!supported || this.isDestroyed || this._startGen !== gen || this._editing) return;
|
|
555
|
+
this._editing = new EditingSender({ send: (path) => this._postEditing(path) });
|
|
556
|
+
this._editing.start();
|
|
557
|
+
this._editing.roster(this._lastRoster);
|
|
558
|
+
}, () => {});
|
|
532
559
|
this._saveSavedHandler = () => {
|
|
533
560
|
this._saveEpoch++;
|
|
534
561
|
// The file now holds what this save wrote: the disk lane's new base,
|
|
@@ -576,6 +603,13 @@ class LiveSync {
|
|
|
576
603
|
// Nothing feeds the roster once the stream is gone, so leaving the stack up
|
|
577
604
|
// would show a list of people who may all have left.
|
|
578
605
|
presence.clear();
|
|
606
|
+
editingMarker.clear();
|
|
607
|
+
|
|
608
|
+
if (this._editing) {
|
|
609
|
+
this._editing.stop();
|
|
610
|
+
this._editing = null;
|
|
611
|
+
}
|
|
612
|
+
this._lastRoster = null;
|
|
579
613
|
|
|
580
614
|
if (this._snapshotHandler) {
|
|
581
615
|
document.removeEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
@@ -760,6 +794,7 @@ class LiveSync {
|
|
|
760
794
|
|
|
761
795
|
this.sse.onopen = () => {
|
|
762
796
|
console.log('[LiveSync] Connected');
|
|
797
|
+
if (this._editing) this._editing.reconnected();
|
|
763
798
|
if (this.onConnect) this.onConnect();
|
|
764
799
|
};
|
|
765
800
|
|
|
@@ -819,6 +854,9 @@ class LiveSync {
|
|
|
819
854
|
return;
|
|
820
855
|
}
|
|
821
856
|
presence.update(data);
|
|
857
|
+
editingMarker.update(data);
|
|
858
|
+
this._lastRoster = data;
|
|
859
|
+
if (this._editing) this._editing.roster(data);
|
|
822
860
|
});
|
|
823
861
|
|
|
824
862
|
this.sse.onmessage = (event) => {
|
|
@@ -1083,6 +1121,16 @@ class LiveSync {
|
|
|
1083
1121
|
});
|
|
1084
1122
|
}
|
|
1085
1123
|
|
|
1124
|
+
_postEditing(path) {
|
|
1125
|
+
const url = new URL('/_/presence', window.location.origin);
|
|
1126
|
+
url.searchParams.set('client-id', this.clientId);
|
|
1127
|
+
return fetch(url.href, {
|
|
1128
|
+
method: 'POST',
|
|
1129
|
+
headers: { 'Content-Type': 'application/json', 'Document-URL': window.location.href },
|
|
1130
|
+
body: JSON.stringify({ editing: path }),
|
|
1131
|
+
}).then((response) => response.ok, () => false);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1086
1134
|
/**
|
|
1087
1135
|
* Route an external disk change (htmlclay watcher) out of the notification
|
|
1088
1136
|
* path. Returns true when the notification was consumed — the caller must
|
|
@@ -1592,7 +1640,7 @@ class LiveSync {
|
|
|
1592
1640
|
// A frame may neither write these onto our root nor, by not carrying them,
|
|
1593
1641
|
// take ours away. Returning false is hyper-morph's veto for both directions.
|
|
1594
1642
|
const beforeAttributeUpdated = (name, element) =>
|
|
1595
|
-
|
|
1643
|
+
isTabLocalAttr(name, element) ? false : undefined;
|
|
1596
1644
|
const authored = uniqueAuthoredIdentity(new WeakMap());
|
|
1597
1645
|
const sideIdentity = (map) =>
|
|
1598
1646
|
isIdMap(map) ? { first: authored, map, then: authored } : authored;
|
|
@@ -1619,11 +1667,13 @@ class LiveSync {
|
|
|
1619
1667
|
if (synthetic) syntheticByAuthored.set(id, synthetic);
|
|
1620
1668
|
}
|
|
1621
1669
|
}
|
|
1670
|
+
remote = scratchpadsEmptied(remote);
|
|
1622
1671
|
|
|
1623
1672
|
const common = {
|
|
1624
1673
|
live: document,
|
|
1625
1674
|
remote,
|
|
1626
1675
|
ignore: (el) => el.matches(SYNC_IGNORE_SELECTOR),
|
|
1676
|
+
opaque: (el) => el.matches(FREEZE_SELECTOR),
|
|
1627
1677
|
remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
|
|
1628
1678
|
ignoreAttribute: isRootLocalAttr,
|
|
1629
1679
|
protectFocusedValue: true,
|
|
@@ -1648,7 +1698,7 @@ class LiveSync {
|
|
|
1648
1698
|
pending = mergeDocument({
|
|
1649
1699
|
...common,
|
|
1650
1700
|
base: null,
|
|
1651
|
-
local: { root: captureLocal(), toLive: originalSnapshotNode },
|
|
1701
|
+
local: { root: emptyScratchpads(captureLocal()), toLive: originalSnapshotNode },
|
|
1652
1702
|
identity: { local: localIdentity, remote: sideIdentity(identityMap) },
|
|
1653
1703
|
});
|
|
1654
1704
|
} else if (!dirty) {
|
|
@@ -1660,7 +1710,7 @@ class LiveSync {
|
|
|
1660
1710
|
// a local edit, and a clean autosave tab would write that "edit" back to
|
|
1661
1711
|
// disk on every frame. Still three-way: merge tags keep their base for
|
|
1662
1712
|
// the JSON merge, and hyper-morph's clean-side shortcuts apply.
|
|
1663
|
-
const localRoot = captureLocal();
|
|
1713
|
+
const localRoot = emptyScratchpads(captureLocal());
|
|
1664
1714
|
const baseDoc = localRoot.ownerDocument;
|
|
1665
1715
|
if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
|
|
1666
1716
|
pending = mergeDocument({
|
|
@@ -1675,7 +1725,7 @@ class LiveSync {
|
|
|
1675
1725
|
},
|
|
1676
1726
|
});
|
|
1677
1727
|
} else {
|
|
1678
|
-
localRoot = captureLocal();
|
|
1728
|
+
localRoot = emptyScratchpads(captureLocal());
|
|
1679
1729
|
applyId = beginApply({
|
|
1680
1730
|
source, seq, etag, root: localRoot,
|
|
1681
1731
|
domain: source === 'disk' ? 'save' : 'sync',
|
|
@@ -1683,7 +1733,7 @@ class LiveSync {
|
|
|
1683
1733
|
try {
|
|
1684
1734
|
pending = mergeDocument({
|
|
1685
1735
|
...common,
|
|
1686
|
-
base,
|
|
1736
|
+
base: scratchpadsEmptied(base),
|
|
1687
1737
|
local: { root: localRoot, toLive: originalSnapshotNode },
|
|
1688
1738
|
identity: {
|
|
1689
1739
|
base: sideIdentity(baseIdentityMap),
|
package/src/sync/presence.js
CHANGED
|
@@ -31,12 +31,14 @@
|
|
|
31
31
|
* hyperclay-local, HTML Clay and makerclay do today and it stays that way until
|
|
32
32
|
* they adopt it.
|
|
33
33
|
*
|
|
34
|
-
* `people[].id` is
|
|
35
|
-
*
|
|
34
|
+
* `people[].id` is the person's people id (the one `clay.me` and `clay.people`
|
|
35
|
+
* use) or a guest's per-document pseudonym. It picks the colour and nothing
|
|
36
|
+
* else: it is not a permission check, and it is never logged.
|
|
36
37
|
*/
|
|
37
38
|
|
|
38
39
|
import { hostSupports } from '../core/host-meta.js';
|
|
39
40
|
import { make, set } from '../lib/hostile-css.js';
|
|
41
|
+
import { initialsOf, personColor } from '../lib/person-face.js';
|
|
40
42
|
import { pageScheme, RUNTIME_ONLY } from '../ui/bevel-controls.js';
|
|
41
43
|
import { TOKENS, FONT_MONO, SHADOW } from '../ui/bevel.js';
|
|
42
44
|
|
|
@@ -52,46 +54,16 @@ const LABEL_FONT = `500 11px/1.4 ${FONT_MONO}`;
|
|
|
52
54
|
const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
|
|
53
55
|
|
|
54
56
|
// The paper a participant's colour sits on: the initials of a solid square, the fill
|
|
55
|
-
// of a hollow one. One literal in both schemes, because every colour
|
|
56
|
-
// against it.
|
|
57
|
+
// of a hollow one. One literal in both schemes, because every colour the shared
|
|
58
|
+
// person-face palette draws with was chosen against it.
|
|
57
59
|
const FACE = '#FFFCF6';
|
|
58
60
|
|
|
59
|
-
// A fixed set rather than a computed hue: every one of these was looked at
|
|
60
|
-
// against white initials, and a hue wheel puts neighbouring pseudonyms on two
|
|
61
|
-
// blues nobody can tell apart.
|
|
62
|
-
const COLORS = [
|
|
63
|
-
'#b91c1c', '#c2410c', '#a16207', '#15803d', '#0f766e',
|
|
64
|
-
'#0369a1', '#1d4ed8', '#6d28d9', '#a21caf', '#9f1239',
|
|
65
|
-
];
|
|
66
|
-
|
|
67
61
|
/** Every element this module creates, marked the same way. */
|
|
68
62
|
function runtimeRoot(el) {
|
|
69
63
|
el.setAttribute('clay', RUNTIME_ONLY);
|
|
70
64
|
return el;
|
|
71
65
|
}
|
|
72
66
|
|
|
73
|
-
function hashOf(id) {
|
|
74
|
-
let h = 5381;
|
|
75
|
-
for (let i = 0; i < id.length; i++) {
|
|
76
|
-
h = (((h << 5) + h) ^ id.charCodeAt(i)) >>> 0;
|
|
77
|
-
}
|
|
78
|
-
return h;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// The pseudonym picks the colour and nothing else.
|
|
82
|
-
function colorFor(id) {
|
|
83
|
-
return COLORS[hashOf(id) % COLORS.length];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Array.from, not [0]: a name starting outside the BMP would otherwise show half
|
|
87
|
-
// a character.
|
|
88
|
-
function initialsOf(name) {
|
|
89
|
-
const words = String(name).trim().split(/\s+/).filter(Boolean);
|
|
90
|
-
if (!words.length) return '?';
|
|
91
|
-
const first = (word) => Array.from(word)[0].toUpperCase();
|
|
92
|
-
return words.length === 1 ? first(words[0]) : first(words[0]) + first(words[1]);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
67
|
/**
|
|
96
68
|
* Read the roster frame defensively. Everything here arrived over a wire, and a
|
|
97
69
|
* frame this function cannot understand must draw nothing rather than draw a
|
|
@@ -144,8 +116,14 @@ class Presence {
|
|
|
144
116
|
* @returns {Promise<void>}
|
|
145
117
|
*/
|
|
146
118
|
async update(data) {
|
|
147
|
-
|
|
119
|
+
const next = normalize(data);
|
|
120
|
+
const same = this.root && JSON.stringify(next) === JSON.stringify(this.roster);
|
|
121
|
+
this.roster = next;
|
|
148
122
|
if (!(await this.supported())) return;
|
|
123
|
+
if (same) {
|
|
124
|
+
set(this.root, 'color-scheme', pageScheme());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
149
127
|
this.render();
|
|
150
128
|
}
|
|
151
129
|
|
|
@@ -260,7 +238,7 @@ class Presence {
|
|
|
260
238
|
}
|
|
261
239
|
|
|
262
240
|
avatar(person) {
|
|
263
|
-
const color =
|
|
241
|
+
const color = personColor(person.id);
|
|
264
242
|
// Solid says this person can change the document; hollow says they are
|
|
265
243
|
// reading it. The two are the same three colours inverted, so a stack of
|
|
266
244
|
// both reads as one set rather than as two kinds of thing.
|
package/src/sync/stream.js
CHANGED
|
@@ -2,6 +2,12 @@ const START_TIMEOUT = 5000;
|
|
|
2
2
|
const HIDDEN_DELAY = 5000;
|
|
3
3
|
const PING_INTERVAL = 15000;
|
|
4
4
|
|
|
5
|
+
let workspaceBackground = window.__clayWorkspaceBackground === true;
|
|
6
|
+
document.addEventListener('clay:workspace-background', () => { workspaceBackground = true; });
|
|
7
|
+
document.addEventListener('clay:workspace-foreground', () => { workspaceBackground = false; });
|
|
8
|
+
|
|
9
|
+
const pageHidden = () => document.hidden || workspaceBackground;
|
|
10
|
+
|
|
5
11
|
export class SyncStream extends EventTarget {
|
|
6
12
|
constructor(url, { shared = false, documentURL, lane, startupCheck = false } = {}) {
|
|
7
13
|
super();
|
|
@@ -28,7 +34,7 @@ export class SyncStream extends EventTarget {
|
|
|
28
34
|
this._source = null;
|
|
29
35
|
this._visibility = () => {
|
|
30
36
|
clearTimeout(this._hiddenTimer);
|
|
31
|
-
if (
|
|
37
|
+
if (pageHidden()) {
|
|
32
38
|
this._worker?.port.postMessage({ v: 1, type: 'hidden' });
|
|
33
39
|
this._hiddenTimer = setTimeout(() => this._suspend(), HIDDEN_DELAY);
|
|
34
40
|
} else {
|
|
@@ -39,12 +45,14 @@ export class SyncStream extends EventTarget {
|
|
|
39
45
|
}
|
|
40
46
|
};
|
|
41
47
|
this._pagehide = () => this._suspend();
|
|
42
|
-
this._pageshow = () => { if (!
|
|
48
|
+
this._pageshow = () => { if (!pageHidden()) this._resume(); };
|
|
43
49
|
document.addEventListener('visibilitychange', this._visibility);
|
|
50
|
+
document.addEventListener('clay:workspace-background', this._visibility);
|
|
51
|
+
document.addEventListener('clay:workspace-foreground', this._visibility);
|
|
44
52
|
window.addEventListener('pagehide', this._pagehide);
|
|
45
53
|
window.addEventListener('pageshow', this._pageshow);
|
|
46
54
|
this._open();
|
|
47
|
-
if (
|
|
55
|
+
if (pageHidden()) this._visibility();
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
_emit(type, data) {
|
|
@@ -99,7 +107,7 @@ export class SyncStream extends EventTarget {
|
|
|
99
107
|
}
|
|
100
108
|
};
|
|
101
109
|
worker.port.start();
|
|
102
|
-
worker.port.postMessage({ v: 1, type:
|
|
110
|
+
worker.port.postMessage({ v: 1, type: pageHidden() ? 'hidden' : 'visible' });
|
|
103
111
|
worker.port.postMessage({ v: 1, type: 'subscribe', document: this._documentURL, lane: this._lane, since: this._since });
|
|
104
112
|
this._pingTimer = setInterval(() => {
|
|
105
113
|
if (this._worker !== worker) return;
|
|
@@ -197,6 +205,8 @@ export class SyncStream extends EventTarget {
|
|
|
197
205
|
clearTimeout(this._hiddenTimer);
|
|
198
206
|
this._disconnect();
|
|
199
207
|
document.removeEventListener('visibilitychange', this._visibility);
|
|
208
|
+
document.removeEventListener('clay:workspace-background', this._visibility);
|
|
209
|
+
document.removeEventListener('clay:workspace-foreground', this._visibility);
|
|
200
210
|
window.removeEventListener('pagehide', this._pagehide);
|
|
201
211
|
window.removeEventListener('pageshow', this._pageshow);
|
|
202
212
|
}
|