diff options
author | Matt A. Tobin <email@mattatobin.com> | 2016-10-16 19:34:53 -0400 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2016-10-16 19:34:53 -0400 |
commit | 81805ce3f63e2e4a799bd54f174083c58a9b5640 (patch) | |
tree | 6e13374b213ac9b2ae74c25d8aac875faf71fdd0 /toolkit/devtools/webaudioeditor | |
parent | 28c8da71bf521bb3ee76f27b8a241919e24b7cd5 (diff) | |
download | palemoon-gre-81805ce3f63e2e4a799bd54f174083c58a9b5640.tar.gz |
Move Mozilla DevTools to Platform - Part 3: Merge the browser/devtools and toolkit/devtools adjusting for directory collisions
Diffstat (limited to 'toolkit/devtools/webaudioeditor')
78 files changed, 9664 insertions, 0 deletions
diff --git a/toolkit/devtools/webaudioeditor/controller.js b/toolkit/devtools/webaudioeditor/controller.js new file mode 100644 index 000000000..2f51b6926 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/controller.js @@ -0,0 +1,226 @@ +/* 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/. */ + +/** + * A collection of `AudioNodeModel`s used throughout the editor + * to keep track of audio nodes within the audio context. + */ +let gAudioNodes = new AudioNodesCollection(); + +/** + * Initializes the web audio editor views + */ +function startupWebAudioEditor() { + return all([ + WebAudioEditorController.initialize(), + ContextView.initialize(), + InspectorView.initialize(), + PropertiesView.initialize(), + AutomationView.initialize() + ]); +} + +/** + * Destroys the web audio editor controller and views. + */ +function shutdownWebAudioEditor() { + return all([ + WebAudioEditorController.destroy(), + ContextView.destroy(), + InspectorView.destroy(), + PropertiesView.destroy(), + AutomationView.destroy() + ]); +} + +/** + * Functions handling target-related lifetime events. + */ +let WebAudioEditorController = { + /** + * Listen for events emitted by the current tab target. + */ + initialize: Task.async(function* () { + telemetry.toolOpened("webaudioeditor"); + this._onTabNavigated = this._onTabNavigated.bind(this); + this._onThemeChange = this._onThemeChange.bind(this); + + gTarget.on("will-navigate", this._onTabNavigated); + gTarget.on("navigate", this._onTabNavigated); + gFront.on("start-context", this._onStartContext); + gFront.on("create-node", this._onCreateNode); + gFront.on("connect-node", this._onConnectNode); + gFront.on("connect-param", this._onConnectParam); + gFront.on("disconnect-node", this._onDisconnectNode); + gFront.on("change-param", this._onChangeParam); + gFront.on("destroy-node", this._onDestroyNode); + + // Hook into theme change so we can change + // the graph's marker styling, since we can't do this + // with CSS + gDevTools.on("pref-changed", this._onThemeChange); + + // Store the AudioNode definitions from the WebAudioFront + AUDIO_NODE_DEFINITION = yield gFront.getDefinition(); + }), + + /** + * Remove events emitted by the current tab target. + */ + destroy: function() { + telemetry.toolClosed("webaudioeditor"); + gTarget.off("will-navigate", this._onTabNavigated); + gTarget.off("navigate", this._onTabNavigated); + gFront.off("start-context", this._onStartContext); + gFront.off("create-node", this._onCreateNode); + gFront.off("connect-node", this._onConnectNode); + gFront.off("connect-param", this._onConnectParam); + gFront.off("disconnect-node", this._onDisconnectNode); + gFront.off("change-param", this._onChangeParam); + gFront.off("destroy-node", this._onDestroyNode); + gDevTools.off("pref-changed", this._onThemeChange); + }, + + /** + * Called when page is reloaded to show the reload notice and waiting + * for an audio context notice. + */ + reset: function () { + $("#content").hidden = true; + ContextView.resetUI(); + InspectorView.resetUI(); + PropertiesView.resetUI(); + }, + + // Since node events (create, disconnect, connect) are all async, + // we have to make sure to wait that the node has finished creating + // before performing an operation on it. + getNode: function* (nodeActor) { + let id = nodeActor.actorID; + let node = gAudioNodes.get(id); + + if (!node) { + let { resolve, promise } = defer(); + gAudioNodes.on("add", function createNodeListener (createdNode) { + if (createdNode.id === id) { + gAudioNodes.off("add", createNodeListener); + resolve(createdNode); + } + }); + node = yield promise; + } + return node; + }, + + /** + * Fired when the devtools theme changes (light, dark, etc.) + * so that the graph can update marker styling, as that + * cannot currently be done with CSS. + */ + _onThemeChange: function (event, data) { + window.emit(EVENTS.THEME_CHANGE, data.newValue); + }, + + /** + * Called for each location change in the debugged tab. + */ + _onTabNavigated: Task.async(function* (event, {isFrameSwitching}) { + switch (event) { + case "will-navigate": { + // Make sure the backend is prepared to handle audio contexts. + if (!isFrameSwitching) { + yield gFront.setup({ reload: false }); + } + + // Clear out current UI. + this.reset(); + + // When switching to an iframe, ensure displaying the reload button. + // As the document has already been loaded without being hooked. + if (isFrameSwitching) { + $("#reload-notice").hidden = false; + $("#waiting-notice").hidden = true; + } else { + // Otherwise, we are loading a new top level document, + // so we don't need to reload anymore and should receive + // new node events. + $("#reload-notice").hidden = true; + $("#waiting-notice").hidden = false; + } + + // Clear out stored audio nodes + gAudioNodes.reset(); + + window.emit(EVENTS.UI_RESET); + break; + } + case "navigate": { + // TODO Case of bfcache, needs investigating + // bug 994250 + break; + } + } + }), + + /** + * Called after the first audio node is created in an audio context, + * signaling that the audio context is being used. + */ + _onStartContext: function() { + $("#reload-notice").hidden = true; + $("#waiting-notice").hidden = true; + $("#content").hidden = false; + window.emit(EVENTS.START_CONTEXT); + }, + + /** + * Called when a new node is created. Creates an `AudioNodeView` instance + * for tracking throughout the editor. + */ + _onCreateNode: Task.async(function* (nodeActor) { + yield gAudioNodes.add(nodeActor); + }), + + /** + * Called on `destroy-node` when an AudioNode is GC'd. Removes + * from the AudioNode array and fires an event indicating the removal. + */ + _onDestroyNode: function (nodeActor) { + gAudioNodes.remove(gAudioNodes.get(nodeActor.actorID)); + }, + + /** + * Called when a node is connected to another node. + */ + _onConnectNode: Task.async(function* ({ source: sourceActor, dest: destActor }) { + let source = yield WebAudioEditorController.getNode(sourceActor); + let dest = yield WebAudioEditorController.getNode(destActor); + source.connect(dest); + }), + + /** + * Called when a node is conneceted to another node's AudioParam. + */ + _onConnectParam: Task.async(function* ({ source: sourceActor, dest: destActor, param }) { + let source = yield WebAudioEditorController.getNode(sourceActor); + let dest = yield WebAudioEditorController.getNode(destActor); + source.connect(dest, param); + }), + + /** + * Called when a node is disconnected. + */ + _onDisconnectNode: Task.async(function* (nodeActor) { + let node = yield WebAudioEditorController.getNode(nodeActor); + node.disconnect(); + }), + + /** + * Called when a node param is changed. + */ + _onChangeParam: Task.async(function* ({ actor, param, value }) { + let node = yield WebAudioEditorController.getNode(actor); + window.emit(EVENTS.CHANGE_PARAM, node, param, value); + }) +}; diff --git a/toolkit/devtools/webaudioeditor/includes.js b/toolkit/devtools/webaudioeditor/includes.js new file mode 100644 index 000000000..2e6b8ece4 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/includes.js @@ -0,0 +1,112 @@ +/* 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 { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +Cu.import("resource:///modules/devtools/gDevTools.jsm"); + +const devtools = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools; +const { require } = devtools; + +let { console } = Cu.import("resource://gre/modules/devtools/Console.jsm", {}); +let { EventTarget } = require("sdk/event/target"); + +const { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); +const { Class } = require("sdk/core/heritage"); +const EventEmitter = require("devtools/toolkit/event-emitter"); +const STRINGS_URI = "chrome://browser/locale/devtools/webaudioeditor.properties" +const L10N = new ViewHelpers.L10N(STRINGS_URI); +const Telemetry = require("devtools/shared/telemetry"); +const telemetry = new Telemetry(); +devtools.lazyImporter(this, "LineGraphWidget", + "resource:///modules/devtools/Graphs.jsm"); + +// `AUDIO_NODE_DEFINITION` defined in the controller's initialization, +// which describes all the properties of an AudioNode +let AUDIO_NODE_DEFINITION; + +// Override DOM promises with Promise.jsm helpers +const { defer, all } = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise; + +/* Events fired on `window` to indicate state or actions*/ +const EVENTS = { + // Fired when the first AudioNode has been created, signifying + // that the AudioContext is being used and should be tracked via the editor. + START_CONTEXT: "WebAudioEditor:StartContext", + + // When the devtools theme changes. + THEME_CHANGE: "WebAudioEditor:ThemeChange", + + // When the UI is reset from tab navigation. + UI_RESET: "WebAudioEditor:UIReset", + + // When a param has been changed via the UI and successfully + // pushed via the actor to the raw audio node. + UI_SET_PARAM: "WebAudioEditor:UISetParam", + + // When a node is to be set in the InspectorView. + UI_SELECT_NODE: "WebAudioEditor:UISelectNode", + + // When the inspector is finished setting a new node. + UI_INSPECTOR_NODE_SET: "WebAudioEditor:UIInspectorNodeSet", + + // When the inspector is finished rendering in or out of view. + UI_INSPECTOR_TOGGLED: "WebAudioEditor:UIInspectorToggled", + + // When an audio node is finished loading in the Properties tab. + UI_PROPERTIES_TAB_RENDERED: "WebAudioEditor:UIPropertiesTabRendered", + + // When an audio node is finished loading in the Automation tab. + UI_AUTOMATION_TAB_RENDERED: "WebAudioEditor:UIAutomationTabRendered", + + // When the Audio Context graph finishes rendering. + // Is called with two arguments, first representing number of nodes + // rendered, second being the number of edge connections rendering (not counting + // param edges), followed by the count of the param edges rendered. + UI_GRAPH_RENDERED: "WebAudioEditor:UIGraphRendered", + + // Called when the inspector splitter is moved and resized. + UI_INSPECTOR_RESIZE: "WebAudioEditor:UIInspectorResize" +}; + +/** + * The current target and the Web Audio Editor front, set by this tool's host. + */ +let gToolbox, gTarget, gFront; + +/** + * Convenient way of emitting events from the panel window. + */ +EventEmitter.decorate(this); + +/** + * DOM query helper. + */ +function $(selector, target = document) { return target.querySelector(selector); } +function $$(selector, target = document) { return target.querySelectorAll(selector); } + +/** + * Takes an iterable collection, and a hash. Return the first + * object in the collection that matches the values in the hash. + * From Backbone.Collection#findWhere + * http://backbonejs.org/#Collection-findWhere + */ +function findWhere (collection, attrs) { + let keys = Object.keys(attrs); + for (let model of collection) { + if (keys.every(key => model[key] === attrs[key])) { + return model; + } + } + return void 0; +} + +function mixin (source, ...args) { + args.forEach(obj => Object.keys(obj).forEach(prop => source[prop] = obj[prop])); + return source; +} diff --git a/toolkit/devtools/webaudioeditor/lib/D3_LICENSE b/toolkit/devtools/webaudioeditor/lib/D3_LICENSE new file mode 100644 index 000000000..fb7d95d70 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/lib/D3_LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2014, Michael Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* The name Michael Bostock may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/toolkit/devtools/webaudioeditor/lib/DAGRE_D3_LICENSE b/toolkit/devtools/webaudioeditor/lib/DAGRE_D3_LICENSE new file mode 100644 index 000000000..1d64ed68c --- /dev/null +++ b/toolkit/devtools/webaudioeditor/lib/DAGRE_D3_LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2013 Chris Pettitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/toolkit/devtools/webaudioeditor/lib/dagre-d3.js b/toolkit/devtools/webaudioeditor/lib/dagre-d3.js new file mode 100644 index 000000000..482ce827f --- /dev/null +++ b/toolkit/devtools/webaudioeditor/lib/dagre-d3.js @@ -0,0 +1,4560 @@ +;(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ +var global=self;/** + * @license + * Copyright (c) 2012-2013 Chris Pettitt + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +global.dagreD3 = require('./index'); + +},{"./index":2}],2:[function(require,module,exports){ +/** + * @license + * Copyright (c) 2012-2013 Chris Pettitt + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +module.exports = { + Digraph: require('graphlib').Digraph, + Renderer: require('./lib/Renderer'), + json: require('graphlib').converter.json, + layout: require('dagre').layout, + version: require('./lib/version') +}; + +},{"./lib/Renderer":3,"./lib/version":4,"dagre":11,"graphlib":28}],3:[function(require,module,exports){ +var layout = require('dagre').layout; + +var d3; +try { d3 = require('d3'); } catch (_) { d3 = window.d3; } + +module.exports = Renderer; + +function Renderer() { + // Set up defaults... + this._layout = layout(); + + this.drawNodes(defaultDrawNodes); + this.drawEdgeLabels(defaultDrawEdgeLabels); + this.drawEdgePaths(defaultDrawEdgePaths); + this.positionNodes(defaultPositionNodes); + this.positionEdgeLabels(defaultPositionEdgeLabels); + this.positionEdgePaths(defaultPositionEdgePaths); + this.transition(defaultTransition); + this.postLayout(defaultPostLayout); + this.postRender(defaultPostRender); + + this.edgeInterpolate('bundle'); + this.edgeTension(0.95); +} + +Renderer.prototype.layout = function(layout) { + if (!arguments.length) { return this._layout; } + this._layout = layout; + return this; +}; + +Renderer.prototype.drawNodes = function(drawNodes) { + if (!arguments.length) { return this._drawNodes; } + this._drawNodes = bind(drawNodes, this); + return this; +}; + +Renderer.prototype.drawEdgeLabels = function(drawEdgeLabels) { + if (!arguments.length) { return this._drawEdgeLabels; } + this._drawEdgeLabels = bind(drawEdgeLabels, this); + return this; +}; + +Renderer.prototype.drawEdgePaths = function(drawEdgePaths) { + if (!arguments.length) { return this._drawEdgePaths; } + this._drawEdgePaths = bind(drawEdgePaths, this); + return this; +}; + +Renderer.prototype.positionNodes = function(positionNodes) { + if (!arguments.length) { return this._positionNodes; } + this._positionNodes = bind(positionNodes, this); + return this; +}; + +Renderer.prototype.positionEdgeLabels = function(positionEdgeLabels) { + if (!arguments.length) { return this._positionEdgeLabels; } + this._positionEdgeLabels = bind(positionEdgeLabels, this); + return this; +}; + +Renderer.prototype.positionEdgePaths = function(positionEdgePaths) { + if (!arguments.length) { return this._positionEdgePaths; } + this._positionEdgePaths = bind(positionEdgePaths, this); + return this; +}; + +Renderer.prototype.transition = function(transition) { + if (!arguments.length) { return this._transition; } + this._transition = bind(transition, this); + return this; +}; + +Renderer.prototype.postLayout = function(postLayout) { + if (!arguments.length) { return this._postLayout; } + this._postLayout = bind(postLayout, this); + return this; +}; + +Renderer.prototype.postRender = function(postRender) { + if (!arguments.length) { return this._postRender; } + this._postRender = bind(postRender, this); + return this; +}; + +Renderer.prototype.edgeInterpolate = function(edgeInterpolate) { + if (!arguments.length) { return this._edgeInterpolate; } + this._edgeInterpolate = edgeInterpolate; + return this; +}; + +Renderer.prototype.edgeTension = function(edgeTension) { + if (!arguments.length) { return this._edgeTension; } + this._edgeTension = edgeTension; + return this; +}; + +Renderer.prototype.run = function(graph, svg) { + // First copy the input graph so that it is not changed by the rendering + // process. + graph = copyAndInitGraph(graph); + + // Create layers + svg + .selectAll('g.edgePaths, g.edgeLabels, g.nodes') + .data(['edgePaths', 'edgeLabels', 'nodes']) + .enter() + .append('g') + .attr('class', function(d) { return d; }); + + + // Create node and edge roots, attach labels, and capture dimension + // information for use with layout. + var svgNodes = this._drawNodes(graph, svg.select('g.nodes')); + var svgEdgeLabels = this._drawEdgeLabels(graph, svg.select('g.edgeLabels')); + + svgNodes.each(function(u) { calculateDimensions(this, graph.node(u)); }); + svgEdgeLabels.each(function(e) { calculateDimensions(this, graph.edge(e)); }); + + // Now apply the layout function + var result = runLayout(graph, this._layout); + + // Run any user-specified post layout processing + this._postLayout(result, svg); + + var svgEdgePaths = this._drawEdgePaths(graph, svg.select('g.edgePaths')); + + // Apply the layout information to the graph + this._positionNodes(result, svgNodes); + this._positionEdgeLabels(result, svgEdgeLabels); + this._positionEdgePaths(result, svgEdgePaths); + + this._postRender(result, svg); + + return result; +}; + +function copyAndInitGraph(graph) { + var copy = graph.copy(); + + // Init labels if they were not present in the source graph + copy.nodes().forEach(function(u) { + var value = copy.node(u); + if (value === undefined) { + value = {}; + copy.node(u, value); + } + if (!('label' in value)) { value.label = ''; } + }); + + copy.edges().forEach(function(e) { + var value = copy.edge(e); + if (value === undefined) { + value = {}; + copy.edge(e, value); + } + if (!('label' in value)) { value.label = ''; } + }); + + return copy; +} + +function calculateDimensions(group, value) { + var bbox = group.getBBox(); + value.width = bbox.width; + value.height = bbox.height; +} + +function runLayout(graph, layout) { + var result = layout.run(graph); + + // Copy labels to the result graph + graph.eachNode(function(u, value) { result.node(u).label = value.label; }); + graph.eachEdge(function(e, u, v, value) { result.edge(e).label = value.label; }); + + return result; +} + +function defaultDrawNodes(g, root) { + var nodes = g.nodes().filter(function(u) { return !isComposite(g, u); }); + + var svgNodes = root + .selectAll('g.node') + .classed('enter', false) + .data(nodes, function(u) { return u; }); + + svgNodes.selectAll('*').remove(); + + svgNodes + .enter() + .append('g') + .style('opacity', 0) + .attr('class', 'node enter'); + + svgNodes.each(function(u) { addLabel(g.node(u), d3.select(this), 10, 10); }); + + this._transition(svgNodes.exit()) + .style('opacity', 0) + .remove(); + + return svgNodes; +} + +function defaultDrawEdgeLabels(g, root) { + var svgEdgeLabels = root + .selectAll('g.edgeLabel') + .classed('enter', false) + .data(g.edges(), function (e) { return e; }); + + svgEdgeLabels.selectAll('*').remove(); + + svgEdgeLabels + .enter() + .append('g') + .style('opacity', 0) + .attr('class', 'edgeLabel enter'); + + svgEdgeLabels.each(function(e) { addLabel(g.edge(e), d3.select(this), 0, 0); }); + + this._transition(svgEdgeLabels.exit()) + .style('opacity', 0) + .remove(); + + return svgEdgeLabels; +} + +var defaultDrawEdgePaths = function(g, root) { + var svgEdgePaths = root + .selectAll('g.edgePath') + .classed('enter', false) + .data(g.edges(), function(e) { return e; }); + + svgEdgePaths + .enter() + .append('g') + .attr('class', 'edgePath enter') + .append('path') + .style('opacity', 0) + .attr('marker-end', 'url(#arrowhead)'); + + this._transition(svgEdgePaths.exit()) + .style('opacity', 0) + .remove(); + + return svgEdgePaths; +}; + +function defaultPositionNodes(g, svgNodes, svgNodesEnter) { + function transform(u) { + var value = g.node(u); + return 'translate(' + value.x + ',' + value.y + ')'; + } + + // For entering nodes, position immediately without transition + svgNodes.filter('.enter').attr('transform', transform); + + this._transition(svgNodes) + .style('opacity', 1) + .attr('transform', transform); +} + +function defaultPositionEdgeLabels(g, svgEdgeLabels) { + function transform(e) { + var value = g.edge(e); + var point = findMidPoint(value.points); + return 'translate(' + point.x + ',' + point.y + ')'; + } + + // For entering edge labels, position immediately without transition + svgEdgeLabels.filter('.enter').attr('transform', transform); + + this._transition(svgEdgeLabels) + .style('opacity', 1) + .attr('transform', transform); +} + +function defaultPositionEdgePaths(g, svgEdgePaths) { + var interpolate = this._edgeInterpolate, + tension = this._edgeTension; + + function calcPoints(e) { + var value = g.edge(e); + var source = g.node(g.incidentNodes(e)[0]); + var target = g.node(g.incidentNodes(e)[1]); + var points = value.points.slice(); + + var p0 = points.length === 0 ? target : points[0]; + var p1 = points.length === 0 ? source : points[points.length - 1]; + + points.unshift(intersectRect(source, p0)); + // TODO: use bpodgursky's shortening algorithm here + points.push(intersectRect(target, p1)); + + return d3.svg.line() + .x(function(d) { return d.x; }) + .y(function(d) { return d.y; }) + .interpolate(interpolate) + .tension(tension) + (points); + } + + svgEdgePaths.filter('.enter').selectAll('path') + .attr('d', calcPoints); + + this._transition(svgEdgePaths.selectAll('path')) + .attr('d', calcPoints) + .style('opacity', 1); +} + +// By default we do not use transitions +function defaultTransition(selection) { + return selection; +} + +function defaultPostLayout() { + // Do nothing +} + +function defaultPostRender(graph, root) { + 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', 8) + .attr('refY', 5) + .attr('markerUnits', 'strokewidth') + .attr('markerWidth', 8) + .attr('markerHeight', 5) + .attr('orient', 'auto') + .attr('style', 'fill: #333') + .append('svg:path') + .attr('d', 'M 0 0 L 10 5 L 0 10 z'); + } +} + +function addLabel(node, root, marginX, marginY) { + // Add the rect first so that it appears behind the label + var label = node.label; + var rect = root.append('rect'); + var labelSvg = root.append('g'); + + if (label[0] === '<') { + addForeignObjectLabel(label, labelSvg); + // No margin for HTML elements + marginX = marginY = 0; + } else { + addTextLabel(label, + labelSvg, + Math.floor(node.labelCols), + node.labelCut); + } + + var bbox = root.node().getBBox(); + + labelSvg.attr('transform', + 'translate(' + (-bbox.width / 2) + ',' + (-bbox.height / 2) + ')'); + + rect + .attr('rx', 5) + .attr('ry', 5) + .attr('x', -(bbox.width / 2 + marginX)) + .attr('y', -(bbox.height / 2 + marginY)) + .attr('width', bbox.width + 2 * marginX) + .attr('height', bbox.height + 2 * marginY); +} + +function addForeignObjectLabel(label, root) { + var fo = root + .append('foreignObject') + .attr('width', '100000'); + + var w, h; + fo + .append('xhtml:div') + .style('float', 'left') + // TODO find a better way to get dimensions for foreignObjects... + .html(function() { return label; }) + .each(function() { + w = this.clientWidth; + h = this.clientHeight; + }); + + fo + .attr('width', w) + .attr('height', h); +} + +function addTextLabel(label, root, labelCols, labelCut) { + if (labelCut === undefined) labelCut = "false"; + labelCut = (labelCut.toString().toLowerCase() === "true"); + + var node = root + .append('text') + .attr('text-anchor', 'left'); + + label = label.replace(/\\n/g, "\n"); + + var arr = labelCols ? wordwrap(label, labelCols, labelCut) : label; + arr = arr.split("\n"); + for (var i = 0; i < arr.length; i++) { + node + .append('tspan') + .attr('dy', '1em') + .attr('x', '1') + .text(arr[i]); + } +} + +// Thanks to +// http://james.padolsey.com/javascript/wordwrap-for-javascript/ +function wordwrap (str, width, cut, brk) { + brk = brk || '\n'; + width = width || 75; + cut = cut || false; + + if (!str) { return str; } + + var regex = '.{1,' +width+ '}(\\s|$)' + (cut ? '|.{' +width+ '}|.+$' : '|\\S+?(\\s|$)'); + + return str.match( RegExp(regex, 'g') ).join( brk ); +} + +function findMidPoint(points) { + var midIdx = points.length / 2; + if (points.length % 2) { + return points[Math.floor(midIdx)]; + } else { + var p0 = points[midIdx - 1]; + var p1 = points[midIdx]; + return {x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2}; + } +} + +function intersectRect(rect, point) { + var x = rect.x; + var y = rect.y; + + // For now we only support rectangles + + // Rectangle intersection algorithm from: + // http://math.stackexchange.com/questions/108113/find-edge-between-two-boxes + var dx = point.x - x; + var dy = point.y - y; + var w = rect.width / 2; + var h = rect.height / 2; + + var sx, sy; + if (Math.abs(dy) * w > Math.abs(dx) * h) { + // Intersection is top or bottom of rect. + if (dy < 0) { + h = -h; + } + sx = dy === 0 ? 0 : h * dx / dy; + sy = h; + } else { + // Intersection is left or right of rect. + if (dx < 0) { + w = -w; + } + sx = w; + sy = dx === 0 ? 0 : w * dy / dx; + } + + return {x: x + sx, y: y + sy}; +} + +function isComposite(g, u) { + return 'children' in g && g.children(u).length; +} + +function bind(func, thisArg) { + // For some reason PhantomJS occassionally fails when using the builtin bind, + // so we check if it is available and if not, use a degenerate polyfill. + if (func.bind) { + return func.bind(thisArg); + } + + return function() { + return func.apply(thisArg, arguments); + }; +} + +},{"d3":10,"dagre":11}],4:[function(require,module,exports){ +module.exports = '0.1.5'; + +},{}],5:[function(require,module,exports){ +exports.Set = require('./lib/Set'); +exports.PriorityQueue = require('./lib/PriorityQueue'); +exports.version = require('./lib/version'); + +},{"./lib/PriorityQueue":6,"./lib/Set":7,"./lib/version":9}],6:[function(require,module,exports){ +module.exports = PriorityQueue; + +/** + * A min-priority queue data structure. This algorithm is derived from Cormen, + * et al., "Introduction to Algorithms". The basic idea of a min-priority + * queue is that you can efficiently (in O(1) time) get the smallest key in + * the queue. Adding and removing elements takes O(log n) time. A key can + * have its priority decreased in O(log n) time. + */ +function PriorityQueue() { + this._arr = []; + this._keyIndices = {}; +} + +/** + * Returns the number of elements in the queue. Takes `O(1)` time. + */ +PriorityQueue.prototype.size = function() { + return this._arr.length; +}; + +/** + * Returns the keys that are in the queue. Takes `O(n)` time. + */ +PriorityQueue.prototype.keys = function() { + return this._arr.map(function(x) { return x.key; }); +}; + +/** + * Returns `true` if **key** is in the queue and `false` if not. + */ +PriorityQueue.prototype.has = function(key) { + return key in this._keyIndices; +}; + +/** + * Returns the priority for **key**. If **key** is not present in the queue + * then this function returns `undefined`. Takes `O(1)` time. + * + * @param {Object} key + */ +PriorityQueue.prototype.priority = function(key) { + var index = this._keyIndices[key]; + if (index !== undefined) { + return this._arr[index].priority; + } +}; + +/** + * Returns the key for the minimum element in this queue. If the queue is + * empty this function throws an Error. Takes `O(1)` time. + */ +PriorityQueue.prototype.min = function() { + if (this.size() === 0) { + throw new Error("Queue underflow"); + } + return this._arr[0].key; +}; + +/** + * Inserts a new key into the priority queue. If the key already exists in + * the queue this function returns `false`; otherwise it will return `true`. + * Takes `O(n)` time. + * + * @param {Object} key the key to add + * @param {Number} priority the initial priority for the key + */ +PriorityQueue.prototype.add = function(key, priority) { + var keyIndices = this._keyIndices; + if (!(key in keyIndices)) { + var arr = this._arr; + var index = arr.length; + keyIndices[key] = index; + arr.push({key: key, priority: priority}); + this._decrease(index); + return true; + } + return false; +}; + +/** + * Removes and returns the smallest key in the queue. Takes `O(log n)` time. + */ +PriorityQueue.prototype.removeMin = function() { + this._swap(0, this._arr.length - 1); + var min = this._arr.pop(); + delete this._keyIndices[min.key]; + this._heapify(0); + return min.key; +}; + +/** + * Decreases the priority for **key** to **priority**. If the new priority is + * greater than the previous priority, this function will throw an Error. + * + * @param {Object} key the key for which to raise priority + * @param {Number} priority the new priority for the key + */ +PriorityQueue.prototype.decrease = function(key, priority) { + var index = this._keyIndices[key]; + if (priority > this._arr[index].priority) { + throw new Error("New priority is greater than current priority. " + + "Key: " + key + " Old: " + this._arr[index].priority + " New: " + priority); + } + this._arr[index].priority = priority; + this._decrease(index); +}; + +PriorityQueue.prototype._heapify = function(i) { + var arr = this._arr; + var l = 2 * i, + r = l + 1, + largest = i; + if (l < arr.length) { + largest = arr[l].priority < arr[largest].priority ? l : largest; + if (r < arr.length) { + largest = arr[r].priority < arr[largest].priority ? r : largest; + } + if (largest !== i) { + this._swap(i, largest); + this._heapify(largest); + } + } +}; + +PriorityQueue.prototype._decrease = function(index) { + var arr = this._arr; + var priority = arr[index].priority; + var parent; + while (index !== 0) { + parent = index >> 1; + if (arr[parent].priority < priority) { + break; + } + this._swap(index, parent); + index = parent; + } +}; + +PriorityQueue.prototype._swap = function(i, j) { + var arr = this._arr; + var keyIndices = this._keyIndices; + var origArrI = arr[i]; + var origArrJ = arr[j]; + arr[i] = origArrJ; + arr[j] = origArrI; + keyIndices[origArrJ.key] = i; + keyIndices[origArrI.key] = j; +}; + +},{}],7:[function(require,module,exports){ +var util = require('./util'); + +module.exports = Set; + +/** + * Constructs a new Set with an optional set of `initialKeys`. + * + * It is important to note that keys are coerced to String for most purposes + * with this object, similar to the behavior of JavaScript's Object. For + * example, the following will add only one key: + * + * var s = new Set(); + * s.add(1); + * s.add("1"); + * + * However, the type of the key is preserved internally so that `keys` returns + * the original key set uncoerced. For the above example, `keys` would return + * `[1]`. + */ +function Set(initialKeys) { + this._size = 0; + this._keys = {}; + + if (initialKeys) { + for (var i = 0, il = initialKeys.length; i < il; ++i) { + this.add(initialKeys[i]); + } + } +} + +/** + * Returns a new Set that represents the set intersection of the array of given + * sets. + */ +Set.intersect = function(sets) { + if (sets.length === 0) { + return new Set(); + } + + var result = new Set(!util.isArray(sets[0]) ? sets[0].keys() : sets[0]); + for (var i = 1, il = sets.length; i < il; ++i) { + var resultKeys = result.keys(), + other = !util.isArray(sets[i]) ? sets[i] : new Set(sets[i]); + for (var j = 0, jl = resultKeys.length; j < jl; ++j) { + var key = resultKeys[j]; + if (!other.has(key)) { + result.remove(key); + } + } + } + + return result; +}; + +/** + * Returns a new Set that represents the set union of the array of given sets. + */ +Set.union = function(sets) { + var totalElems = util.reduce(sets, function(lhs, rhs) { + return lhs + (rhs.size ? rhs.size() : rhs.length); + }, 0); + var arr = new Array(totalElems); + + var k = 0; + for (var i = 0, il = sets.length; i < il; ++i) { + var cur = sets[i], + keys = !util.isArray(cur) ? cur.keys() : cur; + for (var j = 0, jl = keys.length; j < jl; ++j) { + arr[k++] = keys[j]; + } + } + + return new Set(arr); +}; + +/** + * Returns the size of this set in `O(1)` time. + */ +Set.prototype.size = function() { + return this._size; +}; + +/** + * Returns the keys in this set. Takes `O(n)` time. + */ +Set.prototype.keys = function() { + return values(this._keys); +}; + +/** + * Tests if a key is present in this Set. Returns `true` if it is and `false` + * if not. Takes `O(1)` time. + */ +Set.prototype.has = function(key) { + return key in this._keys; +}; + +/** + * Adds a new key to this Set if it is not already present. Returns `true` if + * the key was added and `false` if it was already present. Takes `O(1)` time. + */ +Set.prototype.add = function(key) { + if (!(key in this._keys)) { + this._keys[key] = key; + ++this._size; + return true; + } + return false; +}; + +/** + * Removes a key from this Set. If the key was removed this function returns + * `true`. If not, it returns `false`. Takes `O(1)` time. + */ +Set.prototype.remove = function(key) { + if (key in this._keys) { + delete this._keys[key]; + --this._size; + return true; + } + return false; +}; + +/* + * Returns an array of all values for properties of **o**. + */ +function values(o) { + var ks = Object.keys(o), + len = ks.length, + result = new Array(len), + i; + for (i = 0; i < len; ++i) { + result[i] = o[ks[i]]; + } + return result; +} + +},{"./util":8}],8:[function(require,module,exports){ +/* + * This polyfill comes from + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray + */ +if(!Array.isArray) { + exports.isArray = function (vArg) { + return Object.prototype.toString.call(vArg) === '[object Array]'; + }; +} else { + exports.isArray = Array.isArray; +} + +/* + * Slightly adapted polyfill from + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce + */ +if ('function' !== typeof Array.prototype.reduce) { + exports.reduce = function(array, callback, opt_initialValue) { + 'use strict'; + if (null === array || 'undefined' === typeof array) { + // At the moment all modern browsers, that support strict mode, have + // native implementation of Array.prototype.reduce. For instance, IE8 + // does not support strict mode, so this check is actually useless. + throw new TypeError( + 'Array.prototype.reduce called on null or undefined'); + } + if ('function' !== typeof callback) { + throw new TypeError(callback + ' is not a function'); + } + var index, value, + length = array.length >>> 0, + isValueSet = false; + if (1 < arguments.length) { + value = opt_initialValue; + isValueSet = true; + } + for (index = 0; length > index; ++index) { + if (array.hasOwnProperty(index)) { + if (isValueSet) { + value = callback(value, array[index], index, array); + } + else { + value = array[index]; + isValueSet = true; + } + } + } + if (!isValueSet) { + throw new TypeError('Reduce of empty array with no initial value'); + } + return value; + }; +} else { + exports.reduce = function(array, callback, opt_initialValue) { + return array.reduce(callback, opt_initialValue); + }; +} + +},{}],9:[function(require,module,exports){ +module.exports = '1.1.3'; + +},{}],10:[function(require,module,exports){ +require("./d3"); +module.exports = d3; +(function () { delete this.d3; })(); // unset global + +},{}],11:[function(require,module,exports){ +/* +Copyright (c) 2012-2013 Chris Pettitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +exports.Digraph = require("graphlib").Digraph; +exports.Graph = require("graphlib").Graph; +exports.layout = require("./lib/layout"); +exports.version = require("./lib/version"); + +},{"./lib/layout":12,"./lib/version":27,"graphlib":28}],12:[function(require,module,exports){ +var util = require('./util'), + rank = require('./rank'), + order = require('./order'), + CGraph = require('graphlib').CGraph, + CDigraph = require('graphlib').CDigraph; + +module.exports = function() { + // External configuration + var config = { + // How much debug information to include? + debugLevel: 0, + // Max number of sweeps to perform in order phase + orderMaxSweeps: order.DEFAULT_MAX_SWEEPS, + // Use network simplex algorithm in ranking + rankSimplex: false, + // Rank direction. Valid values are (TB, LR) + rankDir: 'TB' + }; + + // Phase functions + var position = require('./position')(); + + // This layout object + var self = {}; + + self.orderIters = util.propertyAccessor(self, config, 'orderMaxSweeps'); + + self.rankSimplex = util.propertyAccessor(self, config, 'rankSimplex'); + + self.nodeSep = delegateProperty(position.nodeSep); + self.edgeSep = delegateProperty(position.edgeSep); + self.universalSep = delegateProperty(position.universalSep); + self.rankSep = delegateProperty(position.rankSep); + self.rankDir = util.propertyAccessor(self, config, 'rankDir'); + self.debugAlignment = delegateProperty(position.debugAlignment); + + self.debugLevel = util.propertyAccessor(self, config, 'debugLevel', function(x) { + util.log.level = x; + position.debugLevel(x); + }); + + self.run = util.time('Total layout', run); + + self._normalize = normalize; + + return self; + + /* + * Constructs an adjacency graph using the nodes and edges specified through + * config. For each node and edge we add a property `dagre` that contains an + * object that will hold intermediate and final layout information. Some of + * the contents include: + * + * 1) A generated ID that uniquely identifies the object. + * 2) Dimension information for nodes (copied from the source node). + * 3) Optional dimension information for edges. + * + * After the adjacency graph is constructed the code no longer needs to use + * the original nodes and edges passed in via config. + */ + function initLayoutGraph(inputGraph) { + var g = new CDigraph(); + + inputGraph.eachNode(function(u, value) { + if (value === undefined) value = {}; + g.addNode(u, { + width: value.width, + height: value.height + }); + if (value.hasOwnProperty('rank')) { + g.node(u).prefRank = value.rank; + } + }); + + // Set up subgraphs + if (inputGraph.parent) { + inputGraph.nodes().forEach(function(u) { + g.parent(u, inputGraph.parent(u)); + }); + } + + inputGraph.eachEdge(function(e, u, v, value) { + if (value === undefined) value = {}; + var newValue = { + e: e, + minLen: value.minLen || 1, + width: value.width || 0, + height: value.height || 0, + points: [] + }; + + g.addEdge(null, u, v, newValue); + }); + + // Initial graph attributes + var graphValue = inputGraph.graph() || {}; + g.graph({ + rankDir: graphValue.rankDir || config.rankDir, + orderRestarts: graphValue.orderRestarts + }); + + return g; + } + + function run(inputGraph) { + var rankSep = self.rankSep(); + var g; + try { + // Build internal graph + g = util.time('initLayoutGraph', initLayoutGraph)(inputGraph); + + if (g.order() === 0) { + return g; + } + + // Make space for edge labels + g.eachEdge(function(e, s, t, a) { + a.minLen *= 2; + }); + self.rankSep(rankSep / 2); + + // Determine the rank for each node. Nodes with a lower rank will appear + // above nodes of higher rank. + util.time('rank.run', rank.run)(g, config.rankSimplex); + + // Normalize the graph by ensuring that every edge is proper (each edge has + // a length of 1). We achieve this by adding dummy nodes to long edges, + // thus shortening them. + util.time('normalize', normalize)(g); + + // Order the nodes so that edge crossings are minimized. + util.time('order', order)(g, config.orderMaxSweeps); + + // Find the x and y coordinates for every node in the graph. + util.time('position', position.run)(g); + + // De-normalize the graph by removing dummy nodes and augmenting the + // original long edges with coordinate information. + util.time('undoNormalize', undoNormalize)(g); + + // Reverses points for edges that are in a reversed state. + util.time('fixupEdgePoints', fixupEdgePoints)(g); + + // Restore delete edges and reverse edges that were reversed in the rank + // phase. + util.time('rank.restoreEdges', rank.restoreEdges)(g); + + // Construct final result graph and return it + return util.time('createFinalGraph', createFinalGraph)(g, inputGraph.isDirected()); + } finally { + self.rankSep(rankSep); + } + } + + /* + * This function is responsible for 'normalizing' the graph. The process of + * normalization ensures that no edge in the graph has spans more than one + * rank. To do this it inserts dummy nodes as needed and links them by adding + * dummy edges. This function keeps enough information in the dummy nodes and + * edges to ensure that the original graph can be reconstructed later. + * + * This method assumes that the input graph is cycle free. + */ + function normalize(g) { + var dummyCount = 0; + g.eachEdge(function(e, s, t, a) { + var sourceRank = g.node(s).rank; + var targetRank = g.node(t).rank; + if (sourceRank + 1 < targetRank) { + for (var u = s, rank = sourceRank + 1, i = 0; rank < targetRank; ++rank, ++i) { + var v = '_D' + (++dummyCount); + var node = { + width: a.width, + height: a.height, + edge: { id: e, source: s, target: t, attrs: a }, + rank: rank, + dummy: true + }; + + // If this node represents a bend then we will use it as a control + // point. For edges with 2 segments this will be the center dummy + // node. For edges with more than two segments, this will be the + // first and last dummy node. + if (i === 0) node.index = 0; + else if (rank + 1 === targetRank) node.index = 1; + + g.addNode(v, node); + g.addEdge(null, u, v, {}); + u = v; + } + g.addEdge(null, u, t, {}); + g.delEdge(e); + } + }); + } + + /* + * Reconstructs the graph as it was before normalization. The positions of + * dummy nodes are used to build an array of points for the original 'long' + * edge. Dummy nodes and edges are removed. + */ + function undoNormalize(g) { + g.eachNode(function(u, a) { + if (a.dummy) { + if ('index' in a) { + var edge = a.edge; + if (!g.hasEdge(edge.id)) { + g.addEdge(edge.id, edge.source, edge.target, edge.attrs); + } + var points = g.edge(edge.id).points; + points[a.index] = { x: a.x, y: a.y, ul: a.ul, ur: a.ur, dl: a.dl, dr: a.dr }; + } + g.delNode(u); + } + }); + } + + /* + * For each edge that was reversed during the `acyclic` step, reverse its + * array of points. + */ + function fixupEdgePoints(g) { + g.eachEdge(function(e, s, t, a) { if (a.reversed) a.points.reverse(); }); + } + + function createFinalGraph(g, isDirected) { + var out = isDirected ? new CDigraph() : new CGraph(); + out.graph(g.graph()); + g.eachNode(function(u, value) { out.addNode(u, value); }); + g.eachNode(function(u) { out.parent(u, g.parent(u)); }); + g.eachEdge(function(e, u, v, value) { + out.addEdge(value.e, u, v, value); + }); + + // Attach bounding box information + var maxX = 0, maxY = 0; + g.eachNode(function(u, value) { + if (!g.children(u).length) { + maxX = Math.max(maxX, value.x + value.width / 2); + maxY = Math.max(maxY, value.y + value.height / 2); + } + }); + g.eachEdge(function(e, u, v, value) { + var maxXPoints = Math.max.apply(Math, value.points.map(function(p) { return p.x; })); + var maxYPoints = Math.max.apply(Math, value.points.map(function(p) { return p.y; })); + maxX = Math.max(maxX, maxXPoints + value.width / 2); + maxY = Math.max(maxY, maxYPoints + value.height / 2); + }); + out.graph().width = maxX; + out.graph().height = maxY; + + return out; + } + + /* + * Given a function, a new function is returned that invokes the given + * function. The return value from the function is always the `self` object. + */ + function delegateProperty(f) { + return function() { + if (!arguments.length) return f(); + f.apply(null, arguments); + return self; + }; + } +}; + + +},{"./order":13,"./position":18,"./rank":19,"./util":26,"graphlib":28}],13:[function(require,module,exports){ +var util = require('./util'), + crossCount = require('./order/crossCount'), + initLayerGraphs = require('./order/initLayerGraphs'), + initOrder = require('./order/initOrder'), + sortLayer = require('./order/sortLayer'); + +module.exports = order; + +// The maximum number of sweeps to perform before finishing the order phase. +var DEFAULT_MAX_SWEEPS = 24; +order.DEFAULT_MAX_SWEEPS = DEFAULT_MAX_SWEEPS; + +/* + * Runs the order phase with the specified `graph, `maxSweeps`, and + * `debugLevel`. If `maxSweeps` is not specified we use `DEFAULT_MAX_SWEEPS`. + * If `debugLevel` is not set we assume 0. + */ +function order(g, maxSweeps) { + if (arguments.length < 2) { + maxSweeps = DEFAULT_MAX_SWEEPS; + } + + var restarts = g.graph().orderRestarts || 0; + + var layerGraphs = initLayerGraphs(g); + // TODO: remove this when we add back support for ordering clusters + layerGraphs.forEach(function(lg) { + lg = lg.filterNodes(function(u) { return !g.children(u).length; }); + }); + + var iters = 0, + currentBestCC, + allTimeBestCC = Number.MAX_VALUE, + allTimeBest = {}; + + function saveAllTimeBest() { + g.eachNode(function(u, value) { allTimeBest[u] = value.order; }); + } + + for (var j = 0; j < Number(restarts) + 1 && allTimeBestCC !== 0; ++j) { + currentBestCC = Number.MAX_VALUE; + initOrder(g, restarts > 0); + + util.log(2, 'Order phase start cross count: ' + g.graph().orderInitCC); + + var i, lastBest, cc; + for (i = 0, lastBest = 0; lastBest < 4 && i < maxSweeps && currentBestCC > 0; ++i, ++lastBest, ++iters) { + sweep(g, layerGraphs, i); + cc = crossCount(g); + if (cc < currentBestCC) { + lastBest = 0; + currentBestCC = cc; + if (cc < allTimeBestCC) { + saveAllTimeBest(); + allTimeBestCC = cc; + } + } + util.log(3, 'Order phase start ' + j + ' iter ' + i + ' cross count: ' + cc); + } + } + + Object.keys(allTimeBest).forEach(function(u) { + if (!g.children || !g.children(u).length) { + g.node(u).order = allTimeBest[u]; + } + }); + g.graph().orderCC = allTimeBestCC; + + util.log(2, 'Order iterations: ' + iters); + util.log(2, 'Order phase best cross count: ' + g.graph().orderCC); +} + +function predecessorWeights(g, nodes) { + var weights = {}; + nodes.forEach(function(u) { + weights[u] = g.inEdges(u).map(function(e) { + return g.node(g.source(e)).order; + }); + }); + return weights; +} + +function successorWeights(g, nodes) { + var weights = {}; + nodes.forEach(function(u) { + weights[u] = g.outEdges(u).map(function(e) { + return g.node(g.target(e)).order; + }); + }); + return weights; +} + +function sweep(g, layerGraphs, iter) { + if (iter % 2 === 0) { + sweepDown(g, layerGraphs, iter); + } else { + sweepUp(g, layerGraphs, iter); + } +} + +function sweepDown(g, layerGraphs) { + var cg; + for (i = 1; i < layerGraphs.length; ++i) { + cg = sortLayer(layerGraphs[i], cg, predecessorWeights(g, layerGraphs[i].nodes())); + } +} + +function sweepUp(g, layerGraphs) { + var cg; + for (i = layerGraphs.length - 2; i >= 0; --i) { + sortLayer(layerGraphs[i], cg, successorWeights(g, layerGraphs[i].nodes())); + } +} + +},{"./order/crossCount":14,"./order/initLayerGraphs":15,"./order/initOrder":16,"./order/sortLayer":17,"./util":26}],14:[function(require,module,exports){ +var util = require('../util'); + +module.exports = crossCount; + +/* + * Returns the cross count for the given graph. + */ +function crossCount(g) { + var cc = 0; + var ordering = util.ordering(g); + for (var i = 1; i < ordering.length; ++i) { + cc += twoLayerCrossCount(g, ordering[i-1], ordering[i]); + } + return cc; +} + +/* + * This function searches through a ranked and ordered graph and counts the + * number of edges that cross. This algorithm is derived from: + * + * W. Barth et al., Bilayer Cross Counting, JGAA, 8(2) 179–194 (2004) + */ +function twoLayerCrossCount(g, layer1, layer2) { + var indices = []; + layer1.forEach(function(u) { + var nodeIndices = []; + g.outEdges(u).forEach(function(e) { nodeIndices.push(g.node(g.target(e)).order); }); + nodeIndices.sort(function(x, y) { return x - y; }); + indices = indices.concat(nodeIndices); + }); + + var firstIndex = 1; + while (firstIndex < layer2.length) firstIndex <<= 1; + + var treeSize = 2 * firstIndex - 1; + firstIndex -= 1; + + var tree = []; + for (var i = 0; i < treeSize; ++i) { tree[i] = 0; } + + var cc = 0; + indices.forEach(function(i) { + var treeIndex = i + firstIndex; + ++tree[treeIndex]; + while (treeIndex > 0) { + if (treeIndex % 2) { + cc += tree[treeIndex + 1]; + } + treeIndex = (treeIndex - 1) >> 1; + ++tree[treeIndex]; + } + }); + + return cc; +} + +},{"../util":26}],15:[function(require,module,exports){ +var nodesFromList = require('graphlib').filter.nodesFromList, + /* jshint -W079 */ + Set = require('cp-data').Set; + +module.exports = initLayerGraphs; + +/* + * This function takes a compound layered graph, g, and produces an array of + * layer graphs. Each entry in the array represents a subgraph of nodes + * relevant for performing crossing reduction on that layer. + */ +function initLayerGraphs(g) { + var ranks = []; + + function dfs(u) { + if (u === null) { + g.children(u).forEach(function(v) { dfs(v); }); + return; + } + + var value = g.node(u); + value.minRank = ('rank' in value) ? value.rank : Number.MAX_VALUE; + value.maxRank = ('rank' in value) ? value.rank : Number.MIN_VALUE; + var uRanks = new Set(); + g.children(u).forEach(function(v) { + var rs = dfs(v); + uRanks = Set.union([uRanks, rs]); + value.minRank = Math.min(value.minRank, g.node(v).minRank); + value.maxRank = Math.max(value.maxRank, g.node(v).maxRank); + }); + + if ('rank' in value) uRanks.add(value.rank); + + uRanks.keys().forEach(function(r) { + if (!(r in ranks)) ranks[r] = []; + ranks[r].push(u); + }); + + return uRanks; + } + dfs(null); + + var layerGraphs = []; + ranks.forEach(function(us, rank) { + layerGraphs[rank] = g.filterNodes(nodesFromList(us)); + }); + + return layerGraphs; +} + +},{"cp-data":5,"graphlib":28}],16:[function(require,module,exports){ +var crossCount = require('./crossCount'), + util = require('../util'); + +module.exports = initOrder; + +/* + * Given a graph with a set of layered nodes (i.e. nodes that have a `rank` + * attribute) this function attaches an `order` attribute that uniquely + * arranges each node of each rank. If no constraint graph is provided the + * order of the nodes in each rank is entirely arbitrary. + */ +function initOrder(g, random) { + var layers = []; + + g.eachNode(function(u, value) { + var layer = layers[value.rank]; + if (g.children && g.children(u).length > 0) return; + if (!layer) { + layer = layers[value.rank] = []; + } + layer.push(u); + }); + + layers.forEach(function(layer) { + if (random) { + util.shuffle(layer); + } + layer.forEach(function(u, i) { + g.node(u).order = i; + }); + }); + + var cc = crossCount(g); + g.graph().orderInitCC = cc; + g.graph().orderCC = Number.MAX_VALUE; +} + +},{"../util":26,"./crossCount":14}],17:[function(require,module,exports){ +var util = require('../util'); +/* + Digraph = require('graphlib').Digraph, + topsort = require('graphlib').alg.topsort, + nodesFromList = require('graphlib').filter.nodesFromList; +*/ + +module.exports = sortLayer; + +/* +function sortLayer(g, cg, weights) { + var result = sortLayerSubgraph(g, null, cg, weights); + result.list.forEach(function(u, i) { + g.node(u).order = i; + }); + return result.constraintGraph; +} +*/ + +function sortLayer(g, cg, weights) { + var ordering = []; + var bs = {}; + g.eachNode(function(u, value) { + ordering[value.order] = u; + var ws = weights[u]; + if (ws.length) { + bs[u] = util.sum(ws) / ws.length; + } + }); + + var toSort = g.nodes().filter(function(u) { return bs[u] !== undefined; }); + toSort.sort(function(x, y) { + return bs[x] - bs[y] || g.node(x).order - g.node(y).order; + }); + + for (var i = 0, j = 0, jl = toSort.length; j < jl; ++i) { + if (bs[ordering[i]] !== undefined) { + g.node(toSort[j++]).order = i; + } + } +} + +// TOOD: re-enable constrained sorting once we have a strategy for handling +// undefined barycenters. +/* +function sortLayerSubgraph(g, sg, cg, weights) { + cg = cg ? cg.filterNodes(nodesFromList(g.children(sg))) : new Digraph(); + + var nodeData = {}; + g.children(sg).forEach(function(u) { + if (g.children(u).length) { + nodeData[u] = sortLayerSubgraph(g, u, cg, weights); + nodeData[u].firstSG = u; + nodeData[u].lastSG = u; + } else { + var ws = weights[u]; + nodeData[u] = { + degree: ws.length, + barycenter: ws.length > 0 ? util.sum(ws) / ws.length : 0, + list: [u] + }; + } + }); + + resolveViolatedConstraints(g, cg, nodeData); + + var keys = Object.keys(nodeData); + keys.sort(function(x, y) { + return nodeData[x].barycenter - nodeData[y].barycenter; + }); + + var result = keys.map(function(u) { return nodeData[u]; }) + .reduce(function(lhs, rhs) { return mergeNodeData(g, lhs, rhs); }); + return result; +} + +/* +function mergeNodeData(g, lhs, rhs) { + var cg = mergeDigraphs(lhs.constraintGraph, rhs.constraintGraph); + + if (lhs.lastSG !== undefined && rhs.firstSG !== undefined) { + if (cg === undefined) { + cg = new Digraph(); + } + if (!cg.hasNode(lhs.lastSG)) { cg.addNode(lhs.lastSG); } + cg.addNode(rhs.firstSG); + cg.addEdge(null, lhs.lastSG, rhs.firstSG); + } + + return { + degree: lhs.degree + rhs.degree, + barycenter: (lhs.barycenter * lhs.degree + rhs.barycenter * rhs.degree) / + (lhs.degree + rhs.degree), + list: lhs.list.concat(rhs.list), + firstSG: lhs.firstSG !== undefined ? lhs.firstSG : rhs.firstSG, + lastSG: rhs.lastSG !== undefined ? rhs.lastSG : lhs.lastSG, + constraintGraph: cg + }; +} + +function mergeDigraphs(lhs, rhs) { + if (lhs === undefined) return rhs; + if (rhs === undefined) return lhs; + + lhs = lhs.copy(); + rhs.nodes().forEach(function(u) { lhs.addNode(u); }); + rhs.edges().forEach(function(e, u, v) { lhs.addEdge(null, u, v); }); + return lhs; +} + +function resolveViolatedConstraints(g, cg, nodeData) { + // Removes nodes `u` and `v` from `cg` and makes any edges incident on them + // incident on `w` instead. + function collapseNodes(u, v, w) { + // TODO original paper removes self loops, but it is not obvious when this would happen + cg.inEdges(u).forEach(function(e) { + cg.delEdge(e); + cg.addEdge(null, cg.source(e), w); + }); + + cg.outEdges(v).forEach(function(e) { + cg.delEdge(e); + cg.addEdge(null, w, cg.target(e)); + }); + + cg.delNode(u); + cg.delNode(v); + } + + var violated; + while ((violated = findViolatedConstraint(cg, nodeData)) !== undefined) { + var source = cg.source(violated), + target = cg.target(violated); + + var v; + while ((v = cg.addNode(null)) && g.hasNode(v)) { + cg.delNode(v); + } + + // Collapse barycenter and list + nodeData[v] = mergeNodeData(g, nodeData[source], nodeData[target]); + delete nodeData[source]; + delete nodeData[target]; + + collapseNodes(source, target, v); + if (cg.incidentEdges(v).length === 0) { cg.delNode(v); } + } +} + +function findViolatedConstraint(cg, nodeData) { + var us = topsort(cg); + for (var i = 0; i < us.length; ++i) { + var u = us[i]; + var inEdges = cg.inEdges(u); + for (var j = 0; j < inEdges.length; ++j) { + var e = inEdges[j]; + if (nodeData[cg.source(e)].barycenter >= nodeData[u].barycenter) { + return e; + } + } + } +} +*/ + +},{"../util":26}],18:[function(require,module,exports){ +var util = require('./util'); + +/* + * The algorithms here are based on Brandes and Köpf, "Fast and Simple + * Horizontal Coordinate Assignment". + */ +module.exports = function() { + // External configuration + var config = { + nodeSep: 50, + edgeSep: 10, + universalSep: null, + rankSep: 30 + }; + + var self = {}; + + self.nodeSep = util.propertyAccessor(self, config, 'nodeSep'); + self.edgeSep = util.propertyAccessor(self, config, 'edgeSep'); + // If not null this separation value is used for all nodes and edges + // regardless of their widths. `nodeSep` and `edgeSep` are ignored with this + // option. + self.universalSep = util.propertyAccessor(self, config, 'universalSep'); + self.rankSep = util.propertyAccessor(self, config, 'rankSep'); + self.debugLevel = util.propertyAccessor(self, config, 'debugLevel'); + + self.run = run; + + return self; + + function run(g) { + g = g.filterNodes(util.filterNonSubgraphs(g)); + + var layering = util.ordering(g); + + var conflicts = findConflicts(g, layering); + + var xss = {}; + ['u', 'd'].forEach(function(vertDir) { + if (vertDir === 'd') layering.reverse(); + + ['l', 'r'].forEach(function(horizDir) { + if (horizDir === 'r') reverseInnerOrder(layering); + + var dir = vertDir + horizDir; + var align = verticalAlignment(g, layering, conflicts, vertDir === 'u' ? 'predecessors' : 'successors'); + xss[dir]= horizontalCompaction(g, layering, align.pos, align.root, align.align); + + if (config.debugLevel >= 3) + debugPositioning(vertDir + horizDir, g, layering, xss[dir]); + + if (horizDir === 'r') flipHorizontally(xss[dir]); + + if (horizDir === 'r') reverseInnerOrder(layering); + }); + + if (vertDir === 'd') layering.reverse(); + }); + + balance(g, layering, xss); + + g.eachNode(function(v) { + var xs = []; + for (var alignment in xss) { + var alignmentX = xss[alignment][v]; + posXDebug(alignment, g, v, alignmentX); + xs.push(alignmentX); + } + xs.sort(function(x, y) { return x - y; }); + posX(g, v, (xs[1] + xs[2]) / 2); + }); + + // Align y coordinates with ranks + var y = 0, reverseY = g.graph().rankDir === 'BT' || g.graph().rankDir === 'RL'; + layering.forEach(function(layer) { + var maxHeight = util.max(layer.map(function(u) { return height(g, u); })); + y += maxHeight / 2; + layer.forEach(function(u) { + posY(g, u, reverseY ? -y : y); + }); + y += maxHeight / 2 + config.rankSep; + }); + + // Translate layout so that top left corner of bounding rectangle has + // coordinate (0, 0). + var minX = util.min(g.nodes().map(function(u) { return posX(g, u) - width(g, u) / 2; })); + var minY = util.min(g.nodes().map(function(u) { return posY(g, u) - height(g, u) / 2; })); + g.eachNode(function(u) { + posX(g, u, posX(g, u) - minX); + posY(g, u, posY(g, u) - minY); + }); + } + + /* + * Generate an ID that can be used to represent any undirected edge that is + * incident on `u` and `v`. + */ + function undirEdgeId(u, v) { + return u < v + ? u.toString().length + ':' + u + '-' + v + : v.toString().length + ':' + v + '-' + u; + } + + function findConflicts(g, layering) { + var conflicts = {}, // Set of conflicting edge ids + pos = {}, // Position of node in its layer + prevLayer, + currLayer, + k0, // Position of the last inner segment in the previous layer + l, // Current position in the current layer (for iteration up to `l1`) + k1; // Position of the next inner segment in the previous layer or + // the position of the last element in the previous layer + + if (layering.length <= 2) return conflicts; + + function updateConflicts(v) { + var k = pos[v]; + if (k < k0 || k > k1) { + conflicts[undirEdgeId(currLayer[l], v)] = true; + } + } + + layering[1].forEach(function(u, i) { pos[u] = i; }); + for (var i = 1; i < layering.length - 1; ++i) { + prevLayer = layering[i]; + currLayer = layering[i+1]; + k0 = 0; + l = 0; + + // Scan current layer for next node that is incident to an inner segement + // between layering[i+1] and layering[i]. + for (var l1 = 0; l1 < currLayer.length; ++l1) { + var u = currLayer[l1]; // Next inner segment in the current layer or + // last node in the current layer + pos[u] = l1; + k1 = undefined; + + if (g.node(u).dummy) { + var uPred = g.predecessors(u)[0]; + // Note: In the case of self loops and sideways edges it is possible + // for a dummy not to have a predecessor. + if (uPred !== undefined && g.node(uPred).dummy) + k1 = pos[uPred]; + } + if (k1 === undefined && l1 === currLayer.length - 1) + k1 = prevLayer.length - 1; + + if (k1 !== undefined) { + for (; l <= l1; ++l) { + g.predecessors(currLayer[l]).forEach(updateConflicts); + } + k0 = k1; + } + } + } + + return conflicts; + } + + function verticalAlignment(g, layering, conflicts, relationship) { + var pos = {}, // Position for a node in its layer + root = {}, // Root of the block that the node participates in + align = {}; // Points to the next node in the block or, if the last + // element in the block, points to the first block's root + + layering.forEach(function(layer) { + layer.forEach(function(u, i) { + root[u] = u; + align[u] = u; + pos[u] = i; + }); + }); + + layering.forEach(function(layer) { + var prevIdx = -1; + layer.forEach(function(v) { + var related = g[relationship](v), // Adjacent nodes from the previous layer + mid; // The mid point in the related array + + if (related.length > 0) { + related.sort(function(x, y) { return pos[x] - pos[y]; }); + mid = (related.length - 1) / 2; + related.slice(Math.floor(mid), Math.ceil(mid) + 1).forEach(function(u) { + if (align[v] === v) { + if (!conflicts[undirEdgeId(u, v)] && prevIdx < pos[u]) { + align[u] = v; + align[v] = root[v] = root[u]; + prevIdx = pos[u]; + } + } + }); + } + }); + }); + + return { pos: pos, root: root, align: align }; + } + + // This function deviates from the standard BK algorithm in two ways. First + // it takes into account the size of the nodes. Second it includes a fix to + // the original algorithm that is described in Carstens, "Node and Label + // Placement in a Layered Layout Algorithm". + function horizontalCompaction(g, layering, pos, root, align) { + var sink = {}, // Mapping of node id -> sink node id for class + maybeShift = {}, // Mapping of sink node id -> { class node id, min shift } + shift = {}, // Mapping of sink node id -> shift + pred = {}, // Mapping of node id -> predecessor node (or null) + xs = {}; // Calculated X positions + + layering.forEach(function(layer) { + layer.forEach(function(u, i) { + sink[u] = u; + maybeShift[u] = {}; + if (i > 0) + pred[u] = layer[i - 1]; + }); + }); + + function updateShift(toShift, neighbor, delta) { + if (!(neighbor in maybeShift[toShift])) { + maybeShift[toShift][neighbor] = delta; + } else { + maybeShift[toShift][neighbor] = Math.min(maybeShift[toShift][neighbor], delta); + } + } + + function placeBlock(v) { + if (!(v in xs)) { + xs[v] = 0; + var w = v; + do { + if (pos[w] > 0) { + var u = root[pred[w]]; + placeBlock(u); + if (sink[v] === v) { + sink[v] = sink[u]; + } + var delta = sep(g, pred[w]) + sep(g, w); + if (sink[v] !== sink[u]) { + updateShift(sink[u], sink[v], xs[v] - xs[u] - delta); + } else { + xs[v] = Math.max(xs[v], xs[u] + delta); + } + } + w = align[w]; + } while (w !== v); + } + } + + // Root coordinates relative to sink + util.values(root).forEach(function(v) { + placeBlock(v); + }); + + // Absolute coordinates + // There is an assumption here that we've resolved shifts for any classes + // that begin at an earlier layer. We guarantee this by visiting layers in + // order. + layering.forEach(function(layer) { + layer.forEach(function(v) { + xs[v] = xs[root[v]]; + if (v === root[v] && v === sink[v]) { + var minShift = 0; + if (v in maybeShift && Object.keys(maybeShift[v]).length > 0) { + minShift = util.min(Object.keys(maybeShift[v]) + .map(function(u) { + return maybeShift[v][u] + (u in shift ? shift[u] : 0); + } + )); + } + shift[v] = minShift; + } + }); + }); + + layering.forEach(function(layer) { + layer.forEach(function(v) { + xs[v] += shift[sink[root[v]]] || 0; + }); + }); + + return xs; + } + + function findMinCoord(g, layering, xs) { + return util.min(layering.map(function(layer) { + var u = layer[0]; + return xs[u]; + })); + } + + function findMaxCoord(g, layering, xs) { + return util.max(layering.map(function(layer) { + var u = layer[layer.length - 1]; + return xs[u]; + })); + } + + function balance(g, layering, xss) { + var min = {}, // Min coordinate for the alignment + max = {}, // Max coordinate for the alginment + smallestAlignment, + shift = {}; // Amount to shift a given alignment + + function updateAlignment(v) { + xss[alignment][v] += shift[alignment]; + } + + var smallest = Number.POSITIVE_INFINITY; + for (var alignment in xss) { + var xs = xss[alignment]; + min[alignment] = findMinCoord(g, layering, xs); + max[alignment] = findMaxCoord(g, layering, xs); + var w = max[alignment] - min[alignment]; + if (w < smallest) { + smallest = w; + smallestAlignment = alignment; + } + } + + // Determine how much to adjust positioning for each alignment + ['u', 'd'].forEach(function(vertDir) { + ['l', 'r'].forEach(function(horizDir) { + var alignment = vertDir + horizDir; + shift[alignment] = horizDir === 'l' + ? min[smallestAlignment] - min[alignment] + : max[smallestAlignment] - max[alignment]; + }); + }); + + // Find average of medians for xss array + for (alignment in xss) { + g.eachNode(updateAlignment); + } + } + + function flipHorizontally(xs) { + for (var u in xs) { + xs[u] = -xs[u]; + } + } + + function reverseInnerOrder(layering) { + layering.forEach(function(layer) { + layer.reverse(); + }); + } + + function width(g, u) { + switch (g.graph().rankDir) { + case 'LR': return g.node(u).height; + case 'RL': return g.node(u).height; + default: return g.node(u).width; + } + } + + function height(g, u) { + switch(g.graph().rankDir) { + case 'LR': return g.node(u).width; + case 'RL': return g.node(u).width; + default: return g.node(u).height; + } + } + + function sep(g, u) { + if (config.universalSep !== null) { + return config.universalSep; + } + var w = width(g, u); + var s = g.node(u).dummy ? config.edgeSep : config.nodeSep; + return (w + s) / 2; + } + + function posX(g, u, x) { + if (g.graph().rankDir === 'LR' || g.graph().rankDir === 'RL') { + if (arguments.length < 3) { + return g.node(u).y; + } else { + g.node(u).y = x; + } + } else { + if (arguments.length < 3) { + return g.node(u).x; + } else { + g.node(u).x = x; + } + } + } + + function posXDebug(name, g, u, x) { + if (g.graph().rankDir === 'LR' || g.graph().rankDir === 'RL') { + if (arguments.length < 3) { + return g.node(u)[name]; + } else { + g.node(u)[name] = x; + } + } else { + if (arguments.length < 3) { + return g.node(u)[name]; + } else { + g.node(u)[name] = x; + } + } + } + + function posY(g, u, y) { + if (g.graph().rankDir === 'LR' || g.graph().rankDir === 'RL') { + if (arguments.length < 3) { + return g.node(u).x; + } else { + g.node(u).x = y; + } + } else { + if (arguments.length < 3) { + return g.node(u).y; + } else { + g.node(u).y = y; + } + } + } + + function debugPositioning(align, g, layering, xs) { + layering.forEach(function(l, li) { + var u, xU; + l.forEach(function(v) { + var xV = xs[v]; + if (u) { + var s = sep(g, u) + sep(g, v); + if (xV - xU < s) + console.log('Position phase: sep violation. Align: ' + align + '. Layer: ' + li + '. ' + + 'U: ' + u + ' V: ' + v + '. Actual sep: ' + (xV - xU) + ' Expected sep: ' + s); + } + u = v; + xU = xV; + }); + }); + } +}; + +},{"./util":26}],19:[function(require,module,exports){ +var util = require('./util'), + acyclic = require('./rank/acyclic'), + initRank = require('./rank/initRank'), + feasibleTree = require('./rank/feasibleTree'), + constraints = require('./rank/constraints'), + simplex = require('./rank/simplex'), + components = require('graphlib').alg.components, + filter = require('graphlib').filter; + +exports.run = run; +exports.restoreEdges = restoreEdges; + +/* + * Heuristic function that assigns a rank to each node of the input graph with + * the intent of minimizing edge lengths, while respecting the `minLen` + * attribute of incident edges. + * + * Prerequisites: + * + * * Each edge in the input graph must have an assigned 'minLen' attribute + */ +function run(g, useSimplex) { + expandSelfLoops(g); + + // If there are rank constraints on nodes, then build a new graph that + // encodes the constraints. + util.time('constraints.apply', constraints.apply)(g); + + expandSidewaysEdges(g); + + // Reverse edges to get an acyclic graph, we keep the graph in an acyclic + // state until the very end. + util.time('acyclic', acyclic)(g); + + // Convert the graph into a flat graph for ranking + var flatGraph = g.filterNodes(util.filterNonSubgraphs(g)); + + // Assign an initial ranking using DFS. + initRank(flatGraph); + + // For each component improve the assigned ranks. + components(flatGraph).forEach(function(cmpt) { + var subgraph = flatGraph.filterNodes(filter.nodesFromList(cmpt)); + rankComponent(subgraph, useSimplex); + }); + + // Relax original constraints + util.time('constraints.relax', constraints.relax(g)); + + // When handling nodes with constrained ranks it is possible to end up with + // edges that point to previous ranks. Most of the subsequent algorithms assume + // that edges are pointing to successive ranks only. Here we reverse any "back + // edges" and mark them as such. The acyclic algorithm will reverse them as a + // post processing step. + util.time('reorientEdges', reorientEdges)(g); +} + +function restoreEdges(g) { + acyclic.undo(g); +} + +/* + * Expand self loops into three dummy nodes. One will sit above the incident + * node, one will be at the same level, and one below. The result looks like: + * + * /--<--x--->--\ + * node y + * \--<--z--->--/ + * + * Dummy nodes x, y, z give us the shape of a loop and node y is where we place + * the label. + * + * TODO: consolidate knowledge of dummy node construction. + * TODO: support minLen = 2 + */ +function expandSelfLoops(g) { + g.eachEdge(function(e, u, v, a) { + if (u === v) { + var x = addDummyNode(g, e, u, v, a, 0, false), + y = addDummyNode(g, e, u, v, a, 1, true), + z = addDummyNode(g, e, u, v, a, 2, false); + g.addEdge(null, x, u, {minLen: 1, selfLoop: true}); + g.addEdge(null, x, y, {minLen: 1, selfLoop: true}); + g.addEdge(null, u, z, {minLen: 1, selfLoop: true}); + g.addEdge(null, y, z, {minLen: 1, selfLoop: true}); + g.delEdge(e); + } + }); +} + +function expandSidewaysEdges(g) { + g.eachEdge(function(e, u, v, a) { + if (u === v) { + var origEdge = a.originalEdge, + dummy = addDummyNode(g, origEdge.e, origEdge.u, origEdge.v, origEdge.value, 0, true); + g.addEdge(null, u, dummy, {minLen: 1}); + g.addEdge(null, dummy, v, {minLen: 1}); + g.delEdge(e); + } + }); +} + +function addDummyNode(g, e, u, v, a, index, isLabel) { + return g.addNode(null, { + width: isLabel ? a.width : 0, + height: isLabel ? a.height : 0, + edge: { id: e, source: u, target: v, attrs: a }, + dummy: true, + index: index + }); +} + +function reorientEdges(g) { + g.eachEdge(function(e, u, v, value) { + if (g.node(u).rank > g.node(v).rank) { + g.delEdge(e); + value.reversed = true; + g.addEdge(e, v, u, value); + } + }); +} + +function rankComponent(subgraph, useSimplex) { + var spanningTree = feasibleTree(subgraph); + + if (useSimplex) { + util.log(1, 'Using network simplex for ranking'); + simplex(subgraph, spanningTree); + } + normalize(subgraph); +} + +function normalize(g) { + var m = util.min(g.nodes().map(function(u) { return g.node(u).rank; })); + g.eachNode(function(u, node) { node.rank -= m; }); +} + +},{"./rank/acyclic":20,"./rank/constraints":21,"./rank/feasibleTree":22,"./rank/initRank":23,"./rank/simplex":25,"./util":26,"graphlib":28}],20:[function(require,module,exports){ +var util = require('../util'); + +module.exports = acyclic; +module.exports.undo = undo; + +/* + * This function takes a directed graph that may have cycles and reverses edges + * as appropriate to break these cycles. Each reversed edge is assigned a + * `reversed` attribute with the value `true`. + * + * There should be no self loops in the graph. + */ +function acyclic(g) { + var onStack = {}, + visited = {}, + reverseCount = 0; + + function dfs(u) { + if (u in visited) return; + visited[u] = onStack[u] = true; + g.outEdges(u).forEach(function(e) { + var t = g.target(e), + value; + + if (u === t) { + console.error('Warning: found self loop "' + e + '" for node "' + u + '"'); + } else if (t in onStack) { + value = g.edge(e); + g.delEdge(e); + value.reversed = true; + ++reverseCount; + g.addEdge(e, t, u, value); + } else { + dfs(t); + } + }); + + delete onStack[u]; + } + + g.eachNode(function(u) { dfs(u); }); + + util.log(2, 'Acyclic Phase: reversed ' + reverseCount + ' edge(s)'); + + return reverseCount; +} + +/* + * Given a graph that has had the acyclic operation applied, this function + * undoes that operation. More specifically, any edge with the `reversed` + * attribute is again reversed to restore the original direction of the edge. + */ +function undo(g) { + g.eachEdge(function(e, s, t, a) { + if (a.reversed) { + delete a.reversed; + g.delEdge(e); + g.addEdge(e, t, s, a); + } + }); +} + +},{"../util":26}],21:[function(require,module,exports){ +exports.apply = function(g) { + function dfs(sg) { + var rankSets = {}; + g.children(sg).forEach(function(u) { + if (g.children(u).length) { + dfs(u); + return; + } + + var value = g.node(u), + prefRank = value.prefRank; + if (prefRank !== undefined) { + if (!checkSupportedPrefRank(prefRank)) { return; } + + if (!(prefRank in rankSets)) { + rankSets.prefRank = [u]; + } else { + rankSets.prefRank.push(u); + } + + var newU = rankSets[prefRank]; + if (newU === undefined) { + newU = rankSets[prefRank] = g.addNode(null, { originalNodes: [] }); + g.parent(newU, sg); + } + + redirectInEdges(g, u, newU, prefRank === 'min'); + redirectOutEdges(g, u, newU, prefRank === 'max'); + + // Save original node and remove it from reduced graph + g.node(newU).originalNodes.push({ u: u, value: value, parent: sg }); + g.delNode(u); + } + }); + + addLightEdgesFromMinNode(g, sg, rankSets.min); + addLightEdgesToMaxNode(g, sg, rankSets.max); + } + + dfs(null); +}; + +function checkSupportedPrefRank(prefRank) { + if (prefRank !== 'min' && prefRank !== 'max' && prefRank.indexOf('same_') !== 0) { + console.error('Unsupported rank type: ' + prefRank); + return false; + } + return true; +} + +function redirectInEdges(g, u, newU, reverse) { + g.inEdges(u).forEach(function(e) { + var origValue = g.edge(e), + value; + if (origValue.originalEdge) { + value = origValue; + } else { + value = { + originalEdge: { e: e, u: g.source(e), v: g.target(e), value: origValue }, + minLen: g.edge(e).minLen + }; + } + + // Do not reverse edges for self-loops. + if (origValue.selfLoop) { + reverse = false; + } + + if (reverse) { + // Ensure that all edges to min are reversed + g.addEdge(null, newU, g.source(e), value); + value.reversed = true; + } else { + g.addEdge(null, g.source(e), newU, value); + } + }); +} + +function redirectOutEdges(g, u, newU, reverse) { + g.outEdges(u).forEach(function(e) { + var origValue = g.edge(e), + value; + if (origValue.originalEdge) { + value = origValue; + } else { + value = { + originalEdge: { e: e, u: g.source(e), v: g.target(e), value: origValue }, + minLen: g.edge(e).minLen + }; + } + + // Do not reverse edges for self-loops. + if (origValue.selfLoop) { + reverse = false; + } + + if (reverse) { + // Ensure that all edges from max are reversed + g.addEdge(null, g.target(e), newU, value); + value.reversed = true; + } else { + g.addEdge(null, newU, g.target(e), value); + } + }); +} + +function addLightEdgesFromMinNode(g, sg, minNode) { + if (minNode !== undefined) { + g.children(sg).forEach(function(u) { + // The dummy check ensures we don't add an edge if the node is involved + // in a self loop or sideways edge. + if (u !== minNode && !g.outEdges(minNode, u).length && !g.node(u).dummy) { + g.addEdge(null, minNode, u, { minLen: 0 }); + } + }); + } +} + +function addLightEdgesToMaxNode(g, sg, maxNode) { + if (maxNode !== undefined) { + g.children(sg).forEach(function(u) { + // The dummy check ensures we don't add an edge if the node is involved + // in a self loop or sideways edge. + if (u !== maxNode && !g.outEdges(u, maxNode).length && !g.node(u).dummy) { + g.addEdge(null, u, maxNode, { minLen: 0 }); + } + }); + } +} + +/* + * This function "relaxes" the constraints applied previously by the "apply" + * function. It expands any nodes that were collapsed and assigns the rank of + * the collapsed node to each of the expanded nodes. It also restores the + * original edges and removes any dummy edges pointing at the collapsed nodes. + * + * Note that the process of removing collapsed nodes also removes dummy edges + * automatically. + */ +exports.relax = function(g) { + // Save original edges + var originalEdges = []; + g.eachEdge(function(e, u, v, value) { + var originalEdge = value.originalEdge; + if (originalEdge) { + originalEdges.push(originalEdge); + } + }); + + // Expand collapsed nodes + g.eachNode(function(u, value) { + var originalNodes = value.originalNodes; + if (originalNodes) { + originalNodes.forEach(function(originalNode) { + originalNode.value.rank = value.rank; + g.addNode(originalNode.u, originalNode.value); + g.parent(originalNode.u, originalNode.parent); + }); + g.delNode(u); + } + }); + + // Restore original edges + originalEdges.forEach(function(edge) { + g.addEdge(edge.e, edge.u, edge.v, edge.value); + }); +}; + +},{}],22:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require('cp-data').Set, +/* jshint +W079 */ + Digraph = require('graphlib').Digraph, + util = require('../util'); + +module.exports = feasibleTree; + +/* + * Given an acyclic graph with each node assigned a `rank` attribute, this + * function constructs and returns a spanning tree. This function may reduce + * the length of some edges from the initial rank assignment while maintaining + * the `minLen` specified by each edge. + * + * Prerequisites: + * + * * The input graph is acyclic + * * Each node in the input graph has an assigned `rank` attribute + * * Each edge in the input graph has an assigned `minLen` attribute + * + * Outputs: + * + * A feasible spanning tree for the input graph (i.e. a spanning tree that + * respects each graph edge's `minLen` attribute) represented as a Digraph with + * a `root` attribute on graph. + * + * Nodes have the same id and value as that in the input graph. + * + * Edges in the tree have arbitrarily assigned ids. The attributes for edges + * include `reversed`. `reversed` indicates that the edge is a + * back edge in the input graph. + */ +function feasibleTree(g) { + var remaining = new Set(g.nodes()), + tree = new Digraph(); + + if (remaining.size() === 1) { + var root = g.nodes()[0]; + tree.addNode(root, {}); + tree.graph({ root: root }); + return tree; + } + + function addTightEdges(v) { + var continueToScan = true; + g.predecessors(v).forEach(function(u) { + if (remaining.has(u) && !slack(g, u, v)) { + if (remaining.has(v)) { + tree.addNode(v, {}); + remaining.remove(v); + tree.graph({ root: v }); + } + + tree.addNode(u, {}); + tree.addEdge(null, u, v, { reversed: true }); + remaining.remove(u); + addTightEdges(u); + continueToScan = false; + } + }); + + g.successors(v).forEach(function(w) { + if (remaining.has(w) && !slack(g, v, w)) { + if (remaining.has(v)) { + tree.addNode(v, {}); + remaining.remove(v); + tree.graph({ root: v }); + } + + tree.addNode(w, {}); + tree.addEdge(null, v, w, {}); + remaining.remove(w); + addTightEdges(w); + continueToScan = false; + } + }); + return continueToScan; + } + + function createTightEdge() { + var minSlack = Number.MAX_VALUE; + remaining.keys().forEach(function(v) { + g.predecessors(v).forEach(function(u) { + if (!remaining.has(u)) { + var edgeSlack = slack(g, u, v); + if (Math.abs(edgeSlack) < Math.abs(minSlack)) { + minSlack = -edgeSlack; + } + } + }); + + g.successors(v).forEach(function(w) { + if (!remaining.has(w)) { + var edgeSlack = slack(g, v, w); + if (Math.abs(edgeSlack) < Math.abs(minSlack)) { + minSlack = edgeSlack; + } + } + }); + }); + + tree.eachNode(function(u) { g.node(u).rank -= minSlack; }); + } + + while (remaining.size()) { + var nodesToSearch = !tree.order() ? remaining.keys() : tree.nodes(); + for (var i = 0, il = nodesToSearch.length; + i < il && addTightEdges(nodesToSearch[i]); + ++i); + if (remaining.size()) { + createTightEdge(); + } + } + + return tree; +} + +function slack(g, u, v) { + var rankDiff = g.node(v).rank - g.node(u).rank; + var maxMinLen = util.max(g.outEdges(u, v) + .map(function(e) { return g.edge(e).minLen; })); + return rankDiff - maxMinLen; +} + +},{"../util":26,"cp-data":5,"graphlib":28}],23:[function(require,module,exports){ +var util = require('../util'), + topsort = require('graphlib').alg.topsort; + +module.exports = initRank; + +/* + * Assigns a `rank` attribute to each node in the input graph and ensures that + * this rank respects the `minLen` attribute of incident edges. + * + * Prerequisites: + * + * * The input graph must be acyclic + * * Each edge in the input graph must have an assigned 'minLen' attribute + */ +function initRank(g) { + var sorted = topsort(g); + + sorted.forEach(function(u) { + var inEdges = g.inEdges(u); + if (inEdges.length === 0) { + g.node(u).rank = 0; + return; + } + + var minLens = inEdges.map(function(e) { + return g.node(g.source(e)).rank + g.edge(e).minLen; + }); + g.node(u).rank = util.max(minLens); + }); +} + +},{"../util":26,"graphlib":28}],24:[function(require,module,exports){ +module.exports = { + slack: slack +}; + +/* + * A helper to calculate the slack between two nodes (`u` and `v`) given a + * `minLen` constraint. The slack represents how much the distance between `u` + * and `v` could shrink while maintaining the `minLen` constraint. If the value + * is negative then the constraint is currently violated. + * + This function requires that `u` and `v` are in `graph` and they both have a + `rank` attribute. + */ +function slack(graph, u, v, minLen) { + return Math.abs(graph.node(u).rank - graph.node(v).rank) - minLen; +} + +},{}],25:[function(require,module,exports){ +var util = require('../util'), + rankUtil = require('./rankUtil'); + +module.exports = simplex; + +function simplex(graph, spanningTree) { + // The network simplex algorithm repeatedly replaces edges of + // the spanning tree with negative cut values until no such + // edge exists. + initCutValues(graph, spanningTree); + while (true) { + var e = leaveEdge(spanningTree); + if (e === null) break; + var f = enterEdge(graph, spanningTree, e); + exchange(graph, spanningTree, e, f); + } +} + +/* + * Set the cut values of edges in the spanning tree by a depth-first + * postorder traversal. The cut value corresponds to the cost, in + * terms of a ranking's edge length sum, of lengthening an edge. + * Negative cut values typically indicate edges that would yield a + * smaller edge length sum if they were lengthened. + */ +function initCutValues(graph, spanningTree) { + computeLowLim(spanningTree); + + spanningTree.eachEdge(function(id, u, v, treeValue) { + treeValue.cutValue = 0; + }); + + // Propagate cut values up the tree. + function dfs(n) { + var children = spanningTree.successors(n); + for (var c in children) { + var child = children[c]; + dfs(child); + } + if (n !== spanningTree.graph().root) { + setCutValue(graph, spanningTree, n); + } + } + dfs(spanningTree.graph().root); +} + +/* + * Perform a DFS postorder traversal, labeling each node v with + * its traversal order 'lim(v)' and the minimum traversal number + * of any of its descendants 'low(v)'. This provides an efficient + * way to test whether u is an ancestor of v since + * low(u) <= lim(v) <= lim(u) if and only if u is an ancestor. + */ +function computeLowLim(tree) { + var postOrderNum = 0; + + function dfs(n) { + var children = tree.successors(n); + var low = postOrderNum; + for (var c in children) { + var child = children[c]; + dfs(child); + low = Math.min(low, tree.node(child).low); + } + tree.node(n).low = low; + tree.node(n).lim = postOrderNum++; + } + + dfs(tree.graph().root); +} + +/* + * To compute the cut value of the edge parent -> child, we consider + * it and any other graph edges to or from the child. + * parent + * | + * child + * / \ + * u v + */ +function setCutValue(graph, tree, child) { + var parentEdge = tree.inEdges(child)[0]; + + // List of child's children in the spanning tree. + var grandchildren = []; + var grandchildEdges = tree.outEdges(child); + for (var gce in grandchildEdges) { + grandchildren.push(tree.target(grandchildEdges[gce])); + } + + var cutValue = 0; + + // TODO: Replace unit increment/decrement with edge weights. + var E = 0; // Edges from child to grandchild's subtree. + var F = 0; // Edges to child from grandchild's subtree. + var G = 0; // Edges from child to nodes outside of child's subtree. + var H = 0; // Edges from nodes outside of child's subtree to child. + + // Consider all graph edges from child. + var outEdges = graph.outEdges(child); + var gc; + for (var oe in outEdges) { + var succ = graph.target(outEdges[oe]); + for (gc in grandchildren) { + if (inSubtree(tree, succ, grandchildren[gc])) { + E++; + } + } + if (!inSubtree(tree, succ, child)) { + G++; + } + } + + // Consider all graph edges to child. + var inEdges = graph.inEdges(child); + for (var ie in inEdges) { + var pred = graph.source(inEdges[ie]); + for (gc in grandchildren) { + if (inSubtree(tree, pred, grandchildren[gc])) { + F++; + } + } + if (!inSubtree(tree, pred, child)) { + H++; + } + } + + // Contributions depend on the alignment of the parent -> child edge + // and the child -> u or v edges. + var grandchildCutSum = 0; + for (gc in grandchildren) { + var cv = tree.edge(grandchildEdges[gc]).cutValue; + if (!tree.edge(grandchildEdges[gc]).reversed) { + grandchildCutSum += cv; + } else { + grandchildCutSum -= cv; + } + } + + if (!tree.edge(parentEdge).reversed) { + cutValue += grandchildCutSum - E + F - G + H; + } else { + cutValue -= grandchildCutSum - E + F - G + H; + } + + tree.edge(parentEdge).cutValue = cutValue; +} + +/* + * Return whether n is a node in the subtree with the given + * root. + */ +function inSubtree(tree, n, root) { + return (tree.node(root).low <= tree.node(n).lim && + tree.node(n).lim <= tree.node(root).lim); +} + +/* + * Return an edge from the tree with a negative cut value, or null if there + * is none. + */ +function leaveEdge(tree) { + var edges = tree.edges(); + for (var n in edges) { + var e = edges[n]; + var treeValue = tree.edge(e); + if (treeValue.cutValue < 0) { + return e; + } + } + return null; +} + +/* + * The edge e should be an edge in the tree, with an underlying edge + * in the graph, with a negative cut value. Of the two nodes incident + * on the edge, take the lower one. enterEdge returns an edge with + * minimum slack going from outside of that node's subtree to inside + * of that node's subtree. + */ +function enterEdge(graph, tree, e) { + var source = tree.source(e); + var target = tree.target(e); + var lower = tree.node(target).lim < tree.node(source).lim ? target : source; + + // Is the tree edge aligned with the graph edge? + var aligned = !tree.edge(e).reversed; + + var minSlack = Number.POSITIVE_INFINITY; + var minSlackEdge; + if (aligned) { + graph.eachEdge(function(id, u, v, value) { + if (id !== e && inSubtree(tree, u, lower) && !inSubtree(tree, v, lower)) { + var slack = rankUtil.slack(graph, u, v, value.minLen); + if (slack < minSlack) { + minSlack = slack; + minSlackEdge = id; + } + } + }); + } else { + graph.eachEdge(function(id, u, v, value) { + if (id !== e && !inSubtree(tree, u, lower) && inSubtree(tree, v, lower)) { + var slack = rankUtil.slack(graph, u, v, value.minLen); + if (slack < minSlack) { + minSlack = slack; + minSlackEdge = id; + } + } + }); + } + + if (minSlackEdge === undefined) { + var outside = []; + var inside = []; + graph.eachNode(function(id) { + if (!inSubtree(tree, id, lower)) { + outside.push(id); + } else { + inside.push(id); + } + }); + throw new Error('No edge found from outside of tree to inside'); + } + + return minSlackEdge; +} + +/* + * Replace edge e with edge f in the tree, recalculating the tree root, + * the nodes' low and lim properties and the edges' cut values. + */ +function exchange(graph, tree, e, f) { + tree.delEdge(e); + var source = graph.source(f); + var target = graph.target(f); + + // Redirect edges so that target is the root of its subtree. + function redirect(v) { + var edges = tree.inEdges(v); + for (var i in edges) { + var e = edges[i]; + var u = tree.source(e); + var value = tree.edge(e); + redirect(u); + tree.delEdge(e); + value.reversed = !value.reversed; + tree.addEdge(e, v, u, value); + } + } + + redirect(target); + + var root = source; + var edges = tree.inEdges(root); + while (edges.length > 0) { + root = tree.source(edges[0]); + edges = tree.inEdges(root); + } + + tree.graph().root = root; + + tree.addEdge(null, source, target, {cutValue: 0}); + + initCutValues(graph, tree); + + adjustRanks(graph, tree); +} + +/* + * Reset the ranks of all nodes based on the current spanning tree. + * The rank of the tree's root remains unchanged, while all other + * nodes are set to the sum of minimum length constraints along + * the path from the root. + */ +function adjustRanks(graph, tree) { + function dfs(p) { + var children = tree.successors(p); + children.forEach(function(c) { + var minLen = minimumLength(graph, p, c); + graph.node(c).rank = graph.node(p).rank + minLen; + dfs(c); + }); + } + + dfs(tree.graph().root); +} + +/* + * If u and v are connected by some edges in the graph, return the + * minimum length of those edges, as a positive number if v succeeds + * u and as a negative number if v precedes u. + */ +function minimumLength(graph, u, v) { + var outEdges = graph.outEdges(u, v); + if (outEdges.length > 0) { + return util.max(outEdges.map(function(e) { + return graph.edge(e).minLen; + })); + } + + var inEdges = graph.inEdges(u, v); + if (inEdges.length > 0) { + return -util.max(inEdges.map(function(e) { + return graph.edge(e).minLen; + })); + } +} + +},{"../util":26,"./rankUtil":24}],26:[function(require,module,exports){ +/* + * Returns the smallest value in the array. + */ +exports.min = function(values) { + return Math.min.apply(Math, values); +}; + +/* + * Returns the largest value in the array. + */ +exports.max = function(values) { + return Math.max.apply(Math, values); +}; + +/* + * Returns `true` only if `f(x)` is `true` for all `x` in `xs`. Otherwise + * returns `false`. This function will return immediately if it finds a + * case where `f(x)` does not hold. + */ +exports.all = function(xs, f) { + for (var i = 0; i < xs.length; ++i) { + if (!f(xs[i])) { + return false; + } + } + return true; +}; + +/* + * Accumulates the sum of elements in the given array using the `+` operator. + */ +exports.sum = function(values) { + return values.reduce(function(acc, x) { return acc + x; }, 0); +}; + +/* + * Returns an array of all values in the given object. + */ +exports.values = function(obj) { + return Object.keys(obj).map(function(k) { return obj[k]; }); +}; + +exports.shuffle = function(array) { + for (i = array.length - 1; i > 0; --i) { + var j = Math.floor(Math.random() * (i + 1)); + var aj = array[j]; + array[j] = array[i]; + array[i] = aj; + } +}; + +exports.propertyAccessor = function(self, config, field, setHook) { + return function(x) { + if (!arguments.length) return config[field]; + config[field] = x; + if (setHook) setHook(x); + return self; + }; +}; + +/* + * Given a layered, directed graph with `rank` and `order` node attributes, + * this function returns an array of ordered ranks. Each rank contains an array + * of the ids of the nodes in that rank in the order specified by the `order` + * attribute. + */ +exports.ordering = function(g) { + var ordering = []; + g.eachNode(function(u, value) { + var rank = ordering[value.rank] || (ordering[value.rank] = []); + rank[value.order] = u; + }); + return ordering; +}; + +/* + * A filter that can be used with `filterNodes` to get a graph that only + * includes nodes that do not contain others nodes. + */ +exports.filterNonSubgraphs = function(g) { + return function(u) { + return g.children(u).length === 0; + }; +}; + +/* + * Returns a new function that wraps `func` with a timer. The wrapper logs the + * time it takes to execute the function. + * + * The timer will be enabled provided `log.level >= 1`. + */ +function time(name, func) { + return function() { + var start = new Date().getTime(); + try { + return func.apply(null, arguments); + } finally { + log(1, name + ' time: ' + (new Date().getTime() - start) + 'ms'); + } + }; +} +time.enabled = false; + +exports.time = time; + +/* + * A global logger with the specification `log(level, message, ...)` that + * will log a message to the console if `log.level >= level`. + */ +function log(level) { + if (log.level >= level) { + console.log.apply(console, Array.prototype.slice.call(arguments, 1)); + } +} +log.level = 0; + +exports.log = log; + +},{}],27:[function(require,module,exports){ +module.exports = '0.4.5'; + +},{}],28:[function(require,module,exports){ +exports.Graph = require("./lib/Graph"); +exports.Digraph = require("./lib/Digraph"); +exports.CGraph = require("./lib/CGraph"); +exports.CDigraph = require("./lib/CDigraph"); +require("./lib/graph-converters"); + +exports.alg = { + isAcyclic: require("./lib/alg/isAcyclic"), + components: require("./lib/alg/components"), + dijkstra: require("./lib/alg/dijkstra"), + dijkstraAll: require("./lib/alg/dijkstraAll"), + findCycles: require("./lib/alg/findCycles"), + floydWarshall: require("./lib/alg/floydWarshall"), + postorder: require("./lib/alg/postorder"), + preorder: require("./lib/alg/preorder"), + prim: require("./lib/alg/prim"), + tarjan: require("./lib/alg/tarjan"), + topsort: require("./lib/alg/topsort") +}; + +exports.converter = { + json: require("./lib/converter/json.js") +}; + +var filter = require("./lib/filter"); +exports.filter = { + all: filter.all, + nodesFromList: filter.nodesFromList +}; + +exports.version = require("./lib/version"); + +},{"./lib/CDigraph":30,"./lib/CGraph":31,"./lib/Digraph":32,"./lib/Graph":33,"./lib/alg/components":34,"./lib/alg/dijkstra":35,"./lib/alg/dijkstraAll":36,"./lib/alg/findCycles":37,"./lib/alg/floydWarshall":38,"./lib/alg/isAcyclic":39,"./lib/alg/postorder":40,"./lib/alg/preorder":41,"./lib/alg/prim":42,"./lib/alg/tarjan":43,"./lib/alg/topsort":44,"./lib/converter/json.js":46,"./lib/filter":47,"./lib/graph-converters":48,"./lib/version":50}],29:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = BaseGraph; + +function BaseGraph() { + // The value assigned to the graph itself. + this._value = undefined; + + // Map of node id -> { id, value } + this._nodes = {}; + + // Map of edge id -> { id, u, v, value } + this._edges = {}; + + // Used to generate a unique id in the graph + this._nextId = 0; +} + +// Number of nodes +BaseGraph.prototype.order = function() { + return Object.keys(this._nodes).length; +}; + +// Number of edges +BaseGraph.prototype.size = function() { + return Object.keys(this._edges).length; +}; + +// Accessor for graph level value +BaseGraph.prototype.graph = function(value) { + if (arguments.length === 0) { + return this._value; + } + this._value = value; +}; + +BaseGraph.prototype.hasNode = function(u) { + return u in this._nodes; +}; + +BaseGraph.prototype.node = function(u, value) { + var node = this._strictGetNode(u); + if (arguments.length === 1) { + return node.value; + } + node.value = value; +}; + +BaseGraph.prototype.nodes = function() { + var nodes = []; + this.eachNode(function(id) { nodes.push(id); }); + return nodes; +}; + +BaseGraph.prototype.eachNode = function(func) { + for (var k in this._nodes) { + var node = this._nodes[k]; + func(node.id, node.value); + } +}; + +BaseGraph.prototype.hasEdge = function(e) { + return e in this._edges; +}; + +BaseGraph.prototype.edge = function(e, value) { + var edge = this._strictGetEdge(e); + if (arguments.length === 1) { + return edge.value; + } + edge.value = value; +}; + +BaseGraph.prototype.edges = function() { + var es = []; + this.eachEdge(function(id) { es.push(id); }); + return es; +}; + +BaseGraph.prototype.eachEdge = function(func) { + for (var k in this._edges) { + var edge = this._edges[k]; + func(edge.id, edge.u, edge.v, edge.value); + } +}; + +BaseGraph.prototype.incidentNodes = function(e) { + var edge = this._strictGetEdge(e); + return [edge.u, edge.v]; +}; + +BaseGraph.prototype.addNode = function(u, value) { + if (u === undefined || u === null) { + do { + u = "_" + (++this._nextId); + } while (this.hasNode(u)); + } else if (this.hasNode(u)) { + throw new Error("Graph already has node '" + u + "'"); + } + this._nodes[u] = { id: u, value: value }; + return u; +}; + +BaseGraph.prototype.delNode = function(u) { + this._strictGetNode(u); + this.incidentEdges(u).forEach(function(e) { this.delEdge(e); }, this); + delete this._nodes[u]; +}; + +// inMap and outMap are opposite sides of an incidence map. For example, for +// Graph these would both come from the _incidentEdges map, while for Digraph +// they would come from _inEdges and _outEdges. +BaseGraph.prototype._addEdge = function(e, u, v, value, inMap, outMap) { + this._strictGetNode(u); + this._strictGetNode(v); + + if (e === undefined || e === null) { + do { + e = "_" + (++this._nextId); + } while (this.hasEdge(e)); + } + else if (this.hasEdge(e)) { + throw new Error("Graph already has edge '" + e + "'"); + } + + this._edges[e] = { id: e, u: u, v: v, value: value }; + addEdgeToMap(inMap[v], u, e); + addEdgeToMap(outMap[u], v, e); + + return e; +}; + +// See note for _addEdge regarding inMap and outMap. +BaseGraph.prototype._delEdge = function(e, inMap, outMap) { + var edge = this._strictGetEdge(e); + delEdgeFromMap(inMap[edge.v], edge.u, e); + delEdgeFromMap(outMap[edge.u], edge.v, e); + delete this._edges[e]; +}; + +BaseGraph.prototype.copy = function() { + var copy = new this.constructor(); + copy.graph(this.graph()); + this.eachNode(function(u, value) { copy.addNode(u, value); }); + this.eachEdge(function(e, u, v, value) { copy.addEdge(e, u, v, value); }); + copy._nextId = this._nextId; + return copy; +}; + +BaseGraph.prototype.filterNodes = function(filter) { + var copy = new this.constructor(); + copy.graph(this.graph()); + this.eachNode(function(u, value) { + if (filter(u)) { + copy.addNode(u, value); + } + }); + this.eachEdge(function(e, u, v, value) { + if (copy.hasNode(u) && copy.hasNode(v)) { + copy.addEdge(e, u, v, value); + } + }); + return copy; +}; + +BaseGraph.prototype._strictGetNode = function(u) { + var node = this._nodes[u]; + if (node === undefined) { + throw new Error("Node '" + u + "' is not in graph"); + } + return node; +}; + +BaseGraph.prototype._strictGetEdge = function(e) { + var edge = this._edges[e]; + if (edge === undefined) { + throw new Error("Edge '" + e + "' is not in graph"); + } + return edge; +}; + +function addEdgeToMap(map, v, e) { + (map[v] || (map[v] = new Set())).add(e); +} + +function delEdgeFromMap(map, v, e) { + var vEntry = map[v]; + vEntry.remove(e); + if (vEntry.size() === 0) { + delete map[v]; + } +} + + +},{"cp-data":5}],30:[function(require,module,exports){ +var Digraph = require("./Digraph"), + compoundify = require("./compoundify"); + +var CDigraph = compoundify(Digraph); + +module.exports = CDigraph; + +CDigraph.fromDigraph = function(src) { + var g = new CDigraph(), + graphValue = src.graph(); + + if (graphValue !== undefined) { + g.graph(graphValue); + } + + src.eachNode(function(u, value) { + if (value === undefined) { + g.addNode(u); + } else { + g.addNode(u, value); + } + }); + src.eachEdge(function(e, u, v, value) { + if (value === undefined) { + g.addEdge(null, u, v); + } else { + g.addEdge(null, u, v, value); + } + }); + return g; +}; + +CDigraph.prototype.toString = function() { + return "CDigraph " + JSON.stringify(this, null, 2); +}; + +},{"./Digraph":32,"./compoundify":45}],31:[function(require,module,exports){ +var Graph = require("./Graph"), + compoundify = require("./compoundify"); + +var CGraph = compoundify(Graph); + +module.exports = CGraph; + +CGraph.fromGraph = function(src) { + var g = new CGraph(), + graphValue = src.graph(); + + if (graphValue !== undefined) { + g.graph(graphValue); + } + + src.eachNode(function(u, value) { + if (value === undefined) { + g.addNode(u); + } else { + g.addNode(u, value); + } + }); + src.eachEdge(function(e, u, v, value) { + if (value === undefined) { + g.addEdge(null, u, v); + } else { + g.addEdge(null, u, v, value); + } + }); + return g; +}; + +CGraph.prototype.toString = function() { + return "CGraph " + JSON.stringify(this, null, 2); +}; + +},{"./Graph":33,"./compoundify":45}],32:[function(require,module,exports){ +/* + * This file is organized with in the following order: + * + * Exports + * Graph constructors + * Graph queries (e.g. nodes(), edges() + * Graph mutators + * Helper functions + */ + +var util = require("./util"), + BaseGraph = require("./BaseGraph"), +/* jshint -W079 */ + Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = Digraph; + +/* + * Constructor to create a new directed multi-graph. + */ +function Digraph() { + BaseGraph.call(this); + + /*! Map of sourceId -> {targetId -> Set of edge ids} */ + this._inEdges = {}; + + /*! Map of targetId -> {sourceId -> Set of edge ids} */ + this._outEdges = {}; +} + +Digraph.prototype = new BaseGraph(); +Digraph.prototype.constructor = Digraph; + +/* + * Always returns `true`. + */ +Digraph.prototype.isDirected = function() { + return true; +}; + +/* + * Returns all successors of the node with the id `u`. That is, all nodes + * that have the node `u` as their source are returned. + * + * If no node `u` exists in the graph this function throws an Error. + * + * @param {String} u a node id + */ +Digraph.prototype.successors = function(u) { + this._strictGetNode(u); + return Object.keys(this._outEdges[u]) + .map(function(v) { return this._nodes[v].id; }, this); +}; + +/* + * Returns all predecessors of the node with the id `u`. That is, all nodes + * that have the node `u` as their target are returned. + * + * If no node `u` exists in the graph this function throws an Error. + * + * @param {String} u a node id + */ +Digraph.prototype.predecessors = function(u) { + this._strictGetNode(u); + return Object.keys(this._inEdges[u]) + .map(function(v) { return this._nodes[v].id; }, this); +}; + +/* + * Returns all nodes that are adjacent to the node with the id `u`. In other + * words, this function returns the set of all successors and predecessors of + * node `u`. + * + * @param {String} u a node id + */ +Digraph.prototype.neighbors = function(u) { + return Set.union([this.successors(u), this.predecessors(u)]).keys(); +}; + +/* + * Returns all nodes in the graph that have no in-edges. + */ +Digraph.prototype.sources = function() { + var self = this; + return this._filterNodes(function(u) { + // This could have better space characteristics if we had an inDegree function. + return self.inEdges(u).length === 0; + }); +}; + +/* + * Returns all nodes in the graph that have no out-edges. + */ +Digraph.prototype.sinks = function() { + var self = this; + return this._filterNodes(function(u) { + // This could have better space characteristics if we have an outDegree function. + return self.outEdges(u).length === 0; + }); +}; + +/* + * Returns the source node incident on the edge identified by the id `e`. If no + * such edge exists in the graph this function throws an Error. + * + * @param {String} e an edge id + */ +Digraph.prototype.source = function(e) { + return this._strictGetEdge(e).u; +}; + +/* + * Returns the target node incident on the edge identified by the id `e`. If no + * such edge exists in the graph this function throws an Error. + * + * @param {String} e an edge id + */ +Digraph.prototype.target = function(e) { + return this._strictGetEdge(e).v; +}; + +/* + * Returns an array of ids for all edges in the graph that have the node + * `target` as their target. If the node `target` is not in the graph this + * function raises an Error. + * + * Optionally a `source` node can also be specified. This causes the results + * to be filtered such that only edges from `source` to `target` are included. + * If the node `source` is specified but is not in the graph then this function + * raises an Error. + * + * @param {String} target the target node id + * @param {String} [source] an optional source node id + */ +Digraph.prototype.inEdges = function(target, source) { + this._strictGetNode(target); + var results = Set.union(util.values(this._inEdges[target])).keys(); + if (arguments.length > 1) { + this._strictGetNode(source); + results = results.filter(function(e) { return this.source(e) === source; }, this); + } + return results; +}; + +/* + * Returns an array of ids for all edges in the graph that have the node + * `source` as their source. If the node `source` is not in the graph this + * function raises an Error. + * + * Optionally a `target` node may also be specified. This causes the results + * to be filtered such that only edges from `source` to `target` are included. + * If the node `target` is specified but is not in the graph then this function + * raises an Error. + * + * @param {String} source the source node id + * @param {String} [target] an optional target node id + */ +Digraph.prototype.outEdges = function(source, target) { + this._strictGetNode(source); + var results = Set.union(util.values(this._outEdges[source])).keys(); + if (arguments.length > 1) { + this._strictGetNode(target); + results = results.filter(function(e) { return this.target(e) === target; }, this); + } + return results; +}; + +/* + * Returns an array of ids for all edges in the graph that have the `u` as + * their source or their target. If the node `u` is not in the graph this + * function raises an Error. + * + * Optionally a `v` node may also be specified. This causes the results to be + * filtered such that only edges between `u` and `v` - in either direction - + * are included. IF the node `v` is specified but not in the graph then this + * function raises an Error. + * + * @param {String} u the node for which to find incident edges + * @param {String} [v] option node that must be adjacent to `u` + */ +Digraph.prototype.incidentEdges = function(u, v) { + if (arguments.length > 1) { + return Set.union([this.outEdges(u, v), this.outEdges(v, u)]).keys(); + } else { + return Set.union([this.inEdges(u), this.outEdges(u)]).keys(); + } +}; + +/* + * Returns a string representation of this graph. + */ +Digraph.prototype.toString = function() { + return "Digraph " + JSON.stringify(this, null, 2); +}; + +/* + * Adds a new node with the id `u` to the graph and assigns it the value + * `value`. If a node with the id is already a part of the graph this function + * throws an Error. + * + * @param {String} u a node id + * @param {Object} [value] an optional value to attach to the node + */ +Digraph.prototype.addNode = function(u, value) { + u = BaseGraph.prototype.addNode.call(this, u, value); + this._inEdges[u] = {}; + this._outEdges[u] = {}; + return u; +}; + +/* + * Removes a node from the graph that has the id `u`. Any edges incident on the + * node are also removed. If the graph does not contain a node with the id this + * function will throw an Error. + * + * @param {String} u a node id + */ +Digraph.prototype.delNode = function(u) { + BaseGraph.prototype.delNode.call(this, u); + delete this._inEdges[u]; + delete this._outEdges[u]; +}; + +/* + * Adds a new edge to the graph with the id `e` from a node with the id `source` + * to a node with an id `target` and assigns it the value `value`. This graph + * allows more than one edge from `source` to `target` as long as the id `e` + * is unique in the set of edges. If `e` is `null` the graph will assign a + * unique identifier to the edge. + * + * If `source` or `target` are not present in the graph this function will + * throw an Error. + * + * @param {String} [e] an edge id + * @param {String} source the source node id + * @param {String} target the target node id + * @param {Object} [value] an optional value to attach to the edge + */ +Digraph.prototype.addEdge = function(e, source, target, value) { + return BaseGraph.prototype._addEdge.call(this, e, source, target, value, + this._inEdges, this._outEdges); +}; + +/* + * Removes an edge in the graph with the id `e`. If no edge in the graph has + * the id `e` this function will throw an Error. + * + * @param {String} e an edge id + */ +Digraph.prototype.delEdge = function(e) { + BaseGraph.prototype._delEdge.call(this, e, this._inEdges, this._outEdges); +}; + +// Unlike BaseGraph.filterNodes, this helper just returns nodes that +// satisfy a predicate. +Digraph.prototype._filterNodes = function(pred) { + var filtered = []; + this.eachNode(function(u) { + if (pred(u)) { + filtered.push(u); + } + }); + return filtered; +}; + + +},{"./BaseGraph":29,"./util":49,"cp-data":5}],33:[function(require,module,exports){ +/* + * This file is organized with in the following order: + * + * Exports + * Graph constructors + * Graph queries (e.g. nodes(), edges() + * Graph mutators + * Helper functions + */ + +var util = require("./util"), + BaseGraph = require("./BaseGraph"), +/* jshint -W079 */ + Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = Graph; + +/* + * Constructor to create a new undirected multi-graph. + */ +function Graph() { + BaseGraph.call(this); + + /*! Map of nodeId -> { otherNodeId -> Set of edge ids } */ + this._incidentEdges = {}; +} + +Graph.prototype = new BaseGraph(); +Graph.prototype.constructor = Graph; + +/* + * Always returns `false`. + */ +Graph.prototype.isDirected = function() { + return false; +}; + +/* + * Returns all nodes that are adjacent to the node with the id `u`. + * + * @param {String} u a node id + */ +Graph.prototype.neighbors = function(u) { + this._strictGetNode(u); + return Object.keys(this._incidentEdges[u]) + .map(function(v) { return this._nodes[v].id; }, this); +}; + +/* + * Returns an array of ids for all edges in the graph that are incident on `u`. + * If the node `u` is not in the graph this function raises an Error. + * + * Optionally a `v` node may also be specified. This causes the results to be + * filtered such that only edges between `u` and `v` are included. If the node + * `v` is specified but not in the graph then this function raises an Error. + * + * @param {String} u the node for which to find incident edges + * @param {String} [v] option node that must be adjacent to `u` + */ +Graph.prototype.incidentEdges = function(u, v) { + this._strictGetNode(u); + if (arguments.length > 1) { + this._strictGetNode(v); + return v in this._incidentEdges[u] ? this._incidentEdges[u][v].keys() : []; + } else { + return Set.union(util.values(this._incidentEdges[u])).keys(); + } +}; + +/* + * Returns a string representation of this graph. + */ +Graph.prototype.toString = function() { + return "Graph " + JSON.stringify(this, null, 2); +}; + +/* + * Adds a new node with the id `u` to the graph and assigns it the value + * `value`. If a node with the id is already a part of the graph this function + * throws an Error. + * + * @param {String} u a node id + * @param {Object} [value] an optional value to attach to the node + */ +Graph.prototype.addNode = function(u, value) { + u = BaseGraph.prototype.addNode.call(this, u, value); + this._incidentEdges[u] = {}; + return u; +}; + +/* + * Removes a node from the graph that has the id `u`. Any edges incident on the + * node are also removed. If the graph does not contain a node with the id this + * function will throw an Error. + * + * @param {String} u a node id + */ +Graph.prototype.delNode = function(u) { + BaseGraph.prototype.delNode.call(this, u); + delete this._incidentEdges[u]; +}; + +/* + * Adds a new edge to the graph with the id `e` between a node with the id `u` + * and a node with an id `v` and assigns it the value `value`. This graph + * allows more than one edge between `u` and `v` as long as the id `e` + * is unique in the set of edges. If `e` is `null` the graph will assign a + * unique identifier to the edge. + * + * If `u` or `v` are not present in the graph this function will throw an + * Error. + * + * @param {String} [e] an edge id + * @param {String} u the node id of one of the adjacent nodes + * @param {String} v the node id of the other adjacent node + * @param {Object} [value] an optional value to attach to the edge + */ +Graph.prototype.addEdge = function(e, u, v, value) { + return BaseGraph.prototype._addEdge.call(this, e, u, v, value, + this._incidentEdges, this._incidentEdges); +}; + +/* + * Removes an edge in the graph with the id `e`. If no edge in the graph has + * the id `e` this function will throw an Error. + * + * @param {String} e an edge id + */ +Graph.prototype.delEdge = function(e) { + BaseGraph.prototype._delEdge.call(this, e, this._incidentEdges, this._incidentEdges); +}; + + +},{"./BaseGraph":29,"./util":49,"cp-data":5}],34:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = components; + +/** + * Finds all [connected components][] in a graph and returns an array of these + * components. Each component is itself an array that contains the ids of nodes + * in the component. + * + * This function only works with undirected Graphs. + * + * [connected components]: http://en.wikipedia.org/wiki/Connected_component_(graph_theory) + * + * @param {Graph} g the graph to search for components + */ +function components(g) { + var results = []; + var visited = new Set(); + + function dfs(v, component) { + if (!visited.has(v)) { + visited.add(v); + component.push(v); + g.neighbors(v).forEach(function(w) { + dfs(w, component); + }); + } + } + + g.nodes().forEach(function(v) { + var component = []; + dfs(v, component); + if (component.length > 0) { + results.push(component); + } + }); + + return results; +} + +},{"cp-data":5}],35:[function(require,module,exports){ +var PriorityQueue = require("cp-data").PriorityQueue; + +module.exports = dijkstra; + +/** + * This function is an implementation of [Dijkstra's algorithm][] which finds + * the shortest path from **source** to all other nodes in **g**. This + * function returns a map of `u -> { distance, predecessor }`. The distance + * property holds the sum of the weights from **source** to `u` along the + * shortest path or `Number.POSITIVE_INFINITY` if there is no path from + * **source**. The predecessor property can be used to walk the individual + * elements of the path from **source** to **u** in reverse order. + * + * This function takes an optional `weightFunc(e)` which returns the + * weight of the edge `e`. If no weightFunc is supplied then each edge is + * assumed to have a weight of 1. This function throws an Error if any of + * the traversed edges have a negative edge weight. + * + * This function takes an optional `incidentFunc(u)` which returns the ids of + * all edges incident to the node `u` for the purposes of shortest path + * traversal. By default this function uses the `g.outEdges` for Digraphs and + * `g.incidentEdges` for Graphs. + * + * This function takes `O((|E| + |V|) * log |V|)` time. + * + * [Dijkstra's algorithm]: http://en.wikipedia.org/wiki/Dijkstra%27s_algorithm + * + * @param {Graph} g the graph to search for shortest paths from **source** + * @param {Object} source the source from which to start the search + * @param {Function} [weightFunc] optional weight function + * @param {Function} [incidentFunc] optional incident function + */ +function dijkstra(g, source, weightFunc, incidentFunc) { + var results = {}, + pq = new PriorityQueue(); + + function updateNeighbors(e) { + var incidentNodes = g.incidentNodes(e), + v = incidentNodes[0] !== u ? incidentNodes[0] : incidentNodes[1], + vEntry = results[v], + weight = weightFunc(e), + distance = uEntry.distance + weight; + + if (weight < 0) { + throw new Error("dijkstra does not allow negative edge weights. Bad edge: " + e + " Weight: " + weight); + } + + if (distance < vEntry.distance) { + vEntry.distance = distance; + vEntry.predecessor = u; + pq.decrease(v, distance); + } + } + + weightFunc = weightFunc || function() { return 1; }; + incidentFunc = incidentFunc || (g.isDirected() + ? function(u) { return g.outEdges(u); } + : function(u) { return g.incidentEdges(u); }); + + g.eachNode(function(u) { + var distance = u === source ? 0 : Number.POSITIVE_INFINITY; + results[u] = { distance: distance }; + pq.add(u, distance); + }); + + var u, uEntry; + while (pq.size() > 0) { + u = pq.removeMin(); + uEntry = results[u]; + if (uEntry.distance === Number.POSITIVE_INFINITY) { + break; + } + + incidentFunc(u).forEach(updateNeighbors); + } + + return results; +} + +},{"cp-data":5}],36:[function(require,module,exports){ +var dijkstra = require("./dijkstra"); + +module.exports = dijkstraAll; + +/** + * This function finds the shortest path from each node to every other + * reachable node in the graph. It is similar to [alg.dijkstra][], but + * instead of returning a single-source array, it returns a mapping of + * of `source -> alg.dijksta(g, source, weightFunc, incidentFunc)`. + * + * This function takes an optional `weightFunc(e)` which returns the + * weight of the edge `e`. If no weightFunc is supplied then each edge is + * assumed to have a weight of 1. This function throws an Error if any of + * the traversed edges have a negative edge weight. + * + * This function takes an optional `incidentFunc(u)` which returns the ids of + * all edges incident to the node `u` for the purposes of shortest path + * traversal. By default this function uses the `outEdges` function on the + * supplied graph. + * + * This function takes `O(|V| * (|E| + |V|) * log |V|)` time. + * + * [alg.dijkstra]: dijkstra.js.html#dijkstra + * + * @param {Graph} g the graph to search for shortest paths from **source** + * @param {Function} [weightFunc] optional weight function + * @param {Function} [incidentFunc] optional incident function + */ +function dijkstraAll(g, weightFunc, incidentFunc) { + var results = {}; + g.eachNode(function(u) { + results[u] = dijkstra(g, u, weightFunc, incidentFunc); + }); + return results; +} + +},{"./dijkstra":35}],37:[function(require,module,exports){ +var tarjan = require("./tarjan"); + +module.exports = findCycles; + +/* + * Given a Digraph **g** this function returns all nodes that are part of a + * cycle. Since there may be more than one cycle in a graph this function + * returns an array of these cycles, where each cycle is itself represented + * by an array of ids for each node involved in that cycle. + * + * [alg.isAcyclic][] is more efficient if you only need to determine whether + * a graph has a cycle or not. + * + * [alg.isAcyclic]: isAcyclic.js.html#isAcyclic + * + * @param {Digraph} g the graph to search for cycles. + */ +function findCycles(g) { + return tarjan(g).filter(function(cmpt) { return cmpt.length > 1; }); +} + +},{"./tarjan":43}],38:[function(require,module,exports){ +module.exports = floydWarshall; + +/** + * This function is an implementation of the [Floyd-Warshall algorithm][], + * which finds the shortest path from each node to every other reachable node + * in the graph. It is similar to [alg.dijkstraAll][], but it handles negative + * edge weights and is more efficient for some types of graphs. This function + * returns a map of `source -> { target -> { distance, predecessor }`. The + * distance property holds the sum of the weights from `source` to `target` + * along the shortest path of `Number.POSITIVE_INFINITY` if there is no path + * from `source`. The predecessor property can be used to walk the individual + * elements of the path from `source` to `target` in reverse order. + * + * This function takes an optional `weightFunc(e)` which returns the + * weight of the edge `e`. If no weightFunc is supplied then each edge is + * assumed to have a weight of 1. + * + * This function takes an optional `incidentFunc(u)` which returns the ids of + * all edges incident to the node `u` for the purposes of shortest path + * traversal. By default this function uses the `outEdges` function on the + * supplied graph. + * + * This algorithm takes O(|V|^3) time. + * + * [Floyd-Warshall algorithm]: https://en.wikipedia.org/wiki/Floyd-Warshall_algorithm + * [alg.dijkstraAll]: dijkstraAll.js.html#dijkstraAll + * + * @param {Graph} g the graph to search for shortest paths from **source** + * @param {Function} [weightFunc] optional weight function + * @param {Function} [incidentFunc] optional incident function + */ +function floydWarshall(g, weightFunc, incidentFunc) { + var results = {}, + nodes = g.nodes(); + + weightFunc = weightFunc || function() { return 1; }; + incidentFunc = incidentFunc || (g.isDirected() + ? function(u) { return g.outEdges(u); } + : function(u) { return g.incidentEdges(u); }); + + nodes.forEach(function(u) { + results[u] = {}; + results[u][u] = { distance: 0 }; + nodes.forEach(function(v) { + if (u !== v) { + results[u][v] = { distance: Number.POSITIVE_INFINITY }; + } + }); + incidentFunc(u).forEach(function(e) { + var incidentNodes = g.incidentNodes(e), + v = incidentNodes[0] !== u ? incidentNodes[0] : incidentNodes[1], + d = weightFunc(e); + if (d < results[u][v].distance) { + results[u][v] = { distance: d, predecessor: u }; + } + }); + }); + + nodes.forEach(function(k) { + var rowK = results[k]; + nodes.forEach(function(i) { + var rowI = results[i]; + nodes.forEach(function(j) { + var ik = rowI[k]; + var kj = rowK[j]; + var ij = rowI[j]; + var altDistance = ik.distance + kj.distance; + if (altDistance < ij.distance) { + ij.distance = altDistance; + ij.predecessor = kj.predecessor; + } + }); + }); + }); + + return results; +} + +},{}],39:[function(require,module,exports){ +var topsort = require("./topsort"); + +module.exports = isAcyclic; + +/* + * Given a Digraph **g** this function returns `true` if the graph has no + * cycles and returns `false` if it does. This algorithm returns as soon as it + * detects the first cycle. + * + * Use [alg.findCycles][] if you need the actual list of cycles in a graph. + * + * [alg.findCycles]: findCycles.js.html#findCycles + * + * @param {Digraph} g the graph to test for cycles + */ +function isAcyclic(g) { + try { + topsort(g); + } catch (e) { + if (e instanceof topsort.CycleException) return false; + throw e; + } + return true; +} + +},{"./topsort":44}],40:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = postorder; + +// Postorder traversal of g, calling f for each visited node. Assumes the graph +// is a tree. +function postorder(g, root, f) { + var visited = new Set(); + if (g.isDirected()) { + throw new Error("This function only works for undirected graphs"); + } + function dfs(u, prev) { + if (visited.has(u)) { + throw new Error("The input graph is not a tree: " + g); + } + visited.add(u); + g.neighbors(u).forEach(function(v) { + if (v !== prev) dfs(v, u); + }); + f(u); + } + dfs(root); +} + +},{"cp-data":5}],41:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = preorder; + +// Preorder traversal of g, calling f for each visited node. Assumes the graph +// is a tree. +function preorder(g, root, f) { + var visited = new Set(); + if (g.isDirected()) { + throw new Error("This function only works for undirected graphs"); + } + function dfs(u, prev) { + if (visited.has(u)) { + throw new Error("The input graph is not a tree: " + g); + } + visited.add(u); + f(u); + g.neighbors(u).forEach(function(v) { + if (v !== prev) dfs(v, u); + }); + } + dfs(root); +} + +},{"cp-data":5}],42:[function(require,module,exports){ +var Graph = require("../Graph"), + PriorityQueue = require("cp-data").PriorityQueue; + +module.exports = prim; + +/** + * [Prim's algorithm][] takes a connected undirected graph and generates a + * [minimum spanning tree][]. This function returns the minimum spanning + * tree as an undirected graph. This algorithm is derived from the description + * in "Introduction to Algorithms", Third Edition, Cormen, et al., Pg 634. + * + * This function takes a `weightFunc(e)` which returns the weight of the edge + * `e`. It throws an Error if the graph is not connected. + * + * This function takes `O(|E| log |V|)` time. + * + * [Prim's algorithm]: https://en.wikipedia.org/wiki/Prim's_algorithm + * [minimum spanning tree]: https://en.wikipedia.org/wiki/Minimum_spanning_tree + * + * @param {Graph} g the graph used to generate the minimum spanning tree + * @param {Function} weightFunc the weight function to use + */ +function prim(g, weightFunc) { + var result = new Graph(), + parents = {}, + pq = new PriorityQueue(), + u; + + function updateNeighbors(e) { + var incidentNodes = g.incidentNodes(e), + v = incidentNodes[0] !== u ? incidentNodes[0] : incidentNodes[1], + pri = pq.priority(v); + if (pri !== undefined) { + var edgeWeight = weightFunc(e); + if (edgeWeight < pri) { + parents[v] = u; + pq.decrease(v, edgeWeight); + } + } + } + + if (g.order() === 0) { + return result; + } + + g.eachNode(function(u) { + pq.add(u, Number.POSITIVE_INFINITY); + result.addNode(u); + }); + + // Start from an arbitrary node + pq.decrease(g.nodes()[0], 0); + + var init = false; + while (pq.size() > 0) { + u = pq.removeMin(); + if (u in parents) { + result.addEdge(null, u, parents[u]); + } else if (init) { + throw new Error("Input graph is not connected: " + g); + } else { + init = true; + } + + g.incidentEdges(u).forEach(updateNeighbors); + } + + return result; +} + +},{"../Graph":33,"cp-data":5}],43:[function(require,module,exports){ +module.exports = tarjan; + +/** + * This function is an implementation of [Tarjan's algorithm][] which finds + * all [strongly connected components][] in the directed graph **g**. Each + * strongly connected component is composed of nodes that can reach all other + * nodes in the component via directed edges. A strongly connected component + * can consist of a single node if that node cannot both reach and be reached + * by any other specific node in the graph. Components of more than one node + * are guaranteed to have at least one cycle. + * + * This function returns an array of components. Each component is itself an + * array that contains the ids of all nodes in the component. + * + * [Tarjan's algorithm]: http://en.wikipedia.org/wiki/Tarjan's_strongly_connected_components_algorithm + * [strongly connected components]: http://en.wikipedia.org/wiki/Strongly_connected_component + * + * @param {Digraph} g the graph to search for strongly connected components + */ +function tarjan(g) { + if (!g.isDirected()) { + throw new Error("tarjan can only be applied to a directed graph. Bad input: " + g); + } + + var index = 0, + stack = [], + visited = {}, // node id -> { onStack, lowlink, index } + results = []; + + function dfs(u) { + var entry = visited[u] = { + onStack: true, + lowlink: index, + index: index++ + }; + stack.push(u); + + g.successors(u).forEach(function(v) { + if (!(v in visited)) { + dfs(v); + entry.lowlink = Math.min(entry.lowlink, visited[v].lowlink); + } else if (visited[v].onStack) { + entry.lowlink = Math.min(entry.lowlink, visited[v].index); + } + }); + + if (entry.lowlink === entry.index) { + var cmpt = [], + v; + do { + v = stack.pop(); + visited[v].onStack = false; + cmpt.push(v); + } while (u !== v); + results.push(cmpt); + } + } + + g.nodes().forEach(function(u) { + if (!(u in visited)) { + dfs(u); + } + }); + + return results; +} + +},{}],44:[function(require,module,exports){ +module.exports = topsort; +topsort.CycleException = CycleException; + +/* + * Given a graph **g**, this function returns an ordered list of nodes such + * that for each edge `u -> v`, `u` appears before `v` in the list. If the + * graph has a cycle it is impossible to generate such a list and + * **CycleException** is thrown. + * + * See [topological sorting](https://en.wikipedia.org/wiki/Topological_sorting) + * for more details about how this algorithm works. + * + * @param {Digraph} g the graph to sort + */ +function topsort(g) { + if (!g.isDirected()) { + throw new Error("topsort can only be applied to a directed graph. Bad input: " + g); + } + + var visited = {}; + var stack = {}; + var results = []; + + function visit(node) { + if (node in stack) { + throw new CycleException(); + } + + if (!(node in visited)) { + stack[node] = true; + visited[node] = true; + g.predecessors(node).forEach(function(pred) { + visit(pred); + }); + delete stack[node]; + results.push(node); + } + } + + var sinks = g.sinks(); + if (g.order() !== 0 && sinks.length === 0) { + throw new CycleException(); + } + + g.sinks().forEach(function(sink) { + visit(sink); + }); + + return results; +} + +function CycleException() {} + +CycleException.prototype.toString = function() { + return "Graph has at least one cycle"; +}; + +},{}],45:[function(require,module,exports){ +// This file provides a helper function that mixes-in Dot behavior to an +// existing graph prototype. + +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +module.exports = compoundify; + +// Extends the given SuperConstructor with the ability for nodes to contain +// other nodes. A special node id `null` is used to indicate the root graph. +function compoundify(SuperConstructor) { + function Constructor() { + SuperConstructor.call(this); + + // Map of object id -> parent id (or null for root graph) + this._parents = {}; + + // Map of id (or null) -> children set + this._children = {}; + this._children[null] = new Set(); + } + + Constructor.prototype = new SuperConstructor(); + Constructor.prototype.constructor = Constructor; + + Constructor.prototype.parent = function(u, parent) { + this._strictGetNode(u); + + if (arguments.length < 2) { + return this._parents[u]; + } + + if (u === parent) { + throw new Error("Cannot make " + u + " a parent of itself"); + } + if (parent !== null) { + this._strictGetNode(parent); + } + + this._children[this._parents[u]].remove(u); + this._parents[u] = parent; + this._children[parent].add(u); + }; + + Constructor.prototype.children = function(u) { + if (u !== null) { + this._strictGetNode(u); + } + return this._children[u].keys(); + }; + + Constructor.prototype.addNode = function(u, value) { + u = SuperConstructor.prototype.addNode.call(this, u, value); + this._parents[u] = null; + this._children[u] = new Set(); + this._children[null].add(u); + return u; + }; + + Constructor.prototype.delNode = function(u) { + // Promote all children to the parent of the subgraph + var parent = this.parent(u); + this._children[u].keys().forEach(function(child) { + this.parent(child, parent); + }, this); + + this._children[parent].remove(u); + delete this._parents[u]; + delete this._children[u]; + + return SuperConstructor.prototype.delNode.call(this, u); + }; + + Constructor.prototype.copy = function() { + var copy = SuperConstructor.prototype.copy.call(this); + this.nodes().forEach(function(u) { + copy.parent(u, this.parent(u)); + }, this); + return copy; + }; + + Constructor.prototype.filterNodes = function(filter) { + var self = this, + copy = SuperConstructor.prototype.filterNodes.call(this, filter); + + var parents = {}; + function findParent(u) { + var parent = self.parent(u); + if (parent === null || copy.hasNode(parent)) { + parents[u] = parent; + return parent; + } else if (parent in parents) { + return parents[parent]; + } else { + return findParent(parent); + } + } + + copy.eachNode(function(u) { copy.parent(u, findParent(u)); }); + + return copy; + }; + + return Constructor; +} + +},{"cp-data":5}],46:[function(require,module,exports){ +var Graph = require("../Graph"), + Digraph = require("../Digraph"), + CGraph = require("../CGraph"), + CDigraph = require("../CDigraph"); + +exports.decode = function(nodes, edges, Ctor) { + Ctor = Ctor || Digraph; + + if (typeOf(nodes) !== "Array") { + throw new Error("nodes is not an Array"); + } + + if (typeOf(edges) !== "Array") { + throw new Error("edges is not an Array"); + } + + if (typeof Ctor === "string") { + switch(Ctor) { + case "graph": Ctor = Graph; break; + case "digraph": Ctor = Digraph; break; + case "cgraph": Ctor = CGraph; break; + case "cdigraph": Ctor = CDigraph; break; + default: throw new Error("Unrecognized graph type: " + Ctor); + } + } + + var graph = new Ctor(); + + nodes.forEach(function(u) { + graph.addNode(u.id, u.value); + }); + + // If the graph is compound, set up children... + if (graph.parent) { + nodes.forEach(function(u) { + if (u.children) { + u.children.forEach(function(v) { + graph.parent(v, u.id); + }); + } + }); + } + + edges.forEach(function(e) { + graph.addEdge(e.id, e.u, e.v, e.value); + }); + + return graph; +}; + +exports.encode = function(graph) { + var nodes = []; + var edges = []; + + graph.eachNode(function(u, value) { + var node = {id: u, value: value}; + if (graph.children) { + var children = graph.children(u); + if (children.length) { + node.children = children; + } + } + nodes.push(node); + }); + + graph.eachEdge(function(e, u, v, value) { + edges.push({id: e, u: u, v: v, value: value}); + }); + + var type; + if (graph instanceof CDigraph) { + type = "cdigraph"; + } else if (graph instanceof CGraph) { + type = "cgraph"; + } else if (graph instanceof Digraph) { + type = "digraph"; + } else if (graph instanceof Graph) { + type = "graph"; + } else { + throw new Error("Couldn't determine type of graph: " + graph); + } + + return { nodes: nodes, edges: edges, type: type }; +}; + +function typeOf(obj) { + return Object.prototype.toString.call(obj).slice(8, -1); +} + +},{"../CDigraph":30,"../CGraph":31,"../Digraph":32,"../Graph":33}],47:[function(require,module,exports){ +/* jshint -W079 */ +var Set = require("cp-data").Set; +/* jshint +W079 */ + +exports.all = function() { + return function() { return true; }; +}; + +exports.nodesFromList = function(nodes) { + var set = new Set(nodes); + return function(u) { + return set.has(u); + }; +}; + +},{"cp-data":5}],48:[function(require,module,exports){ +var Graph = require("./Graph"), + Digraph = require("./Digraph"); + +// Side-effect based changes are lousy, but node doesn't seem to resolve the +// requires cycle. + +/** + * Returns a new directed graph using the nodes and edges from this graph. The + * new graph will have the same nodes, but will have twice the number of edges: + * each edge is split into two edges with opposite directions. Edge ids, + * consequently, are not preserved by this transformation. + */ +Graph.prototype.toDigraph = +Graph.prototype.asDirected = function() { + var g = new Digraph(); + this.eachNode(function(u, value) { g.addNode(u, value); }); + this.eachEdge(function(e, u, v, value) { + g.addEdge(null, u, v, value); + g.addEdge(null, v, u, value); + }); + return g; +}; + +/** + * Returns a new undirected graph using the nodes and edges from this graph. + * The new graph will have the same nodes, but the edges will be made + * undirected. Edge ids are preserved in this transformation. + */ +Digraph.prototype.toGraph = +Digraph.prototype.asUndirected = function() { + var g = new Graph(); + this.eachNode(function(u, value) { g.addNode(u, value); }); + this.eachEdge(function(e, u, v, value) { + g.addEdge(e, u, v, value); + }); + return g; +}; + +},{"./Digraph":32,"./Graph":33}],49:[function(require,module,exports){ +// Returns an array of all values for properties of **o**. +exports.values = function(o) { + var ks = Object.keys(o), + len = ks.length, + result = new Array(len), + i; + for (i = 0; i < len; ++i) { + result[i] = o[ks[i]]; + } + return result; +}; + +},{}],50:[function(require,module,exports){ +module.exports = '0.7.4'; + +},{}]},{},[1]) +;
\ No newline at end of file diff --git a/toolkit/devtools/webaudioeditor/models.js b/toolkit/devtools/webaudioeditor/models.js new file mode 100644 index 000000000..aa1975f58 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/models.js @@ -0,0 +1,312 @@ +/* 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"; + +// Import as different name `coreEmit`, so we don't conflict +// with the global `window` listener itself. +const { emit: coreEmit } = require("sdk/event/core"); + +/** + * Representational wrapper around AudioNodeActors. Adding and destroying + * AudioNodes should be performed through the AudioNodes collection. + * + * Events: + * - `connect`: node, destinationNode, parameter + * - `disconnect`: node + */ +const AudioNodeModel = Class({ + extends: EventTarget, + + // Will be added via AudioNodes `add` + collection: null, + + initialize: function (actor) { + this.actor = actor; + this.id = actor.actorID; + this.connections = []; + }, + + /** + * After instantiating the AudioNodeModel, calling `setup` caches values + * from the actor onto the model. In this case, only the type of audio node. + * + * @return promise + */ + setup: Task.async(function* () { + yield this.getType(); + + // Query bypass status on start up + this._bypassed = yield this.isBypassed(); + + // Store whether or not this node is bypassable in the first place + this.bypassable = !AUDIO_NODE_DEFINITION[this.type].unbypassable; + }), + + /** + * A proxy for the underlying AudioNodeActor to fetch its type + * and subsequently assign the type to the instance. + * + * @return Promise->String + */ + getType: Task.async(function* () { + this.type = yield this.actor.getType(); + return this.type; + }), + + /** + * Stores connection data inside this instance of this audio node connecting + * to another node (destination). If connecting to another node's AudioParam, + * the second argument (param) must be populated with a string. + * + * Connecting nodes is idempotent. Upon new connection, emits "connect" event. + * + * @param AudioNodeModel destination + * @param String param + */ + connect: function (destination, param) { + let edge = findWhere(this.connections, { destination: destination.id, param: param }); + + if (!edge) { + this.connections.push({ source: this.id, destination: destination.id, param: param }); + coreEmit(this, "connect", this, destination, param); + } + }, + + /** + * Clears out all internal connection data. Emits "disconnect" event. + */ + disconnect: function () { + this.connections.length = 0; + coreEmit(this, "disconnect", this); + }, + + /** + * Gets the bypass status of the audio node. + * + * @return Promise->Boolean + */ + isBypassed: function () { + return this.actor.isBypassed(); + }, + + /** + * Sets the bypass value of an AudioNode. + * + * @param Boolean enable + * @return Promise + */ + bypass: function (enable) { + this._bypassed = enable; + return this.actor.bypass(enable).then(() => coreEmit(this, "bypass", this, enable)); + }, + + /** + * Returns a promise that resolves to an array of objects containing + * both a `param` name property and a `value` property. + * + * @return Promise->Object + */ + getParams: function () { + return this.actor.getParams(); + }, + + /** + * Returns a promise that resolves to an object containing an + * array of event information and an array of automation data. + * + * @param String paramName + * @return Promise->Array + */ + getAutomationData: function (paramName) { + return this.actor.getAutomationData(paramName); + }, + + /** + * Takes a `dagreD3.Digraph` object and adds this node to + * the graph to be rendered. + * + * @param dagreD3.Digraph + */ + addToGraph: function (graph) { + graph.addNode(this.id, { + type: this.type, + label: this.type.replace(/Node$/, ""), + id: this.id, + bypassed: this._bypassed + }); + }, + + /** + * Takes a `dagreD3.Digraph` object and adds edges to + * the graph to be rendered. Separate from `addToGraph`, + * as while we depend on D3/Dagre's constraints, we cannot + * add edges for nodes that have not yet been added to the graph. + * + * @param dagreD3.Digraph + */ + addEdgesToGraph: function (graph) { + for (let edge of this.connections) { + let options = { + source: this.id, + target: edge.destination + }; + + // Only add `label` if `param` specified, as this is an AudioParam + // connection then. `label` adds the magic to render with dagre-d3, + // and `param` is just more explicitly the param, ignoring + // implementation details. + if (edge.param) { + options.label = options.param = edge.param; + } + + graph.addEdge(null, this.id, edge.destination, options); + } + } +}); + + +/** + * Constructor for a Collection of `AudioNodeModel` models. + * + * Events: + * - `add`: node + * - `remove`: node + * - `connect`: node, destinationNode, parameter + * - `disconnect`: node + */ +const AudioNodesCollection = Class({ + extends: EventTarget, + + model: AudioNodeModel, + + initialize: function () { + this.models = new Set(); + this._onModelEvent = this._onModelEvent.bind(this); + }, + + /** + * Iterates over all models within the collection, calling `fn` with the + * model as the first argument. + * + * @param Function fn + */ + forEach: function (fn) { + this.models.forEach(fn); + }, + + /** + * Creates a new AudioNodeModel, passing through arguments into the AudioNodeModel + * constructor, and adds the model to the internal collection store of this + * instance. + * + * Also calls `setup` on the model itself, and sets up event piping, so that + * events emitted on each model propagate to the collection itself. + * + * Emits "add" event on instance when completed. + * + * @param Object obj + * @return Promise->AudioNodeModel + */ + add: Task.async(function* (obj) { + let node = new this.model(obj); + node.collection = this; + yield node.setup(); + + this.models.add(node); + + node.on("*", this._onModelEvent); + coreEmit(this, "add", node); + return node; + }), + + /** + * Removes an AudioNodeModel from the internal collection. Calls `delete` method + * on the model, and emits "remove" on this instance. + * + * @param AudioNodeModel node + */ + remove: function (node) { + this.models.delete(node); + coreEmit(this, "remove", node); + }, + + /** + * Empties out the internal collection of all AudioNodeModels. + */ + reset: function () { + this.models.clear(); + }, + + /** + * Takes an `id` from an AudioNodeModel and returns the corresponding + * AudioNodeModel within the collection that matches that id. Returns `null` + * if not found. + * + * @param Number id + * @return AudioNodeModel|null + */ + get: function (id) { + return findWhere(this.models, { id: id }); + }, + + /** + * Returns the count for how many models are a part of this collection. + * + * @return Number + */ + get length() { + return this.models.size; + }, + + /** + * Returns detailed information about the collection. used during tests + * to query state. Returns an object with information on node count, + * how many edges are within the data graph, as well as how many of those edges + * are for AudioParams. + * + * @return Object + */ + getInfo: function () { + let info = { + nodes: this.length, + edges: 0, + paramEdges: 0 + }; + + this.models.forEach(node => { + let paramEdgeCount = node.connections.filter(edge => edge.param).length; + info.edges += node.connections.length - paramEdgeCount; + info.paramEdges += paramEdgeCount; + }); + return info; + }, + + /** + * Adds all nodes within the collection to the passed in graph, + * as well as their corresponding edges. + * + * @param dagreD3.Digraph + */ + populateGraph: function (graph) { + this.models.forEach(node => node.addToGraph(graph)); + this.models.forEach(node => node.addEdgesToGraph(graph)); + }, + + /** + * Called when a stored model emits any event. Used to manage + * event propagation, or listening to model events to react, like + * removing a model from the collection when it's destroyed. + */ + _onModelEvent: function (eventName, node, ...args) { + if (eventName === "remove") { + // If a `remove` event from the model, remove it + // from the collection, and let the method handle the emitting on + // the collection + this.remove(node); + } else { + // Pipe the event to the collection + coreEmit(this, eventName, node, ...args); + } + } +}); diff --git a/toolkit/devtools/webaudioeditor/moz.build b/toolkit/devtools/webaudioeditor/moz.build new file mode 100644 index 000000000..7fbea6cc9 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/moz.build @@ -0,0 +1,10 @@ +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES.devtools.webaudioeditor += [ + 'panel.js' +] + +BROWSER_CHROME_MANIFESTS += ['test/browser.ini'] diff --git a/toolkit/devtools/webaudioeditor/panel.js b/toolkit/devtools/webaudioeditor/panel.js new file mode 100644 index 000000000..c2da91a9c --- /dev/null +++ b/toolkit/devtools/webaudioeditor/panel.js @@ -0,0 +1,69 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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 { Cc, Ci, Cu, Cr } = require("chrome"); +const EventEmitter = require("devtools/toolkit/event-emitter"); +const { WebAudioFront } = require("devtools/server/actors/webaudio"); +let Promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise; + +function WebAudioEditorPanel (iframeWindow, toolbox) { + this.panelWin = iframeWindow; + this._toolbox = toolbox; + this._destroyer = null; + + EventEmitter.decorate(this); +} + +exports.WebAudioEditorPanel = WebAudioEditorPanel; + +WebAudioEditorPanel.prototype = { + open: function() { + let targetPromise; + + // Local debugging needs to make the target remote. + if (!this.target.isRemote) { + targetPromise = this.target.makeRemote(); + } else { + targetPromise = Promise.resolve(this.target); + } + + return targetPromise + .then(() => { + this.panelWin.gToolbox = this._toolbox; + this.panelWin.gTarget = this.target; + + this.panelWin.gFront = new WebAudioFront(this.target.client, this.target.form); + return this.panelWin.startupWebAudioEditor(); + }) + .then(() => { + this.isReady = true; + this.emit("ready"); + return this; + }) + .then(null, function onError(aReason) { + Cu.reportError("WebAudioEditorPanel open failed. " + + aReason.error + ": " + aReason.message); + }); + }, + + // DevToolPanel API + + get target() this._toolbox.target, + + destroy: function() { + // Make sure this panel is not already destroyed. + if (this._destroyer) { + return this._destroyer; + } + + return this._destroyer = this.panelWin.shutdownWebAudioEditor().then(() => { + // Destroy front to ensure packet handler is removed from client + this.panelWin.gFront.destroy(); + this.emit("destroyed"); + }); + } +}; diff --git a/toolkit/devtools/webaudioeditor/test/440hz_sine.ogg b/toolkit/devtools/webaudioeditor/test/440hz_sine.ogg Binary files differnew file mode 100644 index 000000000..bd84564e2 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/440hz_sine.ogg diff --git a/toolkit/devtools/webaudioeditor/test/browser.ini b/toolkit/devtools/webaudioeditor/test/browser.ini new file mode 100644 index 000000000..f58bdf623 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser.ini @@ -0,0 +1,72 @@ +[DEFAULT] +subsuite = devtools +support-files = + doc_simple-context.html + doc_complex-context.html + doc_simple-node-creation.html + doc_buffer-and-array.html + doc_media-node-creation.html + doc_destroy-nodes.html + doc_connect-param.html + doc_connect-multi-param.html + doc_iframe-context.html + doc_automation.html + doc_bug_1125817.html + doc_bug_1130901.html + doc_bug_1112378.html + 440hz_sine.ogg + head.js + +[browser_audionode-actor-get-param-flags.js] +[browser_audionode-actor-get-params-01.js] +[browser_audionode-actor-get-params-02.js] +[browser_audionode-actor-get-set-param.js] +[browser_audionode-actor-get-type.js] +[browser_audionode-actor-is-source.js] +[browser_audionode-actor-bypass.js] +[browser_audionode-actor-connectnode-disconnect.js] +[browser_audionode-actor-connectparam.js] +skip-if = true # bug 1092571 +# [browser_audionode-actor-add-automation-event.js] bug 1134036 +# [browser_audionode-actor-get-automation-data-01.js] bug 1134036 +# [browser_audionode-actor-get-automation-data-02.js] bug 1134036 +# [browser_audionode-actor-get-automation-data-03.js] bug 1134036 +[browser_callwatcher-01.js] +[browser_callwatcher-02.js] +[browser_webaudio-actor-simple.js] +[browser_webaudio-actor-destroy-node.js] +[browser_webaudio-actor-connect-param.js] +# [browser_webaudio-actor-automation-event.js] bug 1134036 + +# [browser_wa_automation-view-01.js] bug 1134036 +# [browser_wa_automation-view-02.js] bug 1134036 +[browser_wa_controller-01.js] +[browser_wa_destroy-node-01.js] +[browser_wa_first-run.js] +[browser_wa_graph-click.js] +[browser_wa_graph-markers.js] +[browser_wa_graph-render-01.js] +[browser_wa_graph-render-02.js] +[browser_wa_graph-render-03.js] +[browser_wa_graph-render-04.js] +[browser_wa_graph-render-05.js] +skip-if = true # bug 1092571 +[browser_wa_graph-selected.js] +[browser_wa_graph-zoom.js] +[browser_wa_inspector.js] +[browser_wa_inspector-toggle.js] +[browser_wa_inspector-width.js] +[browser_wa_inspector-bypass-01.js] +[browser_wa_navigate.js] +[browser_wa_properties-view.js] +[browser_wa_properties-view-edit-01.js] +skip-if = true # bug 1010423 +[browser_wa_properties-view-edit-02.js] +skip-if = true # bug 1010423 +[browser_wa_properties-view-media-nodes.js] +[browser_wa_properties-view-params.js] +[browser_wa_properties-view-params-objects.js] +[browser_wa_reset-01.js] +[browser_wa_reset-02.js] +[browser_wa_reset-03.js] +[browser_wa_reset-04.js] diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-add-automation-event.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-add-automation-event.js new file mode 100644 index 000000000..0e96b83ab --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-add-automation-event.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#addAutomationEvent(); + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_CONTEXT_URL); + let [_, [destNode, oscNode, gainNode]] = yield Promise.all([ + front.setup({ reload: true }), + get3(front, "create-node") + ]); + let count = 0; + let counter = () => count++; + front.on("automation-event", counter); + + let t0 = 0, t1 = 0.1, t2 = 0.2, t3 = 0.3, t4 = 0.4, t5 = 0.6, t6 = 0.7, t7 = 1; + let curve = [-1, 0, 1]; + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [0.2, t0]); + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [0.3, t1]); + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [0.4, t2]); + yield oscNode.addAutomationEvent("frequency", "linearRampToValueAtTime", [1, t3]); + yield oscNode.addAutomationEvent("frequency", "linearRampToValueAtTime", [0.15, t4]); + yield oscNode.addAutomationEvent("frequency", "exponentialRampToValueAtTime", [0.75, t5]); + yield oscNode.addAutomationEvent("frequency", "exponentialRampToValueAtTime", [0.5, t6]); + yield oscNode.addAutomationEvent("frequency", "setValueCurveAtTime", [curve, t7, t7 - t6]); + yield oscNode.addAutomationEvent("frequency", "setTargetAtTime", [20, 2, 5]); + + ok(true, "successfully set automation events for valid automation events"); + + try { + yield oscNode.addAutomationEvent("frequency", "notAMethod", 20, 2, 5); + ok(false, "non-automation methods should not be successful"); + } catch (e) { + ok(/invalid/.test(e.message), "AudioNode:addAutomationEvent fails for invalid automation methods"); + } + + try { + yield oscNode.addAutomationEvent("invalidparam", "setValueAtTime", 0.2, t0); + ok(false, "automating non-AudioParams should not be successful"); + } catch (e) { + ok(/invalid/.test(e.message), "AudioNode:addAutomationEvent fails for a non AudioParam"); + } + + front.off("automation-event", counter); + + is(count, 9, + "when calling `addAutomationEvent`, the WebAudioActor should still fire `automation-event`."); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-bypass.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-bypass.js new file mode 100644 index 000000000..57ce888ef --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-bypass.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#bypass(), AudioNode#isBypassed() + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_CONTEXT_URL); + let [_, [destNode, oscNode, gainNode]] = yield Promise.all([ + front.setup({ reload: true }), + get3(front, "create-node") + ]); + + is((yield gainNode.isBypassed()), false, "Nodes start off unbypassed."); + + info("Calling node#bypass(true)"); + let isBypassed = yield gainNode.bypass(true); + + is(isBypassed, true, "node.bypass(true) resolves to true"); + is((yield gainNode.isBypassed()), true, "Node is now bypassed."); + + info("Calling node#bypass(false)"); + isBypassed = yield gainNode.bypass(false); + + is(isBypassed, false, "node.bypass(false) resolves to false"); + is((yield gainNode.isBypassed()), false, "Node back to being unbypassed."); + + info("Calling node#bypass(true) on unbypassable node"); + isBypassed = yield destNode.bypass(true); + + is(isBypassed, false, "node.bypass(true) resolves to false for unbypassable node"); + is((yield gainNode.isBypassed()), false, "Unbypassable node is unaffect"); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-connectnode-disconnect.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-connectnode-disconnect.js new file mode 100644 index 000000000..f0e437d86 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-connectnode-disconnect.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that AudioNodeActor#connectNode() and AudioNodeActor#disconnect() work. + * Uses the editor front as the actors do not retain connect state. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + let [dest, osc, gain] = actors; + + info("Disconnecting oscillator..."); + osc.disconnect(); + yield Promise.all([ + waitForGraphRendered(panelWin, 3, 1), + once(gAudioNodes, "disconnect") + ]); + ok(true, "Oscillator disconnected, event emitted."); + + info("Reconnecting oscillator..."); + osc.connectNode(gain); + yield Promise.all([ + waitForGraphRendered(panelWin, 3, 2), + once(gAudioNodes, "connect") + ]); + ok(true, "Oscillator reconnected."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-connectparam.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-connectparam.js new file mode 100644 index 000000000..df1f6e0b3 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-connectparam.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that AudioNodeActor#connectParam() work. + * Uses the editor front as the actors do not retain connect state. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + let [dest, osc, gain] = actors; + + yield osc.disconnect(); + + osc.connectParam(gain, "gain"); + yield Promise.all([ + waitForGraphRendered(panelWin, 3, 1, 1), + once(gAudioNodes, "connect") + ]); + ok(true, "Oscillator connect to Gain's Gain AudioParam, event emitted."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-01.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-01.js new file mode 100644 index 000000000..8bb35d270 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-01.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#addAutomationEvent() checking automation values, also using + * a curve as the last event to check duration spread. + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_CONTEXT_URL); + let [_, [destNode, oscNode, gainNode]] = yield Promise.all([ + front.setup({ reload: true }), + get3(front, "create-node") + ]); + + let t0 = 0, t1 = 0.1, t2 = 0.2, t3 = 0.3, t4 = 0.4, t5 = 0.6, t6 = 0.7, t7 = 1; + let curve = [-1, 0, 1]; + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [0.2, t0]); + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [0.3, t1]); + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [0.4, t2]); + yield oscNode.addAutomationEvent("frequency", "linearRampToValueAtTime", [1, t3]); + yield oscNode.addAutomationEvent("frequency", "linearRampToValueAtTime", [0.15, t4]); + yield oscNode.addAutomationEvent("frequency", "exponentialRampToValueAtTime", [0.75, t5]); + yield oscNode.addAutomationEvent("frequency", "exponentialRampToValueAtTime", [0.05, t6]); + // End with a curve here so we can get proper results on the last event (which takes into account + // duration) + yield oscNode.addAutomationEvent("frequency", "setValueCurveAtTime", [curve, t6, t7 - t6]); + + let { events, values } = yield oscNode.getAutomationData("frequency"); + + is(events.length, 8, "8 recorded events returned."); + is(values.length, 2000, "2000 value points returned."); + + checkAutomationValue(values, 0.05, 0.2); + checkAutomationValue(values, 0.1, 0.3); + checkAutomationValue(values, 0.15, 0.3); + checkAutomationValue(values, 0.2, 0.4); + checkAutomationValue(values, 0.25, 0.7); + checkAutomationValue(values, 0.3, 1); + checkAutomationValue(values, 0.35, 0.575); + checkAutomationValue(values, 0.4, 0.15); + checkAutomationValue(values, 0.45, 0.15 * Math.pow(0.75/0.15,0.05/0.2)); + checkAutomationValue(values, 0.5, 0.15 * Math.pow(0.75/0.15,0.5)); + checkAutomationValue(values, 0.55, 0.15 * Math.pow(0.75/0.15,0.15/0.2)); + checkAutomationValue(values, 0.6, 0.75); + checkAutomationValue(values, 0.65, 0.75 * Math.pow(0.05/0.75, 0.5)); + checkAutomationValue(values, 0.705, -1); // Increase this time a bit to prevent off by the previous exponential amount + checkAutomationValue(values, 0.8, 0); + checkAutomationValue(values, 0.9, 1); + checkAutomationValue(values, 1, 1); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-02.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-02.js new file mode 100644 index 000000000..0ace882cd --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-02.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#addAutomationEvent() when automation series ends with + * `setTargetAtTime`, which approaches its target to infinity. + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_CONTEXT_URL); + let [_, [destNode, oscNode, gainNode]] = yield Promise.all([ + front.setup({ reload: true }), + get3(front, "create-node") + ]); + + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [300, 0.1]); + yield oscNode.addAutomationEvent("frequency", "linearRampToValueAtTime", [500, 0.4]); + yield oscNode.addAutomationEvent("frequency", "exponentialRampToValueAtTime", [200, 0.6]); + // End with a setTargetAtTime event, as the target approaches infinity, which will + // give us more points to render than the default 2000 + yield oscNode.addAutomationEvent("frequency", "setTargetAtTime", [1000, 2, 0.5]); + + var { events, values } = yield oscNode.getAutomationData("frequency"); + + is(events.length, 4, "4 recorded events returned."); + is(values.length, 4000, "4000 value points returned when ending with exponentiall approaching automator."); + + checkAutomationValue(values, 2.01, 215.055) + checkAutomationValue(values, 2.1, 345.930); + checkAutomationValue(values, 3, 891.601); + checkAutomationValue(values, 5, 998.01); + + // Refetch the automation data to ensure it recalculates correctly (bug 1118071) + var { events, values } = yield oscNode.getAutomationData("frequency"); + + checkAutomationValue(values, 2.01, 215.055) + checkAutomationValue(values, 2.1, 345.930); + checkAutomationValue(values, 3, 891.601); + checkAutomationValue(values, 5, 998.01); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-03.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-03.js new file mode 100644 index 000000000..13fb287b3 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-automation-data-03.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that `cancelScheduledEvents` clears out events on and after + * its argument. + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_CONTEXT_URL); + let [_, [destNode, oscNode, gainNode]] = yield Promise.all([ + front.setup({ reload: true }), + get3(front, "create-node") + ]); + + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [300, 0]); + yield oscNode.addAutomationEvent("frequency", "linearRampToValueAtTime", [500, 0.9]); + yield oscNode.addAutomationEvent("frequency", "setValueAtTime", [700, 1]); + yield oscNode.addAutomationEvent("frequency", "exponentialRampToValueAtTime", [1000, 2]); + yield oscNode.addAutomationEvent("frequency", "cancelScheduledValues", [1]); + + var { events, values } = yield oscNode.getAutomationData("frequency"); + + is(events.length, 2, "2 recorded events returned."); + is(values.length, 2000, "2000 value points returned"); + + checkAutomationValue(values, 0, 300); + checkAutomationValue(values, 0.5, 411.15); + checkAutomationValue(values, 0.9, 499.9); + checkAutomationValue(values, 1, 499.9); + checkAutomationValue(values, 2, 499.9); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-param-flags.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-param-flags.js new file mode 100644 index 000000000..c7ba798af --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-param-flags.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#getParamFlags() + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_NODES_URL); + let [_, nodes] = yield Promise.all([ + front.setup({ reload: true }), + getN(front, "create-node", 15) + ]); + + let allNodeParams = yield Promise.all(nodes.map(node => node.getParams())); + let nodeTypes = [ + "AudioDestinationNode", + "AudioBufferSourceNode", "ScriptProcessorNode", "AnalyserNode", "GainNode", + "DelayNode", "BiquadFilterNode", "WaveShaperNode", "PannerNode", "ConvolverNode", + "ChannelSplitterNode", "ChannelMergerNode", "DynamicsCompressorNode", "OscillatorNode", + "StereoPannerNode" + ]; + + // For some reason nodeTypes.forEach and params.forEach fail here so we use + // simple for loops. + for (let i = 0; i < nodeTypes.length; i++) { + let type = nodeTypes[i]; + let params = allNodeParams[i]; + + for (let {param, value, flags} of params) { + let testFlags = yield nodes[i].getParamFlags(param); + ok(typeof testFlags === "object", type + " has flags from #getParamFlags(" + param + ")"); + + if (param === "buffer") { + is(flags.Buffer, true, "`buffer` params have Buffer flag"); + } + else if (param === "bufferSize" || param === "frequencyBinCount") { + is(flags.readonly, true, param + " is readonly"); + } + else if (param === "curve") { + is(flags["Float32Array"], true, "`curve` param has Float32Array flag"); + } + } + } + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-params-01.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-params-01.js new file mode 100644 index 000000000..b04ae2463 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-params-01.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#getParams() + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_NODES_URL); + let [_, nodes] = yield Promise.all([ + front.setup({ reload: true }), + getN(front, "create-node", 15) + ]); + + let allNodeParams = yield Promise.all(nodes.map(node => node.getParams())); + let nodeTypes = [ + "AudioDestinationNode", + "AudioBufferSourceNode", "ScriptProcessorNode", "AnalyserNode", "GainNode", + "DelayNode", "BiquadFilterNode", "WaveShaperNode", "PannerNode", "ConvolverNode", + "ChannelSplitterNode", "ChannelMergerNode", "DynamicsCompressorNode", "OscillatorNode", + "StereoPannerNode" + ]; + + nodeTypes.forEach((type, i) => { + let params = allNodeParams[i]; + params.forEach(({param, value, flags}) => { + ok(param in NODE_DEFAULT_VALUES[type], "expected parameter for " + type); + + ok(typeof flags === "object", type + " has a flags object"); + + if (param === "buffer") { + is(flags.Buffer, true, "`buffer` params have Buffer flag"); + } + else if (param === "bufferSize" || param === "frequencyBinCount") { + is(flags.readonly, true, param + " is readonly"); + } + else if (param === "curve") { + is(flags["Float32Array"], true, "`curve` param has Float32Array flag"); + } + }); + }); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-params-02.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-params-02.js new file mode 100644 index 000000000..ce2eca630 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-params-02.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that default properties are returned with the correct type + * from the AudioNode actors. + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_NODES_URL); + let [_, nodes] = yield Promise.all([ + front.setup({ reload: true }), + getN(front, "create-node", 15) + ]); + + let allParams = yield Promise.all(nodes.map(node => node.getParams())); + let types = [ + "AudioDestinationNode", "AudioBufferSourceNode", "ScriptProcessorNode", + "AnalyserNode", "GainNode", "DelayNode", "BiquadFilterNode", "WaveShaperNode", + "PannerNode", "ConvolverNode", "ChannelSplitterNode", "ChannelMergerNode", + "DynamicsCompressorNode", "OscillatorNode", "StereoPannerNode" + ]; + + allParams.forEach((params, i) => { + compare(params, NODE_DEFAULT_VALUES[types[i]], types[i]); + }); + + yield removeTab(target.tab); +}); + +function compare (actual, expected, type) { + actual.forEach(({ value, param }) => { + value = getGripValue(value); + if (typeof expected[param] === "function") { + ok(expected[param](value), type + " has a passing value for " + param); + } + else { + ise(value, expected[param], type + " has correct default value and type for " + param); + } + }); + + is(actual.length, Object.keys(expected).length, + type + " has correct amount of properties."); +} diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-set-param.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-set-param.js new file mode 100644 index 000000000..4679159aa --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-set-param.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#getParam() / AudioNode#setParam() + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_CONTEXT_URL); + let [_, [destNode, oscNode, gainNode]] = yield Promise.all([ + front.setup({ reload: true }), + get3(front, "create-node") + ]); + + let freq = yield oscNode.getParam("frequency"); + info(typeof freq); + ise(freq, 440, "AudioNode:getParam correctly fetches AudioParam"); + + let type = yield oscNode.getParam("type"); + ise(type, "sine", "AudioNode:getParam correctly fetches non-AudioParam"); + + type = yield oscNode.getParam("not-a-valid-param"); + ok(type.type === "undefined", + "AudioNode:getParam correctly returns a grip value for `undefined` for an invalid param."); + + let resSuccess = yield oscNode.setParam("frequency", 220); + freq = yield oscNode.getParam("frequency"); + ise(freq, 220, "AudioNode:setParam correctly sets a `number` AudioParam"); + is(resSuccess, undefined, "AudioNode:setParam returns undefined for correctly set AudioParam"); + + resSuccess = yield oscNode.setParam("type", "square"); + type = yield oscNode.getParam("type"); + ise(type, "square", "AudioNode:setParam correctly sets a `string` non-AudioParam"); + is(resSuccess, undefined, "AudioNode:setParam returns undefined for correctly set AudioParam"); + + try { + yield oscNode.setParam("frequency", "hello"); + ok(false, "setParam with invalid types should throw"); + } catch (e) { + ok(/is not a finite floating-point/.test(e.message), "AudioNode:setParam returns error with correct message when attempting an invalid assignment"); + is(e.type, "TypeError", "AudioNode:setParam returns error with correct type when attempting an invalid assignment"); + freq = yield oscNode.getParam("frequency"); + ise(freq, 220, "AudioNode:setParam does not modify value when an error occurs"); + } + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-type.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-type.js new file mode 100644 index 000000000..94d31a6c3 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-get-type.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#getType() + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_NODES_URL); + let [_, nodes] = yield Promise.all([ + front.setup({ reload: true }), + getN(front, "create-node", 14) + ]); + + let actualTypes = yield Promise.all(nodes.map(node => node.getType())); + let expectedTypes = [ + "AudioDestinationNode", + "AudioBufferSourceNode", "ScriptProcessorNode", "AnalyserNode", "GainNode", + "DelayNode", "BiquadFilterNode", "WaveShaperNode", "PannerNode", "ConvolverNode", + "ChannelSplitterNode", "ChannelMergerNode", "DynamicsCompressorNode", "OscillatorNode" + ]; + + expectedTypes.forEach((type, i) => { + is(actualTypes[i], type, type + " successfully created with correct type"); + }); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-is-source.js b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-is-source.js new file mode 100644 index 000000000..13523fb36 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_audionode-actor-is-source.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test AudioNode#isSource() + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_NODES_URL); + let [_, nodes] = yield Promise.all([ + front.setup({ reload: true }), + getN(front, "create-node", 14) + ]); + + let actualTypes = yield Promise.all(nodes.map(node => node.getType())); + let isSourceResult = yield Promise.all(nodes.map(node => node.isSource())); + + actualTypes.forEach((type, i) => { + let shouldBeSource = type === "AudioBufferSourceNode" || type === "OscillatorNode"; + if (shouldBeSource) + is(isSourceResult[i], true, type + "'s isSource() yields into `true`"); + else + is(isSourceResult[i], false, type + "'s isSource() yields into `false`"); + }); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_callwatcher-01.js b/toolkit/devtools/webaudioeditor/test/browser_callwatcher-01.js new file mode 100644 index 000000000..a3dc801c6 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_callwatcher-01.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Bug 1130901 + * Tests to ensure that calling call/apply on methods wrapped + * via CallWatcher do not throw a security permissions error: + * "Error: Permission denied to access property 'call'" + */ + +const BUG_1130901_URL = EXAMPLE_URL + "doc_bug_1130901.html"; + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(BUG_1130901_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin; + + reload(target); + + yield waitForGraphRendered(panelWin, 3, 0); + + ok(true, "Successfully created a node from AudioContext via `call`."); + ok(true, "Successfully created a node from AudioContext via `apply`."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_callwatcher-02.js b/toolkit/devtools/webaudioeditor/test/browser_callwatcher-02.js new file mode 100644 index 000000000..8dc95e0c5 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_callwatcher-02.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Bug 1112378 + * Tests to ensure that errors called on wrapped functions via call-watcher + * correctly looks like the error comes from the content, not from within the devtools. + */ + +const BUG_1112378_URL = EXAMPLE_URL + "doc_bug_1112378.html"; + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(BUG_1112378_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin; + + loadFrameScripts(); + + reload(target); + + yield waitForGraphRendered(panelWin, 2, 0); + + let error = yield evalInDebuggee("throwError()"); + is(error.lineNumber, 21, "error has correct lineNumber"); + is(error.columnNumber, 11, "error has correct columnNumber"); + is(error.name, "TypeError", "error has correct name"); + is(error.message, "Argument 1 is not valid for any of the 2-argument overloads of AudioNode.connect.", "error has correct message"); + is(error.stringified, "TypeError: Argument 1 is not valid for any of the 2-argument overloads of AudioNode.connect.", "error is stringified correctly"); + ise(error.instanceof, true, "error is correctly an instanceof TypeError"); + is(error.fileName, "http://example.com/browser/browser/devtools/webaudioeditor/test/doc_bug_1112378.html", "error has correct fileName"); + + error = yield evalInDebuggee("throwDOMException()"); + is(error.lineNumber, 37, "exception has correct lineNumber"); + is(error.columnNumber, 0, "exception has correct columnNumber"); + is(error.code, 9, "exception has correct code"); + is(error.result, 2152923145, "exception has correct result"); + is(error.name, "NotSupportedError", "exception has correct name"); + is(error.message, "Operation is not supported", "exception has correct message"); + is(error.stringified, "NotSupportedError: Operation is not supported", "exception is stringified correctly"); + ise(error.instanceof, true, "exception is correctly an instance of DOMException"); + is(error.filename, "http://example.com/browser/browser/devtools/webaudioeditor/test/doc_bug_1112378.html", "exception has correct filename"); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_automation-view-01.js b/toolkit/devtools/webaudioeditor/test/browser_wa_automation-view-01.js new file mode 100644 index 000000000..59aaf2795 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_automation-view-01.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that automation view shows the correct view depending on if events + * or params exist. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(AUTOMATION_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + let $tabbox = $("#web-audio-editor-tabs"); + + // Oscillator node + click(panelWin, findGraphNode(panelWin, nodeIds[1])); + yield waitForInspectorRender(panelWin, EVENTS); + $tabbox.selectedIndex = 1; + + ok(isVisible($("#automation-graph-container")), "graph container should be visible"); + ok(isVisible($("#automation-content")), "automation content should be visible"); + ok(!isVisible($("#automation-no-events")), "no-events panel should not be visible"); + ok(!isVisible($("#automation-empty")), "empty panel should not be visible"); + + // Gain node + click(panelWin, findGraphNode(panelWin, nodeIds[2])); + yield waitForInspectorRender(panelWin, EVENTS); + $tabbox.selectedIndex = 1; + + ok(!isVisible($("#automation-graph-container")), "graph container should not be visible"); + ok(isVisible($("#automation-content")), "automation content should be visible"); + ok(isVisible($("#automation-no-events")), "no-events panel should be visible"); + ok(!isVisible($("#automation-empty")), "empty panel should not be visible"); + + // destination node + click(panelWin, findGraphNode(panelWin, nodeIds[0])); + yield waitForInspectorRender(panelWin, EVENTS); + $tabbox.selectedIndex = 1; + + ok(!isVisible($("#automation-graph-container")), "graph container should not be visible"); + ok(!isVisible($("#automation-content")), "automation content should not be visible"); + ok(!isVisible($("#automation-no-events")), "no-events panel should not be visible"); + ok(isVisible($("#automation-empty")), "empty panel should be visible"); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_automation-view-02.js b/toolkit/devtools/webaudioeditor/test/browser_wa_automation-view-02.js new file mode 100644 index 000000000..ecb9957ae --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_automation-view-02.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that automation view selects the first parameter by default and + * switching between AudioParam rerenders the graph. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(AUTOMATION_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, AutomationView } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + + // Oscillator node + click(panelWin, findGraphNode(panelWin, nodeIds[1])); + yield waitForInspectorRender(panelWin, EVENTS); + click(panelWin, $("#automation-tab")); + + ok(AutomationView._selectedParamName, "frequency", + "AutomatioView is set on 'frequency'"); + ok($(".automation-param-button[data-param='frequency']").getAttribute("selected"), + "frequency param should be selected on load"); + ok(!$(".automation-param-button[data-param='detune']").getAttribute("selected"), + "detune param should not be selected on load"); + ok(isVisible($("#automation-content")), "automation content should be visible"); + ok(isVisible($("#automation-graph-container")), "graph container should be visible"); + ok(!isVisible($("#automation-no-events")), "no-events panel should not be visible"); + + click(panelWin, $(".automation-param-button[data-param='detune']")); + yield once(panelWin, EVENTS.UI_AUTOMATION_TAB_RENDERED); + + ok(true, "automation tab rerendered"); + + ok(AutomationView._selectedParamName, "detune", + "AutomatioView is set on 'detune'"); + ok(!$(".automation-param-button[data-param='frequency']").getAttribute("selected"), + "frequency param should not be selected after clicking detune"); + ok($(".automation-param-button[data-param='detune']").getAttribute("selected"), + "detune param should be selected after clicking detune"); + ok(isVisible($("#automation-content")), "automation content should be visible"); + ok(!isVisible($("#automation-graph-container")), "graph container should not be visible"); + ok(isVisible($("#automation-no-events")), "no-events panel should be visible"); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_controller-01.js b/toolkit/devtools/webaudioeditor/test/browser_wa_controller-01.js new file mode 100644 index 000000000..3fb70b1ed --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_controller-01.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Bug 1125817 + * Tests to ensure that disconnecting a node immediately + * after creating it does not fail. + */ + +const BUG_1125817_URL = EXAMPLE_URL + "doc_bug_1125817.html"; + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(BUG_1125817_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + once(gAudioNodes, "add", 2), + once(gAudioNodes, "disconnect") + ]); + + ok(true, "Successfully disconnected a just-created node."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_destroy-node-01.js b/toolkit/devtools/webaudioeditor/test/browser_wa_destroy-node-01.js new file mode 100644 index 000000000..ca62a581d --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_destroy-node-01.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the destruction node event is fired and that the nodes are no + * longer stored internally in the tool, that the graph is updated properly, and + * that selecting a soon-to-be dead node clears the inspector. + * + * All done in one test since this test takes a few seconds to clear GC. + */ + +add_task(function*() { + // Use a longer timeout as garbage collection event + // can be unpredictable. + requestLongerTimeout(2); + + let { target, panel } = yield initWebAudioEditor(DESTROY_NODES_URL); + let { panelWin } = panel; + let { gFront, $, $$, gAudioNodes } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + let destroyed = getN(gAudioNodes, "remove", 10); + + forceCC(); + + let [created] = yield Promise.all([ + getNSpread(gAudioNodes, "add", 13), + waitForGraphRendered(panelWin, 13, 2) + ]); + + // Flatten arrays of event arguments and take the first (AudioNodeModel) + // and get its ID. + let actorIDs = created.map(ev => ev[0].id); + + // Click a soon-to-be dead buffer node + yield clickGraphNode(panelWin, actorIDs[5]); + + forceCC(); + + // Wait for destruction and graph to re-render + yield Promise.all([destroyed, waitForGraphRendered(panelWin, 3, 2)]); + + // Test internal storage + is(panelWin.gAudioNodes.length, 3, "All nodes should be GC'd except one gain, osc and dest node."); + + // Test graph rendering + ok(findGraphNode(panelWin, actorIDs[0]), "dest should be in graph"); + ok(findGraphNode(panelWin, actorIDs[1]), "osc should be in graph"); + ok(findGraphNode(panelWin, actorIDs[2]), "gain should be in graph"); + + let { nodes, edges } = countGraphObjects(panelWin); + + is(nodes, 3, "Only 3 nodes rendered in graph."); + is(edges, 2, "Only 2 edges rendered in graph."); + + // Test that the inspector reset to no node selected + ok(isVisible($("#web-audio-editor-details-pane-empty")), + "InspectorView empty message should show if the currently selected node gets collected."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_first-run.js b/toolkit/devtools/webaudioeditor/test/browser_wa_first-run.js new file mode 100644 index 000000000..5229b0923 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_first-run.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/////////////////// +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("Error: Connection closed"); + +/** + * Tests that the reloading/onContentLoaded hooks work. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { gFront, $ } = panel.panelWin; + + is($("#reload-notice").hidden, false, + "The 'reload this page' notice should initially be visible."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should initially be hidden."); + is($("#content").hidden, true, + "The tool's content should initially be hidden."); + + let navigating = once(target, "will-navigate"); + let started = once(gFront, "start-context"); + + reload(target); + + yield navigating; + + is($("#reload-notice").hidden, true, + "The 'reload this page' notice should be hidden when navigating."); + is($("#waiting-notice").hidden, false, + "The 'waiting for an audio context' notice should be visible when navigating."); + is($("#content").hidden, true, + "The tool's content should still be hidden."); + + yield started; + + is($("#reload-notice").hidden, true, + "The 'reload this page' notice should be hidden after context found."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should be hidden after context found."); + is($("#content").hidden, false, + "The tool's content should not be hidden anymore."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-click.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-click.js new file mode 100644 index 000000000..d14d3e027 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-click.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the clicking on a node in the GraphView opens and sets + * the correct node in the InspectorView + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(COMPLEX_CONTEXT_URL); + let panelWin = panel.panelWin; + let { gFront, $, $$, InspectorView } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors, _] = yield Promise.all([ + getN(gFront, "create-node", 8), + waitForGraphRendered(panel.panelWin, 8, 8) + ]); + + let nodeIds = actors.map(actor => actor.actorID); + + ok(!InspectorView.isVisible(), "InspectorView hidden on start."); + + yield clickGraphNode(panelWin, nodeIds[1], true); + + ok(InspectorView.isVisible(), "InspectorView visible after selecting a node."); + is(InspectorView.getCurrentAudioNode().id, nodeIds[1], "InspectorView has correct node set."); + + yield clickGraphNode(panelWin, nodeIds[2]); + + ok(InspectorView.isVisible(), "InspectorView still visible after selecting another node."); + is(InspectorView.getCurrentAudioNode().id, nodeIds[2], "InspectorView has correct node set on second node."); + + yield clickGraphNode(panelWin, nodeIds[2]); + is(InspectorView.getCurrentAudioNode().id, nodeIds[2], "Clicking the same node again works (idempotent)."); + + yield clickGraphNode(panelWin, $("rect", findGraphNode(panelWin, nodeIds[3]))); + is(InspectorView.getCurrentAudioNode().id, nodeIds[3], "Clicking on a <rect> works as expected."); + + yield clickGraphNode(panelWin, $("tspan", findGraphNode(panelWin, nodeIds[4]))); + is(InspectorView.getCurrentAudioNode().id, nodeIds[4], "Clicking on a <tspan> works as expected."); + + ok(InspectorView.isVisible(), + "InspectorView still visible after several nodes have been clicked."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-markers.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-markers.js new file mode 100644 index 000000000..684561161 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-markers.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the SVG marker styling is updated when devtools theme changes. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, MARKER_STYLING } = panelWin; + + let currentTheme = Services.prefs.getCharPref("devtools.theme"); + + ok(MARKER_STYLING.light, "Marker styling exists for light theme."); + ok(MARKER_STYLING.dark, "Marker styling exists for dark theme."); + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + is(getFill($("#arrowhead")), MARKER_STYLING[currentTheme], + "marker initially matches theme."); + + // Switch to light + setTheme("light"); + is(getFill($("#arrowhead")), MARKER_STYLING.light, + "marker styling matches light theme on change."); + + // Switch to dark + setTheme("dark"); + is(getFill($("#arrowhead")), MARKER_STYLING.dark, + "marker styling matches dark theme on change."); + + // Switch to dark again + setTheme("dark"); + is(getFill($("#arrowhead")), MARKER_STYLING.dark, + "marker styling remains dark."); + + // Switch to back to light again + setTheme("light"); + is(getFill($("#arrowhead")), MARKER_STYLING.light, + "marker styling switches back to light once again."); + + yield teardown(target); +}); + +/** + * Returns a hex value found in styling for an element. So parses + * <marker style="fill: #abcdef"> and returns "#abcdef" + */ +function getFill (el) { + return el.getAttribute("style").match(/(#.*)$/)[1]; +} + +/** + * Mimics selecting the theme selector in the toolbox; + * sets the preference and emits an event on gDevTools to trigger + * the themeing. + */ +function setTheme (newTheme) { + let oldTheme = Services.prefs.getCharPref("devtools.theme"); + info("Setting `devtools.theme` to \"" + newTheme + "\""); + Services.prefs.setCharPref("devtools.theme", newTheme); + gDevTools.emit("pref-changed", { + pref: "devtools.theme", + newValue: newTheme, + oldValue: oldTheme + }); +} diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-01.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-01.js new file mode 100644 index 000000000..fdbe1ce5d --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-01.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that SVG nodes and edges were created for the Graph View. + */ + +let connectCount = 0; + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + gAudioNodes.on("connect", onConnectNode); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + let [destId, oscId, gainId] = actors.map(actor => actor.actorID); + + ok(findGraphNode(panelWin, oscId).classList.contains("type-OscillatorNode"), "found OscillatorNode with class"); + ok(findGraphNode(panelWin, gainId).classList.contains("type-GainNode"), "found GainNode with class"); + ok(findGraphNode(panelWin, destId).classList.contains("type-AudioDestinationNode"), "found AudioDestinationNode with class"); + is(findGraphEdge(panelWin, oscId, gainId).toString(), "[object SVGGElement]", "found edge for osc -> gain"); + is(findGraphEdge(panelWin, gainId, destId).toString(), "[object SVGGElement]", "found edge for gain -> dest"); + + yield wait(1000); + + is(connectCount, 2, "Only two node connect events should be fired."); + + gAudioNodes.off("connect", onConnectNode); + + yield teardown(target); +}); + +function onConnectNode () { + ++connectCount; +} diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-02.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-02.js new file mode 100644 index 000000000..825ab76b9 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-02.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests more edge rendering for complex graphs. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(COMPLEX_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$ } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 8), + waitForGraphRendered(panelWin, 8, 8) + ]); + + let nodeIDs = actors.map(actor => actor.actorID); + + let types = ["AudioDestinationNode", "OscillatorNode", "GainNode", "ScriptProcessorNode", + "OscillatorNode", "GainNode", "AudioBufferSourceNode", "BiquadFilterNode"]; + + + types.forEach((type, i) => { + ok(findGraphNode(panelWin, nodeIDs[i]).classList.contains("type-" + type), "found " + type + " with class"); + }); + + let edges = [ + [1, 2, "osc1 -> gain1"], + [1, 3, "osc1 -> proc"], + [2, 0, "gain1 -> dest"], + [4, 5, "osc2 -> gain2"], + [5, 0, "gain2 -> dest"], + [6, 7, "buf -> filter"], + [4, 7, "osc2 -> filter"], + [7, 0, "filter -> dest"], + ]; + + edges.forEach(([source, target, msg], i) => { + is(findGraphEdge(panelWin, nodeIDs[source], nodeIDs[target]).toString(), "[object SVGGElement]", + "found edge for " + msg); + }); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-03.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-03.js new file mode 100644 index 000000000..4486483fa --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-03.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests to ensure that selected nodes stay selected on graph redraw. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 3), + waitForGraphRendered(panelWin, 3, 2) + ]); + + let [dest, osc, gain] = actors; + + yield clickGraphNode(panelWin, gain.actorID); + ok(findGraphNode(panelWin, gain.actorID).classList.contains("selected"), + "Node selected once."); + + // Disconnect a node to trigger a rerender + osc.disconnect(); + + yield once(panelWin, EVENTS.UI_GRAPH_RENDERED); + + ok(findGraphNode(panelWin, gain.actorID).classList.contains("selected"), + "Node still selected after rerender."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-04.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-04.js new file mode 100644 index 000000000..84db904a9 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-04.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests audio param connection rendering. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(CONNECT_MULTI_PARAM_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 5), + waitForGraphRendered(panelWin, 5, 2, 3) + ]); + + let nodeIDs = actors.map(actor => actor.actorID); + + let [, carrier, gain, mod1, mod2] = nodeIDs; + + let edges = [ + [mod1, gain, "gain", "mod1 -> gain[gain]"], + [mod2, carrier, "frequency", "mod2 -> carrier[frequency]"], + [mod2, carrier, "detune", "mod2 -> carrier[detune]"] + ]; + + edges.forEach(([source, target, param, msg], i) => { + let edge = findGraphEdge(panelWin, source, target, param); + ok(edge.classList.contains("param-connection"), "edge is classified as a param-connection"); + }); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-05.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-05.js new file mode 100644 index 000000000..b1ddd78b3 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-render-05.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests to ensure that param connections trigger graph redraws + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 3), + waitForGraphRendered(panelWin, 3, 2, 0) + ]); + + let [dest, osc, gain] = actors; + + yield osc.disconnect(); + + osc.connectParam(gain, "gain"); + yield waitForGraphRendered(panelWin, 3, 1, 1); + ok(true, "Graph re-rendered upon param connection"); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-selected.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-selected.js new file mode 100644 index 000000000..4ee7e04fe --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-selected.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that SVG nodes and edges were created for the Graph View. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS } = panelWin; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + let [destId, oscId, gainId] = actors.map(actor => actor.actorID); + + ok(!findGraphNode(panelWin, destId).classList.contains("selected"), + "No nodes selected on start. (destination)"); + ok(!findGraphNode(panelWin, oscId).classList.contains("selected"), + "No nodes selected on start. (oscillator)"); + ok(!findGraphNode(panelWin, gainId).classList.contains("selected"), + "No nodes selected on start. (gain)"); + + yield clickGraphNode(panelWin, oscId); + + ok(findGraphNode(panelWin, oscId).classList.contains("selected"), + "Selected node has class 'selected'."); + ok(!findGraphNode(panelWin, destId).classList.contains("selected"), + "Non-selected nodes do not have class 'selected'."); + ok(!findGraphNode(panelWin, gainId).classList.contains("selected"), + "Non-selected nodes do not have class 'selected'."); + + yield clickGraphNode(panelWin, gainId); + + ok(!findGraphNode(panelWin, oscId).classList.contains("selected"), + "Previously selected node no longer has class 'selected'."); + ok(!findGraphNode(panelWin, destId).classList.contains("selected"), + "Non-selected nodes do not have class 'selected'."); + ok(findGraphNode(panelWin, gainId).classList.contains("selected"), + "Newly selected node now has class 'selected'."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_graph-zoom.js b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-zoom.js new file mode 100644 index 000000000..d06cc1549 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_graph-zoom.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the graph's scale and position is reset on a page reload. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, ContextView } = panelWin; + + let started = once(gFront, "start-context"); + + yield Promise.all([ + reload(target), + waitForGraphRendered(panelWin, 3, 2) + ]); + + is(ContextView.getCurrentScale(), 1, "Default graph scale is 1."); + is(ContextView.getCurrentTranslation()[0], 20, "Default x-translation is 20."); + is(ContextView.getCurrentTranslation()[1], 20, "Default y-translation is 20."); + + // Change both attribute and D3's internal store + panelWin.d3.select("#graph-target").attr("transform", "translate([100, 400]) scale(10)"); + ContextView._zoomBinding.scale(10); + ContextView._zoomBinding.translate([100, 400]); + + is(ContextView.getCurrentScale(), 10, "After zoom, scale is 10."); + is(ContextView.getCurrentTranslation()[0], 100, "After zoom, x-translation is 100."); + is(ContextView.getCurrentTranslation()[1], 400, "After zoom, y-translation is 400."); + + yield Promise.all([ + reload(target), + waitForGraphRendered(panelWin, 3, 2) + ]); + + is(ContextView.getCurrentScale(), 1, "After refresh, graph scale is 1."); + is(ContextView.getCurrentTranslation()[0], 20, "After refresh, x-translation is 20."); + is(ContextView.getCurrentTranslation()[1], 20, "After refresh, y-translation is 20."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-bypass-01.js b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-bypass-01.js new file mode 100644 index 000000000..b6c2a061c --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-bypass-01.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that nodes are correctly bypassed when bypassing. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, gAudioNodes } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + click(panelWin, findGraphNode(panelWin, nodeIds[1])); + // Wait for the node to be set as well as the inspector to come fully into the view + yield Promise.all([ + waitForInspectorRender(panelWin, EVENTS), + once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED) + ]); + + let $bypass = $("toolbarbutton.bypass"); + + is((yield actors[1].isBypassed()), false, "AudioNodeActor is not bypassed by default.") + is($bypass.checked, true, "Button is 'on' for normal nodes"); + is($bypass.disabled, false, "Bypass button is not disabled for normal nodes"); + + command($bypass); + yield gAudioNodes.once("bypass"); + + is((yield actors[1].isBypassed()), true, "AudioNodeActor is bypassed.") + is($bypass.checked, false, "Button is 'off' when clicked"); + is($bypass.disabled, false, "Bypass button is not disabled after click"); + ok(findGraphNode(panelWin, nodeIds[1]).classList.contains("bypassed"), + "AudioNode has 'bypassed' class."); + + command($bypass); + yield gAudioNodes.once("bypass"); + + is((yield actors[1].isBypassed()), false, "AudioNodeActor is no longer bypassed.") + is($bypass.checked, true, "Button is back on when clicked"); + is($bypass.disabled, false, "Bypass button is not disabled after click"); + ok(!findGraphNode(panelWin, nodeIds[1]).classList.contains("bypassed"), + "AudioNode no longer has 'bypassed' class."); + + click(panelWin, findGraphNode(panelWin, nodeIds[0])); + + yield once(panelWin, EVENTS.UI_INSPECTOR_NODE_SET); + + is((yield actors[0].isBypassed()), false, "Unbypassable AudioNodeActor is not bypassed."); + is($bypass.checked, false, "Button is 'off' for unbypassable nodes"); + is($bypass.disabled, true, "Bypass button is disabled for unbypassable nodes"); + + command($bypass); + is((yield actors[0].isBypassed()), false, + "Clicking button on unbypassable node does not change bypass state on actor."); + is($bypass.checked, false, "Button is still 'off' for unbypassable nodes"); + is($bypass.disabled, true, "Bypass button is still disabled for unbypassable nodes"); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-toggle.js b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-toggle.js new file mode 100644 index 000000000..33b20559d --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-toggle.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the inspector toggle button shows and hides + * the inspector panel as intended. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, InspectorView } = panelWin; + let gVars = InspectorView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + ok(!InspectorView.isVisible(), "InspectorView hidden on start."); + + // Open inspector pane + $("#inspector-pane-toggle").click(); + yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED); + + ok(InspectorView.isVisible(), "InspectorView shown after toggling."); + + ok(isVisible($("#web-audio-editor-details-pane-empty")), + "InspectorView empty message should still be visible."); + ok(!isVisible($("#web-audio-editor-tabs")), + "InspectorView tabs view should still be hidden."); + + // Close inspector pane + $("#inspector-pane-toggle").click(); + yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED); + + ok(!InspectorView.isVisible(), "InspectorView back to being hidden."); + + // Open again to test node loading while open + $("#inspector-pane-toggle").click(); + yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED); + + ok(InspectorView.isVisible(), "InspectorView being shown."); + ok(!isVisible($("#web-audio-editor-tabs")), + "InspectorView tabs are still hidden."); + + yield clickGraphNode(panelWin, findGraphNode(panelWin, nodeIds[1])); + + ok(!isVisible($("#web-audio-editor-details-pane-empty")), + "Empty message hides even when loading node while open."); + ok(isVisible($("#web-audio-editor-tabs")), + "Switches to tab view when loading node while open."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-width.js b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-width.js new file mode 100644 index 000000000..5d2c44c5b --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector-width.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that the WebAudioInspector's Width is saved as + * a preference + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, InspectorView } = panelWin; + let gVars = InspectorView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + ok(!InspectorView.isVisible(), "InspectorView hidden on start."); + + // Open inspector pane + $("#inspector-pane-toggle").click(); + yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED); + + let newInspectorWidth = 500; + + // Setting width to new_inspector_width + $("#web-audio-inspector").setAttribute("width", newInspectorWidth); + reload(target); + + //Width should be 500 after reloading + [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + nodeIds = actors.map(actor => actor.actorID); + + // Open inspector pane + $("#inspector-pane-toggle").click(); + yield once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED); + + yield clickGraphNode(panelWin, findGraphNode(panelWin, nodeIds[1])); + + // Getting the width of the audio inspector + let width = $("#web-audio-inspector").getAttribute("width"); + + is(width, newInspectorWidth, "WebAudioEditor's Inspector width should be saved as a preference"); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_inspector.js b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector.js new file mode 100644 index 000000000..083bf2671 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_inspector.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that inspector view opens on graph node click, and + * loads the correct node inside the inspector. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, InspectorView } = panelWin; + let gVars = InspectorView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + ok(!InspectorView.isVisible(), "InspectorView hidden on start."); + ok(isVisible($("#web-audio-editor-details-pane-empty")), + "InspectorView empty message should show when no node's selected."); + ok(!isVisible($("#web-audio-editor-tabs")), + "InspectorView tabs view should be hidden when no node's selected."); + + // Wait for the node to be set as well as the inspector to come fully into the view + yield clickGraphNode(panelWin, findGraphNode(panelWin, nodeIds[1]), true); + + ok(InspectorView.isVisible(), "InspectorView shown once node selected."); + ok(!isVisible($("#web-audio-editor-details-pane-empty")), + "InspectorView empty message hidden when node selected."); + ok(isVisible($("#web-audio-editor-tabs")), + "InspectorView tabs view visible when node selected."); + + is($("#web-audio-editor-tabs").selectedIndex, 0, + "default tab selected should be the parameters tab."); + + yield clickGraphNode(panelWin, findGraphNode(panelWin, nodeIds[2])); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_navigate.js b/toolkit/devtools/webaudioeditor/test/browser_wa_navigate.js new file mode 100644 index 000000000..598deabd3 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_navigate.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests naviating from a page to another will repopulate + * the audio graph if both pages have an AudioContext. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $ } = panelWin; + + reload(target); + + var [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + var { nodes, edges } = countGraphObjects(panelWin); + ise(nodes, 3, "should only be 3 nodes."); + ise(edges, 2, "should only be 2 edges."); + + navigate(target, SIMPLE_NODES_URL); + + var [actors] = yield Promise.all([ + getN(gFront, "create-node", 15), + waitForGraphRendered(panelWin, 15, 0) + ]); + + is($("#reload-notice").hidden, true, + "The 'reload this page' notice should be hidden after context found after navigation."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should be hidden after context found after navigation."); + is($("#content").hidden, false, + "The tool's content should reappear without closing and reopening the toolbox."); + + var { nodes, edges } = countGraphObjects(panelWin); + ise(nodes, 15, "after navigation, should have 15 nodes"); + ise(edges, 0, "after navigation, should have 0 edges."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-edit-01.js b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-edit-01.js new file mode 100644 index 000000000..bebcbc32d --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-edit-01.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that properties are updated when modifying the VariablesView. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, PropertiesView } = panelWin; + let gVars = PropertiesView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + click(panelWin, findGraphNode(panelWin, nodeIds[1])); + // Wait for the node to be set as well as the inspector to come fully into the view + yield Promise.all([ + waitForInspectorRender(panelWin, EVENTS), + once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED) + ]); + + let setAndCheck = setAndCheckVariable(panelWin, gVars); + + checkVariableView(gVars, 0, { + "type": "sine", + "frequency": 440, + "detune": 0 + }, "default loaded string"); + + click(panelWin, findGraphNode(panelWin, nodeIds[2])); + yield waitForInspectorRender(panelWin, EVENTS), + checkVariableView(gVars, 0, { + "gain": 0 + }, "default loaded number"); + + click(panelWin, findGraphNode(panelWin, nodeIds[1])); + yield waitForInspectorRender(panelWin, EVENTS), + yield setAndCheck(0, "type", "square", "square", "sets string as string"); + + click(panelWin, findGraphNode(panelWin, nodeIds[2])); + yield waitForInspectorRender(panelWin, EVENTS), + yield setAndCheck(0, "gain", "0.005", 0.005, "sets number as number"); + yield setAndCheck(0, "gain", "0.1", 0.1, "sets float as float"); + yield setAndCheck(0, "gain", ".2", 0.2, "sets float without leading zero as float"); + + yield teardown(target); +}); + +function setAndCheckVariable (panelWin, gVars) { + return Task.async(function (varNum, prop, value, expected, desc) { + yield modifyVariableView(panelWin, gVars, varNum, prop, value); + var props = {}; + props[prop] = expected; + checkVariableView(gVars, varNum, props, desc); + }); +} diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-edit-02.js b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-edit-02.js new file mode 100644 index 000000000..f7f439832 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-edit-02.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that properties are not updated when modifying the VariablesView. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(COMPLEX_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, PropertiesView } = panelWin; + let gVars = PropertiesView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 8), + waitForGraphRendered(panelWin, 8, 8) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + click(panelWin, findGraphNode(panelWin, nodeIds[3])); + // Wait for the node to be set as well as the inspector to come fully into the view + yield Promise.all([ + waitForInspectorRender(panelWin, EVENTS), + once(panelWin, EVENTS.UI_INSPECTOR_TOGGLED), + ]); + + let errorEvent = once(panelWin, EVENTS.UI_SET_PARAM_ERROR); + + try { + yield modifyVariableView(panelWin, gVars, 0, "bufferSize", 2048); + } catch(e) { + // we except modifyVariableView to fail here, because bufferSize is not writable + } + + yield errorEvent; + + checkVariableView(gVars, 0, {bufferSize: 4096}, "check that unwritable variable is not updated"); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-media-nodes.js b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-media-nodes.js new file mode 100644 index 000000000..917ecc390 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-media-nodes.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that params view correctly displays all properties for nodes + * correctly, with default values and correct types. + */ + +let MEDIA_PERMISSION = "media.navigator.permission.disabled"; + +function waitForDeviceClosed() { + info("Checking that getUserMedia streams are no longer in use."); + + let temp = {}; + Cu.import("resource:///modules/webrtcUI.jsm", temp); + let webrtcUI = temp.webrtcUI; + + if (!webrtcUI.showGlobalIndicator) + return Promise.resolve(); + + let deferred = Promise.defer(); + + const message = "webrtc:UpdateGlobalIndicators"; + let ppmm = Cc["@mozilla.org/parentprocessmessagemanager;1"] + .getService(Ci.nsIMessageBroadcaster); + ppmm.addMessageListener(message, function listener(aMessage) { + info("Received " + message + " message"); + if (!aMessage.data.showGlobalIndicator) { + ppmm.removeMessageListener(message, listener); + deferred.resolve(); + } + }); + + return deferred.promise; +} + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(MEDIA_NODES_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, PropertiesView } = panelWin; + let gVars = PropertiesView._propsView; + + // Auto enable getUserMedia + let mediaPermissionPref = Services.prefs.getBoolPref(MEDIA_PERMISSION); + Services.prefs.setBoolPref(MEDIA_PERMISSION, true); + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 4), + waitForGraphRendered(panelWin, 4, 0) + ]); + + let nodeIds = actors.map(actor => actor.actorID); + let types = [ + "AudioDestinationNode", "MediaElementAudioSourceNode", + "MediaStreamAudioSourceNode", "MediaStreamAudioDestinationNode" + ]; + + for (let i = 0; i < types.length; i++) { + click(panelWin, findGraphNode(panelWin, nodeIds[i])); + yield waitForInspectorRender(panelWin, EVENTS); + checkVariableView(gVars, 0, NODE_DEFAULT_VALUES[types[i]], types[i]); + } + + // Reset permissions on getUserMedia + Services.prefs.setBoolPref(MEDIA_PERMISSION, mediaPermissionPref); + + yield teardown(target); + + yield waitForDeviceClosed(); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-params-objects.js b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-params-objects.js new file mode 100644 index 000000000..61fe5398e --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-params-objects.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that params view correctly displays non-primitive properties + * like AudioBuffer and Float32Array in properties of AudioNodes. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(BUFFER_AND_ARRAY_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, PropertiesView } = panelWin; + let gVars = PropertiesView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 3), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + click(panelWin, findGraphNode(panelWin, nodeIds[2])); + yield waitForInspectorRender(panelWin, EVENTS); + checkVariableView(gVars, 0, { + "curve": "Float32Array" + }, "WaveShaper's `curve` is listed as an `Float32Array`."); + + let aVar = gVars.getScopeAtIndex(0).get("curve") + let state = aVar.target.querySelector(".theme-twisty").hasAttribute("invisible"); + ok(state, "Float32Array property should not have a dropdown."); + + click(panelWin, findGraphNode(panelWin, nodeIds[1])); + yield waitForInspectorRender(panelWin, EVENTS); + checkVariableView(gVars, 0, { + "buffer": "AudioBuffer" + }, "AudioBufferSourceNode's `buffer` is listed as an `AudioBuffer`."); + + aVar = gVars.getScopeAtIndex(0).get("buffer") + state = aVar.target.querySelector(".theme-twisty").hasAttribute("invisible"); + ok(state, "AudioBuffer property should not have a dropdown."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-params.js b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-params.js new file mode 100644 index 000000000..5af047ff0 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view-params.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that params view correctly displays all properties for nodes + * correctly, with default values and correct types. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_NODES_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, PropertiesView } = panelWin; + let gVars = PropertiesView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + getN(gFront, "create-node", 15), + waitForGraphRendered(panelWin, 15, 0) + ]); + let nodeIds = actors.map(actor => actor.actorID); + let types = [ + "AudioDestinationNode", "AudioBufferSourceNode", "ScriptProcessorNode", + "AnalyserNode", "GainNode", "DelayNode", "BiquadFilterNode", "WaveShaperNode", + "PannerNode", "ConvolverNode", "ChannelSplitterNode", "ChannelMergerNode", + "DynamicsCompressorNode", "OscillatorNode" + ]; + + for (let i = 0; i < types.length; i++) { + click(panelWin, findGraphNode(panelWin, nodeIds[i])); + yield waitForInspectorRender(panelWin, EVENTS); + checkVariableView(gVars, 0, NODE_DEFAULT_VALUES[types[i]], types[i]); + } + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view.js b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view.js new file mode 100644 index 000000000..702f1161f --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_properties-view.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that params view shows params when they exist, and are hidden otherwise. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, $$, EVENTS, PropertiesView } = panelWin; + let gVars = PropertiesView._propsView; + + let started = once(gFront, "start-context"); + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + // Gain node + click(panelWin, findGraphNode(panelWin, nodeIds[2])); + yield waitForInspectorRender(panelWin, EVENTS); + + ok(isVisible($("#properties-content")), "Parameters shown when they exist."); + ok(!isVisible($("#properties-empty")), + "Empty message hidden when AudioParams exist."); + + // Destination node + click(panelWin, findGraphNode(panelWin, nodeIds[0])); + yield waitForInspectorRender(panelWin, EVENTS); + + ok(!isVisible($("#properties-content")), + "Parameters hidden when they don't exist."); + ok(isVisible($("#properties-empty")), + "Empty message shown when no AudioParams exist."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_reset-01.js b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-01.js new file mode 100644 index 000000000..3208227ac --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-01.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/////////////////// +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("Error: Connection closed"); + +/** + * Tests that reloading a tab will properly listen for the `start-context` + * event and reshow the tools after reloading. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { gFront, $ } = panel.panelWin; + + is($("#reload-notice").hidden, false, + "The 'reload this page' notice should initially be visible."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should initially be hidden."); + is($("#content").hidden, true, + "The tool's content should initially be hidden."); + + let navigating = once(target, "will-navigate"); + let started = once(gFront, "start-context"); + + reload(target); + + yield navigating; + + is($("#reload-notice").hidden, true, + "The 'reload this page' notice should be hidden when navigating."); + is($("#waiting-notice").hidden, false, + "The 'waiting for an audio context' notice should be visible when navigating."); + is($("#content").hidden, true, + "The tool's content should still be hidden."); + + yield started; + + is($("#reload-notice").hidden, true, + "The 'reload this page' notice should be hidden after context found."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should be hidden after context found."); + is($("#content").hidden, false, + "The tool's content should not be hidden anymore."); + + navigating = once(target, "will-navigate"); + started = once(gFront, "start-context"); + + reload(target); + + yield Promise.all([navigating, started]); + + is($("#reload-notice").hidden, true, + "The 'reload this page' notice should be hidden after context found after reload."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should be hidden after context found after reload."); + is($("#content").hidden, false, + "The tool's content should reappear without closing and reopening the toolbox."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_reset-02.js b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-02.js new file mode 100644 index 000000000..478b9f5db --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-02.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests reloading a tab with the tools open properly cleans up + * the graph. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $ } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + let { nodes, edges } = countGraphObjects(panelWin); + ise(nodes, 3, "should only be 3 nodes."); + ise(edges, 2, "should only be 2 edges."); + + reload(target); + + [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + + ({ nodes, edges } = countGraphObjects(panelWin)); + ise(nodes, 3, "after reload, should only be 3 nodes."); + ise(edges, 2, "after reload, should only be 2 edges."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_reset-03.js b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-03.js new file mode 100644 index 000000000..60c9572d1 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-03.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests reloading a tab with the tools open properly cleans up + * the inspector and selected node. + */ + +add_task(function*() { + let { target, panel } = yield initWebAudioEditor(SIMPLE_CONTEXT_URL); + let { panelWin } = panel; + let { gFront, $, InspectorView } = panelWin; + + reload(target); + + let [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + let nodeIds = actors.map(actor => actor.actorID); + + yield clickGraphNode(panelWin, nodeIds[1], true); + ok(InspectorView.isVisible(), "InspectorView visible after selecting a node."); + is(InspectorView.getCurrentAudioNode().id, nodeIds[1], "InspectorView has correct node set."); + + /** + * Reload + */ + + reload(target); + + [actors] = yield Promise.all([ + get3(gFront, "create-node"), + waitForGraphRendered(panelWin, 3, 2) + ]); + nodeIds = actors.map(actor => actor.actorID); + + ok(!InspectorView.isVisible(), "InspectorView hidden on start."); + ise(InspectorView.getCurrentAudioNode(), null, + "InspectorView has no current node set on reset."); + + yield clickGraphNode(panelWin, nodeIds[2], true); + ok(InspectorView.isVisible(), + "InspectorView visible after selecting a node after a reset."); + is(InspectorView.getCurrentAudioNode().id, nodeIds[2], "InspectorView has correct node set upon clicking graph node after a reset."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_wa_reset-04.js b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-04.js new file mode 100644 index 000000000..3bef5775d --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_wa_reset-04.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/////////////////// +// +// Whitelisting this test. +// As part of bug 1077403, the leaking uncaught rejection should be fixed. +// +thisTestLeaksUncaughtRejectionsAndShouldBeFixed("Error: Connection closed"); + +/** + * Tests that switching to an iframe works fine. + */ + +add_task(function*() { + Services.prefs.setBoolPref("devtools.command-button-frames.enabled", true); + + let { target, panel, toolbox } = yield initWebAudioEditor(IFRAME_CONTEXT_URL); + let { gFront, $ } = panel.panelWin; + + is($("#reload-notice").hidden, false, + "The 'reload this page' notice should initially be visible."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should initially be hidden."); + is($("#content").hidden, true, + "The tool's content should initially be hidden."); + + let btn = toolbox.doc.getElementById("command-button-frames"); + ok(!btn.firstChild.getAttribute("hidden"), "The frame list button is visible"); + let frameBtns = btn.firstChild.querySelectorAll("[data-window-id]"); + is(frameBtns.length, 2, "We have both frames in the list"); + + // Select the iframe + frameBtns[1].click(); + + let navigating = once(target, "will-navigate"); + + yield navigating; + + is($("#reload-notice").hidden, false, + "The 'reload this page' notice should still be visible when switching to a frame."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should be kept hidden when switching to a frame."); + is($("#content").hidden, true, + "The tool's content should still be hidden."); + + navigating = once(target, "will-navigate"); + let started = once(gFront, "start-context"); + + reload(target); + + yield Promise.all([navigating, started]); + + is($("#reload-notice").hidden, true, + "The 'reload this page' notice should be hidden after reloading the frame."); + is($("#waiting-notice").hidden, true, + "The 'waiting for an audio context' notice should be hidden after reloading the frame."); + is($("#content").hidden, false, + "The tool's content should appear after reload."); + + yield teardown(target); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-automation-event.js b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-automation-event.js new file mode 100644 index 000000000..ef98dfac9 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-automation-event.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that the WebAudioActor receives and emits the `automation-event` events + * with correct arguments from the content. + */ + +add_task(function*() { + let { target, front } = yield initBackend(AUTOMATION_URL); + let events = []; + + let expected = [ + ["setValueAtTime", 0.2, 0], + ["linearRampToValueAtTime", 1, 0.3], + ["exponentialRampToValueAtTime", 0.75, 0.6], + ["setValueCurveAtTime", [-1, 0 ,1], 0.7, 0.3], + ]; + + front.on("automation-event", onAutomationEvent); + + let [_, __, [destNode, oscNode, gainNode], [connect1, connect2]] = yield Promise.all([ + front.setup({ reload: true }), + once(front, "start-context"), + get3(front, "create-node"), + get2(front, "connect-node") + ]); + + is(events.length, 4, "correct number of events fired"); + + function onAutomationEvent (e) { + let { eventName, paramName, args } = e; + let exp = expected[events.length]; + + is(eventName, exp[0], "correct eventName in event"); + is(paramName, "frequency", "correct paramName in event"); + is(args.length, exp.length - 1, "correct length in args"); + + args.forEach((a, i) => { + // In the case of an array + if (typeof a === "object") { + a.forEach((f, j) => is(f, exp[i + 1][j], `correct argument in Float32Array: ${f}`)); + } else { + is(a, exp[i + 1], `correct ${i+1}th argument in args: ${a}`); + } + }); + events.push([eventName].concat(args)); + } + + front.off("automation-event", onAutomationEvent); + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-connect-param.js b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-connect-param.js new file mode 100644 index 000000000..a436401dc --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-connect-param.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test the `connect-param` event on the web audio actor. + */ + +add_task(function*() { + let { target, front } = yield initBackend(CONNECT_PARAM_URL); + let [, , [destNode, carrierNode, modNode, gainNode], , connectParam] = yield Promise.all([ + front.setup({ reload: true }), + once(front, "start-context"), + getN(front, "create-node", 4), + get2(front, "connect-node"), + once(front, "connect-param") + ]); + + info(connectParam); + + is(connectParam.source.actorID, modNode.actorID, "`connect-param` has correct actor for `source`"); + is(connectParam.dest.actorID, gainNode.actorID, "`connect-param` has correct actor for `dest`"); + is(connectParam.param, "gain", "`connect-param` has correct parameter name for `param`"); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-destroy-node.js b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-destroy-node.js new file mode 100644 index 000000000..60140dbde --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-destroy-node.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test `destroy-node` event on WebAudioActor. + */ + +add_task(function*() { + let { target, front } = yield initBackend(DESTROY_NODES_URL); + + let waitUntilDestroyed = getN(front, "destroy-node", 10); + let [, , created] = yield Promise.all([ + front.setup({ reload: true }), + once(front, "start-context"), + // Should create 1 destination node and 10 disposable buffer nodes + getN(front, "create-node", 13) + ]); + + // Force CC so we can ensure it's run to clear out dead AudioNodes + forceCC(); + + let destroyed = yield waitUntilDestroyed; + + let destroyedTypes = yield Promise.all(destroyed.map(actor => actor.getType())); + destroyedTypes.forEach((type, i) => { + ok(type, "AudioBufferSourceNode", "Only buffer nodes are destroyed"); + ok(actorIsInList(created, destroyed[i]), + "`destroy-node` called only on AudioNodes in current document."); + }); + + yield removeTab(target.tab); +}); + +function actorIsInList (list, actor) { + for (let i = 0; i < list.length; i++) { + if (list[i].actorID === actor.actorID) + return list[i]; + } + return null; +} diff --git a/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-simple.js b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-simple.js new file mode 100644 index 000000000..949e19f0f --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/browser_webaudio-actor-simple.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test basic communication of Web Audio actor + */ + +add_task(function*() { + let { target, front } = yield initBackend(SIMPLE_CONTEXT_URL); + let [_, __, [destNode, oscNode, gainNode], [connect1, connect2]] = yield Promise.all([ + front.setup({ reload: true }), + once(front, "start-context"), + get3(front, "create-node"), + get2(front, "connect-node") + ]); + + let destType = yield destNode.getType(); + let oscType = yield oscNode.getType(); + let gainType = yield gainNode.getType(); + + is(destType, "AudioDestinationNode", "WebAudioActor:create-node returns AudioNodeActor for AudioDestination"); + is(oscType, "OscillatorNode", "WebAudioActor:create-node returns AudioNodeActor"); + is(gainType, "GainNode", "WebAudioActor:create-node returns AudioNodeActor"); + + let { source, dest } = connect1; + is(source.actorID, oscNode.actorID, "WebAudioActor:connect-node returns correct actor with ID on source (osc->gain)"); + is(dest.actorID, gainNode.actorID, "WebAudioActor:connect-node returns correct actor with ID on dest (osc->gain)"); + + ({ source, dest } = connect2); + is(source.actorID, gainNode.actorID, "WebAudioActor:connect-node returns correct actor with ID on source (gain->dest)"); + is(dest.actorID, destNode.actorID, "WebAudioActor:connect-node returns correct actor with ID on dest (gain->dest)"); + + yield removeTab(target.tab); +}); diff --git a/toolkit/devtools/webaudioeditor/test/doc_automation.html b/toolkit/devtools/webaudioeditor/test/doc_automation.html new file mode 100644 index 000000000..6f074208c --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_automation.html @@ -0,0 +1,30 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let osc = ctx.createOscillator(); + let gain = ctx.createGain(); + gain.gain.value = 0; + osc.frequency.setValueAtTime(0.2, 0); + osc.frequency.linearRampToValueAtTime(1, 0.3); + osc.frequency.exponentialRampToValueAtTime(0.75, 0.6); + osc.frequency.setValueCurveAtTime(new Float32Array([-1, 0, 1]), 0.7, 0.3); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(0); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_buffer-and-array.html b/toolkit/devtools/webaudioeditor/test/doc_buffer-and-array.html new file mode 100644 index 000000000..49dc91f29 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_buffer-and-array.html @@ -0,0 +1,56 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let audioURL = "http://example.com/browser/browser/devtools/webaudioeditor/test/440hz_sine.ogg"; + + let ctx = new AudioContext(); + let bufferNode = ctx.createBufferSource(); + let shaperNode = ctx.createWaveShaper(); + shaperNode.curve = generateWaveShapingCurve(); + + let xhr = getBuffer(audioURL, () => { + ctx.decodeAudioData(xhr.response, (buffer) => { + bufferNode.buffer = buffer; + bufferNode.connect(shaperNode); + shaperNode.connect(ctx.destination); + }); + }); + + function generateWaveShapingCurve() { + let frames = 65536; + let curve = new Float32Array(frames); + let n = frames; + let n2 = n / 2; + + for (let i = 0; i < n; ++i) { + let x = (i - n2) / n2; + let y = Math.atan(5 * x) / (0.5 * Math.PI); + } + + return curve; + } + + function getBuffer (url, callback) { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.responseType = "arraybuffer"; + xhr.onload = callback; + xhr.send(); + return xhr; + } + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_bug_1112378.html b/toolkit/devtools/webaudioeditor/test/doc_bug_1112378.html new file mode 100644 index 000000000..ecdfd7d63 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_bug_1112378.html @@ -0,0 +1,57 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let osc = ctx.createOscillator(); + + function throwError () { + try { + osc.connect({}); + } catch (e) { + return { + lineNumber: e.lineNumber, + fileName: e.fileName, + columnNumber: e.columnNumber, + message: e.message, + instanceof: e instanceof TypeError, + stringified: e.toString(), + name: e.name + } + } + } + + function throwDOMException () { + try { + osc.frequency.setValueAtTime(0, -1); + } catch (e) { + return { + lineNumber: e.lineNumber, + columnNumber: e.columnNumber, + filename: e.filename, + message: e.message, + code: e.code, + result: e.result, + instanceof: e instanceof DOMException, + stringified: e.toString(), + name: e.name + } + } + } + + + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_bug_1125817.html b/toolkit/devtools/webaudioeditor/test/doc_bug_1125817.html new file mode 100644 index 000000000..49a2be11a --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_bug_1125817.html @@ -0,0 +1,23 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let osc = ctx.createOscillator(); + osc.frequency.value = 200; + osc.disconnect(); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_bug_1130901.html b/toolkit/devtools/webaudioeditor/test/doc_bug_1130901.html new file mode 100644 index 000000000..1ce1ebf55 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_bug_1130901.html @@ -0,0 +1,22 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + ctx.createOscillator.call(ctx); + ctx.createGain.apply(ctx, []); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_complex-context.html b/toolkit/devtools/webaudioeditor/test/doc_complex-context.html new file mode 100644 index 000000000..396bbce3f --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_complex-context.html @@ -0,0 +1,44 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + +/* + ↱ proc + osc → gain → + osc → gain → destination + buffer →↳ filter → +*/ + let ctx = new AudioContext(); + let osc1 = ctx.createOscillator(); + let gain1 = ctx.createGain(); + let proc = ctx.createScriptProcessor(); + osc1.connect(gain1); + osc1.connect(proc); + gain1.connect(ctx.destination); + + let osc2 = ctx.createOscillator(); + let gain2 = ctx.createGain(); + osc2.connect(gain2); + gain2.connect(ctx.destination); + + let buf = ctx.createBufferSource(); + let filter = ctx.createBiquadFilter(); + buf.connect(filter); + osc2.connect(filter); + filter.connect(ctx.destination); + + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_connect-multi-param.html b/toolkit/devtools/webaudioeditor/test/doc_connect-multi-param.html new file mode 100644 index 000000000..ed4bd84e8 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_connect-multi-param.html @@ -0,0 +1,32 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let carrier = ctx.createOscillator(); + let gain = ctx.createGain(); + let modulator = ctx.createOscillator(); + let modulator2 = ctx.createOscillator(); + carrier.connect(gain); + gain.connect(ctx.destination); + modulator.connect(gain.gain); + modulator2.connect(carrier.frequency); + modulator2.connect(carrier.detune); + modulator.start(0); + modulator2.start(0); + carrier.start(0); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_connect-param.html b/toolkit/devtools/webaudioeditor/test/doc_connect-param.html new file mode 100644 index 000000000..9185c0b05 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_connect-param.html @@ -0,0 +1,28 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let carrier = ctx.createOscillator(); + let modulator = ctx.createOscillator(); + let gain = ctx.createGain(); + carrier.connect(gain); + gain.connect(ctx.destination); + modulator.connect(gain.gain); + modulator.start(0); + carrier.start(0); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_destroy-nodes.html b/toolkit/devtools/webaudioeditor/test/doc_destroy-nodes.html new file mode 100644 index 000000000..7738c39ab --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_destroy-nodes.html @@ -0,0 +1,32 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + (function () { + let ctx = new AudioContext(); + let osc = ctx.createOscillator(); + let gain = ctx.createGain(); + + for (let i = 0; i < 10; i++) { + ctx.createBufferSource(); + } + + osc.connect(gain); + gain.connect(ctx.destination); + gain.gain.value = 0; + osc.start(); + })(); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_iframe-context.html b/toolkit/devtools/webaudioeditor/test/doc_iframe-context.html new file mode 100644 index 000000000..a0a411a47 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_iframe-context.html @@ -0,0 +1,14 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page with an iframe</title> + </head> + + <body> + <iframe id="frame" src="doc_simple-context.html" /> + </body> +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_media-node-creation.html b/toolkit/devtools/webaudioeditor/test/doc_media-node-creation.html new file mode 100644 index 000000000..685062e3f --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_media-node-creation.html @@ -0,0 +1,29 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let audio = new Audio(); + let meNode, msNode, mdNode; + navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia; + + navigator.getUserMedia({ audio: true }, stream => { + meNode = ctx.createMediaElementSource(audio); + msNode = ctx.createMediaStreamSource(stream); + mdNode = ctx.createMediaStreamDestination(); + }, () => {}); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_simple-context.html b/toolkit/devtools/webaudioeditor/test/doc_simple-context.html new file mode 100644 index 000000000..89a84b882 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_simple-context.html @@ -0,0 +1,33 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let osc = ctx.createOscillator(); + let gain = ctx.createGain(); + gain.gain.value = 0; + + // Connect multiple times to test that it's disregarded. + osc.connect(gain); + gain.connect(ctx.destination); + gain.connect(ctx.destination); + gain.connect(ctx.destination); + gain.connect(ctx.destination); + gain.connect(ctx.destination); + gain.connect(ctx.destination); + osc.start(0); + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/doc_simple-node-creation.html b/toolkit/devtools/webaudioeditor/test/doc_simple-node-creation.html new file mode 100644 index 000000000..e6dcf7b32 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/doc_simple-node-creation.html @@ -0,0 +1,28 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Web Audio Editor test page</title> + </head> + + <body> + + <script type="text/javascript;version=1.8"> + "use strict"; + + let ctx = new AudioContext(); + let NODE_CREATION_METHODS = [ + "createBufferSource", "createScriptProcessor", "createAnalyser", + "createGain", "createDelay", "createBiquadFilter", "createWaveShaper", + "createPanner", "createConvolver", "createChannelSplitter", "createChannelMerger", + "createDynamicsCompressor", "createOscillator", "createStereoPanner" + ]; + let nodes = NODE_CREATION_METHODS.map(method => ctx[method]()); + + </script> + </body> + +</html> diff --git a/toolkit/devtools/webaudioeditor/test/head.js b/toolkit/devtools/webaudioeditor/test/head.js new file mode 100644 index 000000000..13f526a05 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/test/head.js @@ -0,0 +1,562 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +let { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + +// Enable logging for all the tests. Both the debugger server and frontend will +// be affected by this pref. +let gEnableLogging = Services.prefs.getBoolPref("devtools.debugger.log"); +Services.prefs.setBoolPref("devtools.debugger.log", false); + +let { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); +let { Promise } = Cu.import("resource://gre/modules/Promise.jsm", {}); +let { gDevTools } = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); +let { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); +let { DebuggerServer } = Cu.import("resource://gre/modules/devtools/dbg-server.jsm", {}); +let { generateUUID } = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); + +let { WebAudioFront } = devtools.require("devtools/server/actors/webaudio"); +let TargetFactory = devtools.TargetFactory; +let mm = null; + +const FRAME_SCRIPT_UTILS_URL = "chrome://browser/content/devtools/frame-script-utils.js"; +const EXAMPLE_URL = "http://example.com/browser/browser/devtools/webaudioeditor/test/"; +const SIMPLE_CONTEXT_URL = EXAMPLE_URL + "doc_simple-context.html"; +const COMPLEX_CONTEXT_URL = EXAMPLE_URL + "doc_complex-context.html"; +const SIMPLE_NODES_URL = EXAMPLE_URL + "doc_simple-node-creation.html"; +const MEDIA_NODES_URL = EXAMPLE_URL + "doc_media-node-creation.html"; +const BUFFER_AND_ARRAY_URL = EXAMPLE_URL + "doc_buffer-and-array.html"; +const DESTROY_NODES_URL = EXAMPLE_URL + "doc_destroy-nodes.html"; +const CONNECT_PARAM_URL = EXAMPLE_URL + "doc_connect-param.html"; +const CONNECT_MULTI_PARAM_URL = EXAMPLE_URL + "doc_connect-multi-param.html"; +const IFRAME_CONTEXT_URL = EXAMPLE_URL + "doc_iframe-context.html"; +const AUTOMATION_URL = EXAMPLE_URL + "doc_automation.html"; + +// All tests are asynchronous. +waitForExplicitFinish(); + +let gToolEnabled = Services.prefs.getBoolPref("devtools.webaudioeditor.enabled"); + +gDevTools.testing = true; + +registerCleanupFunction(() => { + gDevTools.testing = false; + info("finish() was called, cleaning up..."); + Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging); + Services.prefs.setBoolPref("devtools.webaudioeditor.enabled", gToolEnabled); + Cu.forceGC(); +}); + +/** + * Call manually in tests that use frame script utils after initializing + * the web audio editor. Call after init but before navigating to a different page. + */ +function loadFrameScripts () { + mm = gBrowser.selectedBrowser.messageManager; + mm.loadFrameScript(FRAME_SCRIPT_UTILS_URL, false); +} + +function addTab(aUrl, aWindow) { + info("Adding tab: " + aUrl); + + let deferred = Promise.defer(); + let targetWindow = aWindow || window; + let targetBrowser = targetWindow.gBrowser; + + targetWindow.focus(); + let tab = targetBrowser.selectedTab = targetBrowser.addTab(aUrl); + let linkedBrowser = tab.linkedBrowser; + + linkedBrowser.addEventListener("load", function onLoad() { + linkedBrowser.removeEventListener("load", onLoad, true); + info("Tab added and finished loading: " + aUrl); + deferred.resolve(tab); + }, true); + + return deferred.promise; +} + +function removeTab(aTab, aWindow) { + info("Removing tab."); + + let deferred = Promise.defer(); + let targetWindow = aWindow || window; + let targetBrowser = targetWindow.gBrowser; + let tabContainer = targetBrowser.tabContainer; + + tabContainer.addEventListener("TabClose", function onClose(aEvent) { + tabContainer.removeEventListener("TabClose", onClose, false); + info("Tab removed and finished closing."); + deferred.resolve(); + }, false); + + targetBrowser.removeTab(aTab); + return deferred.promise; +} + +function once(aTarget, aEventName, aUseCapture = false) { + info("Waiting for event: '" + aEventName + "' on " + aTarget + "."); + + let deferred = Promise.defer(); + + for (let [add, remove] of [ + ["on", "off"], // Use event emitter before DOM events for consistency + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"] + ]) { + if ((add in aTarget) && (remove in aTarget)) { + aTarget[add](aEventName, function onEvent(...aArgs) { + aTarget[remove](aEventName, onEvent, aUseCapture); + info("Got event: '" + aEventName + "' on " + aTarget + "."); + deferred.resolve(...aArgs); + }, aUseCapture); + break; + } + } + + return deferred.promise; +} + +function reload(aTarget, aWaitForTargetEvent = "navigate") { + aTarget.activeTab.reload(); + return once(aTarget, aWaitForTargetEvent); +} + +function navigate(aTarget, aUrl, aWaitForTargetEvent = "navigate") { + executeSoon(() => aTarget.activeTab.navigateTo(aUrl)); + return once(aTarget, aWaitForTargetEvent); +} + +/** + * Adds a new tab, and instantiate a WebAudiFront object. + * This requires calling removeTab before the test ends. + */ +function initBackend(aUrl) { + info("Initializing a web audio editor front."); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + return Task.spawn(function*() { + let tab = yield addTab(aUrl); + let target = TargetFactory.forTab(tab); + + yield target.makeRemote(); + + let front = new WebAudioFront(target.client, target.form); + return { target, front }; + }); +} + +/** + * Adds a new tab, and open the toolbox for that tab, selecting the audio editor + * panel. + * This requires calling teardown before the test ends. + */ +function initWebAudioEditor(aUrl) { + info("Initializing a web audio editor pane."); + + return Task.spawn(function*() { + let tab = yield addTab(aUrl); + let target = TargetFactory.forTab(tab); + + yield target.makeRemote(); + + Services.prefs.setBoolPref("devtools.webaudioeditor.enabled", true); + let toolbox = yield gDevTools.showToolbox(target, "webaudioeditor"); + let panel = toolbox.getCurrentPanel(); + return { target, panel, toolbox }; + }); +} + +/** + * Close the toolbox, destroying all panels, and remove the added test tabs. + */ +function teardown(aTarget) { + info("Destroying the web audio editor."); + + return gDevTools.closeToolbox(aTarget).then(() => { + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } + }); +} + +// Due to web audio will fire most events synchronously back-to-back, +// and we can't yield them in a chain without missing actors, this allows +// us to listen for `n` events and return a promise resolving to them. +// +// Takes a `front` object that is an event emitter, the number of +// programs that should be listened to and waited on, and an optional +// `onAdd` function that calls with the entire actors array on program link +function getN (front, eventName, count, spread) { + let actors = []; + let deferred = Promise.defer(); + front.on(eventName, function onEvent (...args) { + let actor = args[0]; + if (actors.length !== count) { + actors.push(spread ? args : actor); + } + if (actors.length === count) { + front.off(eventName, onEvent); + deferred.resolve(actors); + } + }); + return deferred.promise; +} + +function get (front, eventName) { return getN(front, eventName, 1); } +function get2 (front, eventName) { return getN(front, eventName, 2); } +function get3 (front, eventName) { return getN(front, eventName, 3); } +function getSpread (front, eventName) { return getN(front, eventName, 1, true); } +function get2Spread (front, eventName) { return getN(front, eventName, 2, true); } +function get3Spread (front, eventName) { return getN(front, eventName, 3, true); } +function getNSpread (front, eventName, count) { return getN(front, eventName, count, true); } + +/** + * Waits for the UI_GRAPH_RENDERED event to fire, but only + * resolves when the graph was rendered with the correct count of + * nodes and edges. + */ +function waitForGraphRendered (front, nodeCount, edgeCount, paramEdgeCount) { + let deferred = Promise.defer(); + let eventName = front.EVENTS.UI_GRAPH_RENDERED; + front.on(eventName, function onGraphRendered (_, nodes, edges, pEdges) { + let paramEdgesDone = paramEdgeCount != null ? paramEdgeCount === pEdges : true; + if (nodes === nodeCount && edges === edgeCount && paramEdgesDone) { + front.off(eventName, onGraphRendered); + deferred.resolve(); + } + }); + return deferred.promise; +} + +function checkVariableView (view, index, hash, description = "") { + info("Checking Variable View"); + let scope = view.getScopeAtIndex(index); + let variables = Object.keys(hash); + + // If node shouldn't display any properties, ensure that the 'empty' message is + // visible + if (!variables.length) { + ok(isVisible(scope.window.$("#properties-empty")), + description + " should show the empty properties tab."); + return; + } + + // Otherwise, iterate over expected properties + variables.forEach(variable => { + let aVar = scope.get(variable); + is(aVar.target.querySelector(".name").getAttribute("value"), variable, + "Correct property name for " + variable); + let value = aVar.target.querySelector(".value").getAttribute("value"); + + // Cast value with JSON.parse if possible; + // will fail when displaying Object types like "ArrayBuffer" + // and "Float32Array", but will match the original value. + try { + value = JSON.parse(value); + } + catch (e) {} + if (typeof hash[variable] === "function") { + ok(hash[variable](value), + "Passing property value of " + value + " for " + variable + " " + description); + } + else { + ise(value, hash[variable], + "Correct property value of " + hash[variable] + " for " + variable + " " + description); + } + }); +} + +function modifyVariableView (win, view, index, prop, value) { + let deferred = Promise.defer(); + let scope = view.getScopeAtIndex(index); + let aVar = scope.get(prop); + scope.expand(); + + win.on(win.EVENTS.UI_SET_PARAM, handleSetting); + win.on(win.EVENTS.UI_SET_PARAM_ERROR, handleSetting); + + // Focus and select the variable to begin editing + win.focus(); + aVar.focus(); + EventUtils.sendKey("RETURN", win); + + // Must wait for the scope DOM to be available to receive + // events + executeSoon(() => { + info("Setting " + value + " for " + prop + "...."); + for (let c of (value + "")) { + EventUtils.synthesizeKey(c, {}, win); + } + EventUtils.sendKey("RETURN", win); + }); + + function handleSetting (eventName) { + win.off(win.EVENTS.UI_SET_PARAM, handleSetting); + win.off(win.EVENTS.UI_SET_PARAM_ERROR, handleSetting); + if (eventName === win.EVENTS.UI_SET_PARAM) + deferred.resolve(); + if (eventName === win.EVENTS.UI_SET_PARAM_ERROR) + deferred.reject(); + } + + return deferred.promise; +} + +function findGraphEdge (win, source, target, param) { + let selector = ".edgePaths .edgePath[data-source='" + source + "'][data-target='" + target + "']"; + if (param) { + selector += "[data-param='" + param + "']"; + } + return win.document.querySelector(selector); +} + +function findGraphNode (win, node) { + let selector = ".nodes > g[data-id='" + node + "']"; + return win.document.querySelector(selector); +} + +function click (win, element) { + EventUtils.sendMouseEvent({ type: "click" }, element, win); +} + +function mouseOver (win, element) { + EventUtils.sendMouseEvent({ type: "mouseover" }, element, win); +} + +function command (button) { + let ev = button.ownerDocument.createEvent("XULCommandEvent"); + ev.initCommandEvent("command", true, true, button.ownerDocument.defaultView, 0, false, false, false, false, null); + button.dispatchEvent(ev); +} + +function isVisible (element) { + return !element.getAttribute("hidden"); +} + +/** + * Used in debugging, returns a promise that resolves in `n` milliseconds. + */ +function wait (n) { + let { promise, resolve } = Promise.defer(); + setTimeout(resolve, n); + info("Waiting " + n/1000 + " seconds."); + return promise; +} + +/** + * Clicks a graph node based on actorID or passing in an element. + * Returns a promise that resolves once UI_INSPECTOR_NODE_SET is fired and + * the tabs have rendered, completing all RDP requests for the node. + */ +function clickGraphNode (panelWin, el, waitForToggle = false) { + let { promise, resolve } = Promise.defer(); + let promises = [ + once(panelWin, panelWin.EVENTS.UI_INSPECTOR_NODE_SET), + once(panelWin, panelWin.EVENTS.UI_PROPERTIES_TAB_RENDERED), + once(panelWin, panelWin.EVENTS.UI_AUTOMATION_TAB_RENDERED) + ]; + + if (waitForToggle) { + promises.push(once(panelWin, panelWin.EVENTS.UI_INSPECTOR_TOGGLED)); + } + + // Use `el` as the element if it is one, otherwise + // assume it's an ID and find the related graph node + let element = el.tagName ? el : findGraphNode(panelWin, el); + click(panelWin, element); + + return Promise.all(promises); +} + +/** + * Returns the primitive value of a grip's value, or the + * original form that the string grip.type comes from. + */ +function getGripValue (value) { + if (~["boolean", "string", "number"].indexOf(typeof value)) { + return value; + } + + switch (value.type) { + case "undefined": return undefined; + case "Infinity": return Infinity; + case "-Infinity": return -Infinity; + case "NaN": return NaN; + case "-0": return -0; + case "null": return null; + default: return value; + } +} + +/** + * Counts how many nodes and edges are currently in the graph. + */ +function countGraphObjects (win) { + return { + nodes: win.document.querySelectorAll(".nodes > .audionode").length, + edges: win.document.querySelectorAll(".edgePaths > .edgePath").length + } +} + +/** +* Forces cycle collection and GC, used in AudioNode destruction tests. +*/ +function forceCC () { + SpecialPowers.DOMWindowUtils.cycleCollect(); + SpecialPowers.DOMWindowUtils.garbageCollect(); + SpecialPowers.DOMWindowUtils.garbageCollect(); +} + +/** + * Takes a `values` array of automation value entries, + * looking for the value at `time` seconds, checking + * to see if the value is close to `expected`. + */ +function checkAutomationValue (values, time, expected) { + // Remain flexible on values as we can approximate points + let EPSILON = 0.01; + + let value = getValueAt(values, time); + ok(Math.abs(value - expected) < EPSILON, "Timeline value at " + time + " with value " + value + " should have value very close to " + expected); + + /** + * Entries are ordered in `values` according to time, so if we can't find an exact point + * on a time of interest, return the point in between the threshold. This should + * get us a very close value. + */ + function getValueAt (values, time) { + for (let i = 0; i < values.length; i++) { + if (values[i].delta === time) { + return values[i].value; + } + if (values[i].delta > time) { + return (values[i - 1].value + values[i].value) / 2; + } + } + return values[values.length - 1].value; + } +} + +/** + * Wait for all inspector tabs to complete rendering. + */ +function waitForInspectorRender (panelWin, EVENTS) { + return Promise.all([ + once(panelWin, EVENTS.UI_PROPERTIES_TAB_RENDERED), + once(panelWin, EVENTS.UI_AUTOMATION_TAB_RENDERED) + ]); +} + +/** + * Takes a string `script` and evaluates it directly in the content + * in potentially a different process. + */ +function evalInDebuggee (script) { + let deferred = Promise.defer(); + + if (!mm) { + throw new Error("`loadFrameScripts()` must be called when using MessageManager."); + } + + let id = generateUUID().toString(); + mm.sendAsyncMessage("devtools:test:eval", { script: script, id: id }); + mm.addMessageListener("devtools:test:eval:response", handler); + + function handler ({ data }) { + if (id !== data.id) { + return; + } + + mm.removeMessageListener("devtools:test:eval:response", handler); + deferred.resolve(data.value); + } + + return deferred.promise; +} + +/** + * List of audio node properties to test against expectations of the AudioNode actor + */ + +const NODE_DEFAULT_VALUES = { + "AudioDestinationNode": {}, + "MediaElementAudioSourceNode": {}, + "MediaStreamAudioSourceNode": {}, + "MediaStreamAudioDestinationNode": { + "stream": "MediaStream" + }, + "AudioBufferSourceNode": { + "playbackRate": 1, + "loop": false, + "loopStart": 0, + "loopEnd": 0, + "buffer": null + }, + "ScriptProcessorNode": { + "bufferSize": 4096 + }, + "AnalyserNode": { + "fftSize": 2048, + "minDecibels": -100, + "maxDecibels": -30, + "smoothingTimeConstant": 0.8, + "frequencyBinCount": 1024 + }, + "GainNode": { + "gain": 1 + }, + "DelayNode": { + "delayTime": 0 + }, + "BiquadFilterNode": { + "type": "lowpass", + "frequency": 350, + "Q": 1, + "detune": 0, + "gain": 0 + }, + "WaveShaperNode": { + "curve": null, + "oversample": "none" + }, + "PannerNode": { + "panningModel": "equalpower", + "distanceModel": "inverse", + "refDistance": 1, + "maxDistance": 10000, + "rolloffFactor": 1, + "coneInnerAngle": 360, + "coneOuterAngle": 360, + "coneOuterGain": 0 + }, + "ConvolverNode": { + "buffer": null, + "normalize": true + }, + "ChannelSplitterNode": {}, + "ChannelMergerNode": {}, + "DynamicsCompressorNode": { + "threshold": -24, + "knee": 30, + "ratio": 12, + "reduction": 0, + "attack": 0.003000000026077032, + "release": 0.25 + }, + "OscillatorNode": { + "type": "sine", + "frequency": 440, + "detune": 0 + }, + "StereoPannerNode": { + "pan": 0 + } +}; diff --git a/toolkit/devtools/webaudioeditor/views/automation.js b/toolkit/devtools/webaudioeditor/views/automation.js new file mode 100644 index 000000000..d6107d7f7 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/views/automation.js @@ -0,0 +1,159 @@ +/* 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"; + +/** + * Functions handling the audio node inspector UI. + */ + +let AutomationView = { + + /** + * Initialization function called when the tool starts up. + */ + initialize: function () { + this._buttons = $("#automation-param-toolbar-buttons"); + this.graph = new LineGraphWidget($("#automation-graph"), { avg: false }); + this.graph.selectionEnabled = false; + + this._onButtonClick = this._onButtonClick.bind(this); + this._onNodeSet = this._onNodeSet.bind(this); + this._onResize = this._onResize.bind(this); + + this._buttons.addEventListener("click", this._onButtonClick); + window.on(EVENTS.UI_INSPECTOR_RESIZE, this._onResize); + window.on(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSet); + }, + + /** + * Destruction function called when the tool cleans up. + */ + destroy: function () { + this._buttons.removeEventListener("click", this._onButtonClick); + window.off(EVENTS.UI_INSPECTOR_RESIZE, this._onResize); + window.off(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSet); + }, + + /** + * Empties out the props view. + */ + resetUI: function () { + this._currentNode = null; + }, + + /** + * On a new node selection, create the Automation panel for + * that specific node. + */ + build: Task.async(function* () { + let node = this._currentNode; + + let props = yield node.getParams(); + let params = props.filter(({ flags }) => flags && flags.param); + + this._createParamButtons(params); + + this._selectedParamName = params[0] ? params[0].param : null; + this.render(); + }), + + /** + * Renders the graph for specified `paramName`. Called when + * the parameter view is changed, or when new param data events + * are fired for the currently specified param. + */ + render: Task.async(function *() { + let node = this._currentNode; + let paramName = this._selectedParamName; + // Escape if either node or parameter name does not exist. + if (!node || !paramName) { + this._setState("no-params"); + window.emit(EVENTS.UI_AUTOMATION_TAB_RENDERED, null); + return; + } + + let { values, events } = yield node.getAutomationData(paramName); + this._setState(events.length ? "show" : "no-events"); + yield this.graph.setDataWhenReady(values); + window.emit(EVENTS.UI_AUTOMATION_TAB_RENDERED, node.id); + }), + + /** + * Create the buttons for each AudioParam, that when clicked, + * render the graph for that AudioParam. + */ + _createParamButtons: function (params) { + this._buttons.innerHTML = ""; + params.forEach((param, i) => { + let button = document.createElement("toolbarbutton"); + button.setAttribute("class", "devtools-toolbarbutton automation-param-button"); + button.setAttribute("data-param", param.param); + // Set label to the parameter name, should not be L10N'd + button.setAttribute("label", param.param); + + // If first button, set to 'selected' for styling + if (i === 0) { + button.setAttribute("selected", true); + } + + this._buttons.appendChild(button); + }); + }, + + /** + * Internally sets the current audio node and rebuilds appropriate + * views. + */ + _setAudioNode: function (node) { + this._currentNode = node; + if (this._currentNode) { + this.build(); + } + }, + + /** + * Toggles the subviews to display messages whether or not + * the audio node has no AudioParams, no automation events, or + * shows the graph. + */ + _setState: function (state) { + let contentView = $("#automation-content"); + let emptyView = $("#automation-empty"); + + let graphView = $("#automation-graph-container"); + let noEventsView = $("#automation-no-events"); + + contentView.hidden = state === "no-params"; + emptyView.hidden = state !== "no-params"; + + graphView.hidden = state !== "show"; + noEventsView.hidden = state !== "no-events"; + }, + + /** + * Event handlers + */ + + _onButtonClick: function (e) { + Array.forEach($$(".automation-param-button"), $btn => $btn.removeAttribute("selected")); + let paramName = e.target.getAttribute("data-param"); + e.target.setAttribute("selected", true); + this._selectedParamName = paramName; + this.render(); + }, + + /** + * Called when the inspector is resized. + */ + _onResize: function () { + this.graph.refresh(); + }, + + /** + * Called when the inspector view determines a node is selected. + */ + _onNodeSet: function (_, id) { + this._setAudioNode(id != null ? gAudioNodes.get(id) : null); + } +}; diff --git a/toolkit/devtools/webaudioeditor/views/context.js b/toolkit/devtools/webaudioeditor/views/context.js new file mode 100644 index 000000000..f2160acf8 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/views/context.js @@ -0,0 +1,306 @@ +/* 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 (gDevTools.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); + } +}; diff --git a/toolkit/devtools/webaudioeditor/views/inspector.js b/toolkit/devtools/webaudioeditor/views/inspector.js new file mode 100644 index 000000000..38e94f4b8 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/views/inspector.js @@ -0,0 +1,187 @@ +/* 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 MIN_INSPECTOR_WIDTH = 300; + +// Strings for rendering +const EXPAND_INSPECTOR_STRING = L10N.getStr("expandInspector"); +const COLLAPSE_INSPECTOR_STRING = L10N.getStr("collapseInspector"); + +/** + * Functions handling the audio node inspector UI. + */ + +let InspectorView = { + _currentNode: null, + + // Set up config for view toggling + _collapseString: COLLAPSE_INSPECTOR_STRING, + _expandString: EXPAND_INSPECTOR_STRING, + _toggleEvent: EVENTS.UI_INSPECTOR_TOGGLED, + _animated: true, + _delayed: true, + + /** + * Initialization function called when the tool starts up. + */ + initialize: function () { + // Set up view controller + this.el = $("#web-audio-inspector"); + this.splitter = $("#inspector-splitter"); + this.el.setAttribute("width", Services.prefs.getIntPref("devtools.webaudioeditor.inspectorWidth")); + this.button = $("#inspector-pane-toggle"); + mixin(this, ToggleMixin); + this.bindToggle(); + + // Hide inspector view on startup + this.hideImmediately(); + + this._onNodeSelect = this._onNodeSelect.bind(this); + this._onDestroyNode = this._onDestroyNode.bind(this); + this._onResize = this._onResize.bind(this); + this._onCommandClick = this._onCommandClick.bind(this); + + this.splitter.addEventListener("mouseup", this._onResize); + for (let $el of $$("#audio-node-toolbar toolbarbutton")) { + $el.addEventListener("command", this._onCommandClick); + } + window.on(EVENTS.UI_SELECT_NODE, this._onNodeSelect); + gAudioNodes.on("remove", this._onDestroyNode); + }, + + /** + * Destruction function called when the tool cleans up. + */ + destroy: function () { + this.unbindToggle(); + this.splitter.removeEventListener("mouseup", this._onResize); + + $("#audio-node-toolbar toolbarbutton").removeEventListener("command", this._onCommandClick); + for (let $el of $$("#audio-node-toolbar toolbarbutton")) { + $el.removeEventListener("command", this._onCommandClick); + } + window.off(EVENTS.UI_SELECT_NODE, this._onNodeSelect); + gAudioNodes.off("remove", this._onDestroyNode); + + this.el = null; + this.button = null; + this.splitter = null; + }, + + /** + * Takes a AudioNodeView `node` and sets it as the current + * node and scaffolds the inspector view based off of the new node. + */ + setCurrentAudioNode: Task.async(function* (node) { + this._currentNode = node || null; + + // If no node selected, set the inspector back to "no AudioNode selected" + // view. + if (!node) { + $("#web-audio-editor-details-pane-empty").removeAttribute("hidden"); + $("#web-audio-editor-tabs").setAttribute("hidden", "true"); + window.emit(EVENTS.UI_INSPECTOR_NODE_SET, null); + } + // Otherwise load up the tabs view and hide the empty placeholder + else { + $("#web-audio-editor-details-pane-empty").setAttribute("hidden", "true"); + $("#web-audio-editor-tabs").removeAttribute("hidden"); + yield this._buildToolbar(); + window.emit(EVENTS.UI_INSPECTOR_NODE_SET, this._currentNode.id); + } + }), + + /** + * Returns the current AudioNodeView. + */ + getCurrentAudioNode: function () { + return this._currentNode; + }, + + /** + * Empties out the props view. + */ + resetUI: function () { + // Set current node to empty to load empty view + this.setCurrentAudioNode(); + + // Reset AudioNode inspector and hide + this.hideImmediately(); + }, + + _buildToolbar: Task.async(function* () { + let node = this.getCurrentAudioNode(); + + let bypassable = node.bypassable; + let bypassed = yield node.isBypassed(); + let button = $("#audio-node-toolbar .bypass"); + + if (!bypassable) { + button.setAttribute("disabled", true); + } else { + button.removeAttribute("disabled"); + } + + if (!bypassable || bypassed) { + button.removeAttribute("checked"); + } else { + button.setAttribute("checked", true); + } + }), + + /** + * Event handlers + */ + + /** + * Called on EVENTS.UI_SELECT_NODE, and takes an actorID `id` + * and calls `setCurrentAudioNode` to scaffold the inspector view. + */ + _onNodeSelect: function (_, id) { + this.setCurrentAudioNode(gAudioNodes.get(id)); + + // Ensure inspector is visible when selecting a new node + this.show(); + }, + + _onResize: function () { + if (this.el.getAttribute("width") < MIN_INSPECTOR_WIDTH) { + this.el.setAttribute("width", MIN_INSPECTOR_WIDTH); + } + Services.prefs.setIntPref("devtools.webaudioeditor.inspectorWidth", this.el.getAttribute("width")); + window.emit(EVENTS.UI_INSPECTOR_RESIZE); + }, + + /** + * Called when `DESTROY_NODE` is fired to remove the node from props view if + * it's currently selected. + */ + _onDestroyNode: function (node) { + if (this._currentNode && this._currentNode.id === node.id) { + this.setCurrentAudioNode(null); + } + }, + + _onCommandClick: function (e) { + let node = this.getCurrentAudioNode(); + let button = e.target; + let command = button.getAttribute("data-command"); + let checked = button.getAttribute("checked"); + + if (button.getAttribute("disabled")) { + return; + } + + if (command === "bypass") { + if (checked) { + button.removeAttribute("checked"); + node.bypass(true); + } else { + button.setAttribute("checked", true); + node.bypass(false); + } + } + } +}; diff --git a/toolkit/devtools/webaudioeditor/views/properties.js b/toolkit/devtools/webaudioeditor/views/properties.js new file mode 100644 index 000000000..29ea0a410 --- /dev/null +++ b/toolkit/devtools/webaudioeditor/views/properties.js @@ -0,0 +1,164 @@ +/* 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"; + +Cu.import("resource:///modules/devtools/VariablesView.jsm"); +Cu.import("resource:///modules/devtools/VariablesViewController.jsm"); + +const GENERIC_VARIABLES_VIEW_SETTINGS = { + searchEnabled: false, + editableValueTooltip: "", + editableNameTooltip: "", + preventDisableOnChange: true, + preventDescriptorModifiers: false, + eval: () => {} +}; + +/** + * Functions handling the audio node inspector UI. + */ + +let PropertiesView = { + + /** + * Initialization function called when the tool starts up. + */ + initialize: function () { + this._onEval = this._onEval.bind(this); + this._onNodeSet = this._onNodeSet.bind(this); + + window.on(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSet); + this._propsView = new VariablesView($("#properties-content"), GENERIC_VARIABLES_VIEW_SETTINGS); + this._propsView.eval = this._onEval; + }, + + /** + * Destruction function called when the tool cleans up. + */ + destroy: function () { + window.off(EVENTS.UI_INSPECTOR_NODE_SET, this._onNodeSet); + this._propsView = null; + }, + + /** + * Empties out the props view. + */ + resetUI: function () { + this._propsView.empty(); + this._currentNode = null; + }, + + /** + * Internally sets the current audio node and rebuilds appropriate + * views. + */ + _setAudioNode: function (node) { + this._currentNode = node; + if (this._currentNode) { + this._buildPropertiesView(); + } + }, + + /** + * Reconstructs the `Properties` tab in the inspector + * with the `this._currentNode` as it's source. + */ + _buildPropertiesView: Task.async(function* () { + let propsView = this._propsView; + let node = this._currentNode; + propsView.empty(); + + let audioParamsScope = propsView.addScope("AudioParams"); + let props = yield node.getParams(); + + // Disable AudioParams VariableView expansion + // when there are no props i.e. AudioDestinationNode + this._togglePropertiesView(!!props.length); + + props.forEach(({ param, value, flags }) => { + let descriptor = { + value: value, + writable: !flags || !flags.readonly, + }; + let item = audioParamsScope.addItem(param, descriptor); + + // No items should currently display a dropdown + item.twisty = false; + }); + + audioParamsScope.expanded = true; + + window.emit(EVENTS.UI_PROPERTIES_TAB_RENDERED, node.id); + }), + + /** + * Toggles the display of the "empty" properties view when + * node has no properties to display. + */ + _togglePropertiesView: function (show) { + let propsView = $("#properties-content"); + let emptyView = $("#properties-empty"); + (show ? propsView : emptyView).removeAttribute("hidden"); + (show ? emptyView : propsView).setAttribute("hidden", "true"); + }, + + /** + * Returns the scope for AudioParams in the + * VariablesView. + * + * @return Scope + */ + _getAudioPropertiesScope: function () { + return this._propsView.getScopeAtIndex(0); + }, + + /** + * Event handlers + */ + + /** + * Called when the inspector view determines a node is selected. + */ + _onNodeSet: function (_, id) { + this._setAudioNode(gAudioNodes.get(id)); + }, + + /** + * Executed when an audio prop is changed in the UI. + */ + _onEval: Task.async(function* (variable, value) { + let ownerScope = variable.ownerView; + let node = this._currentNode; + let propName = variable.name; + let error; + + if (!variable._initialDescriptor.writable) { + error = new Error("Variable " + propName + " is not writable."); + } else { + // Cast value to proper type + try { + let number = parseFloat(value); + if (!isNaN(number)) { + value = number; + } else { + value = JSON.parse(value); + } + error = yield node.actor.setParam(propName, value); + } + catch (e) { + error = e; + } + } + + // TODO figure out how to handle and display set prop errors + // and enable `test/brorwser_wa_properties-view-edit.js` + // Bug 994258 + if (!error) { + ownerScope.get(propName).setGrip(value); + window.emit(EVENTS.UI_SET_PARAM, node.id, propName, value); + } else { + window.emit(EVENTS.UI_SET_PARAM_ERROR, node.id, propName, value); + } + }) +}; diff --git a/toolkit/devtools/webaudioeditor/views/utils.js b/toolkit/devtools/webaudioeditor/views/utils.js new file mode 100644 index 000000000..c397a16cb --- /dev/null +++ b/toolkit/devtools/webaudioeditor/views/utils.js @@ -0,0 +1,103 @@ +/* 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"; + +/** + * Takes an element in an SVG graph and iterates over + * ancestors until it finds the graph node container. If not found, + * returns null. + */ + +function findGraphNodeParent (el) { + // Some targets may not contain `classList` property + if (!el.classList) + return null; + + while (!el.classList.contains("nodes")) { + if (el.classList.contains("audionode")) + return el; + else + el = el.parentNode; + } + return null; +} + +/** + * Object for use with `mix` into a view. + * Must have the following properties defined on the view: + * - `el` + * - `button` + * - `_collapseString` + * - `_expandString` + * - `_toggleEvent` + * + * Optional properties on the view can be defined to specify default + * visibility options. + * - `_animated` + * - `_delayed` + */ +let ToggleMixin = { + + bindToggle: function () { + this._onToggle = this._onToggle.bind(this); + this.button.addEventListener("mousedown", this._onToggle, false); + }, + + unbindToggle: function () { + this.button.removeEventListener("mousedown", this._onToggle); + }, + + show: function () { + this._viewController({ visible: true }); + }, + + hide: function () { + this._viewController({ visible: false }); + }, + + hideImmediately: function () { + this._viewController({ visible: false, delayed: false, animated: false }); + }, + + /** + * Returns a boolean indicating whether or not the view. + * is currently being shown. + */ + isVisible: function () { + return !this.el.hasAttribute("pane-collapsed"); + }, + + /** + * Toggles the visibility of the view. + * + * @param object visible + * - visible: boolean indicating whether the panel should be shown or not + * - animated: boolean indiciating whether the pane should be animated + * - delayed: boolean indicating whether the pane's opening should wait + * a few cycles or not + */ + _viewController: function ({ visible, animated, delayed }) { + let flags = { + visible: visible, + animated: animated != null ? animated : !!this._animated, + delayed: delayed != null ? delayed : !!this._delayed, + callback: () => window.emit(this._toggleEvent, visible) + }; + + ViewHelpers.togglePane(flags, this.el); + + if (flags.visible) { + this.button.removeAttribute("pane-collapsed"); + this.button.setAttribute("tooltiptext", this._collapseString); + } + else { + this.button.setAttribute("pane-collapsed", ""); + this.button.setAttribute("tooltiptext", this._expandString); + } + }, + + _onToggle: function () { + this._viewController({ visible: !this.isVisible() }); + } +} diff --git a/toolkit/devtools/webaudioeditor/webaudioeditor.xul b/toolkit/devtools/webaudioeditor/webaudioeditor.xul new file mode 100644 index 000000000..e0af48b6f --- /dev/null +++ b/toolkit/devtools/webaudioeditor/webaudioeditor.xul @@ -0,0 +1,142 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/devtools/webaudioeditor.css" type="text/css"?> +<!DOCTYPE window [ + <!ENTITY % debuggerDTD SYSTEM "chrome://browser/locale/devtools/webaudioeditor.dtd"> + %debuggerDTD; +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript;version=1.8" + src="chrome://browser/content/devtools/theme-switching.js"/> + + <script type="application/javascript" src="chrome://browser/content/devtools/d3.js"/> + <script type="application/javascript" src="dagre-d3.js"/> + <script type="application/javascript" src="webaudioeditor/includes.js"/> + <script type="application/javascript" src="webaudioeditor/models.js"/> + <script type="application/javascript" src="webaudioeditor/controller.js"/> + <script type="application/javascript" src="webaudioeditor/views/utils.js"/> + <script type="application/javascript" src="webaudioeditor/views/context.js"/> + <script type="application/javascript" src="webaudioeditor/views/inspector.js"/> + <script type="application/javascript" src="webaudioeditor/views/properties.js"/> + <script type="application/javascript" src="webaudioeditor/views/automation.js"/> + + <vbox class="theme-body" flex="1"> + <hbox id="reload-notice" + class="notice-container" + align="center" + pack="center" + flex="1"> + <button id="requests-menu-reload-notice-button" + class="devtools-toolbarbutton" + standalone="true" + label="&webAudioEditorUI.reloadNotice1;" + oncommand="gFront.setup({ reload: true });"/> + <label id="requests-menu-reload-notice-label" + class="plain" + value="&webAudioEditorUI.reloadNotice2;"/> + </hbox> + <hbox id="waiting-notice" + class="notice-container devtools-throbber" + align="center" + pack="center" + flex="1" + hidden="true"> + <label id="requests-menu-waiting-notice-label" + class="plain" + value="&webAudioEditorUI.emptyNotice;"/> + </hbox> + + <vbox id="content" + flex="1" + hidden="true"> + <toolbar id="web-audio-toolbar" class="devtools-toolbar"> + <spacer flex="1"></spacer> + <toolbarbutton id="inspector-pane-toggle" class="devtools-toolbarbutton" + tabindex="0"/> + </toolbar> + <splitter class="devtools-horizontal-splitter"/> + <box id="web-audio-content-pane" + class="devtools-responsive-container" + flex="1"> + <hbox flex="1"> + <box id="web-audio-graph" flex="1"> + <vbox flex="1"> + <svg id="graph-svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <g id="graph-target" transform="translate(20,20)"/> + </svg> + </vbox> + </box> + </hbox> + <splitter id="inspector-splitter" class="devtools-side-splitter"/> + <vbox id="web-audio-inspector" hidden="true"> + <deck id="web-audio-editor-details-pane" flex="1"> + <vbox id="web-audio-editor-details-pane-empty" flex="1"> + <label value="&webAudioEditorUI.inspectorEmpty;"></label> + </vbox> + <tabbox id="web-audio-editor-tabs" + class="devtools-sidebar-tabs" + handleCtrlTab="false"> + <toolbar id="audio-node-toolbar" class="devtools-toolbar"> + <hbox class="devtools-toolbarbutton-group"> + <toolbarbutton class="bypass devtools-toolbarbutton" + data-command="bypass" + tabindex="0"/> + </hbox> + </toolbar> + <tabs> + <tab id="properties-tab" + label="&webAudioEditorUI.tab.properties2;"/> + <!-- bug 1134036 + <tab id="automation-tab" + label="&webAudioEditorUI.tab.automation;"/> + --> + </tabs> + <tabpanels flex="1"> + <!-- Properties Panel --> + <tabpanel id="properties-tabpanel" + class="tabpanel-content"> + <vbox id="properties-content" flex="1" hidden="true"> + </vbox> + <vbox id="properties-empty" flex="1" hidden="true"> + <label value="&webAudioEditorUI.propertiesEmpty;"></label> + </vbox> + </tabpanel> + + <!-- Automation Panel --> + <tabpanel id="automation-tabpanel" + class="tabpanel-content"> + <vbox id="automation-content" flex="1" hidden="true"> + <toolbar id="automation-param-toolbar" class="devtools-toolbar"> + <hbox id="automation-param-toolbar-buttons" class="devtools-toolbarbutton-group"> + </hbox> + </toolbar> + <box id="automation-graph-container" flex="1"> + <canvas id="automation-graph"></canvas> + </box> + <vbox id="automation-no-events" flex="1" hidden="true"> + <label value="&webAudioEditorUI.automationNoEvents;"></label> + </vbox> + </vbox> + <vbox id="automation-empty" flex="1" hidden="true"> + <label value="&webAudioEditorUI.automationEmpty;"></label> + </vbox> + </tabpanel> + </tabpanels> + </tabbox> + </deck> + </vbox> + </box> + </vbox> + </vbox> + +</window> |