diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..350959c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/bower_components +/node_modules +/build +deb_build.py +.DS_Store diff --git a/DOMJSONizer.js b/DOMJSONizer.js new file mode 100644 index 0000000..d23e107 --- /dev/null +++ b/DOMJSONizer.js @@ -0,0 +1,443 @@ +// Contains a class DOMJSONizer used to JSONize DOM tree, objects, properties + +// TODO: Remove all logic that pertains to Polymer elements from here and pass them as callbacks +function DOMJSONizer() { + function isPolymerElement(element) { + return element && ('element' in element) && (element.element.localName === 'polymer-element'); + } + + // Polymer-specific stuff (to flag them differently) + // Mostly taken from: http://www.polymer-project.org/docs/polymer/polymer.html#lifecyclemethods + var polymerSpecificProps = { + observe: true, + publish: true, + created: true, + ready: true, + attached: true, + domReady: true, + detached: true, + attributeChanged: true + }; + + /** + * Checks if a property is an accessor property. + * @param {Object} obj The exact object on which the property is present. + * @param {String} prop Name of the property + * @return {Boolean} Whether the property is an accessor (get/set) or not. + */ + function propHasAccessor(obj, prop) { + var descriptor = Object.getOwnPropertyDescriptor(obj, prop); + if (!descriptor) { + console.error(prop); + } + return Boolean(descriptor.set) || Boolean(descriptor.get); + } + + /** + * Copies a property from oldObj to newObjArray and adds some metadata. + * @param {Object} protoObject The exact object in chain where the property exists. + * @param {Object} oldObj The source object + * @param {Array} newObjArray The destination object (which is maintained as an Array). + * @param {String} prop Name of the property + */ + function copyProperty(protoObject, oldObj, newObjArray, prop) { + try { + var tmp = oldObj[prop]; + } catch (e) { + // We encountered an error trying to read the property. + // It must be a getter that is failing. + newObjArray.push({ + type: 'error', + hasAccessor: true, + error: true, + value: e.message, + name: prop + }); + return; + } + if (oldObj[prop] === null) { + newObjArray.push({ + type: 'null', + hasAccessor: false, + value: 'null', + name: prop + }); + } else if (typeof oldObj[prop] === 'string' || + typeof oldObj[prop] === 'number' || + typeof oldObj[prop] === 'boolean') { + newObjArray.push({ + type: typeof oldObj[prop], + hasAccessor: propHasAccessor(protoObject, prop), + value: oldObj[prop].toString(), + name: prop + }); + } else if (((typeof oldObj[prop] === 'object' && + !(oldObj[prop] instanceof Array)) || + typeof oldObj[prop] === 'function')) { + newObjArray.push({ + type: typeof oldObj[prop], + hasAccessor: propHasAccessor(protoObject, prop), + value: [], + name: prop + }); + } else if (typeof oldObj[prop] === 'object') { + newObjArray.push({ + type: 'array', + hasAccessor: propHasAccessor(protoObject, prop), + length: oldObj[prop].length, + value: [], + name: prop + }); + } else { + newObjArray.push({ + type: 'undefined', + hasAccessor: false, + value: 'undefined', + name: prop + }); + } + } + + /** + * Converts an object to JSON but only one-level deep. + * @param {Object} obj The object to be converted. + * @param {Function} filter A filter function that is supposed to filter out properties. + */ + function JSONize(obj, filter) { + + /** + * Gets the own properties of an object. + * @param {Object} obj The object whose properties we want. + * @return {Array} An array of properties. + */ + function getOwnFilteredProps(obj) { + var props = Object.getOwnPropertyNames(obj); + if (filter) { + props = props.filter(filter); + } + return props; + } + + /** + * Explores a Polymer element for its properties by searching multiple + * prototype levels. + * @param {HTMLElement} element The element that we want to explore. + * @param {Array} destObj The destination object (managed as an array) we want to populate. + */ + function explorePolymerObject(element, destObj) { + var addedProps = {}; + /** Tells if a property was already added */ + function isAdded(el) { + return (el in addedProps); + } + + function addToAddedProps(el) { + addedProps[el] = true; + } + if (isPolymerElement(element)) { + var proto = element; + // Go looking into the proto chain. + while (proto && !Polymer.isBase(proto)) { + var props = getOwnFilteredProps(proto); + for (var i = 0; i < props.length; i++) { + if (isAdded(props[i])) { + continue; + } + addToAddedProps(props[i]); + copyProperty(proto, element, destObj, props[i]); + // Add a flag to show Polymer implementation properties separately + if (props[i] in polymerSpecificProps) { + destObj[destObj.length - 1].polymer = true; + } + // Add a flag to show published properties differently + if (props[i] in element.publish) { + destObj[destObj.length - 1].published = true; + } + } + proto = proto.__proto__; + } + destObj.sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + } + } + + /** + * Explores a non-Polymer object for own properties + * @param {Object} obj object to look in + * @param {Array} destObj destination object (managed as an array) to copy properties + */ + function exploreObject(obj, destObj) { + var props = Object.getOwnPropertyNames(obj).sort(); + for (var i = 0; i < props.length; i++) { + if (!filter || filter(props[i])) { + try { + copyProperty(obj, obj, destObj, props[i]); + } catch (e) { + // TODO: Some properties throw when read. Find out more. + } + } + } + // TODO: `__proto__` ? + } + + /** + * Copies the contents of an array to destination. + * @param {Array} arr Source array + * @param {Array} destObj Destination object (managed as an array) + */ + function exploreArray(arr, destObj) { + for (var i = 0; i < arr.length; i++) { + try { + copyProperty(arr, arr, destObj, i); + } catch (e) { + // TODO: Some properties throw when read. Find out more. + } + } + } + + // The root object is named as `Root`. + var res = { + name: 'Root', + value: [] + }; + if (isPolymerElement(obj)) { + res.type = 'object'; + explorePolymerObject(obj, res.value); + } else { + if (obj instanceof Array) { + res.type = 'array'; + exploreArray(obj, res.value); + } else if (typeof obj === 'object' || + typeof obj === 'function') { + res.type = typeof obj; + exploreObject(obj, res.value); + } + } + return res; + } + + /** + * Tells if an element is a script or style element. + * @param {HTMLElement} el The element we're checkin + * @return {Boolean} whether it is a + + diff --git a/devtools.js b/devtools.js new file mode 100644 index 0000000..0be035f --- /dev/null +++ b/devtools.js @@ -0,0 +1,6 @@ +// Create a new panel +chrome.devtools.panels.create("Polymer", + null, + "panel.html", + null +); diff --git a/elements/bread-crumbs/bread-crumbs.html b/elements/bread-crumbs/bread-crumbs.html new file mode 100644 index 0000000..97922a3 --- /dev/null +++ b/elements/bread-crumbs/bread-crumbs.html @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/elements/bread-crumbs/bread-crumbs.js b/elements/bread-crumbs/bread-crumbs.js new file mode 100644 index 0000000..4f91485 --- /dev/null +++ b/elements/bread-crumbs/bread-crumbs.js @@ -0,0 +1,10 @@ +Polymer('bread-crumbs', { + clicked: function(event) { + var index = parseInt(event.target.id.substring(3)); + var key = this.list[index].key; + this.list.splice(index + 1); + this.fire('bread-crumb-click', { + key: key + }); + } +}); diff --git a/elements/core-pages/core-pages.html b/elements/core-pages/core-pages.html new file mode 100644 index 0000000..fe75856 --- /dev/null +++ b/elements/core-pages/core-pages.html @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/elements/core-pages/core-pages.js b/elements/core-pages/core-pages.js new file mode 100644 index 0000000..d273434 --- /dev/null +++ b/elements/core-pages/core-pages.js @@ -0,0 +1,16 @@ +Polymer('core-pages', { + selected: 0, + ready: function() { + if (this.children[this.selected]) { + this.children[this.selected].classList.add('core-pages-selected'); + } + }, + selectedChanged: function(oldVal, newVal) { + if (this.children[this.selected]) { + this.children[oldVal].classList.remove('core-pages-selected'); + this.children[this.selected].classList.add('core-pages-selected'); + } else { + this.selected = oldVal; + } + } +}); diff --git a/elements/editable-label/editable-label.html b/elements/editable-label/editable-label.html new file mode 100644 index 0000000..0d93c0e --- /dev/null +++ b/elements/editable-label/editable-label.html @@ -0,0 +1,23 @@ + + + + + + + diff --git a/elements/editable-label/editable-label.js b/elements/editable-label/editable-label.js new file mode 100644 index 0000000..feaade9 --- /dev/null +++ b/elements/editable-label/editable-label.js @@ -0,0 +1,75 @@ +(function() { + Polymer('editable-label', { + text: '', + // Present state: editing or not + editing: false, + width: 5, + lastText: null, + hidden: false, + /** + * Called when editing begins or ends. Shows and hides the input field and + * span accordingly + */ + toggleEditing: function() { + if (this.editing) { + this.$.dynamic.style.display = 'none'; + this.$.static.style.display = 'block'; + } else { + this.$.dynamic.style.display = 'block'; + this.$.static.style.display = 'none'; + } + this.editing = !this.editing; + }, + ready: function() { + this.$.dynamic.style.display = 'none'; + }, + /** + * When we start editing a field + */ + startEditing: function() { + if (!this.editing) { + this.toggleEditing(); + // Select all text in the dynamic field + this.$.dynamic.select(); + } + }, + /** + * When we are done editing a field + */ + stopEditing: function() { + if (this.editing) { + this.toggleEditing(); + if (this.lastText === this.text) { + return; + } + var that = this; + this.fire('field-changed', { + oldValue: that.lastText, + newValue: that.text, + field: that, + name: that.getAttribute('data-name') + }); + this.lastText = this.text; + } + }, + /** + * Handle `enter` key + */ + handleKeyPress: function(event) { + if (event.keyCode === 13) { + // Enter was pressed. It marks the end of editing. + this.stopEditing(); + } + }, + /** + * When text changes. + * @param {String} oldVal Old value of the field + * @param {String} newVal New value of the field + */ + textChanged: function(oldVal, newVal) { + if (this.lastText === null) { + this.lastText = newVal; + } + } + }); +})(); diff --git a/elements/element-tree/element-tree.html b/elements/element-tree/element-tree.html new file mode 100644 index 0000000..8ef0db3 --- /dev/null +++ b/elements/element-tree/element-tree.html @@ -0,0 +1,83 @@ + + + + + + + + diff --git a/elements/element-tree/element-tree.js b/elements/element-tree/element-tree.js new file mode 100644 index 0000000..29eb36b --- /dev/null +++ b/elements/element-tree/element-tree.js @@ -0,0 +1,186 @@ +(function() { + + function newExpandBtnIcon(collapsed) { + return collapsed ? 'chevron-right' : 'expand-more'; + } + + Polymer('element-tree', { + indent: 0, + collapsed: false, + // Whether the element at the root is selected or not + selected: false, + baseWidth: 10, + expandBtnIcon: newExpandBtnIcon(false), + // Polymer elements are shown differently and can be selected + isPolymer: false, + ready: function() { + this.childElements = []; + this.$.childrenContent.style.marginLeft = this.indent + this.baseWidth + 'px'; + }, + addChild: function(element) { + element.indent = this.indent + this.baseWidth; + this.childElements.push(element); + this.$.childrenContent.appendChild(element); + }, + /** + * Empties the element-tree + */ + empty: function() { + this.text = ''; + if (this.selected) { + this.root.selectedChild = null; + this.selected = false; + this.$.thisElement.removeAttribute('selected'); + } + if (this.isPolymer) { + delete this.isPolymer; + } + if (this.unRendered) { + delete this.unRendered; + } + if (this.keyMap && this.keyMap[this.key]) { + delete this.keyMap[this.key]; + } + for (var i = 0; i < this.childElements.length; i++) { + this.childElements[i].empty(); + this.$.childrenContent.innerHTML = ''; + } + delete this.childElements; + this.childElements = []; + }, + /** + * Helper method that is invoked by initFromDOMTree the first time. + * @param {Object} tree An object with a tagName and children trees + * @param {Boolean} isDiggable If it is possible to hit the `+` button to see inner stuff + * @param {ElementTree} root Root of this tree (top-most parent) + */ + _initFromDOMTreeHelper: function(tree, isDiggable, root) { + this.text = '<' + tree.tagName + '>'; + // conditionally set these to save memory (there can be a huge page with very few + // Polymer elements) + if (tree.isPolymer) { + this.isPolymer = true; + } + if (tree.unRendered) { + this.unRendered = true; + } + this.key = tree.key; + this.keyMap = this.keyMap || (root ? root.keyMap : {}); + this.keyMap[this.key] = this; + this.tree = tree; + this.isDiggable = isDiggable; + this.root = root || this; + for (var i = 0; i < tree.children.length; i++) { + // Create a new ElementTree to hold a child + var child = new ElementTree(); + child._initFromDOMTreeHelper(tree.children[i], isDiggable, this.root); + this.addChild(child); + } + }, + /** + * Populates the tree with a tree object. + * @param {Object} tree An object with a tagName and children trees + * @param {Boolean} isDiggable If it is possible to hit the `+` button to see inner stuff + * @param {ElementTree} root Root of this tree (top-most parent) + */ + initFromDOMTree: function(tree, isDiggable, root) { + this.empty(); + this._initFromDOMTreeHelper(tree, isDiggable, root); + }, + /** + * When the tree is expanded or minimized. + */ + toggle: function() { + if (this.childElements.length === 0) { + return; + } + this.collapsed = !(this.collapsed); + this.expandBtnIcon = newExpandBtnIcon(this.collapsed); + for (var i = 0; i < this.childElements.length; i++) { + if (this.collapsed) { + this.childElements[i].$.content.style.display = 'none'; + } else { + this.childElements[i].$.content.style.display = 'block'; + } + } + }, + /** + * When an element is selected or unselected in the tree. + */ + toggleSelection: function() { + if (this.selected) { + // selectedChild holds the element in the tree that is currently selected + this.root.selectedChild = null; + this.$.thisElement.removeAttribute('selected'); + this.selected = !(this.selected); + this.fire('unselected', { + key: this.key + }); + } else { + var oldKey = null; + if (this.root.selectedChild) { + oldKey = this.root.selectedChild.key; + // First unselect the currently selected child + this.root.selectedChild.selected = false; + this.root.selectedChild.$.thisElement.removeAttribute('selected'); + } + this.root.selectedChild = this; + this.$.thisElement.setAttribute('selected', 'selected'); + this.selected = !(this.selected); + this.fire('selected', { + key: this.key, + oldKey: oldKey + }); + } + }, + /** + * Gets the child tree for a given key + * @param {Number} key Key to find the ElementTree of + * @return {ElementTree} ElementTree that holds the subtree of the element + */ + getChildTreeForKey: function(key) { + return this.keyMap ? this.keyMap[key] : null; + }, + /** + * Tells the extension to highlight the element that was hovered on. + */ + mouseOver: function() { + // Only if this is not already selected + if (!this.selected) { + this.fire('highlight', { + key: this.key + }); + } + }, + /** + * Tells the extension to unhighlight the highlight that was hovered out of. + */ + mouseOut: function() { + // Only if this is not already selected + if (!this.selected) { + this.fire('unhighlight', { + key: this.key + }); + } + }, + /** + * When the `+` is clicked on an element to dig into + */ + magnify: function() { + var eventName = this.isDiggable ? 'magnify' : 'unmagnify'; + this.fire(eventName, { + key: this.key + }); + }, + /** + * When the `view source` button is clicked + * @param {Event} e JS Event object + */ + viewSource: function(e) { + e.stopPropagation(); + this.fire('view-source', { + key: this.key + }); + } + }); +})(); diff --git a/elements/method-list/method-list.html b/elements/method-list/method-list.html new file mode 100644 index 0000000..82a3bfd --- /dev/null +++ b/elements/method-list/method-list.html @@ -0,0 +1,39 @@ + + + + + + + diff --git a/elements/method-list/method-list.js b/elements/method-list/method-list.js new file mode 100644 index 0000000..107a231 --- /dev/null +++ b/elements/method-list/method-list.js @@ -0,0 +1,23 @@ +(function() { + Polymer('method-list', { + filterSelected: function(items) { + var selected = []; + for (var i = 0; i < items.length; i++) { + if (items[i].setBreakpoint) { + selected.push(i); + } + } + return selected; + }, + ready: function() { + this.list = []; + this.addEventListener('core-select', function(event) { + event.stopPropagation(); + this.fire('breakpoint-toggle', { + isSet: event.detail.isSelected, + index: event.detail.item.id.substring(6) + }); + }); + } + }); +})(); diff --git a/elements/object-tree/object-tree.html b/elements/object-tree/object-tree.html new file mode 100644 index 0000000..252a286 --- /dev/null +++ b/elements/object-tree/object-tree.html @@ -0,0 +1,85 @@ + + + + + + + + + diff --git a/elements/object-tree/object-tree.js b/elements/object-tree/object-tree.js new file mode 100644 index 0000000..9112d8b --- /dev/null +++ b/elements/object-tree/object-tree.js @@ -0,0 +1,149 @@ +(function() { + var EXPAND_BTN_IMAGE = '../res/expand.png'; + var COLLAPSE_BTN_IMAGE = '../res/collapse.png'; + var BLANK_IMAGE = '../res/blank.png'; + + function copyArray(arr) { + var newArr = []; + for (var i = 0; i < arr.length; i++) { + newArr.push(arr[i]); + } + return newArr; + } + /** + * Converts a value represented as a string to an actual JS object. + * E.g. : "true" -> true and "5" -> 5 + */ + function smartCast(val) { + if ((val.length >= 2 && + (val[0] === '"' && val[val.length - 1] === '"') || + (val[0] === '\'' && val[val.length - 1] === '\''))) { + return val.substring(1, val.length - 1); + } + switch (val) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'undefined': + return undefined; + } + if (!isNaN(parseInt(val, 10))) { + return parseInt(val, 10); + } + throw 'Bad value'; + } + /** + * Check if a value is valid + */ + function isFieldValueValid(val) { + try { + smartCast(val); + } catch (e) { + return false; + } + return true; + } + Polymer('object-tree', { + baseWidth: 14, + ready: function() { + this.tree = []; + this.path = []; + // When one of the fields change, it will let the object-tree know + // with this event + this.addEventListener('field-changed', function(event) { + var newValue = event.detail.newValue; + var oldValue = event.detail.oldValue; + var index = event.detail.field.id.substring(5); + var path = copyArray(this.path); + path.push(index); + // Reset it to the old value since O.o() will update it anyway after reflection + event.detail.field.text = oldValue; + // Stop propagation since this will fire another event + event.stopPropagation(); + if (!isFieldValueValid(newValue)) { + return; + } + var that = this; + // Fire an event with all the information + this.fire('property-changed', { + path: path, + value: smartCast(newValue), + // reEval is needed if it is an accessor and O.o() won't update it. + reEval: event.detail.field.getAttribute('data-hasAccessor') === 'true', + tree: that.tree, + name: event.detail.name + }); + }); + // When the `tree` property is updated, Polymer might add + // some object-trees under this. Those child trees need to be initialized. + this.addEventListener('child-added', function(event) { + var child = event.detail.child; + if (child === this) { + return; + } + var index = event.detail.index; + if (this.tree[index].value instanceof Array) { + child.tree = this.tree[index].value; + var pathCopy = copyArray(this.path); + pathCopy.push(index); + child.path = pathCopy; + } + event.stopPropagation(); + }); + // When this message is heard, it destroys a child of itself. + this.addEventListener('child-collapsed', function(event) { + var index = event.detail.index; + // Empty the child tree. + this.tree[index].value.length = 0; + event.stopPropagation(); + }); + }, + /** + * Called when Polymer instantiates this object tree because of + * data-binding (with template repeat) + */ + domReady: function() { + var that = this; + this.fire('child-added', { + child: that, + index: that.id.substring(5) + }); + }, + /** + * Collapse/Uncollapse + */ + toggle: function(event) { + var targetId = event.target.id; + var state = event.target.getAttribute('state'); + if (state === 'expanded') { + this.fire('child-collapsed', { + index: targetId.substring(3) + }); + } + var path = copyArray(this.path); + path.push(targetId.substring(3)); + var eventName = (state === 'collapsed') ? 'object-expand' : 'object-collapse'; + this.fire(eventName, { + path: path + }); + }, + /** + * Called when the refresh button is clicked on an accessor + */ + refreshField: function(event) { + var targetIndex = event.target.id.substring(7); + var path = copyArray(this.path); + path.push(targetIndex); + var that = this; + this.fire('refresh-property', { + path: path, + index: targetIndex, + tree: that.tree, + name: event.target.getAttribute('data-name') + }); + } + }); +})(); diff --git a/elements/split-pane/split-pane.html b/elements/split-pane/split-pane.html new file mode 100644 index 0000000..bb22acc --- /dev/null +++ b/elements/split-pane/split-pane.html @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/elements/split-pane/split-pane.js b/elements/split-pane/split-pane.js new file mode 100644 index 0000000..ea46928 --- /dev/null +++ b/elements/split-pane/split-pane.js @@ -0,0 +1,22 @@ +(function() { + Polymer('split-pane', { + // TODO: Remove this unless it is decided to re-write core-splitter. + /*resizePanels: function (event) { + var newX = event.offsetX + this.$.left.offsetWidth; + var totalWidth = this.$.content.offsetWidth; + this.$.left.style.width = (newX / totalWidth * 100) + '%'; + },*/ + get leftScrollTop() { + return this.$.left.scrollTop; + }, + set leftScrollTop(pixels) { + this.$.left.scrollTop = pixels; + }, + get rightScrollTop() { + return this.$.right.scrollTop; + }, + set rightScrollTop(pixels) { + this.$.right.scrollTop = pixels; + } + }); +})(); diff --git a/evalHelper.js b/evalHelper.js new file mode 100644 index 0000000..2b462a5 --- /dev/null +++ b/evalHelper.js @@ -0,0 +1,155 @@ +/** + * A helper object to help `eval` code in host page + */ +function createEvalHelper(callback) { + // The extension's ID serves as the namespace. + var extensionNamespace = chrome.runtime.id; + /** + * Converts any object to a string + */ + function serialize(object) { + return JSON.stringify(object); + } + var srcURLID = 0; + /** + * gets a unique src URL. + */ + function getSrcURL(string) { + srcURLID++; + return '\n//@ sourceURL=src' + srcURLID + '.js'; + } + /** + * Wraps a function into a self executing function that gets called with the + * unique namespace (extension ID) so that the function to be defined in the + * host page gets to know of the namespace and also gets defined in the same + * namespace. + * @param {String} fnName name of the function + * @param {String} fnString body of the function + * @return {String} the wrapped function string + */ + function wrapFunction(fnName, fnString) { + return '(function (NAMESPACE) {' + + 'window["' + extensionNamespace + '"].' + fnName + ' = ' + + fnString + ';' + + '})("' + extensionNamespace + '");'; + } + var helper = { + /** + * Define a function + * @param {String} name Name of the function + * @param {String} string Body of the function + * @param {Function} callback Function to be called after definion of function + */ + defineFunction: function(name, string, callback) { + chrome.devtools.inspectedWindow.eval(wrapFunction(name, string) + getSrcURL(), + function(result, error) { + callback && callback(result, error); + }); + }, + /** + * Define functions in a batch + * @param {Array} functionObjects Objects that have `name` and `string` keys + * to mean the name and body of the function respectively + * @param {Function} callback Function called when definitions are done + */ + defineFunctions: function(functionObjects, callback) { + var toEval = ''; + for (var i = 0; i < functionObjects.length; i++) { + toEval += wrapFunction(functionObjects[i].name, functionObjects[i].string) + ';\n\n'; + } + toEval += getSrcURL(); + chrome.devtools.inspectedWindow.eval(toEval, function(result, error) { + callback && callback(result, error); + }); + }, + /** + * Execute a function with args and optionally assign the result to something + * @param {String} name Name of the function + * @param {Array} args An array of arguments + * @param {Function} callback Called when the execution is done + * @param {String} lhs Name of the variable to assign result to + */ + executeFunction: function(name, args, callback, lhs) { + var params = '('; + for (var i = 0; i < args.length - 1; i++) { + params += serialize(args[i]) + ', '; + } + if (args.length > 0) { + params += serialize(args[i]); + } + params += ')'; + var toEval = (lhs ? ('window["' + extensionNamespace + '"].' + lhs + ' = ') : '') + + 'window["' + extensionNamespace + '"].' + name + params + ';'; + toEval += getSrcURL(); + chrome.devtools.inspectedWindow.eval(toEval, function(result, error) { + callback && callback(result, error); + }); + } + }; + + /** + * Does the necessary clean-up to remove all traces of the extension in the page + */ + function cleanUp() { + window.removeEventListener('clean-up', window[NAMESPACE].cleanUp); + var keys; + var i, j, methodNames; + // Remove all object observers that were registered + keys = Object.keys(window[NAMESPACE].observerCache); + for (i = 0; i < keys.length; i++) { + window[NAMESPACE].removeObjectObserver(keys[i], [], false); + } + // Remove all model object observers that were registered + keys = Object.keys(window[NAMESPACE].modelObserverCache); + for (i = 0; i < keys.length; i++) { + window[NAMESPACE].removeObjectObserver(keys[i], [], true); + } + // Remove any breakpoints that were set + keys = Object.keys(window[NAMESPACE].breakPointIndices); + for (i = 0; i < keys.length; i++) { + methodNames = Object.keys(window[NAMESPACE].breakPointIndices[keys[i]]); + for (j = 0; j < methodNames.length; j++) { + if (methodNames[i] in window[NAMESPACE].DOMCache[keys[i]]) { + undebug(window[NAMESPACE].DOMCache[keys[i]][methodNames[i]]); + } + } + } + keys = Object.keys(window[NAMESPACE].DOMCache); + for (i = 0; i < keys.length; i++) { + // Remove DOM mutation observers + if (keys[i] in window[NAMESPACE].mutationObserverCache) { + window[NAMESPACE].mutationObserverCache[keys[i]].disconnect(); + } + // Remove the key property that we had added to all DOM objects + delete window[NAMESPACE].DOMCache[keys[i]].__keyPolymer__; + } + // Unhighlight any selected element + if (window[NAMESPACE].lastSelectedKey) { + window[NAMESPACE].unhighlight(window[NAMESPACE].lastSelectedKey, false); + } + // TODO: Unhighlight hovered elements too + delete window[NAMESPACE]; + } + // Wait till the namespace is created and clean-up handler is created. + chrome.devtools.inspectedWindow.eval('window["' + extensionNamespace + '"] = {};', + function(result, error) { + // Define cleanUp + helper.defineFunction('cleanUp', cleanUp.toString(), function(result, error) { + if (error) { + throw error; + } + // Add an event listener that removes itself + chrome.devtools.inspectedWindow.eval('window.addEventListener("clean-up", ' + + 'window["' + extensionNamespace + '"].cleanUp);', + function(result, error) { + if (error) { + throw error; + } + // We are ready to let helper be used + callback(helper); + } + ); + }); + } + ); +} diff --git a/hostPageHelpers.js b/hostPageHelpers.js new file mode 100644 index 0000000..29853fb --- /dev/null +++ b/hostPageHelpers.js @@ -0,0 +1,919 @@ +// All these helpers are meant to be injected into the host page as strings +// after .toString(). +// E.g, to define `highlight` a function which takes `NAMESPACE` +// as an argument and has a `window[] = ;` +// in its body is self-executed in the context of the host page. +// +// If you add a new function, and wish for it to be exposed, you will also need +// to add it to `createPageView()` in `panel-orig.js`. + +/** + * Reloads the page. + */ +function reloadPage () { + window.location.reload(); +} + +/** + * Used to check if the page is fresh (untouched by the extension). + * If it is defined in the page, then it won't raise an error. The page is considered unfresh. + * If it goes through, the page is fresh. + * (This is dummy function.) + * @return {String} + */ +function isPageFresh() { + return true; +} + +/** + * Adds the extension ID to the event name so its unique. + * @param {String} name name of event + * @return {String} new event name + */ +function getNamespacedEventName(name) { + return NAMESPACE + '-' + name; +} + +/** + * Creates/updates an overlay at `rect`. Will replace the previous overlay at + * `Overlays[index]` if it exists. + * @param {!ClientRect} rect rectangle to display a highlight over. + * @param {Number} index index of `Overlays` to reuse/set. + */ +function renderOverlay(rect, index) { + var overlay = window[NAMESPACE].overlays[index]; + if (!overlay) { + overlay = window[NAMESPACE].overlays[index] = document.createElement('div'); + document.body.appendChild(overlay); + + overlay.style.position = 'absolute'; + overlay.style.backgroundColor = 'rgba(255, 64, 129, 0.5)'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = 2147483647; // Infinity is not valid. + } + + overlay.style.left = (rect.left + window.scrollX) + 'px'; + overlay.style.top = (rect.top + window.scrollY) + 'px'; + overlay.style.height = rect.height + 'px'; + overlay.style.width = rect.width + 'px'; + overlay.style.visibility = 'visible'; +} + +/** + * Hides overlays at minIndex and above. + * @param {Number} minIndex minimum index to hide at. + */ +function hideOverlays(minIndex) { + var overlays = window[NAMESPACE].overlays; + for (var i = overlays.length - 1; i >= minIndex; i--) { + overlays[i].style.visibility = 'hidden'; + } +} + +/** + * Highlights an element in the page. + * @param {Number} key Key of the element to highlight + */ +function highlight(key) { + var element = window[NAMESPACE].DOMCache[key]; + if (window[NAMESPACE].highlightedElement == element) return; + window[NAMESPACE].highlightedElement = element; + + var rects = element.getClientRects(); + for (var i = 0, rect; rect = rects[i]; i++) { + window[NAMESPACE].renderOverlay(rect, i); + } + // And mop up any extras. + window[NAMESPACE].hideOverlays(rects.length); +} + +/** + * Unhighlights an element in the page. + * @param {Number} key Key of the element in the page + */ +function unhighlight(key) { + var element = window[NAMESPACE].DOMCache[key]; + if (window[NAMESPACE].highlightedElement == element) { + window[NAMESPACE].highlightedElement = null; + window[NAMESPACE].hideOverlays(0); + } +} + +/** + * Scrolls an element into view. + * @param {Number} key key of the element in the page + */ +function scrollIntoView(key) { + if (key in window[NAMESPACE].DOMCache) { + window[NAMESPACE].DOMCache[key].scrollIntoView(); + } +} + +/** + * Sets a breakpoint on a method of an element. + * @param {Number} key Key of the element + * @param {Array} path path to find the method in the element + */ +function setBreakpoint(key, path) { + var method = window[NAMESPACE].resolveObject(key, path); + var methodName = window[NAMESPACE].getPropPath(key, path).pop(); + if (typeof method !== 'function') { + return; + } + if (!(key in window[NAMESPACE].breakPointIndices)) { + window[NAMESPACE].breakPointIndices[key] = {}; + } + window[NAMESPACE].breakPointIndices[key][methodName] = true; + debug(method); +} + +/** + * Clears a breakpoint on a method of an element. + * @param {Number} key Key of the element + * @param {Array} path path to find the method in the element + */ +function clearBreakpoint(key, path) { + var method = window[NAMESPACE].resolveObject(key, path); + var methodName = window[NAMESPACE].getPropPath(key, path).pop(); + if (typeof method !== 'function') { + return; + } + if ((key in window[NAMESPACE].breakPointIndices) && + (methodName in window[NAMESPACE].breakPointIndices[key])) { + delete window[NAMESPACE].breakPointIndices[key][methodName]; + } + undebug(method); +} + +/** + * Returns a string property path from an index property path (which is used by the UI). + * @param {Number} key Key of the element + * @param {Array} path index property path + * @param {Boolean} isModel if the object is from the model-tree + * @return {Array} The string property path (array of property names) + */ +function getPropPath(key, path, isModel) { + var propPath = []; + var indexMap = isModel ? window[NAMESPACE].modelIndexToPropMap[key] : + window[NAMESPACE].indexToPropMap[key]; + path.forEach(function(el) { + indexMap = indexMap[el]; + propPath.push(indexMap.__name__); + }); + return propPath; +} + +/** + * Finds an object given an element's key and a path to reach the object. + * @param {Number} key Key of the element. + * @param {Array} path The index path to find the object. + * @param {Boolean} isModel If the object is to be looked under the model of the element. + * @return {} The object that was found. + * @template T + */ +function resolveObject(key, path, isModel) { + var obj = isModel ? window[NAMESPACE].DOMModelCache[key] : + window[NAMESPACE].DOMCache[key]; + var propPath = window[NAMESPACE].getPropPath(key, path, isModel); + propPath.forEach(function(el) { + obj = obj[el]; + }); + return obj; +} + +/** + * Changes a property of an element in the page. + * @param {Number} key Key of the element + * @param {Array} path index path to find the element + * @param {} newValue the new value to put + * @param {Boolean} isModel if it is a model object + * @template T + */ +function changeProperty(key, path, newValue, isModel) { + var prop = window[NAMESPACE].getPropPath(key, path, isModel).pop(); + path.pop(); + var obj = window[NAMESPACE].resolveObject(key, path, isModel); + if (obj) { + obj[prop] = newValue; + } +} + +/** + * Gets a property of an element. + * @param {Number} key Key of the element + * @param {Array} path Index path to find the property + * @param {Boolean} isModel if it is a model object + * @return {Object} A wrapped JSON object containing the latest value + */ +function getProperty(key, path, isModel) { + var prop = window[NAMESPACE].getPropPath(key, path, isModel).pop(); + path.pop(); + var obj = window[NAMESPACE].resolveObject(key, path, isModel); + return window[NAMESPACE].JSONizer.JSONizeProperty(prop, obj); +} + +/** + * Adds an element to DOMCache and DOMModelCache. + * @param {HTMLElement} obj the element to cache + * @param {Number} key The key to identify it with + */ +function addToCache(obj, key) { + if (obj.tagName === 'TEMPLATE' && obj.model) { + window[NAMESPACE].DOMModelCache[key] = obj.model; + } else if (obj.templateInstance) { + window[NAMESPACE].DOMModelCache[key] = obj.templateInstance.model; + } + window[NAMESPACE].DOMCache[key] = obj; +} + +/** + * Used as a listener to inspector selection changes. + */ +function inspectorSelectionChangeListener() { + if ($0.__keyPolymer__) { + window.dispatchEvent(new CustomEvent(window[NAMESPACE].getNamespacedEventName('inspected-element-changed'), { + detail: { + key: $0.__keyPolymer__ + } + })); + } +} + +/** + * Goes deep into indexMap to find the subIndexMap that stores information about + * the object found by traversing through `path`. + * @param {Number} key Key of the element + * @param {Array} path The index path to traverse with + * @param {Boolean} isModel if it is a model object + * @return {Object} Sub index-map + */ +function getIndexMapObject(key, path, isModel) { + var start = isModel ? window[NAMESPACE].modelIndexToPropMap[key] : + window[NAMESPACE].indexToPropMap[key]; + for (var i = 0; i < path.length; i++) { + start = start[path[i]]; + } + return start; +} + +/** + * Adds a new property to a sub-index map. It creates a mapping between a property + * name and its associated index (as managed by the extension UI) and also a reverse + * mapping. Note: 'name-' is prepended to the property name key. + * @param {Object} indexMap sub-Index map + * @param {String} propName Property name + */ +function addToSubIndexMap(indexMap, propName) { + indexMap.__lastIndex__ = '__lastIndex__' in indexMap ? (indexMap.__lastIndex__ + 1) : 0; + indexMap[indexMap.__lastIndex__] = { + __name__: propName + }; + indexMap['name-' + propName] = indexMap.__lastIndex__; +} + +/** + * Removes a property from a sub index map. + * @param {Object} indexMap The sub-index map + * @param {Number} index The index to remove the property of + */ +function removeFromSubIndexMap(indexMap, index) { + var propName = indexMap[index].__name__; + for (var i = index + 1; i <= indexMap.__lastIndex__; i++) { + indexMap[i - 1] = indexMap[i]; + } + delete indexMap['name-' + propName]; + delete indexMap[indexMap.__lastIndex__--]; +} + +/** + * Empties a sub-index map. + * @param {Number} key Key of the element + * @param {Array} path index array to find the sub-index map + * @param {Boolean} isModel if we have to look in the model index map + */ +function emptySubIndexMap(key, path, isModel) { + // Find the sub-index map + var indexMap = isModel ? window[NAMESPACE].modelIndexToPropMap : + window[NAMESPACE].indexToPropMap; + var start = indexMap[key]; + var lastIndex = path.pop(); + if (!lastIndex) { + indexMap[key] = {}; + indexMap[key].__lastIndex__ = -1; + return; + } + for (var i = 0; i < path.length; i++) { + start = start[path[i]]; + } + var name = start[lastIndex].__name__; + start[lastIndex] = { + __name__: name + }; + start[lastIndex].__lastIndex__ = -1; +} + +/** + * Tells if an element is a Polymer element + * @param {HTMLElement} element the element we want to check + * @return {Boolean} whether it is a Polymer element + */ +function isPolymerElement(element) { + return element && ('element' in element) && (element.element.localName === 'polymer-element'); +} + +/** + * Tells if an element is a custom element. + * @param {HTMLElement} el The element to check. + * @return {Boolean} whether it is a custom element. + */ +function isCustomElement(el) { + return el.localName.indexOf('-') !== -1 || el.getAttribute('is'); +} + +/** + * Tells if a custom element is unregistered + * @param {HTMLElement} el The element to check. + * @return {Boolean} whether it is unregistered or not. + */ +function isUnregisteredCustomElement(el) { + return window[NAMESPACE].isCustomElement(el) && el.constructor === HTMLElement; +} + +/** + * Warns about an unregistered custom element. + * @param {HTMLElement} el The element to check. + */ +function warnUnregisteredCustomElement(el) { + console.log('%cThis custom element isn\'t registered: ', 'color: red; font-size: 13px;'); + console.log(el); +} + +/** + * A property filter + * @param {String} prop A property name + * @return {Boolean} Whether it is to be kept or not. + */ +function filterProperty(prop) { + return prop[0] !== '_' && prop.slice(-1) !== '_' && !filterProperty.blacklist[prop]; +} + +// IMPORTANT: First set filterProperty and then call setBlacklist. +// See : +// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement +// and +// https://developer.mozilla.org/en-US/docs/Web/API/Element +// TODO: Improve blacklist +/** + * Sets the static blacklist property on `filterProperty` + */ +function setBlacklist() { + window[NAMESPACE].filterProperty.blacklist = { + accessKey: true, + align: true, + attributes: true, + baseURI: true, + childElementCount: true, + childNodes: true, + children: true, + classList: true, + className: true, + clientHeight: true, + clientLeft: true, + clientTop: true, + clientWidth: true, + contentEditable: true, + dataset: true, + dir: true, + draggable: true, + firstChild: true, + firstElementChild: true, + hidden: true, + id: true, + innerHTML: true, + innerText: true, + inputMethodContext: true, + isContentEditable: true, + lang: true, + lastChild: true, + lastElementChild: true, + localName: true, + namespaceURI: true, + nextElementSibling: true, + nextSibling: true, + nodeName: true, + nodeType: true, + nodeValue: true, + offsetHeight: true, + offsetLeft: true, + offsetParent: true, + offsetTop: true, + offsetWidth: true, + onabort: true, + onbeforecopy: true, + onbeforecut: true, + onbeforepaste: true, + onblur: true, + oncancel: true, + oncanplay: true, + oncanplaythrough: true, + onchange: true, + onclick: true, + onclose: true, + oncontextmenu: true, + oncopy: true, + oncuechange: true, + oncut: true, + ondblclick: true, + ondrag: true, + ondragend: true, + ondragenter: true, + ondragleave: true, + ondragover: true, + ondragstart: true, + ondrop: true, + ondurationchange: true, + onemptied: true, + onended: true, + onerror: true, + onfocus: true, + oninput: true, + oninvalid: true, + onkeydown: true, + onkeypress: true, + onkeyup: true, + onload: true, + onloadeddata: true, + onloadedmetadata: true, + onloadstart: true, + onmousedown: true, + onmouseenter: true, + onmouseleave: true, + onmousemove: true, + onmouseout: true, + onmouseover: true, + onmouseup: true, + onmousewheel: true, + onpaste: true, + onpause: true, + onplay: true, + onplaying: true, + onprogress: true, + onratechange: true, + onreset: true, + onresize: true, + onscroll: true, + onsearch: true, + onseeked: true, + onseeking: true, + onselect: true, + onselectstart: true, + onshow: true, + onstalled: true, + onsubmit: true, + onsuspend: true, + ontimeupdate: true, + onvolumechange: true, + onwaiting: true, + onwebkitfullscreenchange: true, + onwebkitfullscreenerror: true, + onwheel: true, + outerHTML: true, + outerText: true, + ownerDocument: true, + parentElement: true, + parentNode: true, + prefix: true, + previousElementSibling: true, + previousSibling: true, + scrollHeight: true, + scrollLeft: true, + scrollTop: true, + scrollWidth: true, + shadowRoot: true, + spellcheck: true, + style: true, + tabIndex: true, + tagName: true, + textContent: true, + title: true, + translate: true, + webkitShadowRoot: true, + webkitdropzone: true, + resolvePath: true, + + shadowRoots: true, + $: true, + controller: true, + eventDelegates: true, + reflect: true, + + onautocomplete: true, + onautocompleteerror: true, + ontoggle: true, + hasBeenAttached: true, + element: true + }; +} + +/** + * Processes a list of DOM mutations given by mutation observers and for each + * mutation, creates a JSONized object for the element that got affected. + * @param {Array} mutations Array of mutations + * @return {Array} List of JSON objects each representing a changed element. + */ +function processMutations(mutations) { + var changedElementKeys = {}; + var changedElements = []; + for (var i = 0; i < mutations.length; i++) { + var mutation = mutations[i]; + if (mutation.type !== 'childList') { + // We are interested only in childList mutations + continue; + } + var changedElement = mutation.target; + if (changedElement.host) { + changedElement = changedElement.host; + } + if (changedElement.__keyPolymer__ in changedElementKeys) { + continue; + } + // We should ideally remove all the removed nodes from `DOMCache` but it + // would involve finding all children of a removed node (recursively through the + // composed DOM). So we let them be. + changedElements.push(window[NAMESPACE].getDOMJSON(changedElement)); + changedElementKeys[changedElement.__keyPolymer__] = true; + } + return changedElements; +} + +/** + * Returns JSON representation for a DOM element + * @param {Object} el An element + * @return {Object} A JSON representation of the element + */ +function getDOMJSON(el) { + return { + 'data': window[NAMESPACE].JSONizer.JSONizeDOMObject(el || document.body, + function(domNode, converted, isLightDOM) { + if (!domNode.__keyPolymer__) { + if (isLightDOM) { + // Something that wasn't found in the composed tree but found in the light DOM + // was probably not rendered. + converted.unRendered = true; + } else if ((window[NAMESPACE].isPolymerElement(domNode) && !domNode.shadowRoot)) { + // Polymer elements may not have shadow roots. So, the composed DOM tree is the light DOM tree + // and has nothing do with the shadow DOM. + converted.noShadowRoot = true; + } + // For every element found during traversal, we store it in a hash-table with a unique key. + window[NAMESPACE].lastDOMKey++; + var key = window[NAMESPACE].lastDOMKey; + window[NAMESPACE].addToCache(domNode, key); + // Also make a map to store the actual property names to the indices corresponding to the names + // before passing it to the caller. + window[NAMESPACE].indexToPropMap[key] = {}; + window[NAMESPACE].modelIndexToPropMap[key] = {}; + domNode.__keyPolymer__ = key; + + // DOM mutation listeners are added to the very first element (the parent) of all elements + // so that it will report all light DOM changes and to *all* shadow roots so they will + // report all shadow DOM changes. + if (key === window[NAMESPACE].firstDOMKey || domNode.shadowRoot) { + var observer = new MutationObserver(function(mutations) { + window.dispatchEvent(new CustomEvent(window[NAMESPACE].getNamespacedEventName('dom-mutation'), { + detail: window[NAMESPACE].processMutations(mutations) + })); + }); + window[NAMESPACE].mutationObserverCache[key] = observer; + var config = { + childList: true, + subtree: true + }; + if (key === window[NAMESPACE].firstDOMKey) { + observer.observe(domNode, config); + } + if (domNode.shadowRoot) { + observer.observe(domNode.shadowRoot, config); + } + } + // Warn about unregistered custom elements. + if (window[NAMESPACE].isUnregisteredCustomElement(domNode)) { + window[NAMESPACE].warnUnregisteredCustomElement(domNode); + } + } + converted.key = domNode.__keyPolymer__; + var isPolymer = window[NAMESPACE].isPolymerElement(domNode); + if (domNode.parentNode) { + // Set the parent node key. + // The || is because elements have a 'host' property which is a string. + if (domNode.parentNode.host && typeof domNode.parentNode.host === 'object') { + converted.parentKey = domNode.parentNode.host.__keyPolymer__; + } else { + converted.parentKey = domNode.parentNode.__keyPolymer__; + } + } + // conditionally set the properties + if (isPolymer) { + converted.isPolymer = true; + converted.sourceURL = domNode.element.ownerDocument.URL; + } + } + ) + }; +} + +/** + * Returns a JSON representation for an object + * @param {Number} key The key of the element to which the object belongs + * @param {Array} path Path to reach the object from the element's top level + * @param {Boolean} isModel If the object is a model object + * @return {Object} A JSON object representation + */ +function getObjectJSON(key, path, isModel) { + var obj = window[NAMESPACE].resolveObject(key, path, isModel); + if (!obj) { + return { + 'data': JSON.stringify({ + value: [] + }) + }; + } + var indexMap = window[NAMESPACE].getIndexMapObject(key, path, isModel); + var filter = null; + if (path.length === 0) { + filter = function(prop) { + return prop !== '__keyPolymer__'; + }; + } + if (window[NAMESPACE].isPolymerElement(obj)) { + filter = function(prop) { + return window[NAMESPACE].filterProperty(prop) && prop !== '__keyPolymer__'; + }; + } + return { + 'data': window[NAMESPACE].JSONizer. + JSONizeObject(obj, function(converted) { + var propList = converted.value; + for (var i = 0; i < propList.length; i++) { + if (!isModel) { + if (path.length === 0 && (key in window[NAMESPACE].breakPointIndices) && + (propList[i].name in window[NAMESPACE].breakPointIndices[key])) { + converted.value[i].setBreakpoint = true; + } + } + var propName = propList[i].name; + // Must associate each index to the corresponding property name. + window[NAMESPACE].addToSubIndexMap(indexMap, propName, isModel); + } + }, + filter) + }; +} + +/** + * Adds an object observer to an object + * @param {Number} key Key of the element to which the object belongs + * @param {Array} path Path to reach the object from the element's top level + * @param {Boolean} isModel If the object is a model object + */ +function addObjectObserver(key, path, isModel) { + /** + * Checks if a property is a present in the higher proto levels of the object + * @param {String} prop The property we are looking at + * @param {Object} obj The object that owns the property + * @return {Boolean} If it is present somewhere above. + */ + function checkChainUpForProp(prop, obj) { + var proto = obj.__proto__; + while (proto) { + if (proto.hasOwnProperty(prop)) { + return true; + } + proto = proto.__proto__; + } + return false; + } + /** + * Checks if a property is present in lower proto levels of an object + * @param {String} prop The property + * @param {Object} protoObj The level below which we are looking + * @param {Object} obj The starting level to look from + * @return {Boolean} If it was found. + */ + function checkChainDownForProp(prop, protoObj, obj) { + var proto = obj; + while (proto !== protoObj && proto) { + if (proto.hasOwnProperty(prop)) { + return true; + } + proto = proto.__proto__; + } + return false; + } + var obj = window[NAMESPACE].resolveObject(key, path, isModel); + if (!obj) { + // This is because an element may not have a `model` and we just ignore it. + // No observer can be added + return; + } + var indexMap = window[NAMESPACE].getIndexMapObject(key, path, isModel); + + /** + * Processes O.o() changes + * @param {Array} changes Array of changes given by O.o() + * @return {Object} Looks like: + * { + * path: , + * key: , + * changes: [ + * { + * index: , + * name: , + * type: + * }, + * // one of every change + * ] + * } + */ + function processChanges(changes) { + var processedChangeObject = { + isModel: isModel, + path: path, + key: key, + changes: [] + }; + for (var i = 0; i < changes.length; i++) { + var change = changes[i]; + var summary = { + index: indexMap['name-' + change.name], + type: change.type, + name: change.name + }; + if (change.object !== obj) { + if (checkChainDownForProp(change.name, change.obj, obj)) { + continue; + } + } + switch (change.type) { + case 'update': + // We've chosen to ignore certain Polymer properties which may have gotten updated + if (window[NAMESPACE].isPolymerElement(obj) && + !window[NAMESPACE].filterProperty(change.name)) { + continue; + } + // We might be dealing with non-Objects which DOMJSONizer can't JSONize. + // So we wrap it and then let the caller unwrap later. + var wrappedObject = { + value: change.object[change.name] + }; + summary.object = window[NAMESPACE].JSONizer. + JSONizeObject(wrappedObject); + break; + case 'delete': + if (checkChainUpForProp(change.name, obj)) { + // Though it is a deletion at one level, the same property also exists + // in a higher prototype object, so this is an update in the view of the UI + // This applies only to Polymer elements because of how multiple prototype levels are traversed + // only for Polymer elements. + summary.type = 'update'; + var wrappedObject = { + value: change.object[change.name] + }; + summary.object = window[NAMESPACE].JSONizer. + JSONizeObject(wrappedObject); + } else { + // Update the index-to-propName map to reflect the deletion + window[NAMESPACE].removeFromSubIndexMap(indexMap, indexMap['name-' + change.name], isModel); + } + break; + case 'add': + if (window[NAMESPACE].isPolymerElement(obj) && + !window[NAMESPACE].filterProperty(change.name)) { + continue; + } + var wrappedObject = { + value: change.object[change.name] + }; + summary.object = window[NAMESPACE].JSONizer. + JSONizeObject(wrappedObject); + if (window[NAMESPACE].isPolymerElement(obj) && + checkChainUpForProp(change.name, obj)) { + // Even though this is an addition at one level, this is an update in the view of the UI + // because another property of the same name is already shown. + // This applies only to Polymer elements because of how multiple prototype levels are traversed + // only for Polymer elements. + summary.type = 'update'; + } else { + window[NAMESPACE].addToSubIndexMap(indexMap, change.name, isModel); + } + break; + } + processedChangeObject.changes.push(summary); + } + return processedChangeObject; + } + + function observer(changes) { + window.dispatchEvent(new CustomEvent(window[NAMESPACE].getNamespacedEventName('object-changed'), { + detail: processChanges(changes) + })); + } + Object.observe(obj, observer); + + var proto = obj.__proto__; + while (proto && !Polymer.isBase(proto)) { + Object.observe(proto, observer); + proto = proto.__proto__; + } + var observerCache = isModel ? window[NAMESPACE].modelObserverCache : + window[NAMESPACE].observerCache; + // Store the observer function so that we can unobserve when we need to. + if (!observerCache[key]) { + observerCache[key] = {}; + } + var hashLocation = observerCache[key]; + for (var i = 0; i < path.length; i++) { + if (!hashLocation[path[i]]) { + hashLocation[path[i]] = {}; + } + hashLocation = hashLocation[path[i]]; + } + hashLocation['__objectObserver__'] = observer; +} + +/** + * removes an object observer + * @param {Number} key Key of the element that owns the object + * @param {Array} path Path to reach the object + * @param {Boolean} isModel If the object is a model object + */ +function removeObjectObserver(key, path, isModel) { + function recursiveUnobserve(obj, hashLocation, indexMap) { + if (hashLocation['__objectObserver__']) { + Object.unobserve(obj, hashLocation['__objectObserver__']); + if (window[NAMESPACE].isPolymerElement(obj)) { + // Polymer objects have listeners on multiple proto levels + var proto = obj.__proto__; + while (proto && !Polymer.isBase(proto)) { + Object.unobserve(proto, hashLocation['__objectObserver__']); + proto = proto.__proto__; + } + } + var props = Object.keys(hashLocation); + for (var i = 0; i < props.length; i++) { + if (props[i] === '__objectObserver__') { + continue; + } + recursiveUnobserve(obj[indexMap[props[i]].__name__], hashLocation[props[i]], indexMap[props[i]]); + } + } + } + var obj = window[NAMESPACE].resolveObject(key, path, isModel); + if (!obj) { + // This is because an element may not have a `model` and we just ignore it. + // No observer can be added + return; + } + var parent = isModel ? window[NAMESPACE].modelObserverCache : + window[NAMESPACE].observerCache; + var hashLocation = parent[key]; + var indexMap = isModel ? window[NAMESPACE].modelIndexToPropMap[key] : + window[NAMESPACE].indexToPropMap[key]; + for (var i = 0; i < path.length; i++) { + parent = hashLocation; + hashLocation = hashLocation[path[i]]; + indexMap = indexMap[path[i]]; + } + // All objects under this have to be unobserved + recursiveUnobserve(obj, hashLocation, indexMap); + delete parent[path.length === 0 ? key : path[path.length - 1]]; +} + +/** + * Creates all the data-structures needed by the extension to maintain a consitent image + * of the page. + */ +function createCache() { + // All DOM elements discovered are hashed by a unique key + window[NAMESPACE].DOMCache = {}; + // A similar map is maintained for the models associated with the DOM objects + window[NAMESPACE].DOMModelCache = {}; + // O.o() observers are hashed so they can be removed when needed + window[NAMESPACE].observerCache = {}; + window[NAMESPACE].modelObserverCache = {}; + window[NAMESPACE].breakPointIndices = {}; + // indexToPropMap maps indices to property names + // The UI maintains properties as an array (to keep them ordered). To keep an + // association with real object properties and those, we need a map. + window[NAMESPACE].indexToPropMap = {}; + window[NAMESPACE].modelIndexToPropMap = {}; + window[NAMESPACE].firstDOMKey = 1; + // The key of the last DOM element added + window[NAMESPACE].lastDOMKey = window[NAMESPACE].firstDOMKey - 1; + // Mutation observers are stored so they can be removed later + window[NAMESPACE].mutationObserverCache = {}; + window[NAMESPACE].JSONizer = new window[NAMESPACE].DOMJSONizer(); + // All active overlays + window[NAMESPACE].overlays = []; +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..923a21c --- /dev/null +++ b/manifest.json @@ -0,0 +1,19 @@ +{ + "name" : "Polymer debugging extension", + "version" : "1.1", + "description" : "Helps debug Polymer apps.", + "background" : { + "scripts": ["background.js"] + }, + "devtools_page": "devtools.html", + "permissions": ["", "webNavigation"], + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "manifest_version": 2, + "content_scripts": [ + { + "matches": [""], + "js": ["perfContentScript.js"], + "run_at": "document_start" + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4f9f1d4 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "polymer-debugging-extension", + "version": "1.0.0", + "devDependencies": { + } +} diff --git a/panel-orig.html b/panel-orig.html new file mode 100644 index 0000000..21b80e5 --- /dev/null +++ b/panel-orig.html @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + +
+
+
+ Composed DOM + + Local DOM +
+ +
+
+ +
+
+ Loading... Please wait +
+ +
+
+
+ Loading... Please wait +
+ + +
+
+
+
+
+ + Properties + Model + Breakpoints + +
+ + + + + +
+
+
+ + diff --git a/panel-orig.js b/panel-orig.js new file mode 100644 index 0000000..2c8568a --- /dev/null +++ b/panel-orig.js @@ -0,0 +1,897 @@ +(function() { + // elementTree is the tree used for viewing composed DOM. + // It is initialized once and its branches are kept up-to-date as necessary. + var elementTree; + + // localDOMTree is the tree used for viewing local DOM contents + var localDOMTree; + + // Object-tree is the object tree in the right pane. + var objectTree; + + // The bread crumbs that are shown in the local DOM view + var breadCrumbs; + + // For breakpoints + var methodList; + + var splitPane; + + // Loading bars + var composedTreeLoadingBar; + var localDOMTreeLoadingBar; + + // For injecting code into the host page + var EvalHelper; + + // Last scroll position of the element tree. + var elementTreeScrollTop; + + // localDOMMode is true when we're viewing the local DOM element tree + var localDOMMode = false; + + // deepView is true when the local DOM tree is showing the shadow DOM contents, + // false when showing the light DOM contents + var deepView = false; + + // If the page had DOM mutations while the tree was being rendered, we need + // to account for those later. + var pendingDOMMutations = []; + + // If the page has a pending refresh while the tree was being rendered. + var pendingRefresh = false; + + // isTreeLoading is set to true when the tree is being loaded (upon init or + // DOM mutations) + var isTreeLoading = false; + + /** + * Called when the panel has to re-init itself upon host page reload/change. + */ + function createPageView() { + + // Even though this is to be set only when a tree is loading, during the first + // page view creation, by the time the initialization stuff is done in the page's + // context, we don't want DOM mutations doing stuff. + isTreeLoading = true; + pendingRefresh = false; + // Create an EvalHelper object that will help us interact with host page + // via `eval` calls. + createEvalHelper(function(helper) { + EvalHelper = helper; + // Make all the definitions in the host page + EvalHelper.defineFunctions([{ + name: 'highlight', + string: highlight.toString() + }, { + name: 'unhighlight', + string: unhighlight.toString() + }, { + name: 'scrollIntoView', + string: scrollIntoView.toString() + }, { + name: 'changeProperty', + string: changeProperty.toString() + }, { + name: 'getProperty', + string: getProperty.toString() + }, { + name: 'resolveObject', + string: resolveObject.toString() + }, { + name: 'addObjectObserver', + string: addObjectObserver.toString() + }, { + name: 'removeObjectObserver', + string: removeObjectObserver.toString() + }, { + name: 'createCache', + string: createCache.toString() + }, { + name: 'addToCache', + string: addToCache.toString() + }, { + name: 'getPropPath', + string: getPropPath.toString() + }, { + name: 'getIndexMapObject', + string: getIndexMapObject.toString() + }, { + name: 'addToSubIndexMap', + string: addToSubIndexMap.toString() + }, { + name: 'emptySubIndexMap', + string: emptySubIndexMap.toString() + }, { + name: 'removeFromSubIndexMap', + string: removeFromSubIndexMap.toString() + }, { + name: 'DOMJSONizer', + string: DOMJSONizer.toString() + }, { + name: 'getObjectJSON', + string: getObjectJSON.toString() + }, { + name: 'getDOMJSON', + string: getDOMJSON.toString() + }, { + name: 'setBreakpoint', + string: setBreakpoint.toString() + }, { + name: 'clearBreakpoint', + string: clearBreakpoint.toString() + }, { + name: 'filterProperty', + string: filterProperty.toString() + }, { + name: 'setBlacklist', + string: setBlacklist.toString() + }, { + name: 'isPolymerElement', + string: isPolymerElement.toString() + }, { + name: 'processMutations', + string: processMutations.toString() + }, { + name: 'inspectorSelectionChangeListener', + string: inspectorSelectionChangeListener.toString() + }, { + name: 'getNamespacedEventName', + string: getNamespacedEventName.toString() + }, { + name: 'isPageFresh', + string: isPageFresh.toString() + }, { + name: 'reloadPage', + string: reloadPage.toString() + }, { + name: 'renderOverlay', + string: renderOverlay.toString() + }, { + name: 'hideOverlays', + string: hideOverlays.toString() + }, { + name: 'isCustomElement', + string: isCustomElement.toString() + }, { + name: 'isUnregisteredCustomElement', + string: isUnregisteredCustomElement.toString() + }, { + name: 'warnUnregisteredCustomElement', + string: warnUnregisteredCustomElement.toString() + }], function(result, error) { + // Set the blacklist static property on `filterProperty` + EvalHelper.executeFunction('setBlacklist', [], function(result, error) { + if (error) { + throw error; + } + EvalHelper.executeFunction('createCache', [], function(result, error) { + if (error) { + throw error; + } + EvalHelper.executeFunction('getDOMJSON', [], function(result, error) { + if (error) { + throw error; + } + // Reset object-trees and bread-crumbs + objectTree.tree.length = 0; + modelTree.tree.length = 0; + breadCrumbs.list.length = 0; + // Load element-trees + var DOM = result.data; + console.log('loading composed DOM tree'); + // Load the composed DOM tree + doTreeLoad(function() { + elementTree.initFromDOMTree(DOM, true); + }); + console.log('loading local DOM tree'); + // Load the local DOM tree + initLocalDOMTree(localDOMTree, DOM); + breadCrumbs.list.push({ + name: DOM.tagName, + key: DOM.key + }); + doPendingActions(); + }); + }); + }); + }); + }); + } + + /** + * Called when panel is opened or page is refreshed. + */ + function init() { + elementTree = document.querySelector('element-tree#composedDOMTree'); + localDOMTree = document.querySelector('element-tree#localDOMTree'); + + // objectTree shows the properties of a selected element in the tree + objectTree = document.querySelector('object-tree#main-tree'); + + // modelTree shows the model behind a seleccted element (if any) + modelTree = document.querySelector('object-tree#model-tree'); + + // methodList is the list of methods of the selected element. It is used + // to add breakpoints + methodList = document.querySelector('method-list'); + + splitPane = document.querySelector('split-pane'); + breadCrumbs = document.querySelector('bread-crumbs'); + breadCrumbs.list = []; + + composedTreeLoadingBar = document.querySelector('#composedTreeLoadingBar'); + localDOMTreeLoadingBar = document.querySelector('#localDOMTreeLoadingBar'); + + // tabs is a reference to the paper-tabs that is used to change the object-tree + // shown in view + var tabs = document.querySelector('#tabs'); + var objectTreePages = document.querySelector('#objectTreePages'); + tabs.addEventListener('core-select', function(event) { + objectTreePages.selected = tabs.selected; + }); + + // toggleButton is an instance of paper-toggle-button used to switch between + // composed DOM and local DOM views + var toggleButton = document.querySelector('#toggleButton'); + var elementTreePages = document.querySelector('#elementTreePages'); + toggleButton.addEventListener('change', function(event) { + // Unselect whatever is selected in whichever element-tree + unSelectInTree(); + splitPane.leftScrollTop = elementTreeScrollTop; + elementTreePages.selected = toggleButton.checked ? 1 : 0; + localDOMMode = toggleButton.checked; + }); + + // When the reload button is clicked. + document.querySelector('#reloadPage').addEventListener('click', function (event) { + EvalHelper.executeFunction('reloadPage', function (result, error) { + if (error) { + throw error; + } + }); + }); + createPageView(); + } + + /** + * Sets the loading state and loading sign while the tree is rendered and + * resets them back when done. + * @param {Function} callback called when loading state is set + * @param {Boolean} isLocalDOMTree if it is the local DOM tree that is being rendered + */ + function doTreeLoad(callback, isLocalDOMTree) { + isTreeLoading = true; + var loadingBar = isLocalDOMTree ? localDOMTreeLoadingBar : composedTreeLoadingBar; + var tree = isLocalDOMTree ? localDOMTree : elementTree; + loadingBar.style.display = 'block'; + tree.style.display = 'none'; + if (isLocalDOMTree) { + breadCrumbs.style.display = 'none'; + } + callback(); + loadingBar.style.display = 'none'; + tree.style.display = 'block'; + if (isLocalDOMTree) { + breadCrumbs.style.display = 'block'; + } + isTreeLoading = false; + } + + /** + * Initializes the element tree in the local DOM view with the DOM tree supplied. + * It checks `deepView` and decides how to display the DOM tree (i.e., light DOM or shadow DOM). + * @param {Object} tree either localDOMTree or a sub-tree of it to be rendered. + * @param {Object} DOM The DOM object or part of it extracted from the page. + */ + function initLocalDOMTree(tree, DOM) { + doTreeLoad(function() { + if (!deepView) { + // DOM tree is to be shown as light DOM tree + if (tree === localDOMTree) { + tree.initFromDOMTree(DOM.lightDOMTree, true); + } else { + tree.initFromDOMTree(DOM.lightDOMTree, true, localDOMTree); + } + } else if (tree === localDOMTree) { + // We are trying to set the entire tree here. + // It is done this way: + // 1. First level of children are from the composed DOM + // 2. After that all children of first level children are from light DOM + var treeRoot = { + tagName: DOM.tagName, + key: DOM.key, + children: [], + isPolymer: DOM.isPolymer + }; + tree.initFromDOMTree(treeRoot, false); + tree.tree = DOM; + if (!DOM.noShadowRoot) { + // if the tree object didn't contain this flag it would have meant that this element + // doesn't have a shadow root and shouldn't have a shadow DOM view + var childTree; + for (var i = 0; i < DOM.children.length; i++) { + childTree = new ElementTree(); + childTree.initFromDOMTree(DOM.children[i].lightDOMTree, true, localDOMTree); + tree.addChild(childTree); + } + } + } else { + // called when DOM mutations happen and we need update just one part of the tree + tree.initFromDOMTree(DOM.lightDOMTree, true); + } + }, true); + } + + /** + * Gets the currently selected element's key in whichever view + * @return {Number} The key + */ + function getCurrentElementTreeKey() { + if (localDOMMode) { + return localDOMTree.selectedChild ? localDOMTree.selectedChild.key : null; + } + return elementTree.selectedChild ? elementTree.selectedChild.key : null; + } + + /** + * Gets the currently focused element tree + * @return {ElementTree} Either the composed DOM tree or localDOMTree + */ + function getCurrentElementTree() { + if (localDOMMode) { + return localDOMTree; + } + return elementTree; + } + + /** + * Unselect the selected element in whichever tree + */ + function unSelectInTree() { + var selectedKey = getCurrentElementTreeKey(); + if (selectedKey) { + var childTree = getCurrentElementTree().selectedChild; + childTree.toggleSelection(); + } + } + + /** + * Switch to local DOM view if we're not in it + */ + function switchToLocalDOMView() { + if (localDOMMode) { + return; + } + unSelectInTree(); + elementTreeScrollTop = splitPane.leftScrollTop; + localDOMMode = true; + toggleButton.checked = true; + splitPane.rightScrollTop = 0; + } + + /** + * Zoom out local DOM view to a key in the bread-crumbs. + * @param {Object} newTree Object representing the JSON tree object we need to + * zoom out till. + */ + function zoomOutLocalDOMView(newTree) { + if (!localDOMMode) { + return; + } + unSelectInTree(); + deepView = false; + // Remove last few crumbs from bread-crumbs until we hit key. + while (breadCrumbs.list[breadCrumbs.list.length - 1].key !== newTree.key) { + breadCrumbs.list.pop(); + } + initLocalDOMTree(localDOMTree, newTree); + doPendingActions(); + } + + /** + * elementTree has references to all rendered elements. So if someother + * part of the code wants a reference to a DOM element we can just get it from + * elementTree. It is an alternative to a separate hash table which would have + * needed another complete tree traversal. + * @param {Number} key The key of the DOM element + * @return {HTMLElement} The element corresponding to key. + */ + function getDOMTreeForKey(key) { + var childTree = elementTree.getChildTreeForKey(key); + return childTree ? childTree.tree : null; + } + + /** + * Highlight an element in the page + * @param {Number key The key of the element to be highlighted + */ + function highlightElement(key) { + EvalHelper.executeFunction('highlight', [key], function(result, error) { + if (error) { + throw error; + } + }); + } + + /** + * Unhighlight a highlighted element in the page + * @param {Number} key Key of the element to be unhighlighted + */ + function unhighlightElement(key) { + EvalHelper.executeFunction('unhighlight', [key], function(result, error) { + if (error) { + throw error; + } + }); + } + + /** + * Expands an object in either of the object-trees and adds O.o() listeners. + * @param {Number} key Key of the element whose object is to be expanded. + * @param {Array} path An array representing the path to find the expansion point. + * @param {Boolean} isModel If it is the model-tree we're trying to expand. + */ + function expandObject(key, path, isModel) { + EvalHelper.executeFunction('getObjectJSON', [key, path, isModel], function(result, error) { + if (error) { + throw error; + } + var props = result.data.value; + var childTree = isModel ? modelTree.tree : objectTree.tree; + for (var i = 0; i < path.length; i++) { + childTree = childTree[path[i]].value; + } + childTree.push.apply(childTree, props); + if (!isModel && path.length === 0) { + methodList.list = objectTree.tree; + } + EvalHelper.executeFunction('addObjectObserver', [key, path, isModel], function(result, error) { + if (error) { + throw error; + } + }); + }); + } + + /** + * Selects an element = expands Object-tree and highlights in page + * @param {Number} key Key of element to expand. + */ + function selectElement(key) { + // When an element is selected, we try to open both the main and model trees + expandObject(key, [], false); + expandObject(key, [], true); + // Scroll the element into view when selected. + EvalHelper.executeFunction('scrollIntoView', [key], function(result, error) { + if (error) { + throw error; + } + }); + } + + /** + * Unselects an element = removes O.o() listeners and empties index map. + * @param {Number} key Key of the unselected element. + * @param {Function} callback Called when everything is done. + */ + function unselectElement(key, callback) { + function removeObject(isModel, callback) { + EvalHelper.executeFunction('removeObjectObserver', [key, [], isModel], function(result, error) { + if (error) { + throw error; + } + EvalHelper.executeFunction('emptySubIndexMap', [key, [], isModel], function(result, error) { + if (error) { + throw error; + } + // Empty the object/model tree + if (!isModel) { + objectTree.tree.length = 0; + } else { + modelTree.tree.length = 0; + } + + callback && callback(); + }); + }); + } + // First remove everything associated with the actual object + removeObject(false, function() { + // Then remove everything associated with the model + removeObject(true, callback); + }); + } + + /** + * Refresh an accessor property. + * @param {Number} key Key of the element concerned. + * @param {ObjectTree} childTree The sub-object-tree where this property is rendered. + * @param {Array} path Path to find the property. + * @param {Boolean} isModel If the property belongs to the model-tree. + */ + function refreshProperty(key, childTree, path, isModel) { + var index = path[path.length - 1]; + EvalHelper.executeFunction('getProperty', [key, path, isModel], function(result, error) { + var newObj = result.value[0]; + childTree[index] = newObj; + }); + } + + /** + * Tells if an element is a child of another. + * @param {Number} childKey Key of the child element. + * @param {Number} parentKey Key of the supposed parent element. + * @return {Boolean} Whether it is a child actually. + */ + function isChildOf(childKey, parentKey) { + var childTree = getDOMTreeForKey(childKey); + if (!childTree) { + throw 'No child tree with that key'; + } + var nextParentKey; + // Iterate up parent key links until we reach the top or we find that it is + // parentKey. + do { + nextParentKey = childTree.parentKey; + if (nextParentKey === parentKey) { + return true; + } + childTree = getDOMTreeForKey(nextParentKey); + } while (childTree && childTree.parentKey); + return false; + } + + /** + * Processes DOM mutations. i.e., updates trees with pending DOM mutations. + */ + function addMutations() { + var mutations = pendingDOMMutations.slice(0); + pendingDOMMutations.length = 0; + for (var i = 0; i < mutations.length; i++) { + var newElement = mutations[i].data; + var key = newElement.key; + var childElementTree = elementTree.getChildTreeForKey(key); + var childLocalDOMTree = localDOMTree.getChildTreeForKey(key); + + function resetTree() { + if (childLocalDOMTree) { + // The element to be refreshed is there in the local DOM tree. + initLocalDOMTree(childLocalDOMTree, newElement); + } else if (isChildOf(localDOMTree.key, key)) { + // The root of local DOM tree is a child of the element being + // re-rendered due to DOM mutation. So it will be inconsistent to continue showing it. + // We zoom out to the point where it is consistent. + zoomOutLocalDOMView(newElement); + } + // elementTree has all composed DOM elements. A DOM mutation will might need + // an update there + if (childElementTree) { + doTreeLoad(function() { + childElementTree.initFromDOMTree(newElement, true, elementTree); + }); + } + } + if ((childElementTree && childElementTree.selected) || + (childLocalDOMTree && childLocalDOMTree.selected)) { + // The selected element and the one to be refreshed are the same. + unselectElement(key, resetTree); + } else { + resetTree(); + } + } + doPendingActions(); + } + + /** + * Do pending stuff (page reloads or DOM mutations) that happened while trees were + * being rendered. + */ + function doPendingActions() { + if (pendingRefresh) { + createPageView(); + } else if (pendingDOMMutations.length > 0) { + addMutations(); + } + } + // When the panel is opened + window.addEventListener('polymer-ready', function() { + init(); + + // When an element in the element-tree is selected + window.addEventListener('selected', function(event) { + var key = event.detail.key; + if (event.detail.oldKey) { + unselectElement(event.detail.oldKey, function() { + selectElement(key); + }); + } else { + selectElement(key); + } + }); + + // When an element in the element-tree is unselected + window.addEventListener('unselected', function(event) { + var key = event.detail.key; + unselectElement(key); + }); + + // When a property in the object-tree changes + window.addEventListener('property-changed', function(event) { + var newValue = event.detail.value; + var path = event.detail.path; + var key = getCurrentElementTreeKey(); + var childTree = event.detail.tree; + var isModel = (event.target.id === 'model-tree'); + // Reflect a change in property in the host page + EvalHelper.executeFunction('changeProperty', [key, path, newValue, isModel], + function(result, error) { + if (error) { + throw error; + } + if (event.detail.reEval) { + // The property requires a re-eval because it is accessor + // and O.o() won't update it. + refreshProperty(key, childTree, path, isModel); + } + } + ); + }); + + // When the refresh button is clicked in the object-trees. + window.addEventListener('refresh-property', function(event) { + var key = getCurrentElementTreeKey(); + var childTree = event.detail.tree; + var path = event.detail.path; + var isModel = (event.target.id === 'model-tree'); + refreshProperty(key, childTree, path, isModel); + }); + + // When an object is expanded. + window.addEventListener('object-expand', function(event) { + var isModel = (event.target.id === 'model-tree'); + var key = getCurrentElementTreeKey(); + expandObject(key, event.detail.path, isModel); + }); + + // An object has been collapsed. We must remove the object observer + // and empty the index-propName map in the host page for this object + window.addEventListener('object-collapse', function(event) { + var key = getCurrentElementTreeKey(); + var path = event.detail.path; + var isModel = (event.target.id === 'model-tree'); + EvalHelper.executeFunction('removeObjectObserver', [key, path, isModel], function(result, error) { + if (error) { + throw error; + } + EvalHelper.executeFunction('emptySubIndexMap', [key, path, isModel], function(result, error) { + if (error) { + throw error; + } + }); + }); + }); + + // When a breakpoint is added/removed. + window.addEventListener('breakpoint-toggle', function(event) { + var key = getCurrentElementTreeKey(); + var index = event.detail.index; + var functionName = event.detail.isSet ? 'setBreakpoint' : 'clearBreakpoint'; + EvalHelper.executeFunction(functionName, [key, [index]], function(result, error) { + if (error) { + throw error; + } + }); + }); + + // Happens when an element is hovered over + window.addEventListener('highlight', function(event) { + highlightElement(event.detail.key); + }); + + // Happens when an element is hovered out + window.addEventListener('unhighlight', function(event) { + unhighlightElement(event.detail.key); + }); + + // Happens when an element is to be magnified, + // i.e., either seen in the local DOM view or if already in the + // local DOM view, to see the shadow content of it. + window.addEventListener('magnify', function(event) { + var key = event.detail.key; + var childTree = getCurrentElementTree().getChildTreeForKey(key); + if (!localDOMMode) { + var DOMTree = getDOMTreeForKey(key); + unSelectInTree(); + deepView = false; + breadCrumbsList = []; + var parentDOMTree = DOMTree; + // Iterate through parents until we reach the root to show bread-crumbs for + // each parent. + do { + breadCrumbsList.push({ + name: parentDOMTree.tagName, + key: parentDOMTree.key + }); + parentDOMTree = getDOMTreeForKey(parentDOMTree.parentKey); + } while (parentDOMTree); + + breadCrumbsList.reverse(); + breadCrumbs.list = breadCrumbsList; + initLocalDOMTree(localDOMTree, DOMTree); + switchToLocalDOMView(); + } else { + deepView = true; + var DOMTree = getDOMTreeForKey(key); + unSelectInTree(); + // If the last bread crumb is not the one representing what we want in the + // local DOM view (this means it is not just a peek into the shadow DOM from light DOM) + if (breadCrumbs.list[breadCrumbs.list.length - 1].key !== DOMTree.key) { + breadCrumbs.list.push({ + name: DOMTree.tagName, + key: DOMTree.key + }); + } + initLocalDOMTree(localDOMTree, DOMTree); + } + doPendingActions(); + }); + + // When an element in the local DOM view is to be 'unmagnified', + // i.e., its light DOM is to be seen. + window.addEventListener('unmagnify', function(event) { + // Only possible inside local DOM view and when in deepView + var key = event.detail.key; + var childTree = getCurrentElementTree().getChildTreeForKey(key); + deepView = false; + var DOMTree = childTree.tree; + unSelectInTree(); + initLocalDOMTree(localDOMTree, DOMTree); + doPendingActions(); + }); + + // When a bread crumb click happens we may need to focus something else in + // tree + window.addEventListener('bread-crumb-click', function(event) { + unSelectInTree(); + var key = event.detail.key; + var DOMTree = getDOMTreeForKey(key); + deepView = false; + initLocalDOMTree(localDOMTree, DOMTree); + doPendingActions(); + }); + + // When a Polymer element's definition is to be viewed. + window.addEventListener('view-source', function(event) { + var key = event.detail.key; + var DOMTree = getDOMTreeForKey(key); + var sourceURL = DOMTree.sourceURL; + // TODO: Is there any way to find the exact line and column of definition of + // a Polymer element? + chrome.devtools.panels.openResource(sourceURL, 1, 1); + }); + + var backgroundPageConnection = chrome.runtime.connect({ + name: 'panel' + }); + // All these messages come from the background page and not from the UI of the extension. + backgroundPageConnection.onMessage.addListener(function(message, sender, sendResponse) { + switch (message.name) { + case 'check-page-fresh': + // The page's location has changed. This doesn't necessarily mean that + // the page itself got reloaded. So we try to execute a function which is supposed + // to be defined if the page is not fresh. If it fails, then the page is fresh. + EvalHelper.executeFunction('isPageFresh', [], function(result, error) { + if (error) { + // The page is fresh. + // Check if this is a Polymer page. + chrome.devtools.inspectedWindow.eval('Polymer', function (result, error) { + // Boolean(error) tells if this was a Polymer page. + // Let the background page know so it can spawn the content script. + // Expect a 'refresh' message after that. + backgroundPageConnection.postMessage({ + name: 'fresh-page', + tabId: chrome.devtools.inspectedWindow.tabId, + isPolymerPage: Boolean(error) + }); + }); + } + }); + break; + case 'refresh': + // No use processing DOM mutations of older page. + pendingDOMMutations.length = 0; + // This happens when the page actually got reloaded. + if (isTreeLoading) { + // We don't want to trigger another init process starting if the extension UI + // is still trying to get set up. We just mark it so it can processed afterwards. + pendingRefresh = true; + } else { + createPageView(); + } + break; + case 'object-changed': + // An object has changed. Must update the object-trees + + // The list of changes + var changeObj = message.changeList; + // The path where the change happened + var path = changeObj.path; + var changes = changeObj.changes; + var isModel = changeObj.isModel; + for (var i = 0; i < changes.length; i++) { + var change = changes[i]; + var type = change.type; + // Index refers to the index in object-tree corresponding to the property + var index = change.index; + // Name refers to the actual property name + var name = change.name; + // This is a wrapped object. `value` contains the actual object. + var newObj; + var childTree = isModel ? modelTree.tree : objectTree.tree; + try { + // If the observer reports changes before child-tree is ready, we can + // only wait and ignore it + for (var j = 0; j < path.length; j++) { + childTree = childTree[path[j]].value; + } + } catch (e) { + // TODO: is it okay to do this busy looping until child-tree is ready? + } + if (type !== 'delete') { + newObj = change.object.value[0]; + newObj.name = name; + } else { + childTree.splice(index, 1); + return; + } + switch (type) { + case 'update': + childTree[index] = newObj; + break; + case 'add': + childTree.push(newObj); + break; + } + } + break; + case 'dom-mutation': + // A DOM element has changed. Must re-render it in the element tree. + var mutations = message.changeList; + pendingDOMMutations.push.apply(pendingDOMMutations, mutations); + if (!isTreeLoading) { + addMutations(); + } + break; + case 'inspected-element-changed': + if (localDOMMode) { + return; + } + // An element got selected in the inspector, must select in composed DOM tree + var childTree = elementTree.getChildTreeForKey(message.key); + if (childTree && !childTree.selected) { + childTree.toggleSelection(); + childTree.scrollIntoView(); + } + break; + } + }); + + // Send a message to background page so that the background page can associate panel + // to the current host page + backgroundPageConnection.postMessage({ + name: 'panel-init', + tabId: chrome.devtools.inspectedWindow.tabId + }); + + // When an element selection changes in the inspector, we try to update the new pane with + // the same element selected + chrome.devtools.panels.elements.onSelectionChanged.addListener(function() { + EvalHelper.executeFunction('inspectorSelectionChangeListener', [], function(result, error) { + if (error) { + console.log(error); + } + }); + }); + }); +})(); diff --git a/perfContentScript.js b/perfContentScript.js new file mode 100644 index 0000000..6c87cf7 --- /dev/null +++ b/perfContentScript.js @@ -0,0 +1,28 @@ +// This content script runs on all pages. It can't be conditionally injected because we can't +// miss out on any events by the time the script gets injected. On the other hand manifest.json allows +// us to name content scripts to be run at 'document_start' which seemed like the ideal time. +(function () { + + // Used to style console.log messages. + var messageStyle = 'color: blue; font-size: 15px;'; + var timeStyle = 'color: green; font-size: 13px'; + + function getFormattedDate() { + var date = new Date(); + var str = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate() + " " + + date.getHours() + ":" + date.getMinutes() + ":" + date.getSeconds() + ':' + date.getMilliseconds(); + return str; + } + + console.log('%cDocument start: ', messageStyle); + console.log('%c' + getFormattedDate(), timeStyle); + + // 'polymer-ready' event means that the host page runs a Polymer app and it just got upgraded by Polymer. + window.addEventListener('polymer-ready', function() { + console.log('%cpolymer-ready:', messageStyle); + console.log('%c' + getFormattedDate(), timeStyle); + chrome.runtime.sendMessage({ + name: 'polymer-ready' + }); + }); +})();