@jupyterlab/notebook 4.7.0-alpha.1 → 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/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 = '[ ]';
@@ -2922,16 +2934,84 @@ export class Notebook extends StaticNotebook {
2922
2934
  }
2923
2935
  }
2924
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
+
2925
3005
  /**
2926
3006
  * Find the cell index containing the target html element.
2927
3007
  *
2928
3008
  * #### Notes
2929
3009
  * Returns -1 if the cell is not found.
2930
3010
  */
2931
- private _findCell(node: HTMLElement): number {
3011
+ private _findCell(node: Element): number {
2932
3012
  // Trace up the DOM hierarchy to find the root cell node.
2933
3013
  // Then find the corresponding child and select it.
2934
- let n: HTMLElement | null = node;
3014
+ let n: Element | null = node;
2935
3015
  while (n && n !== this.node) {
2936
3016
  if (n.classList.contains(NB_CELL_CLASS)) {
2937
3017
  const i = ArrayExt.findFirstIndex(
@@ -3165,18 +3245,22 @@ export class Notebook extends StaticNotebook {
3165
3245
  if (targetArea === 'notebook') {
3166
3246
  this.deselectAll();
3167
3247
  } else if (targetArea === 'prompt' || targetArea === 'cell') {
3168
- // We don't want to prevent the default selection behavior
3169
- // if there is currently text selected in an output.
3170
- const hasSelection = (window.getSelection() ?? '').toString() !== '';
3171
3248
  if (
3172
3249
  button === 0 &&
3173
3250
  shiftKey &&
3174
- !hasSelection &&
3175
- !['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)
3176
3255
  ) {
3177
3256
  // Prevent browser selecting text in prompt or output
3178
3257
  event.preventDefault();
3179
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
+
3180
3264
  // Shift-click - extend selection
3181
3265
  try {
3182
3266
  this.extendContiguousSelectionTo(index);
@@ -3474,7 +3558,7 @@ export class Notebook extends StaticNotebook {
3474
3558
  const executionCount = (activeCell.model as ICodeCellModel)
3475
3559
  .executionCount;
3476
3560
  countString = ' ';
3477
- if (executionCount) {
3561
+ if (executionCount !== null) {
3478
3562
  countString = executionCount.toString();
3479
3563
  }
3480
3564
  } else {
@@ -3829,6 +3913,62 @@ namespace Private {
3829
3913
  }
3830
3914
  }
3831
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
+
3832
3972
  /**
3833
3973
  * Create a cell drag image.
3834
3974
  */
package/style/base.css CHANGED
@@ -281,6 +281,42 @@
281
281
  white-space: normal;
282
282
  }
283
283
 
284
+ .jp-CellIdField {
285
+ padding: 12px 0;
286
+ }
287
+
288
+ .jp-CellIdField label {
289
+ display: block;
290
+ line-height: 1.4;
291
+ }
292
+
293
+ .jp-CellIdField-row {
294
+ display: flex;
295
+ gap: 4px;
296
+ margin-top: 4px;
297
+ }
298
+
299
+ .jp-CellIdField .jp-CellIdField-input {
300
+ flex: 1 1 auto;
301
+ width: 100%;
302
+ min-width: 0;
303
+ font-family: var(--jp-code-font-family);
304
+ font-size: var(--jp-code-font-size);
305
+ }
306
+
307
+ .jp-CellIdField .jp-CellIdField-button {
308
+ flex: 0 0 28px;
309
+ width: 28px;
310
+ padding: 2px;
311
+ margin: 0;
312
+ color: var(--jp-ui-font-color2);
313
+ transition: color 120ms ease;
314
+ }
315
+
316
+ .jp-CellIdField .jp-CellIdField-button.jp-mod-copied {
317
+ color: var(--jp-success-color1);
318
+ }
319
+
284
320
  .jp-MetadataEditorTool {
285
321
  flex-direction: column;
286
322
  padding: 12px 0;