1 | /*
|
2 | * Copyright (c) Jupyter Development Team.
|
3 | * Distributed under the terms of the Modified BSD License.
|
4 | */
|
5 |
|
6 | import { Cell, CodeCell } from '@jupyterlab/cells';
|
7 | import {
|
8 | WindowedLayout,
|
9 | WindowedList,
|
10 | WindowedListModel
|
11 | } from '@jupyterlab/ui-components';
|
12 | import { Message, MessageLoop } from '@lumino/messaging';
|
13 | import { Widget } from '@lumino/widgets';
|
14 | import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants';
|
15 |
|
16 | /**
|
17 | * Notebook view model for the windowed list.
|
18 | */
|
19 | export 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 | */
|
90 | export 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 | }
|