Explorar o código

Merge pull request #10131 from goanpeca/perf/virtual-notebook

Perf: Add virtual Notebook for delayed cell rendering
Eric Charles %!s(int64=3) %!d(string=hai) anos
pai
achega
7797682473

+ 8 - 1
packages/cells/src/inputarea.ts

@@ -66,7 +66,9 @@ export class InputArea extends Widget {
 
     const layout = (this.layout = new PanelLayout());
     layout.addWidget(prompt);
-    layout.addWidget(editor);
+    if (!options.placeholder) {
+      layout.addWidget(editor);
+    }
   }
 
   /**
@@ -173,6 +175,11 @@ export namespace InputArea {
      * Whether to send an update request to the editor when it is shown.
      */
     updateOnShow?: boolean;
+
+    /**
+     * Whether this input area is a placeholder for future rendering.
+     */
+    placeholder?: boolean;
   }
 
   /**

+ 41 - 30
packages/cells/src/widget.ts

@@ -206,7 +206,8 @@ export class Cell<T extends ICellModel = ICellModel> extends Widget {
     const input = (this._input = new InputArea({
       model,
       contentFactory,
-      updateOnShow: options.updateEditorOnShow
+      updateOnShow: options.updateEditorOnShow,
+      placeholder: options.placeholder
     }));
     input.addClass(CELL_INPUT_AREA_CLASS);
     inputWrapper.addWidget(inputCollapser);
@@ -462,7 +463,8 @@ export class Cell<T extends ICellModel = ICellModel> extends Widget {
     const constructor = this.constructor as typeof Cell;
     return new constructor({
       model: this.model,
-      contentFactory: this.contentFactory
+      contentFactory: this.contentFactory,
+      placeholder: false
     });
   }
 
@@ -582,6 +584,11 @@ export namespace Cell {
      * The maximum number of output items to display in cell output.
      */
     maxNumberOutputs?: number;
+
+    /**
+     * Whether this cell is a placeholder for future rendering.
+     */
+    placeholder?: boolean;
   }
 
   /**
@@ -710,32 +717,33 @@ export class CodeCell extends Cell<ICodeCellModel> {
     const contentFactory = this.contentFactory;
     const model = this.model;
 
-    // Insert the output before the cell footer.
-    const outputWrapper = (this._outputWrapper = new Panel());
-    outputWrapper.addClass(CELL_OUTPUT_WRAPPER_CLASS);
-    const outputCollapser = new OutputCollapser();
-    outputCollapser.addClass(CELL_OUTPUT_COLLAPSER_CLASS);
-    const output = (this._output = new OutputArea({
-      model: model.outputs,
-      rendermime,
-      contentFactory: contentFactory,
-      maxNumberOutputs: options.maxNumberOutputs
-    }));
-    output.addClass(CELL_OUTPUT_AREA_CLASS);
-    // Set a CSS if there are no outputs, and connect a signal for future
-    // changes to the number of outputs. This is for conditional styling
-    // if there are no outputs.
-    if (model.outputs.length === 0) {
-      this.addClass(NO_OUTPUTS_CLASS);
+    if (!options.placeholder) {
+      // Insert the output before the cell footer.
+      const outputWrapper = (this._outputWrapper = new Panel());
+      outputWrapper.addClass(CELL_OUTPUT_WRAPPER_CLASS);
+      const outputCollapser = new OutputCollapser();
+      outputCollapser.addClass(CELL_OUTPUT_COLLAPSER_CLASS);
+      const output = (this._output = new OutputArea({
+        model: model.outputs,
+        rendermime,
+        contentFactory: contentFactory
+      }));
+      output.addClass(CELL_OUTPUT_AREA_CLASS);
+      // Set a CSS if there are no outputs, and connect a signal for future
+      // changes to the number of outputs. This is for conditional styling
+      // if there are no outputs.
+      if (model.outputs.length === 0) {
+        this.addClass(NO_OUTPUTS_CLASS);
+      }
+      output.outputLengthChanged.connect(this._outputLengthHandler, this);
+      outputWrapper.addWidget(outputCollapser);
+      outputWrapper.addWidget(output);
+      (this.layout as PanelLayout).insertWidget(2, outputWrapper);
+
+      this._outputPlaceholder = new OutputPlaceholder(() => {
+        this.outputHidden = !this.outputHidden;
+      });
     }
-    output.outputLengthChanged.connect(this._outputLengthHandler, this);
-    outputWrapper.addWidget(outputCollapser);
-    outputWrapper.addWidget(output);
-    (this.layout as PanelLayout).insertWidget(2, outputWrapper);
-
-    this._outputPlaceholder = new OutputPlaceholder(() => {
-      this.outputHidden = !this.outputHidden;
-    });
     model.stateChanged.connect(this.onStateChanged, this);
   }
 
@@ -926,7 +934,8 @@ export class CodeCell extends Cell<ICodeCellModel> {
     return new constructor({
       model: this.model,
       contentFactory: this.contentFactory,
-      rendermime: this._rendermime
+      rendermime: this._rendermime,
+      placeholder: false
     });
   }
 
@@ -1668,7 +1677,8 @@ export class MarkdownCell extends AttachmentsCell<IMarkdownCellModel> {
     return new constructor({
       model: this.model,
       contentFactory: this.contentFactory,
-      rendermime: this._rendermime
+      rendermime: this._rendermime,
+      placeholder: false
     });
   }
 
@@ -1721,7 +1731,8 @@ export class RawCell extends Cell<IRawCellModel> {
     const constructor = this.constructor as typeof RawCell;
     return new constructor({
       model: this.model,
-      contentFactory: this.contentFactory
+      contentFactory: this.contentFactory,
+      placeholder: false
     });
   }
 }

+ 3 - 2
packages/console/src/widget.ts

@@ -222,7 +222,8 @@ export class CodeConsole extends Widget {
     model.value.text = '...';
     const banner = (this._banner = new RawCell({
       model,
-      contentFactory: this.contentFactory
+      contentFactory: this.contentFactory,
+      placeholder: false
     })).initializeState();
     banner.addClass(BANNER_CLASS);
     banner.readOnly = true;
@@ -718,7 +719,7 @@ export class CodeConsole extends Widget {
     const modelFactory = this.modelFactory;
     const model = modelFactory.createCodeCell({});
     const rendermime = this.rendermime;
-    return { model, rendermime, contentFactory };
+    return { model, rendermime, contentFactory, placeholder: false };
   }
 
   /**

+ 5 - 0
packages/documentsearch/src/searchoverlay.tsx

@@ -41,6 +41,7 @@ const REGEX_ERROR_CLASS = 'jp-DocumentSearch-regex-error';
 const SEARCH_OPTIONS_CLASS = 'jp-DocumentSearch-search-options';
 const SEARCH_OPTIONS_DISABLED_CLASS =
   'jp-DocumentSearch-search-options-disabled';
+const SEARCH_DOCUMENT_LOADING = 'jp-DocumentSearch-document-loading';
 const REPLACE_ENTRY_CLASS = 'jp-DocumentSearch-replace-entry';
 const REPLACE_BUTTON_CLASS = 'jp-DocumentSearch-replace-button';
 const REPLACE_BUTTON_WRAPPER_CLASS = 'jp-DocumentSearch-replace-button-wrapper';
@@ -617,6 +618,10 @@ class SearchOverlay extends React.Component<
         key={3}
       >
         {this.state.errorMessage}
+      </div>,
+      <div className={SEARCH_DOCUMENT_LOADING} key={4}>
+        This document is still loading. Only loaded content will appear in
+        search results until the entire document loads.
       </div>
     ];
   }

+ 10 - 0
packages/documentsearch/style/base.css

@@ -248,3 +248,13 @@
 .jp-DocumentSearch-replace-toggle:hover {
   background-color: var(--jp-layout-color2);
 }
+
+.jp-DocumentSearch-document-loaded .jp-DocumentSearch-document-loading {
+  display: block !important;
+}
+
+.jp-DocumentSearch-document-loading {
+  display: none;
+  margin: 5px;
+  color: var(--jp-ui-font-color2);
+}

+ 24 - 0
packages/notebook-extension/schema/tracker.json

@@ -596,6 +596,30 @@
       "type": "boolean",
       "default": false
     },
+    "numberCellsToRenderDirectly": {
+      "title": "Number of cells to render directly",
+      "description": "Define the number of cells to render directly when virtual notebook intersection observer is available",
+      "type": "number",
+      "default": 20
+    },
+    "renderCellOnIdle": {
+      "title": "Render cell on browser idle time",
+      "description": "Defines if the placeholder cells should be rendered when the browser is idle",
+      "type": "boolean",
+      "default": true
+    },
+    "observedTopMargin": {
+      "title": "Observed top margin",
+      "description": "Defines the observed top margin for the virtual notebook, set a positive number of pixels to render cells above the visible view",
+      "type": "string",
+      "default": "1000px"
+    },
+    "observedBottomMargin": {
+      "title": "Observed bottom margin",
+      "description": "Defines the observed bottom margin for the virtual notebook, set a positive number of pixels to render cells below the visible view",
+      "type": "string",
+      "default": "1000px"
+    },
     "maxNumberOutputs": {
       "title": "The maximum number of output cells to to be rendered in the output area. Set to 0 to have the complete display.",
       "description": "Defines the maximum number of output cells to to to be rendered in the output area for cells with many outputs. The output area will have a head and a tail, and the outputs between will be trimmed and not displayed unless the user clicks on the information message.",

+ 26 - 21
packages/notebook-extension/src/index.ts

@@ -969,8 +969,28 @@ function activateNotebookHandler(
   const { commands } = app;
   const tracker = new NotebookTracker({ namespace: 'notebook' });
 
+  // Fetch settings if possible.
+  const fetchSettings = settingRegistry
+    ? settingRegistry.load(trackerPlugin.id)
+    : Promise.reject(new Error(`No setting registry for ${trackerPlugin.id}`));
+
   // Handle state restoration.
   if (restorer) {
+    fetchSettings
+      .then(settings => {
+        updateConfig(settings);
+        settings.changed.connect(() => {
+          updateConfig(settings);
+        });
+      })
+      .catch((reason: Error) => {
+        console.warn(reason.message);
+        updateTracker({
+          editorConfig: factory.editorConfig,
+          notebookConfig: factory.notebookConfig,
+          kernelShutdown: factory.shutdownOnClose
+        });
+      });
     void restorer.restore(tracker, {
       command: 'docmanager:open',
       args: panel => ({ path: panel.context.path, factory: FACTORY }),
@@ -1042,6 +1062,12 @@ function activateNotebookHandler(
       scrollPastEnd: settings.get('scrollPastEnd').composite as boolean,
       defaultCell: settings.get('defaultCell').composite as nbformat.CellType,
       recordTiming: settings.get('recordTiming').composite as boolean,
+      numberCellsToRenderDirectly: settings.get('numberCellsToRenderDirectly')
+        .composite as number,
+      renderCellOnIdle: settings.get('renderCellOnIdle').composite as boolean,
+      observedTopMargin: settings.get('observedTopMargin').composite as string,
+      observedBottomMargin: settings.get('observedBottomMargin')
+        .composite as string,
       maxNumberOutputs: settings.get('maxNumberOutputs').composite as number
     };
     factory.shutdownOnClose = settings.get('kernelShutdown')
@@ -1054,27 +1080,6 @@ function activateNotebookHandler(
     });
   }
 
-  // Fetch settings if possible.
-  const fetchSettings = settingRegistry
-    ? settingRegistry.load(trackerPlugin.id)
-    : Promise.reject(new Error(`No setting registry for ${trackerPlugin.id}`));
-  app.restored
-    .then(() => fetchSettings)
-    .then(settings => {
-      updateConfig(settings);
-      settings.changed.connect(() => {
-        updateConfig(settings);
-      });
-    })
-    .catch((reason: Error) => {
-      console.warn(reason.message);
-      updateTracker({
-        editorConfig: factory.editorConfig,
-        notebookConfig: factory.notebookConfig,
-        kernelShutdown: factory.shutdownOnClose
-      });
-    });
-
   // Add main menu notebook menu.
   if (mainMenu) {
     populateMenus(app, mainMenu, tracker, translator, sessionDialogs);

+ 15 - 0
packages/notebook/src/panel.ts

@@ -30,6 +30,11 @@ const NOTEBOOK_PANEL_TOOLBAR_CLASS = 'jp-NotebookPanel-toolbar';
 
 const NOTEBOOK_PANEL_NOTEBOOK_CLASS = 'jp-NotebookPanel-notebook';
 
+/**
+ * The class name to add when the document is loaded for the search box.
+ */
+const SEARCH_DOCUMENT_LOADED_CLASS = 'jp-DocumentSearch-document-loaded';
+
 /**
  * A widget that hosts a notebook toolbar and content area.
  *
@@ -61,6 +66,7 @@ export class NotebookPanel extends DocumentWidget<Notebook, INotebookModel> {
       this._onSessionStatusChanged,
       this
     );
+    this.content.fullyRendered.connect(this._onFullyRendered, this);
     this.context.saveState.connect(this._onSave, this);
     void this.revealed.then(() => {
       if (this.isDisposed) {
@@ -174,6 +180,15 @@ export class NotebookPanel extends DocumentWidget<Notebook, INotebookModel> {
     };
   }
 
+  /**
+   * Handle a fully rendered signal notebook.
+   */
+  private _onFullyRendered(notebook: Notebook, fullyRendered: boolean): void {
+    fullyRendered
+      ? this.removeClass(SEARCH_DOCUMENT_LOADED_CLASS)
+      : this.addClass(SEARCH_DOCUMENT_LOADED_CLASS);
+  }
+
   /**
    * Handle a change in the kernel by updating the document metadata.
    */

+ 202 - 8
packages/notebook/src/widget.ts

@@ -127,6 +127,11 @@ const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells';
  */
 const DRAG_THRESHOLD = 5;
 
+/*
+ * The type of cell insert provided via signal.
+ */
+type InsertType = 'push' | 'insert' | 'set';
+
 /**
  * The class attached to the heading collapser button
  */
@@ -137,6 +142,26 @@ const HEADING_COLLAPSER_CLASS = 'jp-collapseHeadingButton';
  */
 export type NotebookMode = 'command' | 'edit';
 
+if ((window as any).requestIdleCallback === undefined) {
+  // On Safari, requestIdleCallback is not available, so we use replacement functions for `idleCallbacks`
+  // See: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#falling_back_to_settimeout
+  (window as any).requestIdleCallback = function (handler: Function) {
+    let startTime = Date.now();
+    return setTimeout(function () {
+      handler({
+        didTimeout: false,
+        timeRemaining: function () {
+          return Math.max(0, 50.0 - (Date.now() - startTime));
+        }
+      });
+    }, 1);
+  };
+
+  (window as any).cancelIdleCallback = function (id: number) {
+    clearTimeout(id);
+  };
+}
+
 /**
  * A widget which renders static non-interactive notebooks.
  *
@@ -164,6 +189,45 @@ export class StaticNotebook extends Widget {
     this.notebookConfig =
       options.notebookConfig || StaticNotebook.defaultNotebookConfig;
     this._mimetypeService = options.mimeTypeService;
+
+    // Section for the virtual-notebook behavior.
+    this._toRenderMap = new Map<string, { index: number; cell: Cell }>();
+    this._cellsArray = new Array<Cell>();
+    if ('IntersectionObserver' in window) {
+      this._observer = new IntersectionObserver(
+        (entries, observer) => {
+          entries.forEach(o => {
+            if (o.isIntersecting) {
+              observer.unobserve(o.target);
+              const ci = this._toRenderMap.get(o.target.id);
+              if (ci) {
+                const { cell, index } = ci;
+                this._renderPlaceholderCell(cell, index);
+              }
+            }
+          });
+        },
+        {
+          root: this.node,
+          threshold: 1,
+          rootMargin: `${this.notebookConfig.observedTopMargin} 0px ${this.notebookConfig.observedBottomMargin} 0px`
+        }
+      );
+    }
+  }
+
+  /**
+   * A signal emitted when the notebook is fully rendered.
+   */
+  get fullyRendered(): ISignal<this, boolean> {
+    return this._fullyRendered;
+  }
+
+  /**
+   * A signal emitted when the a placeholder cell is rendered.
+   */
+  get placeholderCellRendered(): ISignal<this, Cell> {
+    return this._placeholderCellRendered;
   }
 
   /**
@@ -381,7 +445,7 @@ export class StaticNotebook extends Widget {
     }
 
     each(cells, (cell: ICellModel, i: number) => {
-      this._insertCell(i, cell);
+      this._insertCell(i, cell, 'set');
     });
     cells.changed.connect(this._onCellsChanged, this);
     newValue.contentChanged.connect(this.onModelContentChanged, this);
@@ -399,8 +463,10 @@ export class StaticNotebook extends Widget {
     switch (args.type) {
       case 'add':
         index = args.newIndex;
+        // eslint-disable-next-line no-case-declarations
+        const insertType: InsertType = args.oldIndex == -1 ? 'push' : 'insert';
         each(args.newValues, value => {
-          this._insertCell(index++, value);
+          this._insertCell(index++, value, insertType);
         });
         break;
       case 'move':
@@ -434,7 +500,7 @@ export class StaticNotebook extends Widget {
           // Note: this ordering (insert then remove)
           // is important for getting the active cell
           // index for the editable notebook correct.
-          this._insertCell(index, value);
+          this._insertCell(index, value, 'set');
           this._removeCell(index + 1);
           index++;
         });
@@ -447,7 +513,11 @@ export class StaticNotebook extends Widget {
   /**
    * Create a cell widget and insert into the notebook.
    */
-  private _insertCell(index: number, cell: ICellModel): void {
+  private _insertCell(
+    index: number,
+    cell: ICellModel,
+    insertType: InsertType
+  ): void {
     let widget: Cell;
     switch (cell.type) {
       case 'code':
@@ -464,9 +534,63 @@ export class StaticNotebook extends Widget {
         widget = this._createRawCell(cell as IRawCellModel);
     }
     widget.addClass(NB_CELL_CLASS);
+
     const layout = this.layout as PanelLayout;
-    layout.insertWidget(index, widget);
-    this.onCellInserted(index, widget);
+    this._cellsArray.push(widget);
+    if (
+      this._observer &&
+      insertType === 'push' &&
+      this._renderedCellsCount > this.notebookConfig.numberCellsToRenderDirectly
+    ) {
+      // We have an observer and we are have been asked to push (not to insert).
+      // and we are above the number of cells to render directly, then
+      // we will add a placeholder and let the instersection observer or the
+      // idle browser render those placeholder cells.
+      this._toRenderMap.set(widget.model.id, { index: index, cell: widget });
+      const placeholder = this._createPlaceholderCell(
+        cell as IRawCellModel,
+        index
+      );
+      placeholder.node.id = widget.model.id;
+      layout.insertWidget(index, placeholder);
+      this.onCellInserted(index, placeholder);
+      this._fullyRendered.emit(false);
+      this._observer.observe(placeholder.node);
+    } else {
+      // We have no intersection observer, or we insert, or we are below
+      // the number of cells to render directly, so we render directly.
+      layout.insertWidget(index, widget);
+      this._incrementRenderedCount();
+      this.onCellInserted(index, widget);
+    }
+
+    if (this._observer && this.notebookConfig.renderCellOnIdle) {
+      const renderPlaceholderCells = this._renderPlaceholderCells.bind(this);
+      (window as any).requestIdleCallback(renderPlaceholderCells, {
+        timeout: 1000
+      });
+    }
+  }
+
+  private _renderPlaceholderCells(deadline: any) {
+    if (
+      this._renderedCellsCount < this._cellsArray.length &&
+      this._renderedCellsCount >=
+        this.notebookConfig.numberCellsToRenderDirectly
+    ) {
+      const index = this._renderedCellsCount;
+      const cell = this._cellsArray[index];
+      this._renderPlaceholderCell(cell, index - 1);
+    }
+  }
+
+  private _renderPlaceholderCell(cell: Cell, index: number) {
+    const pl = this.layout as PanelLayout;
+    pl.removeWidgetAt(index);
+    pl.insertWidget(index, cell);
+    this._toRenderMap.delete(cell.model.id);
+    this._incrementRenderedCount();
+    this._placeholderCellRendered.emit(cell);
   }
 
   /**
@@ -504,7 +628,8 @@ export class StaticNotebook extends Widget {
       model,
       rendermime,
       contentFactory,
-      updateEditorOnShow: false
+      updateEditorOnShow: false,
+      placeholder: false
     };
     const cell = this.contentFactory.createMarkdownCell(options, this);
     cell.syncCollapse = true;
@@ -512,6 +637,31 @@ export class StaticNotebook extends Widget {
     return cell;
   }
 
+  /**
+   * Create a placeholder cell widget from a raw cell model.
+   */
+  private _createPlaceholderCell(model: IRawCellModel, index: number): RawCell {
+    const contentFactory = this.contentFactory;
+    const editorConfig = this.editorConfig.raw;
+    const options = {
+      editorConfig,
+      model,
+      contentFactory,
+      updateEditorOnShow: false,
+      placeholder: true
+    };
+    const cell = this.contentFactory.createRawCell(options, this);
+    cell.node.innerHTML = `
+      <div class="jp-Cell-Placeholder">
+        <div class="jp-Cell-Placeholder-wrapper">
+        </div>
+      </div>`;
+    cell.inputHidden = true;
+    cell.syncCollapse = true;
+    cell.syncEditable = true;
+    return cell;
+  }
+
   /**
    * Create a raw cell widget from a raw cell model.
    */
@@ -522,7 +672,8 @@ export class StaticNotebook extends Widget {
       editorConfig,
       model,
       contentFactory,
-      updateEditorOnShow: false
+      updateEditorOnShow: false,
+      placeholder: false
     };
     const cell = this.contentFactory.createRawCell(options, this);
     cell.syncCollapse = true;
@@ -622,6 +773,13 @@ export class StaticNotebook extends Widget {
     );
   }
 
+  private _incrementRenderedCount() {
+    if (this._toRenderMap.size === 0) {
+      this._fullyRendered.emit(true);
+    }
+    this._renderedCellsCount++;
+  }
+
   private _editorConfig = StaticNotebook.defaultEditorConfig;
   private _notebookConfig = StaticNotebook.defaultNotebookConfig;
   private _mimetype = 'text/plain';
@@ -629,6 +787,12 @@ export class StaticNotebook extends Widget {
   private _mimetypeService: IEditorMimeTypeService;
   private _modelChanged = new Signal<this, void>(this);
   private _modelContentChanged = new Signal<this, void>(this);
+  private _fullyRendered = new Signal<this, boolean>(this);
+  private _placeholderCellRendered = new Signal<this, Cell>(this);
+  private _observer: IntersectionObserver;
+  private _renderedCellsCount = 0;
+  private _toRenderMap: Map<string, { index: number; cell: Cell }>;
+  private _cellsArray: Array<Cell>;
 }
 
 /**
@@ -762,6 +926,32 @@ export namespace StaticNotebook {
      */
     recordTiming: boolean;
 
+    /*
+     * Number of cells to render directly when virtual
+     * notebook intersection observer is available.
+     */
+    numberCellsToRenderDirectly: number;
+
+    /**
+     * Defines if the placeholder cells should be rendered
+     * when the browser is idle.
+     */
+    renderCellOnIdle: boolean;
+
+    /**
+     * Defines the observed top margin for the
+     * virtual notebook, set a positive number of pixels
+     * to render cells below the visible view.
+     */
+    observedTopMargin: string;
+
+    /**
+     * Defines the observed bottom margin for the
+     * virtual notebook, set a positive number of pixels
+     * to render cells below the visible view.
+     */
+    observedBottomMargin: string;
+
     /**
      * Defines the maximum number of outputs per cell.
      */
@@ -774,6 +964,10 @@ export namespace StaticNotebook {
     scrollPastEnd: true,
     defaultCell: 'code',
     recordTiming: false,
+    numberCellsToRenderDirectly: 20,
+    renderCellOnIdle: true,
+    observedTopMargin: '1000px',
+    observedBottomMargin: '1000px',
     maxNumberOutputs: 50
   };
 

+ 83 - 0
packages/notebook/style/base.css

@@ -288,3 +288,86 @@
 .jp-mod-presentationMode .jp-Notebook .jp-Cell .jp-OutputPrompt {
   flex: 0 0 110px;
 }
+
+/*-----------------------------------------------------------------------------
+| Placeholder
+|----------------------------------------------------------------------------*/
+
+.jp-Cell-Placeholder {
+  padding-left: 55px;
+}
+
+.jp-Cell-Placeholder-wrapper {
+  background: #fff;
+  border: 1px solid;
+  border-color: #e5e6e9 #dfe0e4 #d0d1d5;
+  border-radius: 4px;
+  -webkit-border-radius: 4px;
+  margin: 10px 15px;
+}
+
+.jp-Cell-Placeholder-wrapper-inner {
+  padding: 15px;
+  position: relative;
+}
+
+.jp-Cell-Placeholder-wrapper-body {
+  background-repeat: repeat;
+  background-size: 50% auto;
+}
+
+.jp-Cell-Placeholder-wrapper-body div {
+  background: #f6f7f8;
+  background-image: -webkit-linear-gradient(
+    left,
+    #f6f7f8 0%,
+    #edeef1 20%,
+    #f6f7f8 40%,
+    #f6f7f8 100%
+  );
+  background-repeat: no-repeat;
+  background-size: 800px 104px;
+  height: 104px;
+  position: relative;
+}
+
+.jp-Cell-Placeholder-wrapper-body div {
+  position: absolute;
+  right: 15px;
+  left: 15px;
+  top: 15px;
+}
+
+div.jp-Cell-Placeholder-h1 {
+  top: 20px;
+  height: 20px;
+  left: 15px;
+  width: 150px;
+}
+
+div.jp-Cell-Placeholder-h2 {
+  left: 15px;
+  top: 50px;
+  height: 10px;
+  width: 100px;
+}
+
+div.jp-Cell-Placeholder-content-1,
+div.jp-Cell-Placeholder-content-2,
+div.jp-Cell-Placeholder-content-3 {
+  left: 15px;
+  right: 15px;
+  height: 10px;
+}
+
+div.jp-Cell-Placeholder-content-1 {
+  top: 100px;
+}
+
+div.jp-Cell-Placeholder-content-2 {
+  top: 120px;
+}
+
+div.jp-Cell-Placeholder-content-3 {
+  top: 140px;
+}

+ 14 - 2
packages/observables/src/observablelist.ts

@@ -431,6 +431,10 @@ export class ObservableList<T> implements IObservableList<T> {
    * #### Complexity
    * Constant.
    *
+   * #### Notes
+   * By convention, the oldIndex is set to -1 to indicate
+   * an push operation.
+   *
    * #### Iterator Validity
    * No changes.
    */
@@ -461,6 +465,8 @@ export class ObservableList<T> implements IObservableList<T> {
    *
    * #### Notes
    * The `index` will be clamped to the bounds of the list.
+   * By convention, the oldIndex is set to -2 to indicate
+   * an insert operation.
    *
    * #### Undefined Behavior
    * An `index` which is non-integral.
@@ -469,7 +475,7 @@ export class ObservableList<T> implements IObservableList<T> {
     ArrayExt.insert(this._array, index, value);
     this._changed.emit({
       type: 'add',
-      oldIndex: -1,
+      oldIndex: -2,
       newIndex: index,
       oldValues: [],
       newValues: [value]
@@ -594,6 +600,10 @@ export class ObservableList<T> implements IObservableList<T> {
    * #### Complexity
    * Linear.
    *
+   * #### Notes
+   * By convention, the oldIndex is set to -1 to indicate
+   * an push operation.
+   *
    * #### Iterator Validity
    * No changes.
    */
@@ -627,6 +637,8 @@ export class ObservableList<T> implements IObservableList<T> {
    *
    * #### Notes
    * The `index` will be clamped to the bounds of the list.
+   * By convention, the oldIndex is set to -2 to indicate
+   * an insert operation.
    *
    * #### Undefined Behavior.
    * An `index` which is non-integral.
@@ -638,7 +650,7 @@ export class ObservableList<T> implements IObservableList<T> {
     });
     this._changed.emit({
       type: 'add',
-      oldIndex: -1,
+      oldIndex: -2,
       newIndex,
       oldValues: [],
       newValues: toArray(values)

+ 2 - 2
packages/observables/test/observablelist.spec.ts

@@ -145,7 +145,7 @@ describe('@jupyterlab/observables', () => {
           expect(sender).toBe(value);
           expect(args.type).toBe('add');
           expect(args.newIndex).toBe(1);
-          expect(args.oldIndex).toBe(-1);
+          expect(args.oldIndex).toBe(-2);
           expect(args.oldValues.length).toBe(0);
           expect(args.newValues[0]).toBe(4);
           called = true;
@@ -323,7 +323,7 @@ describe('@jupyterlab/observables', () => {
           expect(sender).toBe(value);
           expect(args.type).toBe('add');
           expect(args.newIndex).toBe(1);
-          expect(args.oldIndex).toBe(-1);
+          expect(args.oldIndex).toBe(-2);
           expect(toArray(args.newValues)).toEqual([4, 5, 6]);
           expect(args.oldValues.length).toBe(0);
           called = true;