@panphora/clayjs 1.9.1 → 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.
@@ -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 { 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
+ 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, and nodes
68
- // browser extensions inject. The same set the 0.5 morph's sync policy ignored.
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 and library state on a document's root element, on every side
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
- isTabLocalRootAttr(name, element) ? false : undefined;
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),
@@ -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
 
@@ -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 below was chosen
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
- this.roster = normalize(data);
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 = colorFor(person.id);
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.
@@ -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;
@@ -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
  }