observablehq / runtime

The reactive dataflow runtime that powers Observable Framework and Observable notebooks
https://observablehq.com/@observablehq/how-observable-runs
ISC License
1.01k stars 71 forks source link

How to access cell body for custom observer/editor? #323

Closed stefaneidelloth closed 3 years ago

stefaneidelloth commented 3 years ago

I experiment with a custom observer that allows to edit cells and currently I access the cell body in a very hackish way, see below.

=> What is the recommended way to access the cell definition in a custom observer?

I suggest that you pass the cell inputs and definition to the observer instead of only passing the result value, e.g.:

function variable_fulfilled(value) {
  if (this._observer.fulfilled) this._observer.fulfilled(value, this._name, this._inputs, this._definition);
}

instead of

function variable_fulfilled(value) {
  if (this._observer.fulfilled) this._observer.fulfilled(value, this._name);
}

in

https://github.com/observablehq/runtime/blob/34a5c820ac166b08463f10a69ca43b64e836527d/src/variable.js

Screenshot:

image

Draft for custom Editor, based on default Inspector, accessing cell bodies based on ugly workaround:

import {Inspector} from "https://cdn.jsdelivr.net/npm/@observablehq/runtime@4/dist/runtime.js";

export const FORBIDDEN = {};

export default class Editor extends Inspector {

    constructor(element, runtime){
        super(element);
        this._runtime = runtime;
    }

    //overrides the the default fulfilled method of the inspector
    //so that editable input fields are shown instead of spans
    fulfilled(value, name) {
        const {_node} = this;
        if (!isnode(value) || (value.parentNode && value.parentNode !== _node)) {
          let expand = _node.firstChild
              && _node.firstChild.classList
              && _node.firstChild.classList.contains("observablehq--expanded");

          value = this.edit(value, false, expand, name);
          value.classList.add("observablehq--inspect");
        }
        _node.classList.remove("observablehq--running", "observablehq--error");
        if (_node.firstChild !== value) {
          if (_node.firstChild) {
            while (_node.lastChild !== _node.firstChild) _node.removeChild(_node.lastChild);
            _node.replaceChild(value, _node.firstChild);
          } else {
            _node.appendChild(value);
          }
        }
        this.dispatch(_node, "update");
    }

    edit(value, shallow, expand, name) {
      let type = typeof value;
      switch (type) {
        case "boolean":
        case "undefined": { value += ""; break; }
        case "number": { value = value === 0 && 1 / value < 0 ? "-0" : value + ""; break; }
        case "bigint": { value = value + "n"; break; }
        case "symbol": { value = this.formatSymbol(value, name); break; }
        case "function": { return this.editFunction(value, name); }
        case "string": { return this.formatString(value, shallow, expand, name); }
        default: {
          if (value === null) { type = null, value = "null"; break; }
          if (value instanceof Date) { type = "date", value = this.formatDate(value, name); break; }
          if (value === FORBIDDEN) { type = "forbidden", value = "[forbidden]"; break; }
          switch (toString.call(value)) {
            case "[object RegExp]": { type = "regexp", value = this.formatRegExp(value, name); break; }
            case "[object Error]": // https://github.com/lodash/lodash/blob/master/isError.js#L26
            case "[object DOMException]": { type = "error", value = this.formatError(value, name); break; }
            default: return (expand ? this.editExpanded : this.editCollapsed)(value, shallow, name);
          }
          break;
        }
      }

      const editor = document.createElement("div");
      editor.className = `custom-editor observablehq--${type}`; 

      const output = editor.appendChild(document.createElement("span"));      
      output.className = `observablehq--${type}`; 
      output.textContent = value;

      const definition = editor.appendChild(document.createElement("div"));

      if (name) definition.appendChild(this.nameLabel(name));

      let firstModule = this._runtime._modules.values().next().value;

      const input = definition.appendChild(document.createElement("input"));
      input.className = `observablehq--${type}`; 
      input.value = this.cellBody(name);
      input.oninput = (event) => this.inputChanged(name, firstModule, event);

      return editor;
    }

    inputChanged(name, module, event){
        var cellBody = event.currentTarget.value;
        console.log("cell input changed " + name + ': ' + cellBody);
        //module.redefine(name, cellBody);
        //TODO
    }

    cellBody(name){
      let runtime = this._runtime;      
      let firstDefine = this._runtime._modules.keys().next().value;

      let cellBodies = this.extractCellBodies(firstDefine, name);
      let cellBody = cellBodies[name];
      return cellBody;
    }

    extractCellBodies(define, name){
         let cellBodies = {};

         let variableMock = { 
            define: (varName, inputs, definition) => { 
                let extractedDefinition = definition?definition:inputs;
                cellBodies[varName] = this.extractFunctionBody(extractedDefinition);
            }
         };
         let moduleMock = { variable: () => variableMock};
         let runtimeMock = { module: ()=> moduleMock};
         let observerMock = (undefined)=>{};
         define(runtimeMock, observerMock);

         return cellBodies;

    }

    extractFunctionBody(definition){
        let functionString = definition.toString();
        let startIndex = functionString.indexOf('{return(\n') +9;
        let endIndex = functionString.length - 3;
        let body = functionString.substring(startIndex, endIndex);
        return body;
    }

    nameLabel(name) {
      const n = document.createElement("span");
      n.className = "observablehq--cellname";
      n.textContent = `${name} = `;
      return n;
    }

    editFunction(f, name){
        const span = document.createElement("span");
        span.textContent = name;
        return span;
        //TODO
        //Origional source:
        //https://github.com/observablehq/inspector/blob/main/src/formatString.js
    }

    editExpanded(object, _, name){
        const span = document.createElement("span");
        span.textContent = name;
        return span;
        //TODO
        //Original source:
        //https://github.com/observablehq/inspector/blob/main/src/expanded.js
    }

    editCollapsed(object, shallow, name){
        const span = document.createElement("span");
        span.textContent = name;
        return span;
        //TODO
        //Original source:
        //https://github.com/observablehq/inspector/blob/main/src/collapsed.js
    }

    formatString(string, shallow, expanded, name){
        const span = document.createElement("span");
        span.textContent = string;
        return span;
        //TODO
        //Origional source:
        //https://github.com/observablehq/inspector/blob/main/src/inspectFunction.js
    }

    formatSymbol(symbol) {
      return Symbol.prototype.toString.call(symbol);
    }

    formatDate(date) {
      return "mocked-date-string" + date;
      //TODO
      //Origional source:
      //https://github.com/observablehq/inspector/blob/main/src/formatDate.js
    }

    formatRegExp(value) {
      return RegExp.prototype.toString.call(value);
    }

    formatError(value) {
      return value.stack || Error.prototype.toString.call(value);
    }

    dispatch(node, type, detail) {
      detail = detail || {};
      var document = node.ownerDocument, event = document.defaultView.CustomEvent;
      if (typeof event === "function") {
        event = new event(type, {detail: detail});
      } else {
        event = document.createEvent("Event");
        event.initEvent(type, false, false);
        event.detail = detail;
      }
      node.dispatchEvent(event);
    }

}

// Returns true if the given value is something that should be added to the DOM
// by the inspector, rather than being inspected. This deliberately excludes
// DocumentFragment since appending a fragment “dissolves” (mutates) the
// fragment, and we wish for the inspector to not have side-effects. Also,
// HTMLElement.prototype is an instanceof Element, but not an element!
function isnode(value) {
  return (value instanceof Element || value instanceof Text)
      && (value instanceof value.constructor);
}

Editor.into = function(container, runtime) {
  if (typeof container === "string") {
    container = document.querySelector(container);
    if (container == null) throw new Error("container not found");
  }
  return function() {
    let element = container.appendChild(document.createElement("div"));
    return new Editor(element, runtime);
  };
};

Usage example:

<!DOCTYPE html>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@observablehq/inspector@3/dist/inspector.css">
<body>
<!-- Also see
https://github.com/observablehq/runtime
https://github.com/observablehq/runtime/issues/322
-->
<script type="module">

import {Runtime} from "https://cdn.jsdelivr.net/npm/@observablehq/runtime@4/dist/runtime.js";
import Editor from "./editor.js";

//import define from "https://api.observablehq.com/@tmcw/hello-world.js?v=3";
import define from "./797362a5c2538221@23.js"

const runtime = new Runtime();

const editor = Editor.into(document.body, runtime);

const module = runtime.module(define, editor);

</script>

Exported observablehq notebook:


export default function define(runtime, observer) {
  const main = runtime.module();
  main.variable(observer()).define(["md"], function(md){return(
md`# Number demo`
)});
  main.variable(observer("z")).define("z", ["b"], function(b){return(
b+3
)});
  main.variable(observer("b")).define("b", function(){return(
1
)});
  main.variable(observer("a")).define("a", ["b"], function(b){return(
b+2
)});
  main.variable(observer("c")).define("c", ["z"], function(z){return(
z-3
)});
  main.variable(observer("v")).define("v", ["c","z"], function(c,z){return(
c+z
)});
  return main;
}
mbostock commented 3 years ago

I’m not sure I fully understand what you’re trying to do here, but I think the short answer is that observers don’t do that; you can only observe the computation status (whether a variable is pending or not) and when it fulfills or rejects. You can’t observe the definition of the variable itself.