@jupyterlab/notebook 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.
@@ -154,7 +154,9 @@ export function ExecutionIndicatorComponent(
154
154
  return reactElement('busy', progressBar(percentage), [
155
155
  <span key={0}>
156
156
  {trans.__(
157
- `Executed ${executedCellNumber}/${scheduledCellNumber} cells`
157
+ 'Executed %1/%2 cells',
158
+ executedCellNumber,
159
+ scheduledCellNumber
158
160
  )}
159
161
  </span>,
160
162
  <span key={1}>
@@ -230,6 +232,22 @@ export class ExecutionIndicator extends VDomRenderer<ExecutionIndicator.Model> {
230
232
  this.addClass('jp-mod-highlighted');
231
233
  }
232
234
 
235
+ /**
236
+ * Dispose the widget and its model.
237
+ *
238
+ * `VDomRenderer.dispose` drops its reference to the model without
239
+ * disposing it; undisposed, the model stays connected to the session
240
+ * context (which outlives this widget) and its current notebook stays
241
+ * reachable. The model is owned here: it is created in the constructor.
242
+ */
243
+ dispose(): void {
244
+ if (this.isDisposed) {
245
+ return;
246
+ }
247
+ this.model?.dispose();
248
+ super.dispose();
249
+ }
250
+
233
251
  /**
234
252
  * Render the execution status item.
235
253
  */
@@ -378,13 +396,17 @@ export namespace ExecutionIndicator {
378
396
  this
379
397
  );
380
398
 
399
+ // The cleanup handlers are registered with this model as receiver:
400
+ // without one they would sit on the context until it is disposed,
401
+ // keeping the notebook captured by this scope reachable even after
402
+ // the model itself is disposed and cleared.
381
403
  context.disposed.connect(ctx => {
382
404
  ctx.connectionStatusChanged.disconnect(
383
405
  contextConnectionStatusChanged,
384
406
  this
385
407
  );
386
408
  ctx.statusChanged.disconnect(contextStatusChanged, this);
387
- });
409
+ }, this);
388
410
  const handleKernelMsg = (
389
411
  sender: Kernel.IKernelConnection,
390
412
  msg: Kernel.IAnyMessageArgs
@@ -413,9 +435,10 @@ export namespace ExecutionIndicator {
413
435
  this._startTimer(nb);
414
436
  }
415
437
  };
416
- context.session?.kernel?.anyMessage.connect(handleKernelMsg);
417
- context.session?.kernel?.disposed.connect(kernel =>
418
- kernel.anyMessage.disconnect(handleKernelMsg)
438
+ context.session?.kernel?.anyMessage.connect(handleKernelMsg, this);
439
+ context.session?.kernel?.disposed.connect(
440
+ kernel => kernel.anyMessage.disconnect(handleKernelMsg, this),
441
+ this
419
442
  );
420
443
  const kernelChangedSlot = (
421
444
  _: ISessionContext,
@@ -429,13 +452,14 @@ export namespace ExecutionIndicator {
429
452
  this._resetTime(state);
430
453
  this.stateChanged.emit(void 0);
431
454
  if (kernelData.newValue) {
432
- kernelData.newValue.anyMessage.connect(handleKernelMsg);
455
+ kernelData.newValue.anyMessage.connect(handleKernelMsg, this);
433
456
  }
434
457
  }
435
458
  };
436
- context.kernelChanged.connect(kernelChangedSlot);
437
- context.disposed.connect(ctx =>
438
- ctx.kernelChanged.disconnect(kernelChangedSlot)
459
+ context.kernelChanged.connect(kernelChangedSlot, this);
460
+ context.disposed.connect(
461
+ ctx => ctx.kernelChanged.disconnect(kernelChangedSlot, this),
462
+ this
439
463
  );
440
464
  }
441
465
  }
@@ -583,6 +607,15 @@ export namespace ExecutionIndicator {
583
607
  * @param data - the state to be updated.
584
608
  */
585
609
  private _tick(data: IExecutionState): void {
610
+ if (this.isDisposed) {
611
+ // The slots that would clear this interval are disconnected when the
612
+ // model is disposed, so the interval has to terminate itself
613
+ // (`_notebookExecutionProgress` is a `WeakMap`, so `dispose` cannot
614
+ // enumerate the states to clear them there).
615
+ clearInterval(data.interval);
616
+ clearTimeout(data.timeout);
617
+ return;
618
+ }
586
619
  data.totalTime += 1;
587
620
  this.stateChanged.emit(void 0);
588
621
  }
@@ -32,15 +32,28 @@ export class NotebookAdapter extends WidgetLSPAdapter<NotebookPanel> {
32
32
  this.editor = editorWidget.content;
33
33
  this._cellToEditor = new WeakMap();
34
34
  this.isReady = this.isReady.bind(this);
35
- Promise.all([
36
- this.widget.context.sessionContext.ready,
37
- this.connectionManager.ready
38
- ])
35
+ const sessionContextReady = this.widget.context.sessionContext.ready;
36
+ const connectionManagerReady = this.connectionManager.ready;
37
+ const adapter: { current: NotebookAdapter | null } = { current: this };
38
+ this.disposed.connect(() => {
39
+ adapter.current = null;
40
+ });
41
+ void Promise.all([sessionContextReady, connectionManagerReady])
39
42
  .then(async () => {
40
- await this.initOnceReady();
41
- this._readyDelegate.resolve();
43
+ const currentAdapter = adapter.current;
44
+ if (!currentAdapter) {
45
+ return;
46
+ }
47
+ await currentAdapter.initOnceReady();
48
+ currentAdapter._readyDelegate.resolve();
42
49
  })
43
- .catch(console.error);
50
+ .catch(reason => {
51
+ const currentAdapter = adapter.current;
52
+ if (!currentAdapter || currentAdapter.isDisposed) {
53
+ return;
54
+ }
55
+ console.error(reason);
56
+ });
44
57
  }
45
58
 
46
59
  /**
@@ -185,8 +198,14 @@ export class NotebookAdapter extends WidgetLSPAdapter<NotebookPanel> {
185
198
  try {
186
199
  // note: we need to wait until ready before updating language info
187
200
  const oldLanguageInfo = this._languageInfo;
188
- await untilReady(this.isReady, -1);
201
+ await untilReady(() => this.isDisposed || this.isReady(), -1);
202
+ if (this.isDisposed) {
203
+ return;
204
+ }
189
205
  await this._updateLanguageInfo();
206
+ if (this.isDisposed) {
207
+ return;
208
+ }
190
209
  const newLanguageInfo = this._languageInfo;
191
210
  if (
192
211
  oldLanguageInfo?.name != newLanguageInfo.name ||
@@ -350,8 +369,14 @@ export class NotebookAdapter extends WidgetLSPAdapter<NotebookPanel> {
350
369
  * connect various signals.
351
370
  */
352
371
  protected async initOnceReady(): Promise<void> {
353
- await untilReady(this.isReady.bind(this), -1);
372
+ await untilReady(() => this.isDisposed || this.isReady(), -1);
373
+ if (this.isDisposed) {
374
+ return;
375
+ }
354
376
  await this._updateLanguageInfo();
377
+ if (this.isDisposed) {
378
+ return;
379
+ }
355
380
  this.initVirtual();
356
381
 
357
382
  // connect the document, but do not open it as the adapter will handle this
package/src/panel.ts CHANGED
@@ -6,6 +6,7 @@ import { isMarkdownCellModel } from '@jupyterlab/cells';
6
6
  import { PageConfig } from '@jupyterlab/coreutils';
7
7
  import type { DocumentRegistry } from '@jupyterlab/docregistry';
8
8
  import { DocumentWidget } from '@jupyterlab/docregistry';
9
+ import type * as nbformat from '@jupyterlab/nbformat';
9
10
  import type { Kernel, KernelMessage, Session } from '@jupyterlab/services';
10
11
  import type { ITranslator } from '@jupyterlab/translation';
11
12
  import { Token } from '@lumino/coreutils';
@@ -250,10 +251,15 @@ export class NotebookPanel extends DocumentWidget<Notebook, INotebookModel> {
250
251
  if (this.isDisposed) {
251
252
  return;
252
253
  }
254
+ const oldSpec = this.model!.getMetadata(
255
+ 'kernelspec'
256
+ ) as nbformat.IKernelspecMetadata;
257
+ const preserved = oldSpec?.name === kernel.name ? oldSpec : undefined;
253
258
  this.model!.setMetadata('kernelspec', {
254
259
  name: kernel.name,
255
- display_name: spec?.display_name,
256
- language: spec?.language
260
+ display_name:
261
+ spec?.display_name ?? preserved?.display_name ?? kernel.name,
262
+ language: spec?.language ?? preserved?.language
257
263
  });
258
264
  }
259
265
 
@@ -24,6 +24,7 @@ import type { IObservableList, IObservableMap } from '@jupyterlab/observables';
24
24
  import type { ITranslator } from '@jupyterlab/translation';
25
25
  import { nullTranslator } from '@jupyterlab/translation';
26
26
  import { ArrayExt } from '@lumino/algorithm';
27
+ import { Debouncer } from '@lumino/polling';
27
28
  import type { Widget } from '@lumino/widgets';
28
29
  import type { CellList } from './celllist';
29
30
  import { NotebookPanel } from './panel';
@@ -66,18 +67,9 @@ export class NotebookSearchProvider extends SearchProvider<NotebookPanel> {
66
67
 
67
68
  private _onNotebookStateChanged(_: Notebook, args: IChangedArgs<unknown>) {
68
69
  if (args.name === 'mode') {
69
- // Delay the update to ensure that `document.activeElement` settled.
70
- window.setTimeout(() => {
71
- if (
72
- args.newValue === 'command' &&
73
- document.activeElement?.closest('.jp-DocumentSearch-overlay')
74
- ) {
75
- // Do not request updating mode when user switched focus to search overlay.
76
- return;
77
- }
78
- this._updateSelectionMode();
79
- this._filtersChanged.emit();
80
- }, 0);
70
+ // Debounce to ensure that `document.activeElement` settled, and to keep
71
+ // only the most recent mode transition.
72
+ void this._modeChangeDebouncer.invoke();
81
73
  }
82
74
  }
83
75
 
@@ -206,6 +198,8 @@ export class NotebookSearchProvider extends SearchProvider<NotebookPanel> {
206
198
  );
207
199
  this._stopObservingLastCell();
208
200
 
201
+ this._modeChangeDebouncer.dispose();
202
+
209
203
  super.dispose();
210
204
 
211
205
  const index = this.widget.content.activeCellIndex;
@@ -794,7 +788,8 @@ export class NotebookSearchProvider extends SearchProvider<NotebookPanel> {
794
788
  return;
795
789
  }
796
790
  const currentMatch = searchEngine.getCurrentMatch();
797
- if (!currentMatch && this.matchesCount) {
791
+ const matchesCount = this.matchesCount;
792
+ if (!currentMatch && matchesCount !== null && matchesCount > 0) {
798
793
  // Select a match as current by highlighting next (with looping) from
799
794
  // the selection start, to prevent "current" match from jumping around.
800
795
  await this.highlightNext(true, {
@@ -930,6 +925,19 @@ export class NotebookSearchProvider extends SearchProvider<NotebookPanel> {
930
925
  protected delayedActiveCellChangeHandlerReady: Promise<void>;
931
926
  private _currentProviderIndex: number | null = null;
932
927
  private _delayedActiveCellChangeHandler: number | null = null;
928
+ private _modeChangeDebouncer = new Debouncer(() => {
929
+ // The mode can change again before this handler runs; check the
930
+ // current mode because this is what `_updateSelectionMode()` acts on.
931
+ if (
932
+ this.widget.content.mode === 'command' &&
933
+ document.activeElement?.closest('.jp-DocumentSearch-overlay')
934
+ ) {
935
+ // Do not request updating mode when user switched focus to search overlay.
936
+ return;
937
+ }
938
+ this._updateSelectionMode();
939
+ this._filtersChanged.emit();
940
+ }, 0);
933
941
  private _filters: IFilters | undefined;
934
942
  private _onSelection = false;
935
943
  private _selectedCells: number = 1;
package/src/testutils.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import type { ISessionContext } from '@jupyterlab/apputils';
6
6
  import {
7
7
  Clipboard,
8
+ Sanitizer,
8
9
  SessionContextDialogs,
9
10
  SystemClipboard
10
11
  } from '@jupyterlab/apputils';
@@ -202,10 +203,17 @@ export namespace NBTestUtils {
202
203
  kernelHistory: new NotebookHistory({ sessionContext: sessionContext })
203
204
  }
204
205
  : {};
206
+ // Render textual outputs synchronously in tests: the incremental
207
+ // (animation-frame based) pipeline bails out immediately for hosts that
208
+ // are not attached to the document, which is the norm in unit tests that
209
+ // never call `Widget.attach`.
210
+ const sanitizer = new Sanitizer();
211
+ sanitizer.setIncrementalAutolink(false);
205
212
  return new Notebook({
206
213
  rendermime: new RenderMimeRegistry({
207
214
  markdownParser: parser,
208
- initialFactories: standardRendererFactories
215
+ initialFactories: standardRendererFactories,
216
+ sanitizer
209
217
  }),
210
218
  contentFactory: createNotebookFactory(),
211
219
  mimeTypeService,
package/src/toc.ts CHANGED
@@ -244,8 +244,10 @@ export class NotebookToCModel extends TableOfContentsModel<
244
244
  */
245
245
  protected async getHeadings(): Promise<INotebookHeading[] | null> {
246
246
  const cells = this.widget.content.widgets;
247
+ const currentCells = new Set(cells);
247
248
  const headings: INotebookHeading[] = [];
248
249
  const documentLevels = new Array<number>();
250
+ const cellToHeadingIndex = new WeakMap<Cell, number>();
249
251
 
250
252
  // Generate headings by iterating through all notebook cells...
251
253
  for (let i = 0; i < cells.length; i++) {
@@ -306,12 +308,15 @@ export class NotebookToCModel extends TableOfContentsModel<
306
308
  }
307
309
 
308
310
  if (headings.length > 0) {
309
- this._cellToHeadingIndex.set(cell, headings.length - 1);
310
- } else {
311
- // If no headings were found, remove the cell from the map
312
- this._cellToHeadingIndex.delete(cell);
311
+ cellToHeadingIndex.set(cell, headings.length - 1);
313
312
  }
314
313
  }
314
+
315
+ this._cellToHeadingIndex = cellToHeadingIndex;
316
+ this._runningCells = this._runningCells.filter(cell =>
317
+ currentCells.has(cell)
318
+ );
319
+ this._errorCells = this._errorCells.filter(cell => currentCells.has(cell));
315
320
  this.updateRunningStatus(headings);
316
321
  return Promise.resolve(headings);
317
322
  }
@@ -329,7 +334,8 @@ export class NotebookToCModel extends TableOfContentsModel<
329
334
  ): boolean {
330
335
  return (
331
336
  super.isHeadingEqual(heading1, heading2) &&
332
- heading1.cellRef === heading2.cellRef
337
+ heading1.cellRef === heading2.cellRef &&
338
+ heading1.isRunning === heading2.isRunning
333
339
  );
334
340
  }
335
341
 
@@ -463,11 +469,15 @@ export class NotebookToCModel extends TableOfContentsModel<
463
469
  }
464
470
 
465
471
  protected updateRunningStatus(headings: INotebookHeading[]): void {
472
+ headings.forEach(heading => {
473
+ heading.isRunning = RunningStatus.Idle;
474
+ });
475
+
466
476
  // Update isRunning
467
477
  this._runningCells.forEach((cell, index) => {
468
478
  const headingIndex = this._cellToHeadingIndex.get(cell);
469
479
  if (headingIndex !== undefined) {
470
- const heading = this.headings[headingIndex];
480
+ const heading = headings[headingIndex];
471
481
  // Running is prioritized over Scheduled, so if a heading is
472
482
  // running don't change status
473
483
  if (heading.isRunning !== RunningStatus.Running) {
@@ -480,7 +490,7 @@ export class NotebookToCModel extends TableOfContentsModel<
480
490
  this._errorCells.forEach((cell, index) => {
481
491
  const headingIndex = this._cellToHeadingIndex.get(cell);
482
492
  if (headingIndex !== undefined) {
483
- const heading = this.headings[headingIndex];
493
+ const heading = headings[headingIndex];
484
494
  // Running and Scheduled are prioritized over Error, so only if
485
495
  // a heading is idle will it be set to Error
486
496
  if (heading.isRunning === RunningStatus.Idle) {
package/src/widget.ts CHANGED
@@ -386,6 +386,12 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
386
386
  this._contentVisibilityObserver = null;
387
387
  }
388
388
  super.dispose();
389
+ // Dispose cells that windowing modes may have detached from the layout.
390
+ for (const cell of this.cellsArray) {
391
+ cell.dispose();
392
+ }
393
+ this.cellsArray.length = 0;
394
+ this.viewModel.dispose();
389
395
  }
390
396
 
391
397
  protected onBeforeDetach(msg: Message): void {
@@ -620,7 +626,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
620
626
  for (const cell of cells) {
621
627
  this._insertCell(++index, cell);
622
628
  }
623
- this._syncMarkdownCellTrust();
629
+ this._syncCellTrust();
624
630
  newValue.cells.changed.connect(this._onCellsChanged, this);
625
631
  newValue.metadataChanged.connect(this.onMetadataChanged, this);
626
632
  newValue.contentChanged.connect(this.onModelContentChanged, this);
@@ -682,7 +688,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
682
688
  this.addHeader();
683
689
  }
684
690
 
685
- this._syncMarkdownCellTrust();
691
+ this._syncCellTrust();
686
692
  this.update();
687
693
  }
688
694
 
@@ -710,7 +716,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
710
716
  widget.addClass(NB_CELL_CLASS);
711
717
 
712
718
  ArrayExt.insert(this.cellsArray, index, widget);
713
- this._syncMarkdownCellTrust(widget);
719
+ this._syncCellTrust(widget);
714
720
  this.onCellInserted(index, widget);
715
721
 
716
722
  this._scheduleCellRenderOnIdle();
@@ -821,8 +827,8 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
821
827
  widget.dispose();
822
828
  }
823
829
 
824
- private _shouldTrustMarkdown(): boolean {
825
- // Note: this returns false in a notebook without trsuted code cells;
830
+ private _shouldTrustCell(): boolean {
831
+ // Note: this returns false in a notebook without trusted code cells;
826
832
  // This is intended since only Code cells carry trust status on disk.
827
833
  if (!this._notebookModel) {
828
834
  return false;
@@ -839,13 +845,19 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
839
845
  return hasCodeCell;
840
846
  }
841
847
 
842
- private _syncMarkdownCellTrust(cell?: Cell): void {
843
- const trusted = this._shouldTrustMarkdown();
848
+ private _syncCellTrust(cell?: Cell): void {
849
+ const trusted = this._shouldTrustCell();
844
850
  const trustHandler = this.rendermime.trustHandler;
845
851
  if (!trustHandler) {
846
852
  return;
847
853
  }
848
854
 
855
+ if (trusted) {
856
+ trustHandler.markTrusted(this.node);
857
+ } else {
858
+ trustHandler.unmarkTrusted(this.node);
859
+ }
860
+
849
861
  const cells = cell ? [cell] : this.widgets;
850
862
  for (const widget of cells) {
851
863
  if (!(widget instanceof MarkdownCell)) {
@@ -864,7 +876,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
864
876
  args: IChangedArgs<any>
865
877
  ): void {
866
878
  if (args.name === 'trusted') {
867
- this._syncMarkdownCellTrust();
879
+ this._syncCellTrust();
868
880
  }
869
881
  }
870
882
 
@@ -970,7 +982,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
970
982
 
971
983
  private _scheduleCellRenderOnIdle() {
972
984
  if (this.notebookConfig.windowingMode !== 'none' && !this.isDisposed) {
973
- if (!this._idleCallBack) {
985
+ if (this._idleCallBack === null) {
974
986
  this._idleCallBack = requestIdleCallback(
975
987
  (deadline: IdleDeadline) => {
976
988
  this._idleCallBack = null;
@@ -1073,7 +1085,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
1073
1085
  this._scheduleCellRenderOnIdle();
1074
1086
  }
1075
1087
  } else {
1076
- if (this._idleCallBack) {
1088
+ if (this._idleCallBack !== null) {
1077
1089
  window.cancelIdleCallback(this._idleCallBack);
1078
1090
  this._idleCallBack = null;
1079
1091
  }
@@ -1206,7 +1218,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
1206
1218
  this._contentVisibilityObserver!.observe(cell.node);
1207
1219
  });
1208
1220
  });
1209
- });
1221
+ }, this);
1210
1222
  }
1211
1223
 
1212
1224
  protected cellsArray: Array<Cell>;
@@ -1678,7 +1690,7 @@ class ScrollbarItem implements WindowedList.IRenderer.IScrollbarItem {
1678
1690
  state = 'error';
1679
1691
  } else if (model.executionState == 'running') {
1680
1692
  content = '[*]';
1681
- } else if (model.executionCount) {
1693
+ } else if (model.executionCount !== null) {
1682
1694
  content = `[${model.executionCount}]`;
1683
1695
  } else {
1684
1696
  content = '[ ]';
@@ -2883,6 +2895,10 @@ export class Notebook extends StaticNotebook {
2883
2895
  * Ensure that the notebook has proper focus.
2884
2896
  */
2885
2897
  private _ensureFocus(force = false): void {
2898
+ const activeElement = document.activeElement;
2899
+ if (!force && activeElement && !this.node.contains(activeElement)) {
2900
+ return;
2901
+ }
2886
2902
  // No-op is the footer has the focus.
2887
2903
  const footer = (this.layout as NotebookWindowedLayout).footer;
2888
2904
  if (footer && document.activeElement === footer.node) {
@@ -2918,16 +2934,84 @@ export class Notebook extends StaticNotebook {
2918
2934
  }
2919
2935
  }
2920
2936
 
2937
+ /**
2938
+ * Find the cell containing a node, traversing open shadow roots.
2939
+ *
2940
+ * Returned cell is `null` if the node is not in a cell of this notebook.
2941
+ */
2942
+ private _cellContaining(node: Node | null): Cell | null {
2943
+ let current = node;
2944
+ while (current) {
2945
+ const element =
2946
+ current instanceof Element ? current : current.parentElement;
2947
+ if (element) {
2948
+ const index = this._findCell(element);
2949
+ if (index !== -1) {
2950
+ return this.widgets[index];
2951
+ }
2952
+ }
2953
+ const root = (element ?? current).getRootNode();
2954
+ current = root instanceof ShadowRoot ? root.host : null;
2955
+ }
2956
+ return null;
2957
+ }
2958
+
2959
+ /**
2960
+ * Whether a shift-click on `target` should be left to the browser to handle
2961
+ * as an extension of the current text selection, rather than be turned into
2962
+ * a cell range selection.
2963
+ *
2964
+ * This is the case when the click lands in a region of a cell whose text the
2965
+ * browser selects - the output area of a code cell, or the rendered input of
2966
+ * a rendered markdown cell - and the current selection starts in such a
2967
+ * region too; see https://github.com/jupyterlab/jupyterlab/issues/4800. A
2968
+ * click on a cell prompt, on cell chrome, or on a cell without such a region
2969
+ * (an unrendered markdown cell, a code cell without outputs) always selects
2970
+ * cells.
2971
+ *
2972
+ * #### Notes
2973
+ * Only the anchor of the selection is considered, because a shift-click
2974
+ * moves the focus and leaves the anchor in place. The anchor may sit in a
2975
+ * different cell than the one clicked, which is what allows a selection to
2976
+ * be extended across the outputs of several cells; and disregarding the
2977
+ * focus keeps this working when a drag overshot the edge of an output and
2978
+ * left the focus outside of it.
2979
+ *
2980
+ * This trade-off between extending the text selection and extending the cell
2981
+ * selection can be adjusted in the future. For now the text selection wins to
2982
+ * preserve the behaviour from before refactor that users may be accustomed to.
2983
+ */
2984
+ private _isExtendingTextSelection(
2985
+ selection: Selection | null,
2986
+ targetCell: Cell,
2987
+ target: Node
2988
+ ): boolean {
2989
+ if (!selection || selection.isCollapsed) {
2990
+ return false;
2991
+ }
2992
+ if (!Private.isInTextRegion(targetCell, target)) {
2993
+ return false;
2994
+ }
2995
+ const { anchorNode } = selection;
2996
+ // The selection usually starts in the very cell which was clicked, which
2997
+ // can be checked without having to locate the cell of the anchor.
2998
+ if (Private.isInTextRegion(targetCell, anchorNode)) {
2999
+ return true;
3000
+ }
3001
+ const anchorCell = this._cellContaining(anchorNode);
3002
+ return !!anchorCell && Private.isInTextRegion(anchorCell, anchorNode);
3003
+ }
3004
+
2921
3005
  /**
2922
3006
  * Find the cell index containing the target html element.
2923
3007
  *
2924
3008
  * #### Notes
2925
3009
  * Returns -1 if the cell is not found.
2926
3010
  */
2927
- private _findCell(node: HTMLElement): number {
3011
+ private _findCell(node: Element): number {
2928
3012
  // Trace up the DOM hierarchy to find the root cell node.
2929
3013
  // Then find the corresponding child and select it.
2930
- let n: HTMLElement | null = node;
3014
+ let n: Element | null = node;
2931
3015
  while (n && n !== this.node) {
2932
3016
  if (n.classList.contains(NB_CELL_CLASS)) {
2933
3017
  const i = ArrayExt.findFirstIndex(
@@ -3161,18 +3245,22 @@ export class Notebook extends StaticNotebook {
3161
3245
  if (targetArea === 'notebook') {
3162
3246
  this.deselectAll();
3163
3247
  } else if (targetArea === 'prompt' || targetArea === 'cell') {
3164
- // We don't want to prevent the default selection behavior
3165
- // if there is currently text selected in an output.
3166
- const hasSelection = (window.getSelection() ?? '').toString() !== '';
3167
3248
  if (
3168
3249
  button === 0 &&
3169
3250
  shiftKey &&
3170
- !hasSelection &&
3171
- !['INPUT', 'OPTION'].includes(target.tagName)
3251
+ !['INPUT', 'OPTION'].includes(target.tagName) &&
3252
+ // We don't want to prevent the default selection behavior when the
3253
+ // user is extending a text selection into the clicked region.
3254
+ !this._isExtendingTextSelection(window.getSelection(), widget, target)
3172
3255
  ) {
3173
3256
  // Prevent browser selecting text in prompt or output
3174
3257
  event.preventDefault();
3175
3258
 
3259
+ // Preventing the default also stops the browser from collapsing any
3260
+ // text selection which is already there; drop it explicitly so that
3261
+ // stale highlighted text is not left over the new cell selection.
3262
+ window.getSelection()?.removeAllRanges();
3263
+
3176
3264
  // Shift-click - extend selection
3177
3265
  try {
3178
3266
  this.extendContiguousSelectionTo(index);
@@ -3470,7 +3558,7 @@ export class Notebook extends StaticNotebook {
3470
3558
  const executionCount = (activeCell.model as ICodeCellModel)
3471
3559
  .executionCount;
3472
3560
  countString = ' ';
3473
- if (executionCount) {
3561
+ if (executionCount !== null) {
3474
3562
  countString = executionCount.toString();
3475
3563
  }
3476
3564
  } else {
@@ -3825,6 +3913,62 @@ namespace Private {
3825
3913
  }
3826
3914
  }
3827
3915
 
3916
+ /**
3917
+ * Check whether `node` is `parent` or a descendant of it.
3918
+ *
3919
+ * Unlike `Node.contains()` this traverses out of open shadow roots, so a
3920
+ * node rendered by a widget library which attaches a shadow root (such as
3921
+ * Panel or Bokeh) is still recognised as belonging to its host subtree.
3922
+ */
3923
+ function containsDeep(parent: Node, node: Node | null): boolean {
3924
+ let current = node;
3925
+ while (current) {
3926
+ if (parent.contains(current)) {
3927
+ return true;
3928
+ }
3929
+ const root = current.getRootNode();
3930
+ current = root instanceof ShadowRoot ? root.host : null;
3931
+ }
3932
+ return false;
3933
+ }
3934
+
3935
+ /**
3936
+ * Whether a cell exposes an output area.
3937
+ *
3938
+ * #### Notes
3939
+ * This is a structural check rather than `instanceof CodeCell` so that it
3940
+ * still holds when a federated extension loads its own copy of
3941
+ * `@jupyterlab/cells`.
3942
+ */
3943
+ function hasOutputArea(cell: Cell): cell is CodeCell {
3944
+ return 'outputArea' in cell;
3945
+ }
3946
+
3947
+ /**
3948
+ * The regions of a cell whose text the browser, rather than the notebook,
3949
+ * is responsible for selecting: the output area of a code cell and the
3950
+ * rendered input of a cell which renders it, such as a rendered markdown
3951
+ * cell.
3952
+ */
3953
+ function textRegionsOf(cell: Cell): Node[] {
3954
+ const regions: Node[] = [];
3955
+ if (hasOutputArea(cell)) {
3956
+ regions.push(cell.outputArea.node);
3957
+ }
3958
+ const rendered = cell.inputArea?.renderedInput;
3959
+ if (rendered) {
3960
+ regions.push(rendered.node);
3961
+ }
3962
+ return regions;
3963
+ }
3964
+
3965
+ /**
3966
+ * Whether a node is in a region of a cell whose text the browser selects.
3967
+ */
3968
+ export function isInTextRegion(cell: Cell, node: Node | null): boolean {
3969
+ return textRegionsOf(cell).some(region => containsDeep(region, node));
3970
+ }
3971
+
3828
3972
  /**
3829
3973
  * Create a cell drag image.
3830
3974
  */
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Jupyter Development Team.
2
2
  // Distributed under the terms of the Modified BSD License.
3
- /* eslint-disable @typescript-eslint/no-explicit-any */
4
-
5
3
  import type { IEditorMimeTypeService } from '@jupyterlab/codeeditor';
6
4
  import type { DocumentRegistry } from '@jupyterlab/docregistry';
7
5
  import { ABCWidgetFactory } from '@jupyterlab/docregistry';
@@ -82,7 +80,7 @@ export class NotebookWidgetFactory extends ABCWidgetFactory<
82
80
  context: DocumentRegistry.IContext<INotebookModel>,
83
81
  source?: NotebookPanel
84
82
  ): NotebookPanel {
85
- const translator = (context as any).translator;
83
+ const translator = this.translator;
86
84
  const kernelHistory = new NotebookHistory({
87
85
  sessionContext: context.sessionContext,
88
86
  translator: translator