@panphora/clayjs 1.9.2 → 1.11.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.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/dist/clay.standalone.js +9701 -6938
  3. package/entries/clay-router.js +38 -0
  4. package/entries/sap.js +6 -6
  5. package/package.json +2 -2
  6. package/packed-contract.json +1 -0
  7. package/src/attrs/add-from.js +72 -0
  8. package/src/attrs/save-freeze.js +12 -2
  9. package/src/core/conflict-notice.js +2 -5
  10. package/src/core/conflict-presentation.js +42 -0
  11. package/src/core/save-core.js +22 -0
  12. package/src/core/save.js +11 -2
  13. package/src/core/snapshot.js +4 -1
  14. package/src/core/source-map.js +1 -1
  15. package/src/lib/mutation.js +28 -4
  16. package/src/lib/person-face.js +16 -0
  17. package/src/lib/region-capabilities.js +1 -0
  18. package/src/lib/region-policy.js +10 -3
  19. package/src/lib/root-attrs.js +28 -0
  20. package/src/live/checkpoint.js +106 -0
  21. package/src/live/fast-lane.js +590 -0
  22. package/src/live/local-lane.js +582 -0
  23. package/src/live/ops.js +291 -0
  24. package/src/live/server-lane.js +84 -0
  25. package/src/live/store.js +291 -0
  26. package/src/live/stream-bridge.js +60 -0
  27. package/src/loader-logic.js +7 -2
  28. package/src/loader.js +2 -0
  29. package/src/plugins/live.js +446 -0
  30. package/src/plugins/people.js +3 -14
  31. package/src/plugins/sortable.js +3 -1
  32. package/src/route/route.js +188 -0
  33. package/src/standalone.js +1 -0
  34. package/src/sync/conflict-revert.js +2 -3
  35. package/src/sync/editing-marker.js +172 -0
  36. package/src/sync/editing-path.js +46 -0
  37. package/src/sync/editing-sender.js +100 -0
  38. package/src/sync/live-sync.js +85 -19
  39. package/src/sync/presence.js +16 -44
  40. package/src/sync/stream.js +16 -5
  41. package/src/vendor/hyper-morph.vendor.js +6 -4
  42. package/src/sync/section-notice.js +0 -254
@@ -39,40 +39,60 @@ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
39
39
 
40
40
  const { createIdentityStore } = HyperMorph;
41
41
  import Mutation from "../lib/mutation.js";
42
- import { NO_DIRTY_SELECTOR } from "../lib/region-policy.js";
42
+ import { NO_DIRTY_SELECTOR, LIVE_SELECTOR } from "../lib/region-policy.js";
43
43
  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 { isTabLocalRootAttr, TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
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
- // Side-effect import: the section-changed notice wires itself to
55
- // `clay:sync-applied`, which this file is the only dispatcher of.
56
- import './section-notice.js';
57
- import { hostMeta } from '../core/host-meta.js';
54
+ import { editingMarker } from './editing-marker.js';
55
+ import { hostMeta, hostSupports } from '../core/host-meta.js';
56
+ import { EditingSender } from './editing-sender.js';
58
57
  import { servedDocumentEtag } from '../core/host-attrs.js';
59
58
  import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves, representedEtag, forgetRepresentedEtag } from '../core/etag.js';
60
59
  import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
61
60
  import { gestureSeen } from '../lib/user-gesture.js';
62
61
  import { SyncStream } from './stream.js';
62
+ import { streamOpened, streamClosed, streamEvent, LIVE_EVENTS } from '../live/stream-bridge.js';
63
63
  import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
64
64
  import { beginLineageCapture } from './conflict-footprints.js';
65
65
 
66
66
  // 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, and nodes
68
- // browser extensions inject. The same set the 0.5 morph's sync policy ignored.
67
+ // content kept out of the save or the snapshot, the contents of frozen regions,
68
+ // and nodes browser extensions inject. The same set the 0.5 morph's sync policy
69
+ // ignored.
69
70
  const SYNC_IGNORE_SELECTOR = [
70
71
  '[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"]',
72
+ '[no-snapshot]', '[no-save]', '[save-remove]', '[freeze] *', '[save-freeze] *',
73
+ '[clay~="no-save"]', '[clay~="no-snapshot"]', '[clay~="freeze"] *',
74
+ LIVE_SELECTOR,
73
75
  EXTENSION_NODE_SELECTOR,
74
76
  ].join(',');
75
77
 
78
+ // A frozen element is a scratchpad: the element is part of the document and
79
+ // merges like any other, but its contents belong to this tab. Every merge input
80
+ // has its frozen elements emptied, and hyper-morph's opaque option leaves the
81
+ // live contents alone.
82
+ const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
83
+
84
+ function emptyScratchpads(root) {
85
+ for (const el of root.querySelectorAll(FREEZE_SELECTOR)) el.replaceChildren();
86
+ return root;
87
+ }
88
+
89
+ function scratchpadsEmptied(input) {
90
+ if (input == null) return input;
91
+ const doc = typeof input === 'string' ? new DOMParser().parseFromString(input, 'text/html') : input;
92
+ emptyScratchpads(doc);
93
+ return doc;
94
+ }
95
+
76
96
  // Regions whose local state is never work to keep: disposable content
77
97
  // (no-dirty) and regions the mutation system does not watch, whose churn the
78
98
  // dirty gate already excludes. The incoming frame's version wins there.
@@ -81,10 +101,9 @@ const REMOTE_WINS_SELECTOR = [
81
101
  '[clay~="no-watch"]', '[no-watch]', '[mutations-ignore]',
82
102
  ].join(',');
83
103
 
84
- // Host tokens and library state on a document's root element, on every side
104
+ // Host tokens, library state and router state, on every side
85
105
  // 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);
106
+ const isRootLocalAttr = (el, name) => isTabLocalAttr(name, el);
88
107
 
89
108
  // Identity an author wrote into the page, the fallback behind synthetic ids.
90
109
  //
@@ -265,6 +284,7 @@ class LiveSync {
265
284
  // document, and hostMeta() memoizes the request itself.
266
285
  this._profile = null;
267
286
  this._profilePromise = null;
287
+ this._editing = null;
268
288
  this._ready = null;
269
289
  // Bumped by every start(), so a discovery that resolves after a stop/restart
270
290
  // cannot open a stream for the run that has already been torn down.
@@ -529,6 +549,12 @@ class LiveSync {
529
549
  // snapshot listener would never fire — skip registering it.
530
550
  if (this.lane === 'live') {
531
551
  this.listenForSnapshots();
552
+ hostSupports('presence-editing').then((supported) => {
553
+ if (!supported || this.isDestroyed || this._startGen !== gen || this._editing) return;
554
+ this._editing = new EditingSender({ send: (path) => this._postEditing(path) });
555
+ this._editing.start();
556
+ this._editing.roster(this._lastRoster);
557
+ }, () => {});
532
558
  this._saveSavedHandler = () => {
533
559
  this._saveEpoch++;
534
560
  // The file now holds what this save wrote: the disk lane's new base,
@@ -572,10 +598,18 @@ class LiveSync {
572
598
  this.sse.close();
573
599
  this.sse = null;
574
600
  }
601
+ streamClosed();
575
602
 
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,9 +794,26 @@ 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
 
801
+ // clay.live's fast lane rides this stream: report it open and closed, and pass
802
+ // on its named events (stream-bridge.js).
803
+ this.sse.addEventListener('open', () => streamOpened({ clientId: this.clientId, lane: this.lane }));
804
+ this.sse.addEventListener('error', () => streamClosed());
805
+ for (const type of LIVE_EVENTS) {
806
+ this.sse.addEventListener(type, (event) => {
807
+ let data;
808
+ try {
809
+ data = JSON.parse(event.data);
810
+ } catch {
811
+ return;
812
+ }
813
+ streamEvent(type, data);
814
+ });
815
+ }
816
+
766
817
  // The cursor frame is a NAMED SSE event, so it never reaches onmessage and
767
818
  // never looks like data. Its `resync` flag says the server could not retain
768
819
  // everything between where this client resumed and the baseline it is
@@ -819,6 +870,9 @@ class LiveSync {
819
870
  return;
820
871
  }
821
872
  presence.update(data);
873
+ editingMarker.update(data);
874
+ this._lastRoster = data;
875
+ if (this._editing) this._editing.roster(data);
822
876
  });
823
877
 
824
878
  this.sse.onmessage = (event) => {
@@ -1083,6 +1137,16 @@ class LiveSync {
1083
1137
  });
1084
1138
  }
1085
1139
 
1140
+ _postEditing(path) {
1141
+ const url = new URL('/_/presence', window.location.origin);
1142
+ url.searchParams.set('client-id', this.clientId);
1143
+ return fetch(url.href, {
1144
+ method: 'POST',
1145
+ headers: { 'Content-Type': 'application/json', 'Document-URL': window.location.href },
1146
+ body: JSON.stringify({ editing: path }),
1147
+ }).then((response) => response.ok, () => false);
1148
+ }
1149
+
1086
1150
  /**
1087
1151
  * Route an external disk change (htmlclay watcher) out of the notification
1088
1152
  * path. Returns true when the notification was consumed — the caller must
@@ -1592,7 +1656,7 @@ class LiveSync {
1592
1656
  // A frame may neither write these onto our root nor, by not carrying them,
1593
1657
  // take ours away. Returning false is hyper-morph's veto for both directions.
1594
1658
  const beforeAttributeUpdated = (name, element) =>
1595
- isTabLocalRootAttr(name, element) ? false : undefined;
1659
+ isTabLocalAttr(name, element) ? false : undefined;
1596
1660
  const authored = uniqueAuthoredIdentity(new WeakMap());
1597
1661
  const sideIdentity = (map) =>
1598
1662
  isIdMap(map) ? { first: authored, map, then: authored } : authored;
@@ -1619,11 +1683,13 @@ class LiveSync {
1619
1683
  if (synthetic) syntheticByAuthored.set(id, synthetic);
1620
1684
  }
1621
1685
  }
1686
+ remote = scratchpadsEmptied(remote);
1622
1687
 
1623
1688
  const common = {
1624
1689
  live: document,
1625
1690
  remote,
1626
1691
  ignore: (el) => el.matches(SYNC_IGNORE_SELECTOR),
1692
+ opaque: (el) => el.matches(FREEZE_SELECTOR),
1627
1693
  remoteWins: (el) => el.matches(REMOTE_WINS_SELECTOR),
1628
1694
  ignoreAttribute: isRootLocalAttr,
1629
1695
  protectFocusedValue: true,
@@ -1648,7 +1714,7 @@ class LiveSync {
1648
1714
  pending = mergeDocument({
1649
1715
  ...common,
1650
1716
  base: null,
1651
- local: { root: captureLocal(), toLive: originalSnapshotNode },
1717
+ local: { root: emptyScratchpads(captureLocal()), toLive: originalSnapshotNode },
1652
1718
  identity: { local: localIdentity, remote: sideIdentity(identityMap) },
1653
1719
  });
1654
1720
  } else if (!dirty) {
@@ -1660,7 +1726,7 @@ class LiveSync {
1660
1726
  // a local edit, and a clean autosave tab would write that "edit" back to
1661
1727
  // disk on every frame. Still three-way: merge tags keep their base for
1662
1728
  // the JSON merge, and hyper-morph's clean-side shortcuts apply.
1663
- const localRoot = captureLocal();
1729
+ const localRoot = emptyScratchpads(captureLocal());
1664
1730
  const baseDoc = localRoot.ownerDocument;
1665
1731
  if (baseDoc.documentElement !== localRoot) baseDoc.replaceChild(localRoot, baseDoc.documentElement);
1666
1732
  pending = mergeDocument({
@@ -1675,7 +1741,7 @@ class LiveSync {
1675
1741
  },
1676
1742
  });
1677
1743
  } else {
1678
- localRoot = captureLocal();
1744
+ localRoot = emptyScratchpads(captureLocal());
1679
1745
  applyId = beginApply({
1680
1746
  source, seq, etag, root: localRoot,
1681
1747
  domain: source === 'disk' ? 'save' : 'sync',
@@ -1683,7 +1749,7 @@ class LiveSync {
1683
1749
  try {
1684
1750
  pending = mergeDocument({
1685
1751
  ...common,
1686
- base,
1752
+ base: scratchpadsEmptied(base),
1687
1753
  local: { root: localRoot, toLive: originalSnapshotNode },
1688
1754
  identity: {
1689
1755
  base: sideIdentity(baseIdentityMap),
@@ -1920,7 +1986,7 @@ class LiveSync {
1920
1986
  // returns above without reaching here, so nothing can name an author for a
1921
1987
  // change this tab never took. Null on every frame the host did not stamp.
1922
1988
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1923
- detail: { seq, source: 'peer', by: by || null, report, unresolved: this.unresolvedConflicts, conflictIds }
1989
+ detail: { seq, source: 'peer', by: by || null, html, report, unresolved: this.unresolvedConflicts, conflictIds }
1924
1990
  }));
1925
1991
  } finally {
1926
1992
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -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 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.
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
 
@@ -51,47 +53,17 @@ const LABEL_FONT = `500 11px/1.4 ${FONT_MONO}`;
51
53
  // resets color-scheme and direction too, which are put back straight after.
52
54
  const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
53
55
 
54
- // 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 below was chosen
56
- // against it.
56
+ // The fill of every square, under the participant's colour. One literal in both
57
+ // schemes, because every colour the shared person-face palette draws with was
58
+ // 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
@@ -106,7 +78,6 @@ function normalize(data) {
106
78
  people.push({
107
79
  id: person.id,
108
80
  name,
109
- canEdit: person.canEdit === true,
110
81
  you: person.you === true,
111
82
  });
112
83
  }
@@ -144,8 +115,14 @@ class Presence {
144
115
  * @returns {Promise<void>}
145
116
  */
146
117
  async update(data) {
147
- this.roster = normalize(data);
118
+ const next = normalize(data);
119
+ const same = this.root && JSON.stringify(next) === JSON.stringify(this.roster);
120
+ this.roster = next;
148
121
  if (!(await this.supported())) return;
122
+ if (same) {
123
+ set(this.root, 'color-scheme', pageScheme());
124
+ return;
125
+ }
149
126
  this.render();
150
127
  }
151
128
 
@@ -260,13 +237,8 @@ class Presence {
260
237
  }
261
238
 
262
239
  avatar(person) {
263
- const color = colorFor(person.id);
264
- // Solid says this person can change the document; hollow says they are
265
- // reading it. The two are the same three colours inverted, so a stack of
266
- // both reads as one set rather than as two kinds of thing.
267
- const skin = person.canEdit
268
- ? [`background-color:${color}`, `color:${FACE}`, `border-color:${FACE}`]
269
- : [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
240
+ const color = personColor(person.id);
241
+ const skin = [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
270
242
 
271
243
  const el = runtimeRoot(make('div', [
272
244
  ...RESET,
@@ -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 (document.hidden) {
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 (!document.hidden) this._resume(); };
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 (document.hidden) this._visibility();
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: document.hidden ? 'hidden' : 'visible' });
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;
@@ -139,7 +147,8 @@ export class SyncStream extends EventTarget {
139
147
  this._remember(event.data);
140
148
  this._emit('message', event.data);
141
149
  };
142
- for (const type of ['cursor', 'presence']) {
150
+ // `data`, `data-state` and `data-writer` are clay.live's fast lane (stream-bridge.js).
151
+ for (const type of ['cursor', 'presence', 'data', 'data-state', 'data-writer']) {
143
152
  source.addEventListener(type, event => {
144
153
  if (this._source === source) this._emit(type, event.data);
145
154
  });
@@ -197,6 +206,8 @@ export class SyncStream extends EventTarget {
197
206
  clearTimeout(this._hiddenTimer);
198
207
  this._disconnect();
199
208
  document.removeEventListener('visibilitychange', this._visibility);
209
+ document.removeEventListener('clay:workspace-background', this._visibility);
210
+ document.removeEventListener('clay:workspace-foreground', this._visibility);
200
211
  window.removeEventListener('pagehide', this._pagehide);
201
212
  window.removeEventListener('pageshow', this._pageshow);
202
213
  }