diff --git a/object_database/web/content/app.css b/object_database/web/content/app.css index 1f500afe0..6b1c334a6 100644 --- a/object_database/web/content/app.css +++ b/object_database/web/content/app.css @@ -1096,3 +1096,8 @@ pre { overflow: auto; pointer-events: auto; } + +// devtools helpers +.devtools-inspect{ + background-color: lightblue!important; +} diff --git a/object_database/web/content/src/CellHandler.js b/object_database/web/content/src/CellHandler.js index 7664f035c..a1f8b7cd5 100644 --- a/object_database/web/content/src/CellHandler.js +++ b/object_database/web/content/src/CellHandler.js @@ -58,6 +58,10 @@ class CellHandler { // the server-assigned session this.sessionId = null; + + // devtools related messages + this.sendMessageToDevtools = this.sendMessageToDevtools.bind(this); + this.updateDevtools = this.updateDevtools.bind(this); } tearDownAllLiveCells() { @@ -127,6 +131,9 @@ class CellHandler { ]) ]) ); + this.sendMessageToDevtools({ + status: "initial load" + }); } afterConnected() { @@ -158,6 +165,9 @@ class CellHandler { ["Reconnecting in " + waitSeconds + " seconds"]) ]) ); + this.sendMessageToDevtools({ + status: "reconnecting" + }); } /** @@ -167,7 +177,7 @@ class CellHandler { * It will case out the appropriate handling * method based on the `type` field in the * message. - * Note taht we call `doesNotUnderstand()` + * Note that we call `doesNotUnderstand()` * in the event of a message containing a * message type that is unknown to the system. * @param {Object} message - A JSON decoded @@ -365,6 +375,7 @@ class CellHandler { + totalUpdateCount + " nodes created/updated." ) } + this.updateDevtools(); } } @@ -495,6 +506,49 @@ class CellHandler { this.socket.sendString(JSON.stringify(message)); } } + + sendMessageToDevtools(msg){ + // TODO perhaps this should be run by a worker + msg.type = "cells_devtools"; + window.postMessage(msg); + } + + /** + * Send updated cells data to devtools + **/ + updateDevtools(){ + const buildTree = (cell) => { + return { + name: cell.constructor.name, + identity: cell.identity, + children: mapChildren(cell.namedChildren) + } + } + // NOTE: sometimes a named child is a cell, sometimes it's an array of cells + // so we need a helper function to deal with these cases + const mapChildren = (namedChildren) => { + const children = []; + if (namedChildren) { + Object.values(namedChildren).forEach((child) => { + if (child){ + if (child instanceof Array){ + child.forEach((subchild) => { + children.push(buildTree(subchild)); + }) + } + children.push(buildTree(child)); + } + }) + } + return children; + } + const page_root = this.activeCells['page_root']; + const tree = buildTree(page_root); + this.sendMessageToDevtools({ + status: "loaded", + cells: tree + }); + } } export {CellHandler, CellHandler as default}; diff --git a/object_database/web/devtools/DevtoolsExtensions.png b/object_database/web/devtools/DevtoolsExtensions.png new file mode 100644 index 000000000..2fc5d466f Binary files /dev/null and b/object_database/web/devtools/DevtoolsExtensions.png differ diff --git a/object_database/web/devtools/README.md b/object_database/web/devtools/README.md new file mode 100644 index 000000000..eadb20b2b --- /dev/null +++ b/object_database/web/devtools/README.md @@ -0,0 +1,71 @@ +## Chrome Devtools Extensions ## + +The code here is devoted to building devtool extensions and related development. + +**NOTE: as of writing this is tested only in Chrome 106.X.** + +### Installation ### + +Open the extension manager in chrome. Then press "Load Temporary Add-on," select the [manifest.json](./manifest.json) file and press "open." When loading one of our example applications and opening devtools you should see a "Cells" panel. + +### Development #### + +At its core the devtools extension is configured and defined by a [manifest.json](./manifest.json) file [NOTE: currently this is written for Manifest V2 which will no longer be supported in 2023]. The internals of the configuration is pretty self explanatory but the key thing to note is the presence of a `devtools_page` key. This specifies that the current is a "devtools extension" as opposed to a generic chrome extension. You can read more about manifests [here](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json). + +After loading the extension as above you can see your changes by pressing the `reload` button. You can also load a devtools for the devtools by pressing `Inspect`, but the functionalities are limited (for example there is no view on the DOM, since there is no DOM here in the standard sense). + +The rest of the story consists of understanding all the necessary components and how these communicate. This is summarized in the image below: + +![Devtools Extension Setup](DevtoolsExtensions.png) + +With the above "knowledge" you should already be able to guess that everything in the "Devtools" box will log to the devtool's devtool console, while everything in the "Document" box will log to the document's devtools console. + +Lets go through these one at a time: + +[devtools_init.html](./devtools_init.js) and [devtools_init.js](./devtools_init.js) +------------------------------------------------------------------- + + With the extension loaded as above, when the devtools open it will load the `devools_page` specified in the manifest file. Even though [devtools.html](./devtools.html) is technically an html page, devtools pages **do not** have any visible DOM or access to WebExtension API's. What they do have is JS source code included via the `script` tag. In our case this is the [devtools_init.js](./devtools_init.js) file. + +This bundled source file **does** have access to DOM API's through the global `window` object, same WebExtension API as content scripts (see below), and devtools APIs. The first thing this script does is use the latter to [create and configure the panel](./devtools_init.js#L9). Then subsequently it sets up global variables for holding data, callbacks for `panel.onShow` (fired for example when the panel icon is clicked) and `panel.onHidden`, as well as opening a `chrome.runtime.Port` which will allow for communication between the panel and the [background.js](./background.js) processes (see more on that below). + +Note: since `devtools_init.js` has access to the `window` object, which is the context that panel lives in, we can call functions or access variable between the two. +For example, [window.handleMessageFromBackground()](./devtools_init.js#L25) is defined in [cell_panel.js](./js/cell_panel.js) but we can still access it through `_window`. + +Note also, the var `_window` is used here to deal with the possibility that at times the message inspector panel is closed (for example you are looking at console or something else) but there is still message passing going on and data needs to be stored. In this case the panel collects data which will be subsequently processed when the panel opens. This is the core of what is defined in the `devtools_init.js` file. + +[panels](./js) +------------------ + +The files you find in [js](./js) are what you expect of every normal web application: there is html, js and css. As of writing there are two interesting pieces here: + +* the `handleMessageFromBackground()` function, already seen above called in `devtools_init.js`, is the callback for a message coming in from [background.js](background.js) via the panel port connection set up in `devtools_init.js`. The function handles the incoming message and updates in the display in the panel accordingly. As of writing it cases on `msg.status` (initial load, reconnecting, loaded) and calls for corresponding display functions, +* Note the `mouseover` and `mouseleave` event handlers added to the tree in [tree.js](./js/tree.js). This callback uses the `chrome.devtools.inspectedWindow.eval` API to insert a raw script and execute it on the `document`, i.e. on the inspected window. This allows for a way to by-pass the "standard" extension communication protocol and directly interact with the target window, although in a somewhat limited way. Is it safe to call `eval()` on raw scripts strings - depends... so use with caution. + +In short, the code in panels is what you see when you click on the devtools message inspector icon and it communicates with the rest of the world either via `backround.js` or via direct script insertions. + + +[background.js](./background.js) +-------------------------------- + +[background](background.js) is the bridge communication between the devtools panel and the target application (see more on this in the content-script description below). + +It +* listens for `chrome.runtime.Port` communications from both the `content-script.js` and the `cell_panel.js`, +* routes messages as needed via the various open ports. + + +[content-script.js](./content-script.js) +---------------------------------------- + +This script is injected into the target document window and runs each time devtools is open, i.e. everything here is essentially the same as any js code that you import via the `script` tag into your application. The key exception is that **content scripts have access to the background script** defined processes and code via the `chrome.runtime.Port` API **and** it can communicate with the your application (in our case via the [window.postMessage()](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) API. + +Since devtools doesn't have direct access to the target window API, `background.js` becomes the bridge for all communication. + +Now it's pretty clear what content scripts should do: +* set up a port to communication with background, +* listen to messages coming via the `window.postMessage` interface and forward whatever is needed to background. + +The messages that content script here is waiting on are sent in `System.sendMessage()` in our application. + +And that's more or less it. diff --git a/object_database/web/devtools/background.js b/object_database/web/devtools/background.js new file mode 100644 index 000000000..b97fc84c0 --- /dev/null +++ b/object_database/web/devtools/background.js @@ -0,0 +1,56 @@ +/* + * The background script handles communication to and from + * the content script, embedded in the document, and + * the panel scripts, living in devtools. + * These communiccation are handled by chrome.runtime connection + * ports. + * The connections are initialized int he content and panel scripts, + * respectively. Here we listen for these connection and create + * connection/port specific handlers. + */ +var portFromCS; +var portFromPanel; + +function connected(port) { + // handle all communication to and from the panel + if (port.name === "port-from-panel"){ + portFromPanel = port; + // at the moment we don't do anything with messages coming + // from the panels + portFromPanel.onMessage.addListener(function(msg) { + console.log("recieved message from panel", msg); + }); + }; + // handle all communication to and from the content script + if (port.name === "port-from-cs"){ + portFromCS = port; + portFromCS.onMessage.addListener(function(msg) { + // Having received a message from the content script, i.e. + // from the target window we forward this message to the panels + // if the connection is not alive we log this in the devtools's + // debugger console + notifyDevtoolsPabel(msg.data); + }); + } + // notify if the port has disconnected + port.onDisconnect.addListener(function(port) { + if (port.name === "port-from-panel" || port.name === "port-from-cs"){ + console.log(`${port.name} has disconnected`); + }; + }); +} + +chrome.runtime.onConnect.addListener(connected); + +function notifyDevtoolsPabel(msg){ + if (portFromPanel){ + portFromPanel.postMessage(msg); + } else { + console.log(msg); + console.log("failed to send message to devtools panel: port disconnected"); + } +} + +// chrome.browserAction.onClicked.addListener(function() { +// portFromCS.postMessage({greeting: "they clicked the button!"}); +// }); diff --git a/object_database/web/devtools/cells_panel.css b/object_database/web/devtools/cells_panel.css new file mode 100644 index 000000000..eff198b16 --- /dev/null +++ b/object_database/web/devtools/cells_panel.css @@ -0,0 +1,46 @@ +html { + height: 100% !important; +} + +body { + height: 100% !important; + display: flex; + flex-direction: row; + margin: 0px; +} + +div#main { + display: flex; + justify-content: center; + min-width: 70%; + border-right: solid 2px; + align-items: center; +} + +div#cell-info { + display: flex; + justify-content: center; + align-items: center; + min-width: 30%; +} + +.node { + cursor: pointer; +} +.node circle { + fill: #fff; + stroke: steelblue; + stroke-width: 1.5px; +} +.node text { + font: 10px sans-serif; +} +.link { + fill: none; + stroke: #ccc; + stroke-width: 1.5px; +} + +body { + overflow: hidden; +} diff --git a/object_database/web/devtools/cells_panel.html b/object_database/web/devtools/cells_panel.html new file mode 100644 index 000000000..a89140912 --- /dev/null +++ b/object_database/web/devtools/cells_panel.html @@ -0,0 +1,14 @@ + + + + + Cells + + + + +
+
some cells data here
+ + + diff --git a/object_database/web/devtools/content-script.js b/object_database/web/devtools/content-script.js new file mode 100644 index 000000000..a1c02e588 --- /dev/null +++ b/object_database/web/devtools/content-script.js @@ -0,0 +1,34 @@ +/* + * I am the content script which is injected into the target + * document window when devtools is open. + * + * I create connection port to handle communication between myself + * and the devtools browser script (which then passes these messages + * onto the devtools panel scripts). + * + * In addition, I handle incoming window level messaging ( + * window.postMessage() API) and routing these application + * originating messaged to the devtools background. + */ + + +console.log("loading content script"); +var portFromCS = chrome.runtime.connect({name:"port-from-cs"}); + +// at the moment nothing much is done with messages going +// to the content-script port +portFromCS.onMessage.addListener(function(msg) { + // console.log("received message from background"); +}); + +window.addEventListener("message", (event) => { + // filter on the target windows url + // console.log("message in target window") + if(event.origin === window.location.origin){ + // filter the message further to make sure it's for devtools + if(event.data.type == "cells_devtools"){ + // reroute the message to the background script + portFromCS.postMessage({data: event.data}); + } + } +}, false); diff --git a/object_database/web/devtools/devtools.html b/object_database/web/devtools/devtools.html new file mode 100644 index 000000000..717330a16 --- /dev/null +++ b/object_database/web/devtools/devtools.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/object_database/web/devtools/devtools_init.js b/object_database/web/devtools/devtools_init.js new file mode 100644 index 000000000..a9073b3f1 --- /dev/null +++ b/object_database/web/devtools/devtools_init.js @@ -0,0 +1,52 @@ +// Create a new panel +chrome.devtools.panels.create( + "Cells", + null, + "cells_panel.html", + function(panel){ + let _window = null; // hold a reference to cell_panel.html + const data = []; + + // create a connection/port which will handle all communication + // between the panel and the background script + const portFromPanel = chrome.runtime.connect({name: "port-from-panel"}); + portFromPanel.onMessage.addListener(function(msg) { + if (_window){ + // handleMessageFromBackground() is defined in panel.js + _window.handleMessageFromBackground(msg); + } else { + console.log("no connection to background"); + // if the panel's window is undefined store the data for now + data.push(msg); + // console.log(`logged data:`) + // console.log(data); + } + }); + + // when the panel button is clicked + panel.onShown.addListener(function tmp(panelWindow) { + console.log("panel is being shown"); + // clean up any stale listeners + panel.onShown.removeListener(tmp); + + // set the _window const to panelWindow which allows handling + // of messages by the panel, i.e. in the panel's window context + _window = panelWindow; + let msg = data.shift(); + // if any data was logged while the panel was not available + // send it along now + while (msg){ + // console.log("handling logged messages"); + _window.handleMessageFromBackground(msg); + msg = data.shift(); + } + // If we ever need to send messages back via the port + // we can do that as below + _window.respond = function(msg) { + portFromPanel.postMessage(msg); + } + }); + + panel.onHidden.addListener(function() {console.log("panel is being hidden")}); console.log(panel); + } +); diff --git a/object_database/web/devtools/index.html b/object_database/web/devtools/index.html new file mode 100644 index 000000000..02ef5dabc --- /dev/null +++ b/object_database/web/devtools/index.html @@ -0,0 +1,11 @@ + + + + +Devtools Test Page + + + +
Something about devtools
+ + diff --git a/object_database/web/devtools/js/cell_panel.js b/object_database/web/devtools/js/cell_panel.js new file mode 100644 index 000000000..66a16a53b --- /dev/null +++ b/object_database/web/devtools/js/cell_panel.js @@ -0,0 +1,64 @@ +import {CellsTree} from './tree.js'; + +// GLOBALS (TODO: should be handled better) +let state = null; +let cellsJSONCache = null; + +// setup message handling from background +function handleMessageFromBackground(msg){ + // console.log("handling background message"); + // console.log(msg); + switch (msg.status){ + case "initial load": + state = msg.status; + initialLoadDisplay(); + break; + case "reconnecting": + // no need to redisplay reconnection attemps + if(state != msg.status){ + state = msg.status; + reconnectingDisplay(); + } + break; + case "loaded": + // check to see if the cells tree has changed + if(cellsJSONCache != JSON.stringify(msg.cells)){ + state = msg.status; + cellsJSONCache = JSON.stringify(msg.cells); + cellsTreeDisplay(msg.cells); + console.log(msg.cells); + } + } +} + +window.handleMessageFromBackground = handleMessageFromBackground; + + +const initialLoadDisplay = () => { + const main = document.getElementById("main"); + main.textContent = "Initializing: no cells loaded"; +} + +const reconnectingDisplay = () => { + const main = document.getElementById("main"); + main.textContent = "Reconnecting: no cells loaded"; +} + +const cellsTreeDisplay = (cells) => { + clearDisplay(); + // init and run + // NOTE: the tree class itself attaches the + // svg element to #main + const cTree = new CellsTree(cells); + cTree.setupTree(); +} + + +/** + * I clear the views when the application + * views when the application state changes + **/ +const clearDisplay = () => { + document.getElementById("main").replaceChildren(); + document.getElementById("cell-info").replaceChildren(); +} diff --git a/object_database/web/devtools/js/tree.js b/object_database/web/devtools/js/tree.js new file mode 100644 index 000000000..a8ac2594f --- /dev/null +++ b/object_database/web/devtools/js/tree.js @@ -0,0 +1,263 @@ + +class CellsTree extends Object { + constructor(data) { + super(); + + this.data = data; + + // basic view settings + // TODO: maybe these should be passed as params to the constructor + this.duration = 750; + this.rectW = 60; + this.rectH=30; + + // bound methods + this.setupTree = this.setupTree.bind(this); + this.update = this.update.bind(this); + this.collapse = this.collapse.bind(this); + this.redraw = this.redraw.bind(this); + this.onDblclick = this.onDblclick.bind(this); + this.onClick = this.onClick.bind(this); + this.onMouseover = this.onMouseover.bind(this); + this.onMouseleave = this.onMouseleave.bind(this); + } + + setupTree(){ + this.tree = d3.layout.tree().nodeSize([70, 40]); + const zm = d3.behavior.zoom() + .scaleExtent([1,3]) + .on("zoom", this.redraw) + // the main svg container for the tree + this.svg = d3.select("#main").append("svg") + .attr("width", "100%") + .attr("height", 1000) + .call(zm) + .append("g") + .attr( + "transform", + `translate(${350},${20})` + ); + //necessary so that zoom knows where to zoom and unzoom from + zm.translate([350, 20]); + + this.data.x0 = 0; + this.data.y0 = 700 / 2; + + // collapse all children for now + // TODO do we want this? + this.data.children.forEach(this.collapse); + + // build the tree + this.update(this.data); + } + + update(source) { + // need to define these in method scope since a number of the + // callbacks are not bound to the class. TODO + let id = 0; + const rectW = this.rectW; + const rectH = this.rectH; + + const diagonal = d3.svg.diagonal() + .projection(function (d) { + return [d.x + rectW / 2, d.y + rectH / 2]; + }); + // Compute the new tree layout. + const nodes = this.tree.nodes(this.data).reverse(); + const links = this.tree.links(nodes); + + // Normalize for fixed-depth. + nodes.forEach(function (d) { + d.y = d.depth * 180; + }); + + // Update the nodes… + const node = this.svg.selectAll("g.node") + .data(nodes, function (d) { + return d.id = ++id; + } + ); + + // Enter any new nodes at the parent's previous position. + const nodeEnter = node.enter().append("g") + .attr("class", "node") + .attr("transform", function (d) { + return `translate(${source.x0},${source.y0})`; + }) + .on("dblclick", this.onDblclick) + .on("click", this.onClick) + .on("mouseover", this.onMouseover) + .on("mouseleave", this.onMouseleave); + + + nodeEnter.append("rect") + .attr("width", this.rectW) + .attr("height", this.rectH) + .attr("stroke", "black") + .attr("stroke-width", 1) + .style("fill", function (d) { + return d._children ? "lightsteelblue" : "#fff"; + } + ); + + nodeEnter.append("text") + .attr("x", this.rectW / 2) + .attr("y", this.rectH / 2) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .text(function (d) { + // limit the name to 7 chars since text-overflow + // doesn't seem to work here + let name = d.name; + if (name.length > 7){ + name = name.slice(0, 7) + "..."; + } + return name; + } + ); + + // Transition nodes to their new position. + const nodeUpdate = node.transition() + .duration(this.duration) + .attr("transform", function (d) { + return `translate(${d.x},${d.y})`; + } + ); + + nodeUpdate.select("rect") + .attr("width", this.rectW) + .attr("height", this.rectH) + .attr("stroke", "black") + .attr("stroke-width", 1) + .style("fill", function (d) { + return d._children ? "lightsteelblue" : "#fff"; + } + ); + + nodeUpdate.select("text") + .style("fill-opacity", 1); + + // Transition exiting nodes to the parent's new position. + const nodeExit = node.exit().transition() + .duration(this.duration) + .attr("transform", function (d) { + return `translate(${source.x},${source.y})`; + } + ).remove(); + + nodeExit.select("rect") + .attr("width", this.rectW) + .attr("height", this.rectH) + .attr("stroke", "black") + .attr("stroke-width", 1); + + nodeExit.select("text"); + + // Update the links… + const link = this.svg.selectAll("path.link") + .data(links, function (d) { + return d.target.id; + } + ); + + // Enter any new links at the parent's previous position. + link.enter().insert("path", "g") + .attr("class", "link") + .attr("x", this.rectW / 2) + .attr("y", this.rectH / 2) + .attr("d", function (d) { + const o = { + x: source.x0, + y: source.y0 + }; + return diagonal({ + source: o, + target: o + }); + }); + + // Transition links to their new position. + link.transition() + .duration(this.duration) + .attr("d", diagonal); + + // Transition exiting nodes to the parent's new position. + link.exit().transition() + .duration(this.duration) + .attr("d", function (d) { + const o = { + x: source.x, + y: source.y + }; + return diagonal({ + source: o, + target: o + }); + }) + .remove(); + + // Stash the old positions for transition. + nodes.forEach(function (d) { + d.x0 = d.x; + d.y0 = d.y; + }); + } + + collapse(d) { + if (d.children) { + d._children = d.children.slice(); + d._children.forEach(this.collapse); + d.children = null; + } + } + + + // Toggle children on click. + onDblclick(d) { + // prevent the default zooming in/out behavior + d3.event.stopPropagation(); + if (d.children) { + d._children = d.children.slice(); + d.children = null; + } else { + d.children = d._children.slice(); + d._children = null; + } + this.update(d); + } + + onClick(event){ + // update the cell data + // probably should be handled by a different class + const infoDiv = document.getElementById("cell-info") + infoDiv.textContent = `${event.name} (id: ${event.identity})`; + } + + onMouseover(event){ + // highlighte the corresponding element in the target window + chrome.devtools.inspectedWindow.eval( + `document.querySelector("[data-cell-id='${event.identity}']").style.backgroundColor = 'lightblue'` + ); + } + + onMouseleave(event){ + console.log(event); + // highlighte the corresponding element in the target window + chrome.devtools.inspectedWindow.eval( + `document.querySelector("[data-cell-id='${event.identity}']").style.backgroundColor= 'initial'` + ); + } + //Redraw for zoom + redraw() { + this.svg.attr("transform", + `translate(${d3.event.translate})` + +`scale(${d3.event.scale})` + ); + } +} + + +export { + CellsTree, + CellsTree as default +} diff --git a/object_database/web/devtools/manifest.json b/object_database/web/devtools/manifest.json new file mode 100644 index 000000000..27b9c1883 --- /dev/null +++ b/object_database/web/devtools/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "cells devtools", + "version": "1.0", + "manifest_version": 2, + "devtools_page": "devtools.html", + "content_security_policy": "script-src 'self' https://d3js.org; object-src 'self'", + "content_scripts": [ + { + "matches": [ + "*://*/*" + ], + "js": [ + "content-script.js" + ] + } + ], + "background": { + "scripts": [ + "background.js" + ] + } +}