|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, |
|
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 "use strict"; |
|
5 |
|
6 Cu.import("resource:///modules/devtools/VariablesView.jsm"); |
|
7 Cu.import("resource:///modules/devtools/VariablesViewController.jsm"); |
|
8 const { debounce } = require("sdk/lang/functional"); |
|
9 |
|
10 // Globals for d3 stuff |
|
11 // Width/height in pixels of SVG graph |
|
12 // TODO investigate to see how this works in other host types bug 994257 |
|
13 const WIDTH = 1000; |
|
14 const HEIGHT = 400; |
|
15 |
|
16 // Sizes of SVG arrows in graph |
|
17 const ARROW_HEIGHT = 5; |
|
18 const ARROW_WIDTH = 8; |
|
19 |
|
20 const GRAPH_DEBOUNCE_TIMER = 100; |
|
21 |
|
22 const GENERIC_VARIABLES_VIEW_SETTINGS = { |
|
23 lazyEmpty: true, |
|
24 lazyEmptyDelay: 10, // ms |
|
25 searchEnabled: false, |
|
26 editableValueTooltip: "", |
|
27 editableNameTooltip: "", |
|
28 preventDisableOnChange: true, |
|
29 preventDescriptorModifiers: true, |
|
30 eval: () => {} |
|
31 }; |
|
32 |
|
33 /** |
|
34 * Functions handling the graph UI. |
|
35 */ |
|
36 let WebAudioGraphView = { |
|
37 /** |
|
38 * Initialization function, called when the tool is started. |
|
39 */ |
|
40 initialize: function() { |
|
41 this._onGraphNodeClick = this._onGraphNodeClick.bind(this); |
|
42 this.draw = debounce(this.draw.bind(this), GRAPH_DEBOUNCE_TIMER); |
|
43 }, |
|
44 |
|
45 /** |
|
46 * Destruction function, called when the tool is closed. |
|
47 */ |
|
48 destroy: function() { |
|
49 if (this._zoomBinding) { |
|
50 this._zoomBinding.on("zoom", null); |
|
51 } |
|
52 }, |
|
53 |
|
54 /** |
|
55 * Called when a page is reloaded and waiting for a "start-context" event |
|
56 * and clears out old content |
|
57 */ |
|
58 resetUI: function () { |
|
59 $("#reload-notice").hidden = true; |
|
60 $("#waiting-notice").hidden = false; |
|
61 $("#content").hidden = true; |
|
62 this.resetGraph(); |
|
63 }, |
|
64 |
|
65 /** |
|
66 * Called once "start-context" is fired, indicating that there is audio context |
|
67 * activity to view and inspect |
|
68 */ |
|
69 showContent: function () { |
|
70 $("#reload-notice").hidden = true; |
|
71 $("#waiting-notice").hidden = true; |
|
72 $("#content").hidden = false; |
|
73 this.draw(); |
|
74 }, |
|
75 |
|
76 /** |
|
77 * Clears out the rendered graph, called when resetting the SVG elements to draw again, |
|
78 * or when resetting the entire UI tool |
|
79 */ |
|
80 resetGraph: function () { |
|
81 $("#graph-target").innerHTML = ""; |
|
82 }, |
|
83 |
|
84 /** |
|
85 * Makes the corresponding graph node appear "focused", called from WebAudioParamView |
|
86 */ |
|
87 focusNode: function (actorID) { |
|
88 // Remove class "selected" from all nodes |
|
89 Array.prototype.forEach.call($$(".nodes > g"), $node => $node.classList.remove("selected")); |
|
90 // Add to "selected" |
|
91 this._getNodeByID(actorID).classList.add("selected"); |
|
92 }, |
|
93 |
|
94 /** |
|
95 * Unfocuses the corresponding graph node, called from WebAudioParamView |
|
96 */ |
|
97 blurNode: function (actorID) { |
|
98 this._getNodeByID(actorID).classList.remove("selected"); |
|
99 }, |
|
100 |
|
101 /** |
|
102 * Takes an actorID and returns the corresponding DOM SVG element in the graph |
|
103 */ |
|
104 _getNodeByID: function (actorID) { |
|
105 return $(".nodes > g[data-id='" + actorID + "']"); |
|
106 }, |
|
107 |
|
108 /** |
|
109 * `draw` renders the ViewNodes currently available in `AudioNodes` with `AudioNodeConnections`, |
|
110 * and is throttled to be called at most every `GRAPH_DEBOUNCE_TIMER` milliseconds. Is called |
|
111 * whenever the audio context routing changes, after being debounced. |
|
112 */ |
|
113 draw: function () { |
|
114 // Clear out previous SVG information |
|
115 this.resetGraph(); |
|
116 |
|
117 let graph = new dagreD3.Digraph(); |
|
118 let edges = []; |
|
119 |
|
120 AudioNodes.forEach(node => { |
|
121 // Add node to graph |
|
122 graph.addNode(node.id, { label: node.type, id: node.id }); |
|
123 |
|
124 // Add all of the connections from this node to the edge array to be added |
|
125 // after all the nodes are added, otherwise edges will attempted to be created |
|
126 // for nodes that have not yet been added |
|
127 AudioNodeConnections.get(node, []).forEach(dest => edges.push([node, dest])); |
|
128 }); |
|
129 |
|
130 edges.forEach(([node, dest]) => graph.addEdge(null, node.id, dest.id, { |
|
131 source: node.id, |
|
132 target: dest.id |
|
133 })); |
|
134 |
|
135 let renderer = new dagreD3.Renderer(); |
|
136 |
|
137 // Post-render manipulation of the nodes |
|
138 let oldDrawNodes = renderer.drawNodes(); |
|
139 renderer.drawNodes(function(graph, root) { |
|
140 let svgNodes = oldDrawNodes(graph, root); |
|
141 svgNodes.attr("class", (n) => { |
|
142 let node = graph.node(n); |
|
143 return "type-" + node.label; |
|
144 }); |
|
145 svgNodes.attr("data-id", (n) => { |
|
146 let node = graph.node(n); |
|
147 return node.id; |
|
148 }); |
|
149 return svgNodes; |
|
150 }); |
|
151 |
|
152 // Post-render manipulation of edges |
|
153 let oldDrawEdgePaths = renderer.drawEdgePaths(); |
|
154 renderer.drawEdgePaths(function(graph, root) { |
|
155 let svgNodes = oldDrawEdgePaths(graph, root); |
|
156 svgNodes.attr("data-source", (n) => { |
|
157 let edge = graph.edge(n); |
|
158 return edge.source; |
|
159 }); |
|
160 svgNodes.attr("data-target", (n) => { |
|
161 let edge = graph.edge(n); |
|
162 return edge.target; |
|
163 }); |
|
164 return svgNodes; |
|
165 }); |
|
166 |
|
167 // Override Dagre-d3's post render function by passing in our own. |
|
168 // This way we can leave styles out of it. |
|
169 renderer.postRender(function (graph, root) { |
|
170 // TODO change arrowhead color depending on theme-dark/theme-light |
|
171 // and possibly refactor rendering this as it's ugly |
|
172 // Bug 994256 |
|
173 // let color = window.classList.contains("theme-dark") ? "#f5f7fa" : "#585959"; |
|
174 if (graph.isDirected() && root.select("#arrowhead").empty()) { |
|
175 root |
|
176 .append("svg:defs") |
|
177 .append("svg:marker") |
|
178 .attr("id", "arrowhead") |
|
179 .attr("viewBox", "0 0 10 10") |
|
180 .attr("refX", ARROW_WIDTH) |
|
181 .attr("refY", ARROW_HEIGHT) |
|
182 .attr("markerUnits", "strokewidth") |
|
183 .attr("markerWidth", ARROW_WIDTH) |
|
184 .attr("markerHeight", ARROW_HEIGHT) |
|
185 .attr("orient", "auto") |
|
186 .attr("style", "fill: #f5f7fa") |
|
187 .append("svg:path") |
|
188 .attr("d", "M 0 0 L 10 5 L 0 10 z"); |
|
189 } |
|
190 |
|
191 // Fire an event upon completed rendering |
|
192 window.emit(EVENTS.UI_GRAPH_RENDERED, AudioNodes.length, edges.length); |
|
193 }); |
|
194 |
|
195 let layout = dagreD3.layout().rankDir("LR"); |
|
196 renderer.layout(layout).run(graph, d3.select("#graph-target")); |
|
197 |
|
198 // Handle the sliding and zooming of the graph, |
|
199 // store as `this._zoomBinding` so we can unbind during destruction |
|
200 if (!this._zoomBinding) { |
|
201 this._zoomBinding = d3.behavior.zoom().on("zoom", function () { |
|
202 var ev = d3.event; |
|
203 d3.select("#graph-target") |
|
204 .attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")"); |
|
205 }); |
|
206 d3.select("svg").call(this._zoomBinding); |
|
207 } |
|
208 }, |
|
209 |
|
210 /** |
|
211 * Event handlers |
|
212 */ |
|
213 |
|
214 /** |
|
215 * Fired when a node in the svg graph is clicked. Used to handle triggering the AudioNodePane. |
|
216 * |
|
217 * @param Object AudioNodeView |
|
218 * The object stored in `AudioNodes` which contains render information, but most importantly, |
|
219 * the actorID under `id` property. |
|
220 */ |
|
221 _onGraphNodeClick: function (node) { |
|
222 WebAudioParamView.focusNode(node.id); |
|
223 } |
|
224 }; |
|
225 |
|
226 let WebAudioParamView = { |
|
227 _paramsView: null, |
|
228 |
|
229 /** |
|
230 * Initialization function called when the tool starts up. |
|
231 */ |
|
232 initialize: function () { |
|
233 this._paramsView = new VariablesView($("#web-audio-inspector-content"), GENERIC_VARIABLES_VIEW_SETTINGS); |
|
234 this._paramsView.eval = this._onEval.bind(this); |
|
235 window.on(EVENTS.CREATE_NODE, this.addNode = this.addNode.bind(this)); |
|
236 window.on(EVENTS.DESTROY_NODE, this.removeNode = this.removeNode.bind(this)); |
|
237 }, |
|
238 |
|
239 /** |
|
240 * Destruction function called when the tool cleans up. |
|
241 */ |
|
242 destroy: function() { |
|
243 window.off(EVENTS.CREATE_NODE, this.addNode); |
|
244 window.off(EVENTS.DESTROY_NODE, this.removeNode); |
|
245 }, |
|
246 |
|
247 /** |
|
248 * Empties out the params view. |
|
249 */ |
|
250 resetUI: function () { |
|
251 this._paramsView.empty(); |
|
252 }, |
|
253 |
|
254 /** |
|
255 * Takes an `id` and focuses and expands the corresponding scope. |
|
256 */ |
|
257 focusNode: function (id) { |
|
258 let scope = this._getScopeByID(id); |
|
259 if (!scope) return; |
|
260 |
|
261 scope.focus(); |
|
262 scope.expand(); |
|
263 }, |
|
264 |
|
265 /** |
|
266 * Executed when an audio param is changed in the UI. |
|
267 */ |
|
268 _onEval: Task.async(function* (variable, value) { |
|
269 let ownerScope = variable.ownerView; |
|
270 let node = getViewNodeById(ownerScope.actorID); |
|
271 let propName = variable.name; |
|
272 let errorMessage = yield node.actor.setParam(propName, value); |
|
273 |
|
274 // TODO figure out how to handle and display set param errors |
|
275 // and enable `test/brorwser_wa_params_view_edit_error.js` |
|
276 // Bug 994258 |
|
277 if (!errorMessage) { |
|
278 ownerScope.get(propName).setGrip(value); |
|
279 window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value); |
|
280 } else { |
|
281 window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value); |
|
282 } |
|
283 }), |
|
284 |
|
285 /** |
|
286 * Takes an `id` and returns the corresponding variables scope. |
|
287 */ |
|
288 _getScopeByID: function (id) { |
|
289 let view = this._paramsView; |
|
290 for (let i = 0; i < view._store.length; i++) { |
|
291 let scope = view.getScopeAtIndex(i); |
|
292 if (scope.actorID === id) |
|
293 return scope; |
|
294 } |
|
295 return null; |
|
296 }, |
|
297 |
|
298 /** |
|
299 * Called when hovering over a variable scope. |
|
300 */ |
|
301 _onMouseOver: function (e) { |
|
302 let id = WebAudioParamView._getScopeID(this); |
|
303 |
|
304 if (!id) return; |
|
305 |
|
306 WebAudioGraphView.focusNode(id); |
|
307 }, |
|
308 |
|
309 /** |
|
310 * Called when hovering out of a variable scope. |
|
311 */ |
|
312 _onMouseOut: function (e) { |
|
313 let id = WebAudioParamView._getScopeID(this); |
|
314 |
|
315 if (!id) return; |
|
316 |
|
317 WebAudioGraphView.blurNode(id); |
|
318 }, |
|
319 |
|
320 /** |
|
321 * Uses in event handlers, takes an element `$el` and finds the |
|
322 * associated actor ID with that variable scope to be used in other contexts. |
|
323 */ |
|
324 _getScopeID: function ($el) { |
|
325 let match = $el.parentNode.id.match(/\(([^\)]*)\)/); |
|
326 return match ? match[1] : null; |
|
327 }, |
|
328 |
|
329 /** |
|
330 * Called when `CREATE_NODE` is fired to update the params view with the |
|
331 * freshly created audio node. |
|
332 */ |
|
333 addNode: Task.async(function* (_, id) { |
|
334 let viewNode = getViewNodeById(id); |
|
335 let type = viewNode.type; |
|
336 |
|
337 let audioParamsTitle = type + " (" + id + ")"; |
|
338 let paramsView = this._paramsView; |
|
339 let paramsScopeView = paramsView.addScope(audioParamsTitle); |
|
340 |
|
341 paramsScopeView.actorID = id; |
|
342 paramsScopeView.expanded = false; |
|
343 |
|
344 paramsScopeView.addEventListener("mouseover", this._onMouseOver, false); |
|
345 paramsScopeView.addEventListener("mouseout", this._onMouseOut, false); |
|
346 |
|
347 let params = yield viewNode.getParams(); |
|
348 params.forEach(({ param, value }) => { |
|
349 let descriptor = { value: value }; |
|
350 paramsScopeView.addItem(param, descriptor); |
|
351 }); |
|
352 |
|
353 window.emit(EVENTS.UI_ADD_NODE_LIST, id); |
|
354 }), |
|
355 |
|
356 /** |
|
357 * Called when `DESTROY_NODE` is fired to remove the node from params view. |
|
358 * TODO bug 994263, dependent on node GC events |
|
359 */ |
|
360 removeNode: Task.async(function* (viewNode) { |
|
361 |
|
362 }) |
|
363 }; |