@jupyterlab/notebook 4.6.0-alpha.4 → 4.6.0-alpha.5

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.
@@ -0,0 +1,523 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { DOMUtils } from '@jupyterlab/apputils';
7
+ import type { Popup } from '@jupyterlab/statusbar';
8
+ import { showPopup, TextItem } from '@jupyterlab/statusbar';
9
+ import type { ITranslator, TranslationBundle } from '@jupyterlab/translation';
10
+ import { nullTranslator } from '@jupyterlab/translation';
11
+ import {
12
+ classes,
13
+ lineFormIcon,
14
+ ReactWidget,
15
+ VDomModel,
16
+ VDomRenderer
17
+ } from '@jupyterlab/ui-components';
18
+ import React from 'react';
19
+ import type { Notebook } from '.';
20
+
21
+ /**
22
+ * A namespace for CellNumberFormComponent statics.
23
+ */
24
+ namespace CellNumberFormComponent {
25
+ /**
26
+ * Props for the form component.
27
+ */
28
+ export interface IProps {
29
+ /**
30
+ * A callback for when the form is submitted.
31
+ */
32
+ handleSubmit: (value: number) => void;
33
+
34
+ /**
35
+ * The maximum cell number the form can take.
36
+ */
37
+ maxCell: number;
38
+
39
+ /**
40
+ * The application language translator.
41
+ */
42
+ translator?: ITranslator;
43
+ }
44
+
45
+ /**
46
+ * State for the form component.
47
+ */
48
+ export interface IState {
49
+ /**
50
+ * The current value of the form.
51
+ */
52
+ value: string;
53
+
54
+ /**
55
+ * Whether the form has focus.
56
+ */
57
+ hasFocus: boolean;
58
+
59
+ /**
60
+ * A generated ID for the input field.
61
+ */
62
+ textInputId: string;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * A component for rendering a "go-to-cell" form.
68
+ */
69
+ class CellNumberFormComponent extends React.Component<
70
+ CellNumberFormComponent.IProps,
71
+ CellNumberFormComponent.IState
72
+ > {
73
+ /**
74
+ * Construct a new CellNumberFormComponent.
75
+ */
76
+ constructor(props: CellNumberFormComponent.IProps) {
77
+ super(props);
78
+ const translator = props.translator || nullTranslator;
79
+ this._trans = translator.load('jupyterlab');
80
+ this.state = {
81
+ value: '',
82
+ hasFocus: false,
83
+ textInputId: DOMUtils.createDomID() + '-cell-number-input'
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Focus the element on mount.
89
+ */
90
+ componentDidMount() {
91
+ this._textInput?.focus();
92
+ }
93
+
94
+ /**
95
+ * Render the CellNumberFormComponent.
96
+ */
97
+ render() {
98
+ return (
99
+ <div className="jp-lineFormSearch">
100
+ <form name="cellNumberForm" onSubmit={this._handleSubmit} noValidate>
101
+ <div
102
+ className={classes(
103
+ 'jp-lineFormWrapper',
104
+ 'lm-lineForm-wrapper',
105
+ this.state.hasFocus ? 'jp-lineFormWrapperFocusWithin' : undefined
106
+ )}
107
+ >
108
+ <input
109
+ type="number"
110
+ id={this.state.textInputId}
111
+ className="jp-lineFormInput"
112
+ min={1}
113
+ max={this.props.maxCell}
114
+ onChange={this._handleChange}
115
+ onFocus={this._handleFocus}
116
+ onBlur={this._handleBlur}
117
+ value={this.state.value}
118
+ ref={input => {
119
+ this._textInput = input;
120
+ }}
121
+ />
122
+ <div className="jp-baseLineForm jp-lineFormButtonContainer">
123
+ <lineFormIcon.react
124
+ className="jp-baseLineForm jp-lineFormButtonIcon"
125
+ elementPosition="center"
126
+ />
127
+ <input
128
+ type="submit"
129
+ className="jp-baseLineForm jp-lineFormButton"
130
+ value=""
131
+ />
132
+ </div>
133
+ </div>
134
+ <label
135
+ className="jp-lineFormCaption"
136
+ htmlFor={this.state.textInputId}
137
+ >
138
+ {this._trans.__(
139
+ 'Go to cell number between 1 and %1',
140
+ this.props.maxCell
141
+ )}
142
+ </label>
143
+ </form>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Handle a change to the value in the input field.
150
+ */
151
+ private _handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
152
+ this.setState({ value: event.currentTarget.value });
153
+ };
154
+
155
+ /**
156
+ * Handle submission of the input field.
157
+ */
158
+ private _handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
159
+ event.preventDefault();
160
+
161
+ const value = parseInt(this._textInput?.value ?? '', 10);
162
+ if (
163
+ !isNaN(value) &&
164
+ isFinite(value) &&
165
+ 1 <= value &&
166
+ value <= this.props.maxCell
167
+ ) {
168
+ this.props.handleSubmit(value);
169
+ }
170
+
171
+ return false;
172
+ };
173
+
174
+ /**
175
+ * Handle focusing of the input field.
176
+ */
177
+ private _handleFocus = () => {
178
+ this.setState({ hasFocus: true });
179
+ };
180
+
181
+ /**
182
+ * Handle blurring of the input field.
183
+ */
184
+ private _handleBlur = () => {
185
+ this.setState({ hasFocus: false });
186
+ };
187
+
188
+ private _trans: TranslationBundle;
189
+ private _textInput: HTMLInputElement | null = null;
190
+ }
191
+
192
+ /**
193
+ * Props for CellCounterComponent.
194
+ */
195
+ namespace CellCounterComponent {
196
+ export interface IProps {
197
+ /**
198
+ * Current active cell number (1-based).
199
+ */
200
+ activeCell: number;
201
+
202
+ /**
203
+ * First selected cell number (1-based).
204
+ */
205
+ selectionStart: number;
206
+
207
+ /**
208
+ * Last selected cell number (1-based).
209
+ */
210
+ selectionEnd: number;
211
+
212
+ /**
213
+ * Total number of notebook cells.
214
+ */
215
+ totalCells: number;
216
+
217
+ /**
218
+ * The application language translator.
219
+ */
220
+ translator?: ITranslator;
221
+
222
+ /**
223
+ * Click handler used to launch the go-to-cell form.
224
+ */
225
+ handleClick: () => void;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * A pure functional component for rendering a notebook cell counter.
231
+ */
232
+ function CellCounterComponent(
233
+ props: CellCounterComponent.IProps
234
+ ): React.ReactElement<CellCounterComponent.IProps> {
235
+ const translator = props.translator || nullTranslator;
236
+ const trans = translator.load('jupyterlab');
237
+ const source =
238
+ props.selectionStart > 0 && props.selectionStart !== props.selectionEnd
239
+ ? trans.__(
240
+ '%1:%2/%3',
241
+ props.selectionStart,
242
+ props.selectionEnd,
243
+ props.totalCells
244
+ )
245
+ : trans.__('Cell %1/%2', props.activeCell, props.totalCells);
246
+ const keydownHandler = (event: React.KeyboardEvent<HTMLImageElement>) => {
247
+ if (
248
+ event.key === 'Enter' ||
249
+ event.key === 'Spacebar' ||
250
+ event.key === ' '
251
+ ) {
252
+ event.preventDefault();
253
+ event.stopPropagation();
254
+ props.handleClick();
255
+ }
256
+ };
257
+
258
+ return (
259
+ <TextItem
260
+ role="button"
261
+ aria-haspopup
262
+ onClick={props.handleClick}
263
+ source={source}
264
+ title={trans.__('Go to cell…')}
265
+ tabIndex={0}
266
+ onKeyDown={keydownHandler}
267
+ />
268
+ );
269
+ }
270
+
271
+ /**
272
+ * A widget implementing a notebook cell counter status item.
273
+ */
274
+ export class CellCounterStatus extends VDomRenderer<CellCounterStatus.Model> {
275
+ /**
276
+ * Construct a new CellCounterStatus status item.
277
+ */
278
+ constructor(options: CellCounterStatus.IOptions = {}) {
279
+ super(new CellCounterStatus.Model());
280
+ this.addClass('jp-mod-highlighted');
281
+ this._translator = options.translator || nullTranslator;
282
+ }
283
+
284
+ /**
285
+ * Render the status item.
286
+ */
287
+ render(): JSX.Element | null {
288
+ if (this.model === null) {
289
+ return null;
290
+ }
291
+
292
+ return (
293
+ <CellCounterComponent
294
+ activeCell={this.model.activeCell}
295
+ selectionStart={this.model.selectionStart}
296
+ selectionEnd={this.model.selectionEnd}
297
+ totalCells={this.model.totalCells}
298
+ translator={this._translator}
299
+ handleClick={() => this._handleClick()}
300
+ />
301
+ );
302
+ }
303
+
304
+ /**
305
+ * A click handler for the widget.
306
+ */
307
+ private _handleClick(): void {
308
+ if (this.model!.totalCells < 1) {
309
+ return;
310
+ }
311
+
312
+ if (this._popup) {
313
+ this._popup.dispose();
314
+ }
315
+
316
+ const body = ReactWidget.create(
317
+ <CellNumberFormComponent
318
+ handleSubmit={value => this._handleSubmit(value)}
319
+ maxCell={this.model!.totalCells}
320
+ translator={this._translator}
321
+ />
322
+ );
323
+
324
+ this._popup = showPopup({
325
+ body,
326
+ anchor: this,
327
+ align: 'right'
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Handle submission for the widget.
333
+ */
334
+ private _handleSubmit(value: number): void {
335
+ const notebook = this.model!.notebook;
336
+ if (!notebook) {
337
+ return;
338
+ }
339
+
340
+ const cellIndex = value - 1;
341
+ notebook.activeCellIndex = cellIndex;
342
+ notebook.deselectAll();
343
+ void notebook.scrollToItem(cellIndex).catch(reason => {
344
+ console.error('Go to cell', reason);
345
+ });
346
+
347
+ this._popup?.dispose();
348
+ notebook.activate();
349
+ }
350
+
351
+ private _translator: ITranslator;
352
+ private _popup: Popup | null = null;
353
+ }
354
+
355
+ /**
356
+ * A namespace for CellCounterStatus statics.
357
+ */
358
+ export namespace CellCounterStatus {
359
+ /**
360
+ * Options for creating a CellCounterStatus item.
361
+ */
362
+ export interface IOptions {
363
+ /**
364
+ * The application language translator.
365
+ */
366
+ translator?: ITranslator;
367
+ }
368
+
369
+ /**
370
+ * Snapshot of the model state used for change detection.
371
+ */
372
+ interface IState {
373
+ /**
374
+ * Current active cell number (1-based).
375
+ */
376
+ activeCell: number;
377
+
378
+ /**
379
+ * First selected cell number (1-based).
380
+ */
381
+ selectionStart: number;
382
+
383
+ /**
384
+ * Last selected cell number (1-based).
385
+ */
386
+ selectionEnd: number;
387
+
388
+ /**
389
+ * Total number of cells.
390
+ */
391
+ totalCells: number;
392
+ }
393
+
394
+ /**
395
+ * A VDom model for a status item tracking active and total notebook cells.
396
+ */
397
+ export class Model extends VDomModel {
398
+ /**
399
+ * The notebook tracked by this model.
400
+ */
401
+ get notebook(): Notebook | null {
402
+ return this._notebook;
403
+ }
404
+
405
+ set notebook(notebook: Notebook | null) {
406
+ const oldNotebook = this._notebook;
407
+ if (oldNotebook) {
408
+ oldNotebook.activeCellChanged.disconnect(this._onChanged, this);
409
+ oldNotebook.modelContentChanged.disconnect(this._onChanged, this);
410
+ oldNotebook.selectionChanged.disconnect(this._onChanged, this);
411
+ }
412
+
413
+ const oldState = this._getAllState();
414
+ this._notebook = notebook;
415
+
416
+ if (!this._notebook) {
417
+ this._activeCell = 0;
418
+ this._selectionStart = 0;
419
+ this._selectionEnd = 0;
420
+ this._totalCells = 0;
421
+ } else {
422
+ this._notebook.activeCellChanged.connect(this._onChanged, this);
423
+ this._notebook.modelContentChanged.connect(this._onChanged, this);
424
+ this._notebook.selectionChanged.connect(this._onChanged, this);
425
+ this._updateStateFromNotebook(this._notebook);
426
+ }
427
+
428
+ this._triggerChange(oldState, this._getAllState());
429
+ }
430
+
431
+ /**
432
+ * The current active cell index shown to users (1-based).
433
+ */
434
+ get activeCell(): number {
435
+ return this._activeCell;
436
+ }
437
+
438
+ /**
439
+ * The first selected cell index shown to users (1-based).
440
+ */
441
+ get selectionStart(): number {
442
+ return this._selectionStart;
443
+ }
444
+
445
+ /**
446
+ * The last selected cell index shown to users (1-based).
447
+ */
448
+ get selectionEnd(): number {
449
+ return this._selectionEnd;
450
+ }
451
+
452
+ /**
453
+ * The total number of cells.
454
+ */
455
+ get totalCells(): number {
456
+ return this._totalCells;
457
+ }
458
+
459
+ /**
460
+ * React to notebook changes by refreshing the tracked state.
461
+ */
462
+ private _onChanged(notebook: Notebook): void {
463
+ const oldState = this._getAllState();
464
+ this._updateStateFromNotebook(notebook);
465
+ this._triggerChange(oldState, this._getAllState());
466
+ }
467
+
468
+ private _updateStateFromNotebook(notebook: Notebook): void {
469
+ const activeCellIndex = notebook.activeCellIndex;
470
+ this._activeCell = activeCellIndex >= 0 ? activeCellIndex + 1 : 0;
471
+ this._totalCells = notebook.widgets.length;
472
+
473
+ let selectionStart = this._activeCell;
474
+ let selectionEnd = this._activeCell;
475
+ let seenSelection = false;
476
+
477
+ notebook.widgets.forEach((cell, index) => {
478
+ if (!notebook.isSelectedOrActive(cell)) {
479
+ return;
480
+ }
481
+
482
+ const oneBasedIndex = index + 1;
483
+ if (!seenSelection) {
484
+ selectionStart = oneBasedIndex;
485
+ selectionEnd = oneBasedIndex;
486
+ seenSelection = true;
487
+ return;
488
+ }
489
+
490
+ selectionEnd = oneBasedIndex;
491
+ });
492
+
493
+ this._selectionStart = seenSelection ? selectionStart : 0;
494
+ this._selectionEnd = seenSelection ? selectionEnd : 0;
495
+ }
496
+
497
+ private _getAllState(): IState {
498
+ return {
499
+ activeCell: this._activeCell,
500
+ selectionStart: this._selectionStart,
501
+ selectionEnd: this._selectionEnd,
502
+ totalCells: this._totalCells
503
+ };
504
+ }
505
+
506
+ private _triggerChange(oldState: IState, newState: IState) {
507
+ if (
508
+ oldState.activeCell !== newState.activeCell ||
509
+ oldState.selectionStart !== newState.selectionStart ||
510
+ oldState.selectionEnd !== newState.selectionEnd ||
511
+ oldState.totalCells !== newState.totalCells
512
+ ) {
513
+ this.stateChanged.emit(void 0);
514
+ }
515
+ }
516
+
517
+ private _activeCell = 0;
518
+ private _selectionStart = 0;
519
+ private _selectionEnd = 0;
520
+ private _totalCells = 0;
521
+ private _notebook: Notebook | null = null;
522
+ }
523
+ }
@@ -363,7 +363,7 @@ export class CellTypeSwitcher extends ReactWidget {
363
363
  * Handle `keydown` events for the HTMLSelect component.
364
364
  */
365
365
  handleKeyDown = (event: React.KeyboardEvent): void => {
366
- if (event.keyCode === 13) {
366
+ if (event.key === 'Enter') {
367
367
  this._notebook.activate();
368
368
  }
369
369
  };
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  export * from './actions';
9
+ export * from './cellcounterstatus';
9
10
  export * from './cellexecutor';
10
11
  export * from './celllist';
11
12
  export * from './default-toolbar';
package/src/widget.ts CHANGED
@@ -614,6 +614,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
614
614
  for (const cell of cells) {
615
615
  this._insertCell(++index, cell);
616
616
  }
617
+ this._syncMarkdownCellTrust();
617
618
  newValue.cells.changed.connect(this._onCellsChanged, this);
618
619
  newValue.metadataChanged.connect(this.onMetadataChanged, this);
619
620
  newValue.contentChanged.connect(this.onModelContentChanged, this);
@@ -675,6 +676,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
675
676
  this.addHeader();
676
677
  }
677
678
 
679
+ this._syncMarkdownCellTrust();
678
680
  this.update();
679
681
  }
680
682
 
@@ -697,10 +699,12 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
697
699
  default:
698
700
  widget = this._createRawCell(cell as IRawCellModel);
699
701
  }
702
+ cell.stateChanged.connect(this._onCellStateChanged, this);
700
703
  widget.inViewportChanged.connect(this._onCellInViewportChanged, this);
701
704
  widget.addClass(NB_CELL_CLASS);
702
705
 
703
706
  ArrayExt.insert(this.cellsArray, index, widget);
707
+ this._syncMarkdownCellTrust(widget);
704
708
  this.onCellInserted(index, widget);
705
709
 
706
710
  this._scheduleCellRenderOnIdle();
@@ -725,9 +729,15 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
725
729
  translator: this.translator
726
730
  };
727
731
  const cell = this.contentFactory.createCodeCell(options);
728
- cell.syncCollapse = true;
729
- cell.syncEditable = true;
730
- cell.syncScrolled = true;
732
+ if (cell.syncCollapse === undefined) {
733
+ cell.syncCollapse = true;
734
+ }
735
+ if (cell.syncEditable === undefined) {
736
+ cell.syncEditable = true;
737
+ }
738
+ if (cell.syncScrolled === undefined) {
739
+ cell.syncScrolled = true;
740
+ }
731
741
  cell.outputArea.inputRequested.connect((_, stdin) => {
732
742
  this._onInputRequested(cell).catch(reason => {
733
743
  console.error('Failed to scroll to cell requesting input.', reason);
@@ -759,8 +769,12 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
759
769
  this._notebookConfig.showEditorForReadOnlyMarkdown
760
770
  };
761
771
  const cell = this.contentFactory.createMarkdownCell(options);
762
- cell.syncCollapse = true;
763
- cell.syncEditable = true;
772
+ if (cell.syncCollapse === undefined) {
773
+ cell.syncCollapse = true;
774
+ }
775
+ if (cell.syncEditable === undefined) {
776
+ cell.syncEditable = true;
777
+ }
764
778
  // Connect collapsed signal for each markdown cell widget
765
779
  cell.headingCollapsedChanged.connect(this._onCellCollapsed, this);
766
780
  return cell;
@@ -779,8 +793,12 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
779
793
  placeholder: this._notebookConfig.windowingMode !== 'none'
780
794
  };
781
795
  const cell = this.contentFactory.createRawCell(options);
782
- cell.syncCollapse = true;
783
- cell.syncEditable = true;
796
+ if (cell.syncCollapse === undefined) {
797
+ cell.syncCollapse = true;
798
+ }
799
+ if (cell.syncEditable === undefined) {
800
+ cell.syncEditable = true;
801
+ }
784
802
  return cell;
785
803
  }
786
804
 
@@ -789,12 +807,60 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
789
807
  */
790
808
  private _removeCell(index: number): void {
791
809
  const widget = this.cellsArray[index];
810
+ widget.model.stateChanged.disconnect(this._onCellStateChanged, this);
792
811
  widget.parent = null;
793
812
  ArrayExt.removeAt(this.cellsArray, index);
794
813
  this.onCellRemoved(index, widget);
795
814
  widget.dispose();
796
815
  }
797
816
 
817
+ private _shouldTrustMarkdown(): boolean {
818
+ // Note: this returns false in a notebook without trsuted code cells;
819
+ // This is intended since only Code cells carry trust status on disk.
820
+ if (!this._notebookModel) {
821
+ return false;
822
+ }
823
+ let hasCodeCell = false;
824
+ for (const cell of this._notebookModel.cells) {
825
+ if (cell.type === 'code') {
826
+ hasCodeCell = true;
827
+ if (!cell.trusted) {
828
+ return false;
829
+ }
830
+ }
831
+ }
832
+ return hasCodeCell;
833
+ }
834
+
835
+ private _syncMarkdownCellTrust(cell?: Cell): void {
836
+ const trusted = this._shouldTrustMarkdown();
837
+ const trustHandler = this.rendermime.trustHandler;
838
+ if (!trustHandler) {
839
+ return;
840
+ }
841
+
842
+ const cells = cell ? [cell] : this.widgets;
843
+ for (const widget of cells) {
844
+ if (!(widget instanceof MarkdownCell)) {
845
+ continue;
846
+ }
847
+ if (trusted) {
848
+ trustHandler.markTrusted(widget.node);
849
+ } else {
850
+ trustHandler.unmarkTrusted(widget.node);
851
+ }
852
+ }
853
+ }
854
+
855
+ private _onCellStateChanged(
856
+ model: ICellModel,
857
+ args: IChangedArgs<any>
858
+ ): void {
859
+ if (args.name === 'trusted') {
860
+ this._syncMarkdownCellTrust();
861
+ }
862
+ }
863
+
798
864
  /**
799
865
  * Update the mimetype of the notebook.
800
866
  */
@@ -821,6 +887,14 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
821
887
  cell
822
888
  .getHeadings()
823
889
  .then(() => {
890
+ // Heading parsing is async; ignore stale callbacks that no longer match
891
+ // the current collapsed state.
892
+ if (
893
+ cell.isDisposed ||
894
+ (cell instanceof MarkdownCell && cell.headingCollapsed !== collapsed)
895
+ ) {
896
+ return;
897
+ }
824
898
  NotebookActions.setHeadingCollapse(cell, collapsed, this);
825
899
  this._cellCollapsed.emit(cell);
826
900
  })
@@ -1635,6 +1709,7 @@ export class Notebook extends StaticNotebook {
1635
1709
  * Construct a notebook widget.
1636
1710
  */
1637
1711
  constructor(options: Notebook.IOptions) {
1712
+ const trans = (options.translator || nullTranslator).load('jupyterlab');
1638
1713
  super({
1639
1714
  renderer: {
1640
1715
  createOuter(): HTMLElement {
@@ -1644,7 +1719,7 @@ export class Notebook extends StaticNotebook {
1644
1719
  createViewport(): HTMLElement {
1645
1720
  const el = document.createElement('div');
1646
1721
  el.setAttribute('role', 'feed');
1647
- el.setAttribute('aria-label', 'Cells');
1722
+ el.setAttribute('aria-label', trans.__('Cells'));
1648
1723
  return el;
1649
1724
  },
1650
1725
 
@@ -2824,7 +2899,9 @@ export class Notebook extends StaticNotebook {
2824
2899
  if (widget && widget.editorWidget?.node.contains(target)) {
2825
2900
  // Prevent CodeMirror from focusing the editor.
2826
2901
  // TODO: find an editor-agnostic solution.
2827
- event.preventDefault();
2902
+ if (!target.closest('[data-jp-suppress-context-menu]')) {
2903
+ event.preventDefault();
2904
+ }
2828
2905
  }
2829
2906
  }
2830
2907