UNPKG

14.3 kBPlain TextView Raw
1/*
2 * Copyright (c) Jupyter Development Team.
3 * Distributed under the terms of the Modified BSD License.
4 */
5
6import { Cell, CodeCell } from '@jupyterlab/cells';
7import {
8 WindowedLayout,
9 WindowedList,
10 WindowedListModel
11} from '@jupyterlab/ui-components';
12import { Message, MessageLoop } from '@lumino/messaging';
13import { Widget } from '@lumino/widgets';
14import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants';
15
16/**
17 * Notebook view model for the windowed list.
18 */
19export class NotebookViewModel extends WindowedListModel {
20 /**
21 * Default cell height
22 */
23 static DEFAULT_CELL_SIZE = 39;
24 /**
25 * Default editor line height
26 */
27 static DEFAULT_EDITOR_LINE_HEIGHT = 17;
28 /**
29 * Default cell margin (top + bottom)
30 */
31 static DEFAULT_CELL_MARGIN = 22;
32
33 /**
34 * Construct a notebook windowed list model.
35 */
36 constructor(
37 protected cells: Cell[],
38 options?: WindowedList.IModelOptions
39 ) {
40 super(options);
41 // Set default cell size
42 this._estimatedWidgetSize = NotebookViewModel.DEFAULT_CELL_SIZE;
43 }
44
45 /**
46 * Cell size estimator
47 *
48 * @param index Cell index
49 * @returns Cell height in pixels
50 */
51 estimateWidgetSize = (index: number): number => {
52 // TODO could be improved, takes only into account the editor height
53 const nLines = this.cells[index].model.sharedModel
54 .getSource()
55 .split('\n').length;
56 return (
57 NotebookViewModel.DEFAULT_EDITOR_LINE_HEIGHT * nLines +
58 NotebookViewModel.DEFAULT_CELL_MARGIN
59 );
60 };
61
62 /**
63 * Render the cell at index.
64 *
65 * @param index Cell index
66 * @returns Cell widget
67 */
68 widgetRenderer = (index: number): Widget => {
69 return this.cells[index];
70 };
71
72 /**
73 * Threshold used to decide if the cell should be scrolled to in the `smart` mode.
74 * Defaults to scrolling when less than a full line of the cell is visible.
75 */
76 readonly scrollDownThreshold =
77 NotebookViewModel.DEFAULT_CELL_MARGIN / 2 +
78 NotebookViewModel.DEFAULT_EDITOR_LINE_HEIGHT;
79
80 /**
81 * Threshold used to decide if the cell should be scrolled to in the `smart` mode.
82 * Defaults to scrolling when the cell margin or more is invisible.
83 */
84 readonly scrollUpThreshold = NotebookViewModel.DEFAULT_CELL_MARGIN / 2;
85}
86
87/**
88 * Windowed list layout for the notebook.
89 */
90export class NotebookWindowedLayout extends WindowedLayout {
91 private _header: Widget | null = null;
92 private _footer: Widget | null = null;
93
94 /**
95 * Notebook's header
96 */
97 get header(): Widget | null {
98 return this._header;
99 }
100 set header(header: Widget | null) {
101 if (this._header && this._header.isAttached) {
102 Widget.detach(this._header);
103 }
104 this._header = header;
105 if (this._header && this.parent?.isAttached) {
106 Widget.attach(this._header, this.parent!.node);
107 }
108 }
109
110 /**
111 * Notebook widget's footer
112 */
113 get footer(): Widget | null {
114 return this._footer;
115 }
116 set footer(footer: Widget | null) {
117 if (this._footer && this._footer.isAttached) {
118 Widget.detach(this._footer);
119 }
120 this._footer = footer;
121 if (this._footer && this.parent?.isAttached) {
122 Widget.attach(this._footer, this.parent!.outerNode);
123 }
124 }
125
126 /**
127 * Notebook's active cell
128 */
129 get activeCell(): Widget | null {
130 return this._activeCell;
131 }
132 set activeCell(widget: Widget | null) {
133 this._activeCell = widget;
134 }
135 private _activeCell: Widget | null;
136
137 /**
138 * Dispose the layout
139 * */
140 dispose(): void {
141 if (this.isDisposed) {
142 return;
143 }
144 this._header?.dispose();
145 this._footer?.dispose();
146 super.dispose();
147 }
148
149 /**
150 * * A message handler invoked on a `'child-removed'` message.
151 * *
152 * @param widget - The widget to remove from the layout.
153 *
154 * #### Notes
155 * A widget is automatically removed from the layout when its `parent`
156 * is set to `null`. This method should only be invoked directly when
157 * removing a widget from a layout which has yet to be installed on a
158 * parent widget.
159 *
160 * This method does *not* modify the widget's `parent`.
161 */
162 removeWidget(widget: Widget): void {
163 const index = this.widgets.indexOf(widget);
164 // We need to deal with code cell widget not in viewport (aka not in this.widgets) but still
165 // partly attached
166 if (index >= 0) {
167 this.removeWidgetAt(index);
168 } // If the layout is parented, detach the widget from the DOM.
169 else if (widget === this._willBeRemoved && this.parent) {
170 this.detachWidget(index, widget);
171 }
172 }
173
174 /**
175 * Attach a widget to the parent's DOM node.
176 *
177 * @param index - The current index of the widget in the layout.
178 *
179 * @param widget - The widget to attach to the parent.
180 *
181 * #### Notes
182 * This method is called automatically by the panel layout at the
183 * appropriate time. It should not be called directly by user code.
184 *
185 * The default implementation adds the widgets's node to the parent's
186 * node at the proper location, and sends the appropriate attach
187 * messages to the widget if the parent is attached to the DOM.
188 *
189 * Subclasses may reimplement this method to control how the widget's
190 * node is added to the parent's node.
191 */
192 protected attachWidget(index: number, widget: Widget): void {
193 // Status may change in onBeforeAttach
194 const wasPlaceholder = (widget as Cell).isPlaceholder();
195 // Initialized sub-widgets or attached them for CodeCell
196 // Because this reattaches all sub-widget to the DOM which leads
197 // to a loss of focus, we do not call it for soft-hidden cells.
198 const isSoftHidden = this._isSoftHidden(widget);
199 if (this.parent!.isAttached && !isSoftHidden) {
200 MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach);
201 }
202 if (isSoftHidden) {
203 // Restore visibility for active, or previously active cell
204 this._toggleSoftVisibility(widget, true);
205 }
206 if (
207 !wasPlaceholder &&
208 widget instanceof CodeCell &&
209 widget.node.parentElement
210 ) {
211 // We don't remove code cells to preserve outputs internal state
212 widget.node.style.display = '';
213
214 // Reset cache
215 this._topHiddenCodeCells = -1;
216 } else if (!isSoftHidden) {
217 // Look up the next sibling reference node.
218 const siblingIndex = this._findNearestChildBinarySearch(
219 this.parent!.viewportNode.childElementCount - 1,
220 0,
221 parseInt(widget.dataset.windowedListIndex!, 10) + 1
222 );
223 let ref = this.parent!.viewportNode.children[siblingIndex];
224
225 // Insert the widget's node before the sibling.
226 this.parent!.viewportNode.insertBefore(widget.node, ref);
227
228 // Send an `'after-attach'` message if the parent is attached.
229 // Event listeners will be added here
230 // Some widgets are updating/resetting when attached, so
231 // we should not recall this each time a cell move into the
232 // viewport.
233 if (this.parent!.isAttached) {
234 MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach);
235 }
236 }
237
238 (widget as Cell).inViewport = true;
239 }
240
241 /**
242 * Detach a widget from the parent's DOM node.
243 *
244 * @param index - The previous index of the widget in the layout.
245 *
246 * @param widget - The widget to detach from the parent.
247 *
248 * #### Notes
249 * This method is called automatically by the panel layout at the
250 * appropriate time. It should not be called directly by user code.
251 *
252 * The default implementation removes the widget's node from the
253 * parent's node, and sends the appropriate detach messages to the
254 * widget if the parent is attached to the DOM.
255 *
256 * Subclasses may reimplement this method to control how the widget's
257 * node is removed from the parent's node.
258 */
259 protected detachWidget(index: number, widget: Widget): void {
260 (widget as Cell).inViewport = false;
261
262 // Note: `index` is relative to the displayed cells, not all cells,
263 // hence we compare with the widget itself.
264 if (widget === this.activeCell) {
265 // Do not change display of the active cell to allow user to continue providing input
266 // into the code mirror editor when out of view. We still hide the cell so to prevent
267 // minor visual glitches when scrolling.
268 this._toggleSoftVisibility(widget, false);
269 // Return before sending "AfterDetach" message to CodeCell
270 // to prevent removing contents of the active cell.
271 return;
272 }
273 // TODO we could improve this further by discarding also the code cell without outputs
274 if (
275 // We detach the code cell currently dragged otherwise it won't be attached at the correct position
276 widget instanceof CodeCell &&
277 !widget.node.classList.contains(DROP_SOURCE_CLASS) &&
278 widget !== this._willBeRemoved
279 ) {
280 // We don't remove code cells to preserve outputs internal state
281 // Transform does not work because the widget height is kept (at least in FF)
282 widget.node.style.display = 'none';
283
284 // Reset cache
285 this._topHiddenCodeCells = -1;
286 } else {
287 // Send a `'before-detach'` message if the parent is attached.
288 // This should not be called every time a cell leaves the viewport
289 // as it will remove listeners that won't be added back as afterAttach
290 // is shunted to avoid unwanted update/reset.
291 if (this.parent!.isAttached) {
292 // Event listeners will be removed here
293 MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
294 }
295 // Remove the widget's node from the parent.
296 this.parent!.viewportNode.removeChild(widget.node);
297
298 // Ensure to clean up drop target class if the widget move out of the viewport
299 widget.node.classList.remove(DROP_TARGET_CLASS);
300 }
301
302 if (this.parent!.isAttached) {
303 // Detach sub widget of CodeCell except the OutputAreaWrapper
304 MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
305 }
306 }
307
308 /**
309 * Move a widget in the parent's DOM node.
310 *
311 * @param fromIndex - The previous index of the widget in the layout.
312 *
313 * @param toIndex - The current index of the widget in the layout.
314 *
315 * @param widget - The widget to move in the parent.
316 *
317 * #### Notes
318 * This method is called automatically by the panel layout at the
319 * appropriate time. It should not be called directly by user code.
320 *
321 * The default implementation moves the widget's node to the proper
322 * location in the parent's node and sends the appropriate attach and
323 * detach messages to the widget if the parent is attached to the DOM.
324 *
325 * Subclasses may reimplement this method to control how the widget's
326 * node is moved in the parent's node.
327 */
328 protected moveWidget(
329 fromIndex: number,
330 toIndex: number,
331 widget: Widget
332 ): void {
333 // Optimize move without de-/attaching as motion appends with parent attached
334 // Case fromIndex === toIndex, already checked in PanelLayout.insertWidget
335 if (this._topHiddenCodeCells < 0) {
336 this._topHiddenCodeCells = 0;
337 for (
338 let idx = 0;
339 idx < this.parent!.viewportNode.children.length;
340 idx++
341 ) {
342 const n = this.parent!.viewportNode.children[idx];
343 if ((n as HTMLElement).style.display == 'none') {
344 this._topHiddenCodeCells++;
345 } else {
346 break;
347 }
348 }
349 }
350
351 const ref =
352 this.parent!.viewportNode.children[toIndex + this._topHiddenCodeCells];
353 if (fromIndex < toIndex) {
354 ref.insertAdjacentElement('afterend', widget.node);
355 } else {
356 ref.insertAdjacentElement('beforebegin', widget.node);
357 }
358 }
359
360 protected onAfterAttach(msg: Message): void {
361 super.onAfterAttach(msg);
362 if (this._header && !this._header.isAttached) {
363 Widget.attach(
364 this._header,
365 this.parent!.node,
366 this.parent!.node.firstElementChild as HTMLElement | null
367 );
368 }
369 if (this._footer && !this._footer.isAttached) {
370 Widget.attach(this._footer, this.parent!.outerNode);
371 }
372 }
373
374 protected onBeforeDetach(msg: Message): void {
375 if (this._header?.isAttached) {
376 Widget.detach(this._header);
377 }
378 if (this._footer?.isAttached) {
379 Widget.detach(this._footer);
380 }
381 super.onBeforeDetach(msg);
382 }
383
384 /**
385 * A message handler invoked on a `'child-removed'` message.
386 *
387 * @param msg Message
388 */
389 protected onChildRemoved(msg: Widget.ChildMessage): void {
390 this._willBeRemoved = msg.child;
391 super.onChildRemoved(msg);
392 this._willBeRemoved = null;
393 }
394
395 /**
396 * Toggle "soft" visibility of the widget.
397 *
398 * #### Notes
399 * To ensure that user events reach the CodeMirror editor, this method
400 * does not toggle `display` nor `visibility` which have side effects,
401 * but instead hides it in the compositor and ensures that the bounding
402 * box is has an area equal to zero.
403 * To ensure we do not trigger style recalculation, we set the styles
404 * directly on the node instead of using a class.
405 */
406 private _toggleSoftVisibility(widget: Widget, show: boolean): void {
407 if (show) {
408 widget.node.style.opacity = '';
409 widget.node.style.height = '';
410 widget.node.style.padding = '';
411 } else {
412 widget.node.style.opacity = '0';
413 // Both padding and height need to be set to zero
414 // to ensure bounding box collapses to invisible.
415 widget.node.style.height = '0';
416 widget.node.style.padding = '0';
417 }
418 }
419
420 private _isSoftHidden(widget: Widget): boolean {
421 return widget.node.style.opacity === '0';
422 }
423
424 private _findNearestChildBinarySearch(
425 high: number,
426 low: number,
427 index: number
428 ): number {
429 while (low <= high) {
430 const middle = low + Math.floor((high - low) / 2);
431 const currentIndex = parseInt(
432 (this.parent!.viewportNode.children[middle] as HTMLElement).dataset
433 .windowedListIndex!,
434 10
435 );
436
437 if (currentIndex === index) {
438 return middle;
439 } else if (currentIndex < index) {
440 low = middle + 1;
441 } else if (currentIndex > index) {
442 high = middle - 1;
443 }
444 }
445
446 if (low > 0) {
447 return low;
448 } else {
449 return 0;
450 }
451 }
452
453 private _willBeRemoved: Widget | null = null;
454 private _topHiddenCodeCells: number = -1;
455}