Notes on the JupyterLab Notebook HTML DOM Model, Part 5: Setting DOM Classes and Cell Tags From Notebook Toolbar Buttons

Following on from the previous posts in this series, let’s now see how we can toggle cell HTML DOM classes, as well as persistent metadata and tag state, from a notebook toolbar button.

The code for this recipe is taken pretty much wholesale from the Toolbar button example in the jupyterlab/extension-examples repo (one of the two notebook examples I could find.

Given pretty much all the classic notebook extensions were notebook related (that’s all there was), I think this demonstrates how low down the list of interested concerns notebook documents fall in JupyterLab. Maybe there needs to be a RetroLab extensions examples repo to collect exampls of working with notebooks and bringing them back to the fore a bit more?

Once again, I’ll use the JupyterLab Plugin Playground to test out the code.

To begin with, here is a minimal recipe to add a single button to the toolbar that can toggle a metadata tag on a selected cell.

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


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

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

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

import { ToolbarButton } from '@jupyterlab/apputils';

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,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = () => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // If the tag exists...
        if (tagList.includes("TOGGLETAG")) {
            console.log("I see TOGGLETAG")

            // ...then remove it
            const index = tagList.indexOf("TOGGLETAG", 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
        }
        // ...else add it
        else {
            console.log("I don't see TOGGLETAG")
            tagList.push("TOGGLETAG")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);

    };

    const button = new ToolbarButton({
      className: 'my-action-button',
      label: 'Tagger',
      //iconClass: 'fa fa-exclamation-circle' 
      //https://github.com/jupyterlab/jupyterlab/issues/8277
      onClick: toggleTagButtonAction,
      tooltip: 'Toggle a persistent metadata tag on a cell',
    });

    panel.toolbar.insertItem(10, 'toggleTagAction', button);
    return new DisposableDelegate(() => {
      button.dispose();
    });
  }
}

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

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

And here’s how it works:

There also seems to be an option to add an icon for the toolbar button. I’ve previously used FontAwesome icons (e.g. in classic notebook extensions), and whilst it appears that these were once supported off-the-shelf in JupyterLab (pehaps with something like iconClass: 'fa fa-exclamation-circle') on the grounds (I think?) that things might not render in other themes. So in the face of that hostility to half-hour-hack end-user-developers, I will add multiple buttons with obscure single character labels that the user will have to try to guess at to remember what they do, rather than go hunting down how to create and style my own icons.

The classic notebook empinken extension supports four toggle class buttons (the three shown below plus a fourth one with a default green colour):

So let’s extend the extension to add four buttons which add the tags ou-activity, ou-solution, ou-student and ou-commentate.

We might also want to add an updater routine that checks for the original empinken extension style tags and converts those to the new tags.

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


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

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

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

import { ToolbarButton } from '@jupyterlab/apputils';

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,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // Set the tag name
        const tagname = "ou-"+typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)

            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
            
            // Remove class
            //activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
            
            // Add class
            //activeCell.node.classList.add(tagname + "-node")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
        
    };

    // The following really needs to be simplified to a loop
    // that iterates over activity, solution, learner and tutor
    
    const activityButton = new ToolbarButton({
      className: 'tagger-activity-button',
      label: 'A',
      // The following construction lets us attach a 
      // parameterised function to the onclick event
      onClick: () => toggleTagButtonAction("activity"),
      tooltip: 'Toggle ou-activity metadata tag on a cell',
    });

    const solutionButton = new ToolbarButton({
      className: 'tagger-solution-button',
      label: 'S',
      onClick: () => toggleTagButtonAction("solution"),
      tooltip: 'Toggle ou-solution metadata tag on a cell',
    });
        
    const learnerButton = new ToolbarButton({
      className: 'tagger-learner-button',
      label: 'L',
      onClick: () => toggleTagButtonAction("learner"),
      tooltip: 'Toggle ou-learner metadata tag on a cell',
    });
        
    const tutorButton = new ToolbarButton({
      className: 'tagger-tutor-button',
      label: 'T',
      onClick: () => toggleTagButtonAction("tutor"),
      tooltip: 'Toggle ou-tutor metadata tag on a cell',
    });

    panel.toolbar.insertItem(10, 'toggleActivityTagButtonAction', activityButton);
    panel.toolbar.insertItem(11, 'toggleSolutionTagButtonAction', solutionButton);
    panel.toolbar.insertItem(12, 'toggleLearnerTagButtonAction', learnerButton);
    panel.toolbar.insertItem(13, 'toggleTutorTagButtonAction', tutorButton); 
    
    return new DisposableDelegate(() => {
      activityButton.dispose();
      solutionButton.dispose();
      learnerButton.dispose();
      tutorButton.dispose();
    });
  }
}

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

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

If we define style based on corresponding “generated” HTML DOM tag2class attributes, and ensure that such classes are added in synch with the metadata elements, we pretty much now have all the ingredients in place for a JuptyerLab empinken extension.

For example, we can add a class to the active (selected) node with constructions of the form activeCell.node.classList.add(typ + "-node") and activeCell.node.classList.remove(typ + "-node") and then style on activity-node etc.

We can simplify the code a little by creating a function that will handle the creation of buttons from a simple button config array:

    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
    
    tagButtonSpec['activity'] = {'typ': 'activity',
                                 'label': 'A', 'location': 10 };

    tagButtonSpec['solution'] = {'typ': 'solution',
                                 'label': 'S', 'location': 11 }; 

    tagButtonSpec['learner'] = {'typ': 'learner',
                                'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor',
                              'label': 'T', 'location': 13 };
    
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ou-' + typ + ' metadata tag on a cell',
        })
        
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }

    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
   

The whole script then becomes:

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


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

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

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

import { ToolbarButton } from '@jupyterlab/apputils';

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,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // Set the tag name
        const tagname = "ou-"+typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)

            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
            
            // Remove class
            activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
            
            // Add class
            activeCell.node.classList.add(tagname + "-node")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
        
    };

    // If we specify the buttons we want in an array
    // we can then construct them all via the same function
    
    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
    
    tagButtonSpec['activity'] = {'typ': 'activity',
                                 'label': 'A', 'location': 10 };

    tagButtonSpec['solution'] = {'typ': 'solution',
                                 'label': 'S', 'location': 11 }; 

    tagButtonSpec['learner'] = {'typ': 'learner',
                                'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor',
                              'label': 'T', 'location': 13 };
    
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ou-' + typ + ' metadata tag on a cell',
        })
        
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }
    
    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
  }
}

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

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

Given we now have a set-up array, we might contemplate using an extension settings file (for example, cribbing from the settings example in jupyterlab/extension-examples) to contain the button definitions and perhaps also the tag and class prefix. I don’t know if the Plugin Playground gives us access to settings the extension can use, so let’s save this unil we have an actual extension to hand.

It might also be handy to be able to use a settings file to custom set the colours used to with the tag classes, but I’m not sure how we can propagate values as CSS variable definitions?

In the next post in the series, we’ll pull all the pieces together and see if we can actually build a JupyterLab empinken extension.

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...

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: