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.