@jupyter/collaboration 5.0.2 → 5.0.4

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/lib/cursors.d.ts CHANGED
@@ -54,6 +54,13 @@ export interface IAwarenessState extends Record<string, any> {
54
54
  * Facet storing the Yjs document objects
55
55
  */
56
56
  export declare const editorAwarenessFacet: Facet<EditorAwareness, EditorAwareness>;
57
+ /**
58
+ * Build the avatar and name badge shown for a collaborator.
59
+ *
60
+ * @param user The collaborator identity, if known
61
+ * @returns The badge element
62
+ */
63
+ export declare function collaboratorPill(user: User.IIdentity | undefined): HTMLDivElement;
57
64
  /**
58
65
  * CodeMirror extension to display remote users cursors
59
66
  *
package/lib/cursors.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Jupyter Development Team.
2
2
  // Distributed under the terms of the Modified BSD License.
3
- import { Annotation, EditorSelection, Facet } from '@codemirror/state';
4
- import { EditorView, hoverTooltip, layer, RectangleMarker, tooltips, ViewPlugin } from '@codemirror/view';
3
+ import { Annotation, EditorSelection, Facet, StateField } from '@codemirror/state';
4
+ import { EditorView, hoverTooltip, layer, RectangleMarker, showTooltip, tooltips, ViewPlugin } from '@codemirror/view';
5
5
  import { JSONExt } from '@lumino/coreutils';
6
6
  import { createAbsolutePositionFromRelativePosition, createRelativePositionFromJSON, createRelativePositionFromTypeIndex } from 'yjs';
7
7
  /**
@@ -18,7 +18,8 @@ export const editorAwarenessFacet = Facet.define({
18
18
  const remoteSelectionTheme = EditorView.baseTheme({
19
19
  '.jp-remote-cursor': {
20
20
  borderLeft: '1px solid black',
21
- marginLeft: '-1px'
21
+ marginLeft: '-1px',
22
+ pointerEvents: 'none'
22
23
  },
23
24
  '.jp-remote-cursor.jp-mod-primary': {
24
25
  borderLeftWidth: '2px'
@@ -29,13 +30,205 @@ const remoteSelectionTheme = EditorView.baseTheme({
29
30
  '.cm-tooltip': {
30
31
  border: 'none'
31
32
  },
32
- '.cm-tooltip .jp-remote-userInfo': {
33
- color: 'var(--jp-ui-inverse-font-color0)',
34
- padding: '0px 2px'
33
+ '.jp-remote-userFlag': {
34
+ display: 'flex',
35
+ alignItems: 'center',
36
+ gap: '4px',
37
+ maxWidth: '140px',
38
+ padding: '2px 6px 2px 2px',
39
+ background: 'var(--jp-layout-color1)',
40
+ color: 'var(--jp-ui-font-color1)',
41
+ border: '1px solid',
42
+ borderRadius: '10px',
43
+ fontSize: '11px',
44
+ lineHeight: '1.2',
45
+ whiteSpace: 'nowrap',
46
+ boxShadow: 'var(--jp-elevation-z2)',
47
+ pointerEvents: 'none',
48
+ userSelect: 'none',
49
+ opacity: '1',
50
+ transition: 'opacity 300ms ease'
51
+ },
52
+ '.jp-remote-userFlag.jp-mod-idle': {
53
+ opacity: '0'
54
+ },
55
+ '.cm-tooltip.jp-remote-userFlag-host': {
56
+ background: 'none'
57
+ },
58
+ '.jp-remote-userFlag-avatar': {
59
+ width: '16px',
60
+ height: '16px',
61
+ flex: '0 0 auto',
62
+ display: 'flex',
63
+ alignItems: 'center',
64
+ justifyContent: 'center',
65
+ overflow: 'hidden',
66
+ borderRadius: '100%',
67
+ color: 'var(--jp-ui-inverse-font-color1)',
68
+ fontSize: '8px'
69
+ },
70
+ '.jp-remote-userFlag-avatar img': {
71
+ width: '100%',
72
+ height: '100%',
73
+ objectFit: 'cover'
74
+ },
75
+ '.jp-remote-userFlag-name': {
76
+ overflow: 'hidden',
77
+ textOverflow: 'ellipsis',
78
+ whiteSpace: 'nowrap'
35
79
  }
36
80
  });
37
81
  // TODO fix which user needs update
38
82
  const remoteSelectionsAnnotation = Annotation.define();
83
+ /**
84
+ * How long a collaborator flag stays visible after their last edit.
85
+ */
86
+ const FLAG_IDLE_MS = 2000;
87
+ const collaborators = new WeakMap();
88
+ function collaborator(awareness, clientID) {
89
+ let byClient = collaborators.get(awareness);
90
+ if (!byClient) {
91
+ byClient = new Map();
92
+ collaborators.set(awareness, byClient);
93
+ }
94
+ let entry = byClient.get(clientID);
95
+ if (!entry) {
96
+ entry = { at: 0 };
97
+ byClient.set(clientID, entry);
98
+ }
99
+ return entry;
100
+ }
101
+ function markActive(awareness, clientIDs) {
102
+ const now = Date.now();
103
+ for (const clientID of clientIDs) {
104
+ collaborator(awareness, clientID).at = now;
105
+ }
106
+ }
107
+ function isFlagVisible(awareness, clientID) {
108
+ return Date.now() - collaborator(awareness, clientID).at < FLAG_IDLE_MS;
109
+ }
110
+ const editTrackedDocs = new WeakSet();
111
+ /**
112
+ * Mark edit authors from the Yjs clocks; awareness misses end-of-line typing.
113
+ */
114
+ function trackRemoteEdits(awareness, ydoc) {
115
+ if (editTrackedDocs.has(ydoc)) {
116
+ return;
117
+ }
118
+ editTrackedDocs.add(ydoc);
119
+ // Before the observers, so the flag is up when the edit reaches the editor.
120
+ ydoc.on('beforeObserverCalls', (tr) => {
121
+ const authors = [];
122
+ tr.afterState.forEach((clock, clientID) => {
123
+ if (clientID !== ydoc.clientID &&
124
+ tr.beforeState.get(clientID) !== clock) {
125
+ authors.push(clientID);
126
+ }
127
+ });
128
+ if (authors.length > 0) {
129
+ markActive(awareness, authors);
130
+ }
131
+ });
132
+ }
133
+ /**
134
+ * Build the avatar and name badge shown for a collaborator.
135
+ *
136
+ * @param user The collaborator identity, if known
137
+ * @returns The badge element
138
+ */
139
+ export function collaboratorPill(user) {
140
+ const dom = document.createElement('div');
141
+ dom.className = 'jp-remote-userFlag';
142
+ renderPill(dom, user);
143
+ return dom;
144
+ }
145
+ function renderPill(dom, user) {
146
+ var _a, _b, _c, _d;
147
+ dom.replaceChildren();
148
+ dom.style.borderColor = (_a = user === null || user === void 0 ? void 0 : user.color) !== null && _a !== void 0 ? _a : 'darkgrey';
149
+ const avatar = document.createElement('div');
150
+ avatar.className = 'jp-remote-userFlag-avatar';
151
+ if (user === null || user === void 0 ? void 0 : user.avatar_url) {
152
+ const img = document.createElement('img');
153
+ img.src = user.avatar_url;
154
+ img.alt = '';
155
+ img.onerror = () => {
156
+ var _a, _b;
157
+ avatar.style.backgroundColor = (_a = user.color) !== null && _a !== void 0 ? _a : 'darkgrey';
158
+ avatar.textContent = (_b = user.initials) !== null && _b !== void 0 ? _b : '';
159
+ };
160
+ avatar.appendChild(img);
161
+ }
162
+ else {
163
+ avatar.style.backgroundColor = (_b = user === null || user === void 0 ? void 0 : user.color) !== null && _b !== void 0 ? _b : 'darkgrey';
164
+ avatar.textContent = (_c = user === null || user === void 0 ? void 0 : user.initials) !== null && _c !== void 0 ? _c : '';
165
+ }
166
+ const name = document.createElement('span');
167
+ name.className = 'jp-remote-userFlag-name';
168
+ name.textContent = (_d = user === null || user === void 0 ? void 0 : user.display_name) !== null && _d !== void 0 ? _d : 'Anonymous';
169
+ dom.append(avatar, name);
170
+ }
171
+ function drawFlag(awareness, clientID) {
172
+ const entry = collaborator(awareness, clientID);
173
+ const dom = collaboratorPill(entry.user);
174
+ let shown = entry.user;
175
+ const sync = () => {
176
+ if (!JSONExt.deepEqual({ ...shown }, { ...entry.user })) {
177
+ shown = entry.user;
178
+ renderPill(dom, shown);
179
+ }
180
+ dom.classList.toggle('jp-mod-idle', !isFlagVisible(awareness, clientID));
181
+ };
182
+ sync();
183
+ return { dom, update: sync };
184
+ }
185
+ function flagCreator(awareness, clientID) {
186
+ var _a;
187
+ const entry = collaborator(awareness, clientID);
188
+ return ((_a = entry.create) !== null && _a !== void 0 ? _a : (entry.create = () => drawFlag(awareness, clientID)));
189
+ }
190
+ function collaboratorFlags(state) {
191
+ const { awareness, ytext } = state.facet(editorAwarenessFacet);
192
+ const ydoc = ytext.doc;
193
+ if (!ydoc) {
194
+ return [];
195
+ }
196
+ const flags = [];
197
+ awareness.getStates().forEach((remote, clientID) => {
198
+ var _a;
199
+ if (clientID === awareness.doc.clientID) {
200
+ return;
201
+ }
202
+ const cursor = (_a = remote.cursors) === null || _a === void 0 ? void 0 : _a.find(c => { var _a; return (_a = c.primary) !== null && _a !== void 0 ? _a : true; });
203
+ if (!(cursor === null || cursor === void 0 ? void 0 : cursor.head)) {
204
+ return;
205
+ }
206
+ const head = createAbsolutePositionFromRelativePosition(cursor.head, ydoc);
207
+ if ((head === null || head === void 0 ? void 0 : head.type) !== ytext) {
208
+ return;
209
+ }
210
+ collaborator(awareness, clientID).user = remote.user;
211
+ flags.push({
212
+ pos: Math.min(head.index, state.doc.length),
213
+ above: true,
214
+ create: flagCreator(awareness, clientID)
215
+ });
216
+ });
217
+ return flags;
218
+ }
219
+ /**
220
+ * Extension showing a fading name flag above each remote cursor
221
+ */
222
+ const remoteCursorFlags = StateField.define({
223
+ create: collaboratorFlags,
224
+ update(flags, tr) {
225
+ return tr.docChanged ||
226
+ tr.annotation(remoteSelectionsAnnotation) !== undefined
227
+ ? collaboratorFlags(tr.state)
228
+ : flags;
229
+ },
230
+ provide: field => showTooltip.computeN([field], state => state.field(field))
231
+ });
39
232
  /**
40
233
  * Wrapper around RectangleMarker to be able to set the user color for the remote cursor and selection ranges.
41
234
  */
@@ -127,6 +320,9 @@ const userHover = hoverTooltip((view, pos) => {
127
320
  if (clientID === awareness.doc.clientID) {
128
321
  continue;
129
322
  }
323
+ if (isFlagVisible(awareness, clientID)) {
324
+ continue;
325
+ }
130
326
  for (const cursor of (_a = state.cursors) !== null && _a !== void 0 ? _a : []) {
131
327
  if (!(cursor === null || cursor === void 0 ? void 0 : cursor.head)) {
132
328
  continue;
@@ -136,18 +332,18 @@ const userHover = hoverTooltip((view, pos) => {
136
332
  continue;
137
333
  }
138
334
  // Use some margin around the cursor to display the user.
139
- if (head.index - 3 <= pos && pos <= head.index + 3) {
335
+ const index = Math.min(head.index, view.state.doc.length);
336
+ if (index - 3 <= pos && pos <= index + 3) {
140
337
  return {
141
- pos: head.index,
338
+ pos: index,
142
339
  above: true,
143
340
  create: () => {
144
- var _a, _b, _c, _d;
145
- const dom = document.createElement('div');
146
- dom.classList.add('jp-remote-userInfo');
147
- dom.style.backgroundColor = (_b = (_a = state.user) === null || _a === void 0 ? void 0 : _a.color) !== null && _b !== void 0 ? _b : 'darkgrey';
148
- dom.textContent =
149
- (_d = (_c = state.user) === null || _c === void 0 ? void 0 : _c.display_name) !== null && _d !== void 0 ? _d : 'Anonymous';
150
- return { dom };
341
+ const dom = collaboratorPill(state.user);
342
+ return {
343
+ dom,
344
+ overlap: true,
345
+ mount: () => { var _a; return (_a = dom.parentElement) === null || _a === void 0 ? void 0 : _a.classList.add('jp-remote-userFlag-host'); }
346
+ };
151
347
  }
152
348
  };
153
349
  }
@@ -156,7 +352,7 @@ const userHover = hoverTooltip((view, pos) => {
156
352
  return null;
157
353
  }, {
158
354
  hideOn: (tr, tooltip) => !!tr.annotation(remoteSelectionsAnnotation),
159
- hoverTime: 0
355
+ hoverTime: 1
160
356
  });
161
357
  /**
162
358
  * Extension defining a new editor layer storing the remote selections
@@ -206,15 +402,32 @@ const showCollaborators = ViewPlugin.fromClass(class {
206
402
  constructor(view) {
207
403
  this.editorAwareness = view.state.facet(editorAwarenessFacet);
208
404
  this._listener = ({ added, updated, removed }) => {
209
- const clients = added.concat(updated).concat(removed);
210
- if (clients.findIndex(id => id !== this.editorAwareness.awareness.doc.clientID) >= 0) {
405
+ const { awareness } = this.editorAwareness;
406
+ const clients = added
407
+ .concat(updated)
408
+ .concat(removed)
409
+ .filter(id => id !== awareness.doc.clientID);
410
+ if (clients.length > 0) {
411
+ markActive(awareness, clients);
211
412
  // Trick to get the remoteCursorLayers to be updated
212
413
  view.dispatch({ annotations: [remoteSelectionsAnnotation.of([])] });
414
+ this.scheduleFade(view);
213
415
  }
214
416
  };
417
+ const ydoc = this.editorAwareness.ytext.doc;
418
+ if (ydoc) {
419
+ trackRemoteEdits(this.editorAwareness.awareness, ydoc);
420
+ }
215
421
  this.editorAwareness.awareness.on('change', this._listener);
216
422
  }
423
+ scheduleFade(view) {
424
+ clearTimeout(this._fadeTimer);
425
+ this._fadeTimer = setTimeout(() => {
426
+ view.dispatch({ annotations: [remoteSelectionsAnnotation.of([])] });
427
+ }, FLAG_IDLE_MS + 50);
428
+ }
217
429
  destroy() {
430
+ clearTimeout(this._fadeTimer);
218
431
  this.editorAwareness.awareness.off('change', this._listener);
219
432
  }
220
433
  /**
@@ -222,6 +435,9 @@ const showCollaborators = ViewPlugin.fromClass(class {
222
435
  */
223
436
  update(update) {
224
437
  var _a;
438
+ if (update.docChanged) {
439
+ this.scheduleFade(update.view);
440
+ }
225
441
  if (!update.docChanged && !update.selectionSet) {
226
442
  return;
227
443
  }
@@ -270,6 +486,7 @@ const showCollaborators = ViewPlugin.fromClass(class {
270
486
  remoteSelectionTheme,
271
487
  remoteCursorsLayer,
272
488
  remoteSelectionLayer,
489
+ remoteCursorFlags,
273
490
  userHover,
274
491
  // As we use relative positioning of widget, the tooltip must be positioned absolutely
275
492
  // And we attach the tooltip to the body to avoid overflow rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/collaboration",
3
- "version": "5.0.2",
3
+ "version": "5.0.4",
4
4
  "description": "JupyterLab - Real-Time Collaboration Widgets",
5
5
  "homepage": "https://github.com/jupyterlab/jupyter-collaboration",
6
6
  "bugs": {