"use strict";
/** @file JavaScript execution within a document, inspired by
<a href='http://reference.wolfram.com/language/guide/NotebookBasics.html'>Mathematica's</a>
and
<a href='https://jupyter-notebook.readthedocs.io/en/stable/notebook.html#structure-of-a-notebook-document'>Jupyter's</a>
notebook interfaces.
@module Notebook
@author Axel T. Schreiner <axel@schreiner-family.net>
@copyright © 2022 Axel T. Schreiner
@version 3.0
*/
// \jsdoc -p -d doc -R etc/README.md notebook.js worker.js
export { init };
/** Default for the maximum number of printed lines.
@type {number}
*/
const printLimit = 32;
/** Path to code acting as JavaScript interpreter, can be set by {@linkcode module:Notebook~init init()}.
@type {string}
*/
let worker = 'worker.js';
/** Singleton: result area and active JavaScript interpreter, if any.
@type {module:Notebook~Result}
*/
let result = null;
/** List of all views, but for {@linkcode module:Notebook~result result}.
@type {module:Notebook~Cell[]}
*/
const cells = [];
/** List of all executable code.
@type {module:Notebook~Run[]}
*/
const runs = [];
/** Collection of all {@linkcode module:Notebook~File File} objects by their ids.
@type {Object.<string, module:Notebook~File>}
*/
const files = {};
/** Changes the user interface state and stores in <code>enable.state</code>.
@param {boolean} _flag - new state.
<table>
<tr> <td> </td> <td><tt> true </tt></td> <td><tt> false </tt></td> </tr>
<tr> <td><tt> Run </tt></td> <td> edit, run </td> <td> </td> </tr>
<tr> <td><tt> File </tt></td> <td> edit </td> <td> </td> </tr>
<tr> <td><tt> Result </tt></td> <td> close </td> <td> terminate </td> </tr>
</table>
*/
const enable = _flag => {
// store new state
enable.state = _flag;
// set all cells
if (result) result.cell.enable();
$.each(cells, (_, _cell) => _cell.enable());
};
/** Creates the notebook infrastructure.
@param {jQuery} _root - parent(s) of HTML structure(s) with cells.
@param {string} [_worker] - path to JavaScript interpreter.
@see enable
*/
const init = (_root, _worker) => {
// set path to interpreter
if (_worker) worker = _worker;
// wrap each input.nb-run type=text
_root.find('input[type=text].nb-run').each(function () { new Run($(this), false); });
// wrap each textarea.nb-run
_root.find('textarea.nb-run').each(function () { new Run($(this), true); });
// wrap each textarea.nb-file id=x
_root.find('textarea.nb-file').each(function () { new File($(this), true); });
// enable UI
enable(true);
};
/** View: represents an input element with a label.
@property {module:Notebook~Run|module:Notebook~File|module:Notebook~Result} owner - points to controller.
@property {jQuery} input - points to input element.
@property {boolean} area - true for <tt>textarea</tt> input element.
@property {string} name - to be displayed in label.
@property {jQuery} div - points to <tt>div</tt> wrapper.
@property {jQuery} span - points to <tt>span</tt> label.
*/
class Cell {
/** Wraps an input element into a <tt>div.nb-cell</tt> and adds a <tt>span</tt> as a label.
@param {module:Notebook~Run|module:Notebook~File|module:Notebook~Result} _owner - points to controller.
@param {jQuery} _input - points to input element.
@param {boolean} _area - true for <tt>textarea</tt> input element.
@param {string} [_name] - to be displayed in label.
*/
constructor (_owner, _input, _area, _name) {
// remove trailing whitespace and trailing empty comments
const val = _input.val().replace(/\s+$/, '').replace(/\/\/\s*\n/g, '\n');
_input.val(val);
// if textarea set rows by content
if (_area) _input.attr('rows', Math.max(2, val.split(/\r\n|\r|\n/).length));
this.owner = _owner;
this.input = _input;
this.area = _area;
this.name = _name ? _name : '';
this.div = $('<div/>').addClass('nb-cell');
this.span = $('<span/>').text(this.name);
// div.nb-cell
// input element
// span
this.input.replaceWith(this.div);
this.div.append(
this.input,
this.span
);
}
/** Controls user interface.
@see module:Notebook~enable
*/
enable () {
// result: always active to close or abort
if (this.owner === result)
if (enable.state)
this.span.removeClass('nb-disabled');
else
this.span.addClass('nb-disabled');
else
if (enable.state) {
this.input.prop('disabled', false);
this.span.removeClass('nb-disabled');
} else {
this.input.prop('disabled', true);
this.span.addClass('nb-disabled');
}
}
}
/** Model and controller: represents executable code in an input element.
@property {module:Notebook~Cell} cell - view containing code.
*/
class Run {
/** Wraps an input element as a cell with editable and executable code.
@param {jQuery} _input - points to input element.
@param {boolean} _area - true for <tt>textarea</tt> input element.
*/
constructor (_input, _area) {
this.cell = new Cell(this, _input, _area);
// connect cell.span.click to this.click
this.cell.span.click(event => this.click(event));
// connect "return" keypress
if (!_area)
this.cell.input.keypress(event => {
if (event.which == 13) // "return"
this.click(event);
});
// maintain runs and cells
runs.push(this);
cells.push(this.cell);
}
/** Click event handler: Executes value unless disabled.
@param {Event} _event - previous result is erased unless option key is pressed.
*/
click (_event) {
// UI disabled? quit
if (!enable.state) return;
// get code
const code = this.cell.input.val().trim();
// no code? quit
if (!code) return;
// zombie result cell? close
if (result && result.terminated) {
result.cell.div.remove();
result = null;
}
// no result cell? create, else clear (or not)
if (!result)
result = new Result();
else if (!_event.altKey)
result.erase();
// explicit result size?
var resultRows = this.cell.input.attr('data-result-rows');
if (resultRows) result.cell.input.attr('rows', resultRows);
// position result at caller
this.cell.div.after(result.cell.div);
// no worker? quit
if (!result.worker) return;
// set print limit
var limit = this.cell.input.attr('data-print-limit');
result.remaining = limit ? limit : printLimit;
// get files
// unfortunately, because they must be available for synchronous loading
var codes = {};
$.each(files, function (key, value) { codes[key] = value.cell.input.val().trim(); });
// disable user interface (re-enabled by Result)
enable(false);
// execute
try {
result.worker.postMessage([code, codes]);
} catch (e) {
result.error(e);
result.stop();
}
}
}
/** Model and controller: represents editable code in a named input element.
@property {module:Notebook~Cell} cell - view containing code.
*/
class File {
/** Wraps an input element with an <tt>id</tt> attribute as a cell with editable code.
@param {jQuery} _input - points to input element.
@param {boolean} _area - true for <tt>textarea</tt> input element.
*/
constructor (_input, _area) {
const id = _input.attr('id');
if (id) {
this.cell = new Cell(this, _input, _area, id);
files[id] = this;
cells.push(this.cell);
}
}
}
/** Model and controller singleton: represents result values in a text area.
@property {module:Notebook~Cell} cell - view containing result values.
@property {number} remaining - number of lines to print before termination.
@property {boolean} terminated - interpreter has been terminated.
@property {DedicatedWorker} worker - interface to parallel executing JavaScript interpreter.
*/
class Result {
/** Wraps a cell with selectable result values.
*/
constructor () {
this.cell = new Cell(this, $('<textarea/>').addClass('nb-result').attr('readonly', 'readonly'), true);
this.remaining = 0;
this.terminated = false;
// connect cell.span.click to this.click
this.cell.span.click(() => this.click());
// create the worker with a cross-domain shim,
// taken from https://benohead.com/cross-domain-cross-browser-web-workers/
try { // use constructor
this.worker = new Worker(worker);
} catch (e) {
try { // create from blob
let blob;
try { // use constructor
blob = new Blob(["importScripts('" + worker + "');"], { "type": 'application/javascript' });
} catch (e1) {
// use blob builder
let blobBuilder = new (window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder)();
blobBuilder.append("importScripts('" + worker + "');");
blob = blobBuilder.getBlob('application/javascript');
}
let url = window.URL || window.webkitURL;
let blobUrl = url.createObjectURL(blob);
this.worker = new Worker(blobUrl);
} catch (e2) {
this.message('cannot create interpreter: ' + e2);
this.stop();
}
}
// event handling for worker
if (this.worker) {
// error in Worker
this.worker.onerror = () => {
this.message('interpreter execution error');
this.stop();
};
// error in message to Worker
this.worker.onmessageerror = () => {
this.message('interpreter message error');
this.stop();
};
// react to Worker
this.worker.onmessage = e => {
switch (e.data[0]) {
// ['exit']
// ['exit', result]
case 'exit':
if (e.data.length > 1)
this.message(e.data[1]);
enable(true);
break;
// ['error', error]
case 'error':
this.message(e.data[1]);
enable(true);
break;
// ['print', [message]]
case 'print':
this.print(e.data[1]);
break;
}
};
}
}
/** Click event handler: closes cell or
terminates worker and enables user interface, depending on {@linkcode enable}.
*/
click () {
this.message('interpreter terminated');
// UI enabled? close
if (enable.state) {
result = null;
this.cell.div.remove();
}
// definitely terminate worker and enable UI
this.stop();
}
/** Display message if there is room.
@param {string|string[]} _messages - array elements will be separated by blanks.
*/
print (_messages) {
if (this.remaining > 0)
this.message(_messages);
else
switch (this.remaining) {
case 0:
this.message('too many printed lines');
default:
this.stop();
}
}
/** Display message unconditionally.
@param {string|string[]} _messages - array elements will be separated by blanks.
*/
message (_messages) {
const textarea = this.cell.input;
// print and count
textarea.val(textarea.val() + '\n' + (_messages instanceof Array ? _messages.join(' ') : _messages));
-- this.remaining;
// scroll to bottom
textarea.scrollTop(textarea.get(0).scrollHeight); // fudge factor
}
/** Erase (previous) content; sets remaining lines to zero.
*/
erase () {
const textarea = this.cell.input;
textarea.val('');
this.remaining = 0;
textarea.scrollTop(0);
}
/** Terminate interpreter, (re-)enable user interface.
*/
stop () {
// change state
this.terminated = true;
this.cell.input.addClass('nb-terminated');
// destroy the Worker
this.worker.terminate();
this.worker = null;
enable(true);
}
}