Files
tubestation/browser/devtools/webaudioeditor/views/context.js
2015-07-02 04:56:57 -05:00

307 lines
9.6 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { debounce } = require("sdk/lang/functional");
// Globals for d3 stuff
// Default properties of the graph on rerender
const GRAPH_DEFAULTS = {
translate: [20, 20],
scale: 1
};
// Sizes of SVG arrows in graph
const ARROW_HEIGHT = 5;
const ARROW_WIDTH = 8;
// Styles for markers as they cannot be done with CSS.
const MARKER_STYLING = {
light: "#AAA",
dark: "#CED3D9"
};
const GRAPH_DEBOUNCE_TIMER = 100;
// `gAudioNodes` events that should require the graph
// to redraw
const GRAPH_REDRAW_EVENTS = ["add", "connect", "disconnect", "remove"];
/**
* Functions handling the graph UI.
*/
let ContextView = {
/**
* Initialization function, called when the tool is started.
*/
initialize: function() {
this._onGraphClick = this._onGraphClick.bind(this);
this._onThemeChange = this._onThemeChange.bind(this);
this._onStartContext = this._onStartContext.bind(this);
this._onEvent = this._onEvent.bind(this);
this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER);
$("#graph-target").addEventListener("click", this._onGraphClick, false);
window.on(EVENTS.THEME_CHANGE, this._onThemeChange);
window.on(EVENTS.START_CONTEXT, this._onStartContext);
gAudioNodes.on("*", this._onEvent);
},
/**
* Destruction function, called when the tool is closed.
*/
destroy: function() {
// If the graph was rendered at all, then the handler
// for zooming in will be set. We must remove it to prevent leaks.
if (this._zoomBinding) {
this._zoomBinding.on("zoom", null);
}
$("#graph-target").removeEventListener("click", this._onGraphClick, false);
window.off(EVENTS.THEME_CHANGE, this._onThemeChange);
window.off(EVENTS.START_CONTEXT, this._onStartContext);
gAudioNodes.off("*", this._onEvent);
},
/**
* Called when a page is reloaded and waiting for a "start-context" event
* and clears out old content
*/
resetUI: function () {
this.clearGraph();
this.resetGraphTransform();
},
/**
* Clears out the rendered graph, called when resetting the SVG elements to draw again,
* or when resetting the entire UI tool
*/
clearGraph: function () {
$("#graph-target").innerHTML = "";
},
/**
* Moves the graph back to its original scale and translation.
*/
resetGraphTransform: function () {
// Only reset if the graph was ever drawn.
if (this._zoomBinding) {
let { translate, scale } = GRAPH_DEFAULTS;
// Must set the `zoomBinding` so the next `zoom` event is in sync with
// where the graph is visually (set by the `transform` attribute).
this._zoomBinding.scale(scale);
this._zoomBinding.translate(translate);
d3.select("#graph-target")
.attr("transform", "translate(" + translate + ") scale(" + scale + ")");
}
},
getCurrentScale: function () {
return this._zoomBinding ? this._zoomBinding.scale() : null;
},
getCurrentTranslation: function () {
return this._zoomBinding ? this._zoomBinding.translate() : null;
},
/**
* Makes the corresponding graph node appear "focused", removing
* focused styles from all other nodes. If no `actorID` specified,
* make all nodes appear unselected.
*/
focusNode: function (actorID) {
// Remove class "selected" from all nodes
Array.forEach($$(".nodes > g"), $node => $node.classList.remove("selected"));
// Add to "selected"
if (actorID) {
this._getNodeByID(actorID).classList.add("selected");
}
},
/**
* Takes an actorID and returns the corresponding DOM SVG element in the graph
*/
_getNodeByID: function (actorID) {
return $(".nodes > g[data-id='" + actorID + "']");
},
/**
* Sets the appropriate class on an SVG node when its bypass
* status is toggled.
*/
_bypassNode: function (node, enabled) {
let el = this._getNodeByID(node.id);
el.classList[enabled ? "add" : "remove"]("bypassed");
},
/**
* This method renders the nodes currently available in `gAudioNodes` and is
* throttled to be called at most every `GRAPH_DEBOUNCE_TIMER` milliseconds.
* It's called whenever the audio context routing changes, after being debounced.
*/
draw: function () {
// Clear out previous SVG information
this.clearGraph();
let graph = new dagreD3.Digraph();
let renderer = new dagreD3.Renderer();
gAudioNodes.populateGraph(graph);
// Post-render manipulation of the nodes
let oldDrawNodes = renderer.drawNodes();
renderer.drawNodes(function(graph, root) {
let svgNodes = oldDrawNodes(graph, root);
svgNodes.each(function (n) {
let node = graph.node(n);
let classString = "audionode type-" + node.type + (node.bypassed ? " bypassed" : "");
this.setAttribute("class", classString);
this.setAttribute("data-id", node.id);
this.setAttribute("data-type", node.type);
});
return svgNodes;
});
// Post-render manipulation of edges
let oldDrawEdgePaths = renderer.drawEdgePaths();
let defaultClasses = "edgePath enter";
renderer.drawEdgePaths(function(graph, root) {
let svgEdges = oldDrawEdgePaths(graph, root);
svgEdges.each(function (e) {
let edge = graph.edge(e);
// We have to manually specify the default classes on the edges
// as to not overwrite them
let edgeClass = defaultClasses + (edge.param ? (" param-connection " + edge.param) : "");
this.setAttribute("data-source", edge.source);
this.setAttribute("data-target", edge.target);
this.setAttribute("data-param", edge.param ? edge.param : null);
this.setAttribute("class", edgeClass);
});
return svgEdges;
});
// Override Dagre-d3's post render function by passing in our own.
// This way we can leave styles out of it.
renderer.postRender((graph, root) => {
// We have to manually set the marker styling since we cannot
// do this currently with CSS, although it is in spec for SVG2
// https://svgwg.org/svg2-draft/painting.html#VertexMarkerProperties
// For now, manually set it on creation, and the `_onThemeChange`
// function will fire when the devtools theme changes to update the
// styling manually.
let theme = Services.prefs.getCharPref("devtools.theme");
let markerColor = MARKER_STYLING[theme];
if (graph.isDirected() && root.select("#arrowhead").empty()) {
root
.append("svg:defs")
.append("svg:marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 0 10 10")
.attr("refX", ARROW_WIDTH)
.attr("refY", ARROW_HEIGHT)
.attr("markerUnits", "strokewidth")
.attr("markerWidth", ARROW_WIDTH)
.attr("markerHeight", ARROW_HEIGHT)
.attr("orient", "auto")
.attr("style", "fill: " + markerColor)
.append("svg:path")
.attr("d", "M 0 0 L 10 5 L 0 10 z");
}
// Reselect the previously selected audio node
let currentNode = InspectorView.getCurrentAudioNode();
if (currentNode) {
this.focusNode(currentNode.id);
}
// Fire an event upon completed rendering, with extra information
// if in testing mode only.
let info = {};
if (DevToolsUtils.testing) {
info = gAudioNodes.getInfo();
}
window.emit(EVENTS.UI_GRAPH_RENDERED, info.nodes, info.edges, info.paramEdges);
});
let layout = dagreD3.layout().rankDir("LR");
renderer.layout(layout).run(graph, d3.select("#graph-target"));
// Handle the sliding and zooming of the graph,
// store as `this._zoomBinding` so we can unbind during destruction
if (!this._zoomBinding) {
this._zoomBinding = d3.behavior.zoom().on("zoom", function () {
var ev = d3.event;
d3.select("#graph-target")
.attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")");
});
d3.select("svg").call(this._zoomBinding);
// Set initial translation and scale -- this puts D3's awareness of
// the graph in sync with what the user sees originally.
this.resetGraphTransform();
}
},
/**
* Event handlers
*/
/**
* Called once "start-context" is fired, indicating that there is an audio
* context being created to view so render the graph.
*/
_onStartContext: function () {
this.draw();
},
/**
* Called when `gAudioNodes` fires an event -- most events (listed
* in GRAPH_REDRAW_EVENTS) qualify as a redraw event.
*/
_onEvent: function (eventName, ...args) {
// If bypassing, just toggle the class on the SVG node
// rather than rerendering everything
if (eventName === "bypass") {
this._bypassNode.apply(this, args);
}
if (~GRAPH_REDRAW_EVENTS.indexOf(eventName)) {
this.draw();
}
},
/**
* Fired when the devtools theme changes.
*/
_onThemeChange: function (eventName, theme) {
let markerColor = MARKER_STYLING[theme];
let marker = $("#arrowhead");
if (marker) {
marker.setAttribute("style", "fill: " + markerColor);
}
},
/**
* Fired when a click occurs in the graph.
*
* @param Event e
* Click event.
*/
_onGraphClick: function (e) {
let node = findGraphNodeParent(e.target);
// If node not found (clicking outside of an audio node in the graph),
// then ignore this event
if (!node)
return;
let id = node.getAttribute("data-id");
this.focusNode(id);
window.emit(EVENTS.UI_SELECT_NODE, id);
}
};