1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/browser/devtools/webaudioeditor/webaudioeditor-view.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,363 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this file, 1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 +"use strict"; 1.8 + 1.9 +Cu.import("resource:///modules/devtools/VariablesView.jsm"); 1.10 +Cu.import("resource:///modules/devtools/VariablesViewController.jsm"); 1.11 +const { debounce } = require("sdk/lang/functional"); 1.12 + 1.13 +// Globals for d3 stuff 1.14 +// Width/height in pixels of SVG graph 1.15 +// TODO investigate to see how this works in other host types bug 994257 1.16 +const WIDTH = 1000; 1.17 +const HEIGHT = 400; 1.18 + 1.19 +// Sizes of SVG arrows in graph 1.20 +const ARROW_HEIGHT = 5; 1.21 +const ARROW_WIDTH = 8; 1.22 + 1.23 +const GRAPH_DEBOUNCE_TIMER = 100; 1.24 + 1.25 +const GENERIC_VARIABLES_VIEW_SETTINGS = { 1.26 + lazyEmpty: true, 1.27 + lazyEmptyDelay: 10, // ms 1.28 + searchEnabled: false, 1.29 + editableValueTooltip: "", 1.30 + editableNameTooltip: "", 1.31 + preventDisableOnChange: true, 1.32 + preventDescriptorModifiers: true, 1.33 + eval: () => {} 1.34 +}; 1.35 + 1.36 +/** 1.37 + * Functions handling the graph UI. 1.38 + */ 1.39 +let WebAudioGraphView = { 1.40 + /** 1.41 + * Initialization function, called when the tool is started. 1.42 + */ 1.43 + initialize: function() { 1.44 + this._onGraphNodeClick = this._onGraphNodeClick.bind(this); 1.45 + this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER); 1.46 + }, 1.47 + 1.48 + /** 1.49 + * Destruction function, called when the tool is closed. 1.50 + */ 1.51 + destroy: function() { 1.52 + if (this._zoomBinding) { 1.53 + this._zoomBinding.on("zoom", null); 1.54 + } 1.55 + }, 1.56 + 1.57 + /** 1.58 + * Called when a page is reloaded and waiting for a "start-context" event 1.59 + * and clears out old content 1.60 + */ 1.61 + resetUI: function () { 1.62 + $("#reload-notice").hidden = true; 1.63 + $("#waiting-notice").hidden = false; 1.64 + $("#content").hidden = true; 1.65 + this.resetGraph(); 1.66 + }, 1.67 + 1.68 + /** 1.69 + * Called once "start-context" is fired, indicating that there is audio context 1.70 + * activity to view and inspect 1.71 + */ 1.72 + showContent: function () { 1.73 + $("#reload-notice").hidden = true; 1.74 + $("#waiting-notice").hidden = true; 1.75 + $("#content").hidden = false; 1.76 + this.draw(); 1.77 + }, 1.78 + 1.79 + /** 1.80 + * Clears out the rendered graph, called when resetting the SVG elements to draw again, 1.81 + * or when resetting the entire UI tool 1.82 + */ 1.83 + resetGraph: function () { 1.84 + $("#graph-target").innerHTML = ""; 1.85 + }, 1.86 + 1.87 + /** 1.88 + * Makes the corresponding graph node appear "focused", called from WebAudioParamView 1.89 + */ 1.90 + focusNode: function (actorID) { 1.91 + // Remove class "selected" from all nodes 1.92 + Array.prototype.forEach.call($$(".nodes > g"), $node => $node.classList.remove("selected")); 1.93 + // Add to "selected" 1.94 + this._getNodeByID(actorID).classList.add("selected"); 1.95 + }, 1.96 + 1.97 + /** 1.98 + * Unfocuses the corresponding graph node, called from WebAudioParamView 1.99 + */ 1.100 + blurNode: function (actorID) { 1.101 + this._getNodeByID(actorID).classList.remove("selected"); 1.102 + }, 1.103 + 1.104 + /** 1.105 + * Takes an actorID and returns the corresponding DOM SVG element in the graph 1.106 + */ 1.107 + _getNodeByID: function (actorID) { 1.108 + return $(".nodes > g[data-id='" + actorID + "']"); 1.109 + }, 1.110 + 1.111 + /** 1.112 + * `draw` renders the ViewNodes currently available in `AudioNodes` with `AudioNodeConnections`, 1.113 + * and is throttled to be called at most every `GRAPH_DEBOUNCE_TIMER` milliseconds. Is called 1.114 + * whenever the audio context routing changes, after being debounced. 1.115 + */ 1.116 + draw: function () { 1.117 + // Clear out previous SVG information 1.118 + this.resetGraph(); 1.119 + 1.120 + let graph = new dagreD3.Digraph(); 1.121 + let edges = []; 1.122 + 1.123 + AudioNodes.forEach(node => { 1.124 + // Add node to graph 1.125 + graph.addNode(node.id, { label: node.type, id: node.id }); 1.126 + 1.127 + // Add all of the connections from this node to the edge array to be added 1.128 + // after all the nodes are added, otherwise edges will attempted to be created 1.129 + // for nodes that have not yet been added 1.130 + AudioNodeConnections.get(node, []).forEach(dest => edges.push([node, dest])); 1.131 + }); 1.132 + 1.133 + edges.forEach(([node, dest]) => graph.addEdge(null, node.id, dest.id, { 1.134 + source: node.id, 1.135 + target: dest.id 1.136 + })); 1.137 + 1.138 + let renderer = new dagreD3.Renderer(); 1.139 + 1.140 + // Post-render manipulation of the nodes 1.141 + let oldDrawNodes = renderer.drawNodes(); 1.142 + renderer.drawNodes(function(graph, root) { 1.143 + let svgNodes = oldDrawNodes(graph, root); 1.144 + svgNodes.attr("class", (n) => { 1.145 + let node = graph.node(n); 1.146 + return "type-" + node.label; 1.147 + }); 1.148 + svgNodes.attr("data-id", (n) => { 1.149 + let node = graph.node(n); 1.150 + return node.id; 1.151 + }); 1.152 + return svgNodes; 1.153 + }); 1.154 + 1.155 + // Post-render manipulation of edges 1.156 + let oldDrawEdgePaths = renderer.drawEdgePaths(); 1.157 + renderer.drawEdgePaths(function(graph, root) { 1.158 + let svgNodes = oldDrawEdgePaths(graph, root); 1.159 + svgNodes.attr("data-source", (n) => { 1.160 + let edge = graph.edge(n); 1.161 + return edge.source; 1.162 + }); 1.163 + svgNodes.attr("data-target", (n) => { 1.164 + let edge = graph.edge(n); 1.165 + return edge.target; 1.166 + }); 1.167 + return svgNodes; 1.168 + }); 1.169 + 1.170 + // Override Dagre-d3's post render function by passing in our own. 1.171 + // This way we can leave styles out of it. 1.172 + renderer.postRender(function (graph, root) { 1.173 + // TODO change arrowhead color depending on theme-dark/theme-light 1.174 + // and possibly refactor rendering this as it's ugly 1.175 + // Bug 994256 1.176 + // let color = window.classList.contains("theme-dark") ? "#f5f7fa" : "#585959"; 1.177 + if (graph.isDirected() && root.select("#arrowhead").empty()) { 1.178 + root 1.179 + .append("svg:defs") 1.180 + .append("svg:marker") 1.181 + .attr("id", "arrowhead") 1.182 + .attr("viewBox", "0 0 10 10") 1.183 + .attr("refX", ARROW_WIDTH) 1.184 + .attr("refY", ARROW_HEIGHT) 1.185 + .attr("markerUnits", "strokewidth") 1.186 + .attr("markerWidth", ARROW_WIDTH) 1.187 + .attr("markerHeight", ARROW_HEIGHT) 1.188 + .attr("orient", "auto") 1.189 + .attr("style", "fill: #f5f7fa") 1.190 + .append("svg:path") 1.191 + .attr("d", "M 0 0 L 10 5 L 0 10 z"); 1.192 + } 1.193 + 1.194 + // Fire an event upon completed rendering 1.195 + window.emit(EVENTS.UI_GRAPH_RENDERED, AudioNodes.length, edges.length); 1.196 + }); 1.197 + 1.198 + let layout = dagreD3.layout().rankDir("LR"); 1.199 + renderer.layout(layout).run(graph, d3.select("#graph-target")); 1.200 + 1.201 + // Handle the sliding and zooming of the graph, 1.202 + // store as `this._zoomBinding` so we can unbind during destruction 1.203 + if (!this._zoomBinding) { 1.204 + this._zoomBinding = d3.behavior.zoom().on("zoom", function () { 1.205 + var ev = d3.event; 1.206 + d3.select("#graph-target") 1.207 + .attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")"); 1.208 + }); 1.209 + d3.select("svg").call(this._zoomBinding); 1.210 + } 1.211 + }, 1.212 + 1.213 + /** 1.214 + * Event handlers 1.215 + */ 1.216 + 1.217 + /** 1.218 + * Fired when a node in the svg graph is clicked. Used to handle triggering the AudioNodePane. 1.219 + * 1.220 + * @param Object AudioNodeView 1.221 + * The object stored in `AudioNodes` which contains render information, but most importantly, 1.222 + * the actorID under `id` property. 1.223 + */ 1.224 + _onGraphNodeClick: function (node) { 1.225 + WebAudioParamView.focusNode(node.id); 1.226 + } 1.227 +}; 1.228 + 1.229 +let WebAudioParamView = { 1.230 + _paramsView: null, 1.231 + 1.232 + /** 1.233 + * Initialization function called when the tool starts up. 1.234 + */ 1.235 + initialize: function () { 1.236 + this._paramsView = new VariablesView($("#web-audio-inspector-content"), GENERIC_VARIABLES_VIEW_SETTINGS); 1.237 + this._paramsView.eval = this._onEval.bind(this); 1.238 + window.on(EVENTS.CREATE_NODE, this.addNode = this.addNode.bind(this)); 1.239 + window.on(EVENTS.DESTROY_NODE, this.removeNode = this.removeNode.bind(this)); 1.240 + }, 1.241 + 1.242 + /** 1.243 + * Destruction function called when the tool cleans up. 1.244 + */ 1.245 + destroy: function() { 1.246 + window.off(EVENTS.CREATE_NODE, this.addNode); 1.247 + window.off(EVENTS.DESTROY_NODE, this.removeNode); 1.248 + }, 1.249 + 1.250 + /** 1.251 + * Empties out the params view. 1.252 + */ 1.253 + resetUI: function () { 1.254 + this._paramsView.empty(); 1.255 + }, 1.256 + 1.257 + /** 1.258 + * Takes an `id` and focuses and expands the corresponding scope. 1.259 + */ 1.260 + focusNode: function (id) { 1.261 + let scope = this._getScopeByID(id); 1.262 + if (!scope) return; 1.263 + 1.264 + scope.focus(); 1.265 + scope.expand(); 1.266 + }, 1.267 + 1.268 + /** 1.269 + * Executed when an audio param is changed in the UI. 1.270 + */ 1.271 + _onEval: Task.async(function* (variable, value) { 1.272 + let ownerScope = variable.ownerView; 1.273 + let node = getViewNodeById(ownerScope.actorID); 1.274 + let propName = variable.name; 1.275 + let errorMessage = yield node.actor.setParam(propName, value); 1.276 + 1.277 + // TODO figure out how to handle and display set param errors 1.278 + // and enable `test/brorwser_wa_params_view_edit_error.js` 1.279 + // Bug 994258 1.280 + if (!errorMessage) { 1.281 + ownerScope.get(propName).setGrip(value); 1.282 + window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value); 1.283 + } else { 1.284 + window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value); 1.285 + } 1.286 + }), 1.287 + 1.288 + /** 1.289 + * Takes an `id` and returns the corresponding variables scope. 1.290 + */ 1.291 + _getScopeByID: function (id) { 1.292 + let view = this._paramsView; 1.293 + for (let i = 0; i < view._store.length; i++) { 1.294 + let scope = view.getScopeAtIndex(i); 1.295 + if (scope.actorID === id) 1.296 + return scope; 1.297 + } 1.298 + return null; 1.299 + }, 1.300 + 1.301 + /** 1.302 + * Called when hovering over a variable scope. 1.303 + */ 1.304 + _onMouseOver: function (e) { 1.305 + let id = WebAudioParamView._getScopeID(this); 1.306 + 1.307 + if (!id) return; 1.308 + 1.309 + WebAudioGraphView.focusNode(id); 1.310 + }, 1.311 + 1.312 + /** 1.313 + * Called when hovering out of a variable scope. 1.314 + */ 1.315 + _onMouseOut: function (e) { 1.316 + let id = WebAudioParamView._getScopeID(this); 1.317 + 1.318 + if (!id) return; 1.319 + 1.320 + WebAudioGraphView.blurNode(id); 1.321 + }, 1.322 + 1.323 + /** 1.324 + * Uses in event handlers, takes an element `$el` and finds the 1.325 + * associated actor ID with that variable scope to be used in other contexts. 1.326 + */ 1.327 + _getScopeID: function ($el) { 1.328 + let match = $el.parentNode.id.match(/\(([^\)]*)\)/); 1.329 + return match ? match[1] : null; 1.330 + }, 1.331 + 1.332 + /** 1.333 + * Called when `CREATE_NODE` is fired to update the params view with the 1.334 + * freshly created audio node. 1.335 + */ 1.336 + addNode: Task.async(function* (_, id) { 1.337 + let viewNode = getViewNodeById(id); 1.338 + let type = viewNode.type; 1.339 + 1.340 + let audioParamsTitle = type + " (" + id + ")"; 1.341 + let paramsView = this._paramsView; 1.342 + let paramsScopeView = paramsView.addScope(audioParamsTitle); 1.343 + 1.344 + paramsScopeView.actorID = id; 1.345 + paramsScopeView.expanded = false; 1.346 + 1.347 + paramsScopeView.addEventListener("mouseover", this._onMouseOver, false); 1.348 + paramsScopeView.addEventListener("mouseout", this._onMouseOut, false); 1.349 + 1.350 + let params = yield viewNode.getParams(); 1.351 + params.forEach(({ param, value }) => { 1.352 + let descriptor = { value: value }; 1.353 + paramsScopeView.addItem(param, descriptor); 1.354 + }); 1.355 + 1.356 + window.emit(EVENTS.UI_ADD_NODE_LIST, id); 1.357 + }), 1.358 + 1.359 + /** 1.360 + * Called when `DESTROY_NODE` is fired to remove the node from params view. 1.361 + * TODO bug 994263, dependent on node GC events 1.362 + */ 1.363 + removeNode: Task.async(function* (viewNode) { 1.364 + 1.365 + }) 1.366 +};