@jupyterlab/notebook 4.6.2 → 4.6.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.
@@ -26,6 +26,7 @@ import type { IObservableList, IObservableMap } from '@jupyterlab/observables';
26
26
  import type { ITranslator } from '@jupyterlab/translation';
27
27
  import { nullTranslator } from '@jupyterlab/translation';
28
28
  import { ArrayExt } from '@lumino/algorithm';
29
+ import { Debouncer } from '@lumino/polling';
29
30
  import type { Widget } from '@lumino/widgets';
30
31
  import type { CellList } from './celllist';
31
32
  import { NotebookPanel } from './panel';
@@ -68,18 +69,9 @@ export class NotebookSearchProvider extends SearchProvider<NotebookPanel> {
68
69
 
69
70
  private _onNotebookStateChanged(_: Notebook, args: IChangedArgs<any>) {
70
71
  if (args.name === 'mode') {
71
- // Delay the update to ensure that `document.activeElement` settled.
72
- window.setTimeout(() => {
73
- if (
74
- args.newValue === 'command' &&
75
- document.activeElement?.closest('.jp-DocumentSearch-overlay')
76
- ) {
77
- // Do not request updating mode when user switched focus to search overlay.
78
- return;
79
- }
80
- this._updateSelectionMode();
81
- this._filtersChanged.emit();
82
- }, 0);
72
+ // Debounce to ensure that `document.activeElement` settled, and to keep
73
+ // only the most recent mode transition.
74
+ void this._modeChangeDebouncer.invoke();
83
75
  }
84
76
  }
85
77
 
@@ -208,6 +200,8 @@ export class NotebookSearchProvider extends SearchProvider<NotebookPanel> {
208
200
  );
209
201
  this._stopObservingLastCell();
210
202
 
203
+ this._modeChangeDebouncer.dispose();
204
+
211
205
  super.dispose();
212
206
 
213
207
  const index = this.widget.content.activeCellIndex;
@@ -932,6 +926,19 @@ export class NotebookSearchProvider extends SearchProvider<NotebookPanel> {
932
926
  protected delayedActiveCellChangeHandlerReady: Promise<void>;
933
927
  private _currentProviderIndex: number | null = null;
934
928
  private _delayedActiveCellChangeHandler: number | null = null;
929
+ private _modeChangeDebouncer = new Debouncer(() => {
930
+ // The mode can change again before this handler runs; check the
931
+ // current mode because this is what `_updateSelectionMode()` acts on.
932
+ if (
933
+ this.widget.content.mode === 'command' &&
934
+ document.activeElement?.closest('.jp-DocumentSearch-overlay')
935
+ ) {
936
+ // Do not request updating mode when user switched focus to search overlay.
937
+ return;
938
+ }
939
+ this._updateSelectionMode();
940
+ this._filtersChanged.emit();
941
+ }, 0);
935
942
  private _filters: IFilters | undefined;
936
943
  private _onSelection = false;
937
944
  private _selectedCells: number = 1;
package/src/widget.ts CHANGED
@@ -618,7 +618,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
618
618
  for (const cell of cells) {
619
619
  this._insertCell(++index, cell);
620
620
  }
621
- this._syncMarkdownCellTrust();
621
+ this._syncCellTrust();
622
622
  newValue.cells.changed.connect(this._onCellsChanged, this);
623
623
  newValue.metadataChanged.connect(this.onMetadataChanged, this);
624
624
  newValue.contentChanged.connect(this.onModelContentChanged, this);
@@ -680,7 +680,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
680
680
  this.addHeader();
681
681
  }
682
682
 
683
- this._syncMarkdownCellTrust();
683
+ this._syncCellTrust();
684
684
  this.update();
685
685
  }
686
686
 
@@ -708,7 +708,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
708
708
  widget.addClass(NB_CELL_CLASS);
709
709
 
710
710
  ArrayExt.insert(this.cellsArray, index, widget);
711
- this._syncMarkdownCellTrust(widget);
711
+ this._syncCellTrust(widget);
712
712
  this.onCellInserted(index, widget);
713
713
 
714
714
  this._scheduleCellRenderOnIdle();
@@ -818,8 +818,8 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
818
818
  widget.dispose();
819
819
  }
820
820
 
821
- private _shouldTrustMarkdown(): boolean {
822
- // Note: this returns false in a notebook without trsuted code cells;
821
+ private _shouldTrustCell(): boolean {
822
+ // Note: this returns false in a notebook without trusted code cells;
823
823
  // This is intended since only Code cells carry trust status on disk.
824
824
  if (!this._notebookModel) {
825
825
  return false;
@@ -836,13 +836,19 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
836
836
  return hasCodeCell;
837
837
  }
838
838
 
839
- private _syncMarkdownCellTrust(cell?: Cell): void {
840
- const trusted = this._shouldTrustMarkdown();
839
+ private _syncCellTrust(cell?: Cell): void {
840
+ const trusted = this._shouldTrustCell();
841
841
  const trustHandler = this.rendermime.trustHandler;
842
842
  if (!trustHandler) {
843
843
  return;
844
844
  }
845
845
 
846
+ if (trusted) {
847
+ trustHandler.markTrusted(this.node);
848
+ } else {
849
+ trustHandler.unmarkTrusted(this.node);
850
+ }
851
+
846
852
  const cells = cell ? [cell] : this.widgets;
847
853
  for (const widget of cells) {
848
854
  if (!(widget instanceof MarkdownCell)) {
@@ -861,7 +867,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
861
867
  args: IChangedArgs<any>
862
868
  ): void {
863
869
  if (args.name === 'trusted') {
864
- this._syncMarkdownCellTrust();
870
+ this._syncCellTrust();
865
871
  }
866
872
  }
867
873
 
@@ -1203,7 +1209,7 @@ export class StaticNotebook extends WindowedList<NotebookViewModel> {
1203
1209
  this._contentVisibilityObserver!.observe(cell.node);
1204
1210
  });
1205
1211
  });
1206
- });
1212
+ }, this);
1207
1213
  }
1208
1214
 
1209
1215
  protected cellsArray: Array<Cell>;
@@ -2873,16 +2879,84 @@ export class Notebook extends StaticNotebook {
2873
2879
  }
2874
2880
  }
2875
2881
 
2882
+ /**
2883
+ * Find the cell containing a node, traversing open shadow roots.
2884
+ *
2885
+ * Returned cell is `null` if the node is not in a cell of this notebook.
2886
+ */
2887
+ private _cellContaining(node: Node | null): Cell | null {
2888
+ let current = node;
2889
+ while (current) {
2890
+ const element =
2891
+ current instanceof Element ? current : current.parentElement;
2892
+ if (element) {
2893
+ const index = this._findCell(element);
2894
+ if (index !== -1) {
2895
+ return this.widgets[index];
2896
+ }
2897
+ }
2898
+ const root = (element ?? current).getRootNode();
2899
+ current = root instanceof ShadowRoot ? root.host : null;
2900
+ }
2901
+ return null;
2902
+ }
2903
+
2904
+ /**
2905
+ * Whether a shift-click on `target` should be left to the browser to handle
2906
+ * as an extension of the current text selection, rather than be turned into
2907
+ * a cell range selection.
2908
+ *
2909
+ * This is the case when the click lands in a region of a cell whose text the
2910
+ * browser selects - the output area of a code cell, or the rendered input of
2911
+ * a rendered markdown cell - and the current selection starts in such a
2912
+ * region too; see https://github.com/jupyterlab/jupyterlab/issues/4800. A
2913
+ * click on a cell prompt, on cell chrome, or on a cell without such a region
2914
+ * (an unrendered markdown cell, a code cell without outputs) always selects
2915
+ * cells.
2916
+ *
2917
+ * #### Notes
2918
+ * Only the anchor of the selection is considered, because a shift-click
2919
+ * moves the focus and leaves the anchor in place. The anchor may sit in a
2920
+ * different cell than the one clicked, which is what allows a selection to
2921
+ * be extended across the outputs of several cells; and disregarding the
2922
+ * focus keeps this working when a drag overshot the edge of an output and
2923
+ * left the focus outside of it.
2924
+ *
2925
+ * This trade-off between extending the text selection and extending the cell
2926
+ * selection can be adjusted in the future. For now the text selection wins to
2927
+ * preserve the behaviour from before refactor that users may be accustomed to.
2928
+ */
2929
+ private _isExtendingTextSelection(
2930
+ selection: Selection | null,
2931
+ targetCell: Cell,
2932
+ target: Node
2933
+ ): boolean {
2934
+ if (!selection || selection.isCollapsed) {
2935
+ return false;
2936
+ }
2937
+ if (!Private.isInTextRegion(targetCell, target)) {
2938
+ return false;
2939
+ }
2940
+ const { anchorNode } = selection;
2941
+ // The selection usually starts in the very cell which was clicked, which
2942
+ // can be checked without having to locate the cell of the anchor.
2943
+ if (Private.isInTextRegion(targetCell, anchorNode)) {
2944
+ return true;
2945
+ }
2946
+ const anchorCell = this._cellContaining(anchorNode);
2947
+ return !!anchorCell && Private.isInTextRegion(anchorCell, anchorNode);
2948
+ }
2949
+
2876
2950
  /**
2877
2951
  * Find the cell index containing the target html element.
2878
2952
  *
2879
2953
  * #### Notes
2880
2954
  * Returns -1 if the cell is not found.
2881
2955
  */
2882
- private _findCell(node: HTMLElement): number {
2956
+ private _findCell(node: Element): number {
2883
2957
  // Trace up the DOM hierarchy to find the root cell node.
2884
2958
  // Then find the corresponding child and select it.
2885
- let n: HTMLElement | null = node;
2959
+ let n: Element | null = node;
2886
2960
  while (n && n !== this.node) {
2887
2961
  if (n.classList.contains(NB_CELL_CLASS)) {
2888
2962
  const i = ArrayExt.findFirstIndex(
@@ -3116,18 +3190,22 @@ export class Notebook extends StaticNotebook {
3116
3190
  if (targetArea === 'notebook') {
3117
3191
  this.deselectAll();
3118
3192
  } else if (targetArea === 'prompt' || targetArea === 'cell') {
3119
- // We don't want to prevent the default selection behavior
3120
- // if there is currently text selected in an output.
3121
- const hasSelection = (window.getSelection() ?? '').toString() !== '';
3122
3193
  if (
3123
3194
  button === 0 &&
3124
3195
  shiftKey &&
3125
- !hasSelection &&
3126
- !['INPUT', 'OPTION'].includes(target.tagName)
3196
+ !['INPUT', 'OPTION'].includes(target.tagName) &&
3197
+ // We don't want to prevent the default selection behavior when the
3198
+ // user is extending a text selection into the clicked region.
3199
+ !this._isExtendingTextSelection(window.getSelection(), widget, target)
3127
3200
  ) {
3128
3201
  // Prevent browser selecting text in prompt or output
3129
3202
  event.preventDefault();
3130
3203
 
3204
+ // Preventing the default also stops the browser from collapsing any
3205
+ // text selection which is already there; drop it explicitly so that
3206
+ // stale highlighted text is not left over the new cell selection.
3207
+ window.getSelection()?.removeAllRanges();
3208
+
3131
3209
  // Shift-click - extend selection
3132
3210
  try {
3133
3211
  this.extendContiguousSelectionTo(index);
@@ -3780,6 +3858,62 @@ namespace Private {
3780
3858
  }
3781
3859
  }
3782
3860
 
3861
+ /**
3862
+ * Check whether `node` is `parent` or a descendant of it.
3863
+ *
3864
+ * Unlike `Node.contains()` this traverses out of open shadow roots, so a
3865
+ * node rendered by a widget library which attaches a shadow root (such as
3866
+ * Panel or Bokeh) is still recognised as belonging to its host subtree.
3867
+ */
3868
+ function containsDeep(parent: Node, node: Node | null): boolean {
3869
+ let current = node;
3870
+ while (current) {
3871
+ if (parent.contains(current)) {
3872
+ return true;
3873
+ }
3874
+ const root = current.getRootNode();
3875
+ current = root instanceof ShadowRoot ? root.host : null;
3876
+ }
3877
+ return false;
3878
+ }
3879
+
3880
+ /**
3881
+ * Whether a cell exposes an output area.
3882
+ *
3883
+ * #### Notes
3884
+ * This is a structural check rather than `instanceof CodeCell` so that it
3885
+ * still holds when a federated extension loads its own copy of
3886
+ * `@jupyterlab/cells`.
3887
+ */
3888
+ function hasOutputArea(cell: Cell): cell is CodeCell {
3889
+ return 'outputArea' in cell;
3890
+ }
3891
+
3892
+ /**
3893
+ * The regions of a cell whose text the browser, rather than the notebook,
3894
+ * is responsible for selecting: the output area of a code cell and the
3895
+ * rendered input of a cell which renders it, such as a rendered markdown
3896
+ * cell.
3897
+ */
3898
+ function textRegionsOf(cell: Cell): Node[] {
3899
+ const regions: Node[] = [];
3900
+ if (hasOutputArea(cell)) {
3901
+ regions.push(cell.outputArea.node);
3902
+ }
3903
+ const rendered = cell.inputArea?.renderedInput;
3904
+ if (rendered) {
3905
+ regions.push(rendered.node);
3906
+ }
3907
+ return regions;
3908
+ }
3909
+
3910
+ /**
3911
+ * Whether a node is in a region of a cell whose text the browser selects.
3912
+ */
3913
+ export function isInTextRegion(cell: Cell, node: Node | null): boolean {
3914
+ return textRegionsOf(cell).some(region => containsDeep(region, node));
3915
+ }
3916
+
3783
3917
  /**
3784
3918
  * Create a cell drag image.
3785
3919
  */