Source: notebook.js

"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);
  }
}