@jupyterlab/notebook-extension 4.7.0-alpha.0 → 4.7.0-alpha.2

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.
@@ -5,13 +5,23 @@
5
5
 
6
6
  import React from 'react';
7
7
  import type { FieldProps } from '@rjsf/utils';
8
+ import { SystemClipboard } from '@jupyterlab/apputils';
8
9
  import type { IEditorLanguageRegistry } from '@jupyterlab/codemirror';
10
+ import { PageConfig } from '@jupyterlab/coreutils';
9
11
  import type { INotebookTracker } from '@jupyterlab/notebook';
10
12
  import { NotebookTools } from '@jupyterlab/notebook';
11
13
  import type { ISharedText } from '@jupyter/ydoc';
12
14
  import { PanelLayout, Widget } from '@lumino/widgets';
13
- import type { CodeCellModel, ICellModel } from '@jupyterlab/cells';
14
- import { InputPrompt } from '@jupyterlab/cells';
15
+ import type { ICellModel } from '@jupyterlab/cells';
16
+ import { InputPrompt, isCodeCellModel } from '@jupyterlab/cells';
17
+ import type { ITranslator } from '@jupyterlab/translation';
18
+ import { nullTranslator } from '@jupyterlab/translation';
19
+ import {
20
+ checkIcon,
21
+ copyIcon,
22
+ linkIcon,
23
+ ToolbarButtonComponent
24
+ } from '@jupyterlab/ui-components';
15
25
  import { Debouncer } from '@lumino/polling';
16
26
 
17
27
  /**
@@ -27,6 +37,25 @@ const ACTIVE_CELL_TOOL_CONTENT_CLASS = 'jp-ActiveCellTool-Content';
27
37
  */
28
38
  const ACTIVE_CELL_TOOL_CELL_CONTENT_CLASS = 'jp-ActiveCellTool-CellContent';
29
39
 
40
+ /**
41
+ * The class name added to the cell ID field.
42
+ */
43
+ const CELL_ID_FIELD_CLASS = 'jp-CellIdField';
44
+
45
+ type CopiedAction = 'id' | 'link';
46
+
47
+ interface ICellIdFieldProps extends FieldProps {
48
+ /**
49
+ * The tracker to the notebook panel.
50
+ */
51
+ tracker: INotebookTracker;
52
+
53
+ /**
54
+ * Language translator.
55
+ */
56
+ translator?: ITranslator;
57
+ }
58
+
30
59
  namespace Private {
31
60
  /**
32
61
  * Custom active cell field options.
@@ -44,17 +73,138 @@ namespace Private {
44
73
  }
45
74
  }
46
75
 
76
+ /**
77
+ * The cell ID field, displaying the ID of the active cell.
78
+ *
79
+ * ## Note
80
+ * This field does not work as other metadata form fields, as it does not update metadata.
81
+ */
82
+ export function CellIdField(props: ICellIdFieldProps): JSX.Element {
83
+ const translator = props.translator ?? nullTranslator;
84
+ const trans = translator.load('jupyterlab');
85
+ const title = props.schema.title ?? trans.__('Cell ID');
86
+ const activeCellId = props.tracker.activeCell?.model.id ?? '';
87
+ const [copiedAction, setCopiedAction] = React.useState<CopiedAction | null>(
88
+ null
89
+ );
90
+ const copiedTimeout = React.useRef<number | null>(null);
91
+
92
+ React.useEffect(() => {
93
+ setCopiedAction(null);
94
+ }, [activeCellId]);
95
+
96
+ React.useEffect(() => {
97
+ return () => {
98
+ if (copiedTimeout.current !== null) {
99
+ window.clearTimeout(copiedTimeout.current);
100
+ }
101
+ };
102
+ }, []);
103
+
104
+ const showCopied = (action: CopiedAction) => {
105
+ setCopiedAction(action);
106
+ if (copiedTimeout.current !== null) {
107
+ window.clearTimeout(copiedTimeout.current);
108
+ }
109
+ copiedTimeout.current = window.setTimeout(() => {
110
+ setCopiedAction(null);
111
+ copiedTimeout.current = null;
112
+ }, 1400);
113
+ };
114
+
115
+ const onCopyId = () => {
116
+ if (!activeCellId) {
117
+ return;
118
+ }
119
+ void SystemClipboard.getInstance()
120
+ .setData('text/plain', activeCellId)
121
+ .then(() => showCopied('id'));
122
+ };
123
+
124
+ const onCopyLink = () => {
125
+ const notebookPanel = props.tracker.currentWidget;
126
+ const activeCell = props.tracker.activeCell;
127
+ if (!notebookPanel || !activeCell) {
128
+ return;
129
+ }
130
+
131
+ const url = PageConfig.getUrl({
132
+ workspace: PageConfig.defaultWorkspace,
133
+ treePath: notebookPanel.context.path,
134
+ toShare: true
135
+ });
136
+ void SystemClipboard.getInstance()
137
+ .setData(
138
+ 'text/plain',
139
+ `${url}#cell-id=${encodeURIComponent(activeCell.model.id)}`
140
+ )
141
+ .then(() => showCopied('link'));
142
+ };
143
+
144
+ return (
145
+ <div className={CELL_ID_FIELD_CLASS}>
146
+ <label htmlFor={props.idSchema.$id}>{title}</label>
147
+ <div className="jp-CellIdField-row">
148
+ <input
149
+ className="jp-mod-styled jp-CellIdField-input"
150
+ id={props.idSchema.$id}
151
+ readOnly
152
+ type="text"
153
+ value={activeCellId}
154
+ />
155
+ <ToolbarButtonComponent
156
+ className={`jp-CellIdField-button ${
157
+ copiedAction === 'id' ? 'jp-mod-copied' : ''
158
+ }`}
159
+ enabled={!!activeCellId}
160
+ icon={copiedAction === 'id' ? checkIcon : copyIcon}
161
+ iconLabel={trans.__('Copy cell ID')}
162
+ onClick={onCopyId}
163
+ tooltip={
164
+ copiedAction === 'id'
165
+ ? trans.__('Copied')
166
+ : trans.__('Copy cell ID')
167
+ }
168
+ />
169
+ <ToolbarButtonComponent
170
+ className={`jp-CellIdField-button ${
171
+ copiedAction === 'link' ? 'jp-mod-copied' : ''
172
+ }`}
173
+ enabled={!!activeCellId}
174
+ icon={copiedAction === 'link' ? checkIcon : linkIcon}
175
+ iconLabel={trans.__('Copy link to cell')}
176
+ onClick={onCopyLink}
177
+ tooltip={
178
+ copiedAction === 'link'
179
+ ? trans.__('Copied')
180
+ : trans.__('Copy link to cell')
181
+ }
182
+ />
183
+ </div>
184
+ </div>
185
+ );
186
+ }
187
+
47
188
  /**
48
189
  * The active cell field, displaying the first line and execution count of the active cell.
49
190
  *
50
191
  * ## Note
51
192
  * This field does not work as other metadata form fields, as it does not update metadata.
193
+ *
194
+ * A single instance is meant to be shared by every render of the field. The
195
+ * displayed cell follows the notebook tracker rather than the render calls, so
196
+ * that the field keeps working when the metadata form is not being rebuilt, and
197
+ * so that it does not hold on to a cell model of a closed notebook.
198
+ *
199
+ * One instance owns one node, so the field can only be mounted in one place at
200
+ * a time: `render` moves the node to the most recent mount point and leaves any
201
+ * earlier one empty. Nothing renders this field twice today.
52
202
  */
53
203
  export class ActiveCellTool extends NotebookTools.Tool {
54
204
  constructor(options: Private.IOptions) {
55
205
  super();
56
- const { languages } = options;
57
206
  this._tracker = options.tracker;
207
+ this._languages = options.languages;
58
208
 
59
209
  this.addClass(ACTIVE_CELL_TOOL_CLASS);
60
210
  this.layout = new PanelLayout();
@@ -71,51 +221,191 @@ export class ActiveCellTool extends NotebookTools.Tool {
71
221
  this._editorEl = editor;
72
222
  (this.layout as PanelLayout).addWidget(new Widget({ node }));
73
223
 
74
- const update = async () => {
75
- this._editorEl.innerHTML = '';
76
- if (this._cellModel?.type === 'code') {
77
- this._inputPrompt.executionCount = `${
78
- (this._cellModel as CodeCellModel).executionCount ?? ''
79
- }`;
80
- this._inputPrompt.show();
81
- } else {
82
- this._inputPrompt.executionCount = null;
83
- this._inputPrompt.hide();
84
- }
224
+ // Only edits to the current cell are rate-limited; switching cells updates
225
+ // the display immediately, see `_onActiveCellChanged`.
226
+ this._previewDebouncer = new Debouncer<void, void, null[]>(
227
+ () => this._update(),
228
+ 150
229
+ );
85
230
 
86
- if (this._cellModel) {
87
- await languages.highlight(
88
- this._cellModel.sharedModel.getSource().split('\n')[0],
89
- languages.findByMIME(this._cellModel.mimeType),
90
- this._editorEl
91
- );
92
- }
93
- };
231
+ this._tracker.activeCellChanged.connect(this._onActiveCellChanged, this);
232
+ // `activeCellChanged` is not emitted when the last notebook is closed:
233
+ // NotebookTracker.onCurrentChanged returns early on a null widget. Without
234
+ // this second connection the field would keep the cell model of a closed
235
+ // notebook, and its shared model, alive for the rest of the session.
236
+ this._tracker.currentChanged.connect(this._onActiveCellChanged, this);
237
+ this._onActiveCellChanged();
238
+ }
94
239
 
95
- this._refreshDebouncer = new Debouncer(update, 150);
240
+ dispose(): void {
241
+ if (this.isDisposed) {
242
+ return;
243
+ }
244
+ this._tracker.activeCellChanged.disconnect(this._onActiveCellChanged, this);
245
+ this._tracker.currentChanged.disconnect(this._onActiveCellChanged, this);
246
+ this._disconnectCellModel();
247
+ this._previewDebouncer.dispose();
248
+ super.dispose();
96
249
  }
97
250
 
98
251
  render(props: FieldProps): JSX.Element {
99
- const activeCell = this._tracker.activeCell;
100
- if (activeCell) this._cellModel = activeCell?.model || null;
101
- (this._cellModel?.sharedModel as ISharedText).changed.connect(
102
- this.refresh,
252
+ // The content is driven by the tracker; React only supplies the mount
253
+ // point, which is a different element on every rebuild of the form.
254
+ return (
255
+ <div
256
+ ref={ref => {
257
+ if (!ref || this.node.parentElement === ref) {
258
+ return;
259
+ }
260
+ ref.appendChild(this.node);
261
+ if (this._pendingUpdate) {
262
+ this._update().catch(console.warn);
263
+ }
264
+ }}
265
+ ></div>
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Follow the active cell of the tracker, which is null once the last
271
+ * notebook is closed.
272
+ */
273
+ private _onActiveCellChanged(): void {
274
+ const cellModel = this._tracker.activeCell?.model ?? null;
275
+ if (cellModel === this._cellModel) {
276
+ return;
277
+ }
278
+ this._disconnectCellModel();
279
+ this._cellModel = cellModel;
280
+ if (cellModel) {
281
+ (cellModel.sharedModel as ISharedText).changed.connect(
282
+ this._onCellContentChanged,
283
+ this
284
+ );
285
+ cellModel.mimeTypeChanged.connect(this._onCellContentChanged, this);
286
+ }
287
+ // The prompt now belongs to a cell whose source line is not on screen yet,
288
+ // so leave it to `_update` to write both together.
289
+ this._promptOutdated = true;
290
+ this._update().catch(console.warn);
291
+ }
292
+
293
+ /**
294
+ * Handle a change to the current cell source, mime type or execution count.
295
+ */
296
+ private _onCellContentChanged(): void {
297
+ if (!this.node.isConnected) {
298
+ this._pendingUpdate = true;
299
+ this._promptOutdated = true;
300
+ return;
301
+ }
302
+ if (!this._promptOutdated) {
303
+ // The cell is unchanged and no switch is in flight, so writing the prompt
304
+ // now cannot pair it with the source line of a different cell.
305
+ this._updatePrompt();
306
+ }
307
+ this._previewDebouncer.invoke().catch(console.warn);
308
+ }
309
+
310
+ /**
311
+ * Stop listening to the cell model the tool is currently displaying.
312
+ */
313
+ private _disconnectCellModel(): void {
314
+ const cellModel = this._cellModel;
315
+ if (!cellModel) {
316
+ return;
317
+ }
318
+ (cellModel.sharedModel as ISharedText).changed.disconnect(
319
+ this._onCellContentChanged,
103
320
  this
104
321
  );
105
- this._cellModel?.mimeTypeChanged.connect(this.refresh, this);
106
- this.refresh()
107
- .then(() => undefined)
108
- .catch(console.warn);
109
- return <div ref={ref => ref?.appendChild(this.node)}></div>;
322
+ cellModel.mimeTypeChanged.disconnect(this._onCellContentChanged, this);
323
+ this._cellModel = null;
110
324
  }
111
325
 
112
- private async refresh(): Promise<void> {
113
- await this._refreshDebouncer.invoke();
326
+ /**
327
+ * Reflect the execution count of the current cell.
328
+ */
329
+ private _updatePrompt(): void {
330
+ const cellModel = this._cellModel;
331
+ if (cellModel && isCodeCellModel(cellModel)) {
332
+ this._inputPrompt.executionCount = `${cellModel.executionCount ?? ''}`;
333
+ this._inputPrompt.show();
334
+ } else {
335
+ this._inputPrompt.executionCount = null;
336
+ this._inputPrompt.hide();
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Refresh the preview, writing an outdated prompt along with it.
342
+ *
343
+ * The prompt is written in the same task as the preview it belongs with, so
344
+ * that the field never shows the prompt of one cell above the source line of
345
+ * another while a language mode is being loaded.
346
+ */
347
+ private async _update(): Promise<void> {
348
+ if (!this.node.isConnected) {
349
+ // Nothing is on screen; catch up when the field is mounted again.
350
+ this._pendingUpdate = true;
351
+ return;
352
+ }
353
+ this._pendingUpdate = false;
354
+
355
+ const cellModel = this._cellModel;
356
+ const pending = ++this._updateId;
357
+
358
+ if (!cellModel) {
359
+ this._promptOutdated = false;
360
+ this._updatePrompt();
361
+ this._editorEl.replaceChildren();
362
+ return;
363
+ }
364
+
365
+ const source = cellModel.sharedModel.getSource();
366
+ const lineEnd = source.indexOf('\n');
367
+ const firstLine = lineEnd === -1 ? source : source.slice(0, lineEnd);
368
+
369
+ // Highlight into a detached node, so that the preview is never cleared
370
+ // while waiting for a language mode to load.
371
+ const staging = document.createElement('pre');
372
+ try {
373
+ await this._languages.highlight(
374
+ firstLine,
375
+ this._languages.findByMIME(cellModel.mimeType),
376
+ staging
377
+ );
378
+ } catch (error) {
379
+ // Fall back to unhighlighted source rather than keeping the previous
380
+ // cell's line on screen.
381
+ console.warn(error);
382
+ staging.replaceChildren(document.createTextNode(firstLine));
383
+ }
384
+
385
+ if (pending !== this._updateId) {
386
+ // A newer update started while this one was highlighting; it still owes
387
+ // the prompt write, so `_promptOutdated` is deliberately left set.
388
+ return;
389
+ }
390
+
391
+ if (this._promptOutdated) {
392
+ this._updatePrompt();
393
+ this._promptOutdated = false;
394
+ }
395
+ const fragment = document.createDocumentFragment();
396
+ while (staging.firstChild) {
397
+ fragment.appendChild(staging.firstChild);
398
+ }
399
+ this._editorEl.replaceChildren(fragment);
114
400
  }
115
401
 
116
402
  private _tracker: INotebookTracker;
117
- private _cellModel: ICellModel | null;
118
- private _refreshDebouncer: Debouncer<void, void, null[]>;
403
+ private _languages: IEditorLanguageRegistry;
404
+ private _cellModel: ICellModel | null = null;
405
+ private _previewDebouncer: Debouncer<void, void, null[]>;
406
+ private _updateId = 0;
407
+ private _pendingUpdate = false;
408
+ private _promptOutdated = false;
119
409
  private _editorEl: HTMLPreElement;
120
410
  private _inputPrompt: InputPrompt;
121
411
  }
@@ -77,9 +77,12 @@ export class CellMetadataField extends NotebookTools.MetadataEditorTool {
77
77
 
78
78
  render(props: FieldProps): JSX.Element {
79
79
  const cell = this._tracker.activeCell;
80
+ // Replace and dispose the source created for the previous render.
81
+ const previousSource = this.editor.source;
80
82
  this.editor.source = cell
81
83
  ? new ObservableJSON({ values: cell.model.metadata as JSONObject })
82
84
  : null;
85
+ previousSource?.dispose();
83
86
  this.editor.source?.changed.connect(this._onSourceChanged, this);
84
87
 
85
88
  return (
@@ -120,9 +123,12 @@ export class NotebookMetadataField extends NotebookTools.MetadataEditorTool {
120
123
 
121
124
  render(props: FieldProps): JSX.Element {
122
125
  const notebook = this._tracker.currentWidget;
126
+ // Replace and dispose the source created for the previous render.
127
+ const previousSource = this.editor.source;
123
128
  this.editor.source = notebook
124
129
  ? new ObservableJSON({ values: notebook.model?.metadata as JSONObject })
125
130
  : null;
131
+ previousSource?.dispose();
126
132
  this.editor.source?.changed.connect(this._onSourceChanged, this);
127
133
 
128
134
  return (