Notes on the JupyterLab Notebook HTML DOM Model, Part 3: Setting Classes Based on Cell Tags From a JupyterLab Extension

In the previous two posts in this series, I reviewed the HTML DOM structure or rendered notebook markdown and code cells in JupyterLab (I assume the same structure exists in RetroLab, but need to check…). In this post, I’ll review one way in which we can set classes on those elements from a JupyterLab extension.

To simplify the extension building process, the following recipe has been tested using the JupyterLab Plugin Playground, a JupyterLab extension that lets you write and test simple extensions purely within a JupyterLab context (i.e. without any need to build and install an extension from the command line).

Note that this is a playground with caveats: you need to be strict in your Javascript or things will fail to run, even though they could be executed in a more forgiving environment. I’m also not sure if this is a supersitious behaviour or a necessary one: each time I modified the script, I closed my test notebook, saved the script, reloaded the browser page, clicked the jigsaw icon in the playground editor to “install” the extension, then opened the test notebook to see if things had worked. Certainly, I often had no success in effecting changes without the above steps. But it’s probably quicker than rebuilding JupyterLab each time.

To begin with, I’ll see if I can create a simple extension to add class tags to various parts of the cell HTML DOM based on the presence of cell metadata tags.

Docs for how to drive changes from notebook events seem to be in JupyterLab API notebook module notebookactions, but they aren’t relevant if you want to take action over a notebook on load, as, for example, when cells have been rendered into the DOM and are available for classing.

The notebook class docs briefly reference various accessor methods that may be connected to. I thought a promising one (from the name of it) might be the fullyrendered accessor, which seems to be “[a] signal emitted when the notebook is fully rendered” but a Stack Overflow comment suggests otherwise:

notebook.fullyRendered is fairly new and may not be what you are looking for. Previously there was no concept of “notebook.ready” (it was just cells getting added one by one on load), but with the virtualized rendering – if configured to rely solely on content intersection) users may never see a fully rendered notebook (if they do not scroll down to the very last cell); in any case this is all subject to change based on the feedback we got about problems with this feature (a lot of changes were already made), so personally I would just go one-by-one on every change.

@krassowski

That said, I found the fullyRendered signal did fire, albeit multiple times, when I loaded a notebook and could be used to class the cells based on metadata, but the modelChanged signal did not seem to do anyhting when the notebook loaded, when I added metadata to a cell, or when I moved a cell? (I note there is also a stateChanged signal?)

Just as an aside, I note some signals that may be useful for a cell execution status extension in the notebookActions class, specifically the executed (“A signal that emits whenever a cell execution is scheduled.”) and executionScheduled (“A signal that emits whenever a cell execution is scheduled”) signals, although I note that selectionExecuted also claims to be “A signal that emits whenever a cell execution is scheduled”? An example of connecting to the NotebookActions.executed signal can be found in the jupyterlab-cell-flash extension. I’m not sure if there’s a way of detecting whether the execution ran successfully or returned an error? There NotebookActions.executed signal does seem to return an error? (yes, the ? is there…) KernelError object and a success boolean, so maybe we can use those. In the classic cell execution status extension, it looks like there was a code cell _handle_execute_reply action that returned various status messages (ok, aborted) that could be reasoned around.

Another possibly useful crib is the agoose77/jupyterlab-imarkdown extension which resembles the classic notebook Python Markdown extension which renders variables into markdown content (via this discourse forum post by @agoose77).

Anywhere, here’s the code I used, which was largely inspired by @krassowski’s response to my Stack Overflow question on Accessing notebook cell metadata and HTML class attributes in JupyterLab Extensions.

// Most of the code cribbed from @krassowski
// https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/*
The most relevant docs appear to be:
https://jupyterlab.readthedocs.io/en/stable/api/modules/notebook.notebookactions.html

*/
export class ClassDemoExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
    /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    function check_tags(notebook){
        /*
        Iterate through all the cells
        If we see a cell with a tag that starts with class-
        then add a corresponding tag to differnt elements in the
        notebook HTML DOM
        */
        for (const cell of notebook.widgets) {
            let tagList = cell.model.metadata.get('tags') as string[];
            if (tagList) {
              for (let i = 0; i < tagList.length; i++) {
                var tag = tagList[i];
                if tag.startsWith("class-") {
                    /* 
                    The plugin playground extension is rather hostile 
                    to have-a-go end-user devs (it's rather strict in 
                    its Javascript epxectations) so if you don't 
                    declare things with let, const or var then an 
                    error of the following form is likely to be thrown 
                    that stops further execution:
                    
                    ReferenceError: class_name is not definred
                    */
                    
                    //This will replace just the first instance of class-
                    const class_name = tag.replace("class-", "tag-")
                    
                    // Add class tags to various parts of the DOM
                    // What sort of cell are we?
                    console.log("Cell type: " + cell.model.type)

                    // Try to see what's going on...
                    console.log("Try node...")
                    cell.node.classList.add(class_name + "-" + cell.model.type + "-node");
                    
                    console.log("Try inputArea.node...")
                    cell.inputArea.node.classList.add(class_name + "-" + cell.model.type + "-inputArea_node");
                    
                    console.log("Try editor.node...")
                    cell.editor.host.classList.add(class_name + "-" + cell.model.type + "-editor_host");
                    
                    console.log("Try .inputArea.promptNode...")
                    cell.inputArea.promptNode.classList.add(class_name + "-" + cell.model.type + "-inputArea_promptNode");
                    
                    if (cell.model.type=="markdown") {
                        //console.log("Try RenderedHTMLCommon.node...")
                        //cell.RenderedHTMLCommon.node.classList.add(class_name + "-" + cell.model.type + "-RenderedHTMLCommon.node")
                        //TO DO - access lm-Widget p-Widget jp-RenderedHTMLCommon jp-RenderedMarkdown jp-MarkdownOutput
                        // DOESN'T WORK: cell.MarkdownOutput.node, MarkdownOutput.host, RenderedMarkdown.node, RenderedHTMLCommon.node
                    }
                    else {
                        console.log("Try outputArea.node...")
                        cell.outputArea.node.classList.add(class_name + "-" + cell.model.type + "-outputArea_node")
                    }
                } // end: if class starts with tag...
              } // end: tags iterator
            } //end: if there are tags
        } // end: iterate cells
    } // end: check_tags function definition
    
    notebook.modelChanged.connect((notebook) => {
        console.log("I think we're changed");
        // This doesn't seem to do anything on notebook load
        // iterate cells and toggle DOM classes as needed, e.g.
        //check_tags(notebook);
        
    });
        
    notebook.fullyRendered.connect((notebook) => {
        /*
        I don't think this means fully rendered on a cells becuase it seems
        like we try to add the tags mutliple times on notebook load
        which is really inefficient.
        This may be unstable anyway, eg the following comment:
            https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107
        I'll with go with in now in the expectation it will be break
        and I will hopefully be able to find another even to fire from.
        
        I get the impression the UI is some some of signal hell and
        the solution is just to keep redoing things on everything
        if anything changes. Who needs efficiency or elegance anyway...!
        After all, this is just an end-user hack-from-a-position-of-ignorance
        and works sort of enough to do something that I couldn't do before...
        */
        
        console.log("I think we're fullyRendered...(?!)");
        // iterate cells and toggle DOM classes as needed, e.g.
        check_tags(notebook);
    });
    
    return new DisposableDelegate(() => {
    });
  }
}


/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ClassDemoExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

With the extension, when I open a notebook with some class-TEST tags on various markdown and code cells, the following class attributes are set on a markdown cell:

And the following class attributes are set on a code cell:

Having set classes on various elements, we now need to style them. An example of how to do that will (hopefully!) be provided in the next post in this series…

PS Just as an aside, one interesting possibility that occurs to me is if we can intercept a cell run command and swap in our own execution call, we could define “magic-tags” that, if detected on a cell, allow us to grab the cell content and then treat it in a magic way. That is, rather than use a block magic at the start of a cell, magic-tag the cell and treat its contents via some tag powered magic. Various bits of signalling around cell execution scheduling requests might be relevant here, eg in the context of trying to set up a pipeline around the processing of code in a code cell before (and after) its execution.

Author: Tony Hirst

I'm a Senior Lecturer at The Open University, with an interest in #opendata policy and practice, as well as general web tinkering...

%d bloggers like this: