Bug 1518512 - (Part 1) Add basic context menu to Changes panel. r=gl
authorRazvan Caliman <rcaliman@mozilla.com>
Fri, 25 Jan 2019 15:45:38 +0000
changeset 515468 21af538eb319b60bd10bf7cb542e51667af29a98
parent 515467 27ab766b2c4457b4f50aeaa6fb0a6417d21e839d
child 515469 99655666ac5a6a7d387de42f5d5cf3d47f144fc1
push id1953
push userffxbld-merge
push dateMon, 11 Mar 2019 12:10:20 +0000
treeherdermozilla-release@9c35dcbaa899 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgl
bugs1518512
milestone66.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1518512 - (Part 1) Add basic context menu to Changes panel. r=gl Adds context menu with options to select all and copy text content from the Changes panel. Differential Revision: https://phabricator.services.mozilla.com/D17255
devtools/client/inspector/changes/ChangesContextMenu.js
devtools/client/inspector/changes/ChangesView.js
devtools/client/inspector/changes/components/ChangesApp.js
devtools/client/inspector/changes/moz.build
devtools/client/inspector/changes/test/browser_changes_rule_selector.js
devtools/client/inspector/changes/test/head.js
devtools/client/locales/en-US/changes.properties
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/changes/ChangesContextMenu.js
@@ -0,0 +1,99 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set 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";
+
+loader.lazyRequireGetter(this, "Menu", "devtools/client/framework/menu");
+loader.lazyRequireGetter(this, "MenuItem", "devtools/client/framework/menu-item");
+loader.lazyRequireGetter(this, "clipboardHelper", "devtools/shared/platform/clipboard");
+
+const { getStr } = require("./utils/l10n");
+
+/**
+ * Context menu for the Changes panel with options to select, copy and export CSS changes.
+ */
+class ChangesContextMenu {
+  /**
+   * @param {ChangesView} view
+   */
+  constructor(view) {
+    this.view = view;
+    this.inspector = this.view.inspector;
+    // Document object to which the Changes panel belongs to.
+    this.document = this.view.document;
+    // DOM element container for the Changes panel content.
+    this.panel = this.document.getElementById("sidebar-panel-changes");
+    // Window object to which the Changes panel belongs to.
+    this.window = this.document.defaultView;
+
+    this._onCopy = this._onCopy.bind(this);
+    this._onSelectAll = this._onSelectAll.bind(this);
+  }
+
+  show(event) {
+    this._openMenu({
+      target: event.explicitOriginalTarget,
+      screenX: event.screenX,
+      screenY: event.screenY,
+    });
+  }
+
+  _openMenu({ target, screenX = 0, screenY = 0 } = {}) {
+    this.window.focus();
+
+    const menu = new Menu();
+
+    // Copy option
+    const menuitemCopy = new MenuItem({
+      label: getStr("changes.contextmenu.copy"),
+      accesskey: getStr("changes.contextmenu.copy.accessKey"),
+      click: this._onCopy,
+      disabled: !this._hasTextSelected(),
+    });
+    menu.append(menuitemCopy);
+
+    // Select All option
+    const menuitemSelectAll = new MenuItem({
+      label: getStr("changes.contextmenu.selectAll"),
+      accesskey: getStr("changes.contextmenu.selectAll.accessKey"),
+      click: this._onSelectAll,
+    });
+    menu.append(menuitemSelectAll);
+
+    menu.popup(screenX, screenY, this.inspector.toolbox);
+    return menu;
+  }
+
+  _hasTextSelected() {
+    const selection = this.window.getSelection();
+    return selection.toString() && !selection.isCollapsed;
+  }
+
+  /**
+   * Select all text.
+   */
+  _onSelectAll() {
+    const selection = this.window.getSelection();
+    selection.selectAllChildren(this.panel);
+  }
+
+  /**
+   * Copy the selected text to clipboard.
+   */
+  _onCopy() {
+    const text = this.window.getSelection().toString();
+    clipboardHelper.copyString(text);
+  }
+
+  destroy() {
+    this.inspector = null;
+    this.panel = null;
+    this.view = null;
+    this.window = null;
+  }
+}
+
+module.exports = ChangesContextMenu;
--- a/devtools/client/inspector/changes/ChangesView.js
+++ b/devtools/client/inspector/changes/ChangesView.js
@@ -4,40 +4,52 @@
  * 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 { createFactory, createElement } = require("devtools/client/shared/vendor/react");
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 
+loader.lazyRequireGetter(this, "ChangesContextMenu", "devtools/client/inspector/changes/ChangesContextMenu");
+
 const ChangesApp = createFactory(require("./components/ChangesApp"));
 
 const {
   resetChanges,
   trackChange,
 } = require("./actions/changes");
 
 class ChangesView {
   constructor(inspector, window) {
     this.document = window.document;
     this.inspector = inspector;
     this.store = this.inspector.store;
-    this.toolbox = this.inspector.toolbox;
 
     this.onAddChange = this.onAddChange.bind(this);
     this.onClearChanges = this.onClearChanges.bind(this);
     this.onChangesFront = this.onChangesFront.bind(this);
+    this.onContextMenu = this.onContextMenu.bind(this);
     this.destroy = this.destroy.bind(this);
 
     this.init();
   }
 
+  get contextMenu() {
+    if (!this._contextMenu) {
+      this._contextMenu = new ChangesContextMenu(this);
+    }
+
+    return this._contextMenu;
+  }
+
   init() {
-    const changesApp = ChangesApp({});
+    const changesApp = ChangesApp({
+      onContextMenu: this.onContextMenu,
+    });
 
     // listen to the front for initialization, add listeners
     // when it is ready
     this._getChangesFront();
 
     // Expose the provider to let inspector.js use it in setupSidebar.
     this.provider = createElement(Provider, {
       id: "changesview",
@@ -83,27 +95,35 @@ class ChangesView {
     // Turn data into a suitable change to send to the store.
     this.store.dispatch(trackChange(change));
   }
 
   onClearChanges() {
     this.store.dispatch(resetChanges());
   }
 
+  onContextMenu(e) {
+    this.contextMenu.show(e);
+  }
+
   /**
    * Destruction function called when the inspector is destroyed.
    */
   async destroy() {
     this.store.dispatch(resetChanges());
 
     // ensure we finish waiting for the front before destroying.
     const changesFront = await this.changesFrontPromise;
     changesFront.off("add-change", this.onAddChange);
     changesFront.off("clear-changes", this.onClearChanges);
 
     this.document = null;
     this.inspector = null;
     this.store = null;
-    this.toolbox = null;
+
+    if (this._contextMenu) {
+      this._contextMenu.destroy();
+      this._contextMenu = null;
+    }
   }
 }
 
 module.exports = ChangesView;
--- a/devtools/client/inspector/changes/components/ChangesApp.js
+++ b/devtools/client/inspector/changes/components/ChangesApp.js
@@ -14,16 +14,18 @@ const { getChangesTree } = require("../s
 const { getSourceForDisplay } = require("../utils/changes-utils");
 const { getStr } = require("../utils/l10n");
 
 class ChangesApp extends PureComponent {
   static get propTypes() {
     return {
       // Nested CSS rule tree structure of CSS changes grouped by source (stylesheet)
       changesTree: PropTypes.object.isRequired,
+      // Event handler for "contextmenu" event
+      onContextMenu: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
   }
 
   renderDeclarations(remove = [], add = []) {
@@ -135,16 +137,17 @@ class ChangesApp extends PureComponent {
   }
 
   render() {
     const hasChanges = Object.keys(this.props.changesTree).length > 0;
     return dom.div(
       {
         className: "theme-sidebar inspector-tabpanel",
         id: "sidebar-panel-changes",
+        onContextMenu: this.props.onContextMenu,
       },
       !hasChanges && this.renderEmptyState(),
       hasChanges && this.renderDiff(this.props.changesTree)
     );
   }
 }
 
 const mapStateToProps = state => {
--- a/devtools/client/inspector/changes/moz.build
+++ b/devtools/client/inspector/changes/moz.build
@@ -8,12 +8,13 @@ DIRS += [
     'actions',
     'components',
     'reducers',
     'selectors',
     'utils',
 ]
 
 DevToolsModules(
+    'ChangesContextMenu.js',
     'ChangesView.js',
 )
 
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
--- a/devtools/client/inspector/changes/test/browser_changes_rule_selector.js
+++ b/devtools/client/inspector/changes/test/browser_changes_rule_selector.js
@@ -46,21 +46,24 @@ add_task(async function() {
   const firstSelector = rules.item(0).querySelector(".selector");
   is(firstSelector.title, "div", "Old selector name was tracked.");
   ok(firstSelector.classList.contains("diff-remove"), "Old selector was removed.");
 
   const secondSelector = rules.item(1).querySelector(".selector");
   is(secondSelector.title, ".test", "New selector name was tracked.");
   ok(secondSelector.classList.contains("diff-add"), "New selector was added.");
 
-  info("Checking that the two rules have identical declarations");
-  const firstDecl = rules.item(0).querySelectorAll(".declaration");
-  is(firstDecl.length, 1, "First rule has only one declaration");
-  is(firstDecl.item(0).textContent, "color:red;", "First rule has correct declaration");
-  ok(firstDecl.item(0).classList.contains("diff-remove"),
-    "First rule has declaration tracked as removed");
+  info("Get removed declarations from first rule");
+  const removeDecl = getRemovedDeclarations(doc, rules.item(0));
+  is(removeDecl.length, 1, "First rule has correct number of declarations removed");
+
+  info("Get added declarations from second rule");
+  const addDecl = getAddedDeclarations(doc, rules.item(1));
+  is(addDecl.length, 1, "Second rule has correct number of declarations added");
 
-  const secondDecl = rules.item(1).querySelectorAll(".declaration");
-  is(secondDecl.length, 1, "Second rule has only one declaration");
-  is(secondDecl.item(0).textContent, "color:red;", "Second rule has correct declaration");
-  ok(secondDecl.item(0).classList.contains("diff-add"),
-    "Second rule has declaration tracked as added");
+  info("Checking that the two rules have identical declarations");
+  for (let i = 0; i < removeDecl.length; i++) {
+    is(removeDecl[i].property, addDecl[i].property,
+      `Declaration names match at index ${i}`);
+    is(removeDecl[i].value, addDecl[i].value,
+      `Declaration values match at index ${i}`);
+  }
 });
--- a/devtools/client/inspector/changes/test/head.js
+++ b/devtools/client/inspector/changes/test/head.js
@@ -35,28 +35,36 @@ registerCleanupFunction(() => {
  * in the Changes panel.
  *
  * @param  {Document} panelDoc
  *         Host document of the Changes panel.
  * @param  {String} selector
  *         Optional selector to filter rendered declaration DOM elements.
  *         One of ".diff-remove" or ".diff-add".
  *         If omitted, all declarations will be returned.
+ * @param  {DOMNode} containerNode
+ *         Optional element to restrict results to declaration DOM elements which are
+ *         descendants of this container node.
+ *         If omitted, all declarations will be returned
  * @return {Array}
  */
-function getDeclarations(panelDoc, selector = "") {
+function getDeclarations(panelDoc, selector = "", containerNode = null) {
   const els = panelDoc.querySelectorAll(`#sidebar-panel-changes .declaration${selector}`);
 
-  return [...els].map(el => {
-    return {
-      property: el.querySelector(".declaration-name").textContent,
-      value: el.querySelector(".declaration-value").textContent,
-    };
-  });
+  return [...els]
+    .filter(el => {
+      return containerNode ? containerNode.contains(el) : true;
+    })
+    .map(el => {
+      return {
+        property: el.querySelector(".declaration-name").textContent,
+        value: el.querySelector(".declaration-value").textContent,
+      };
+    });
 }
 
-function getAddedDeclarations(panelDoc) {
-  return getDeclarations(panelDoc, ".diff-add");
+function getAddedDeclarations(panelDoc, containerNode) {
+  return getDeclarations(panelDoc, ".diff-add", containerNode);
 }
 
-function getRemovedDeclarations(panelDoc) {
-  return getDeclarations(panelDoc, ".diff-remove");
+function getRemovedDeclarations(panelDoc, containerNode) {
+  return getDeclarations(panelDoc, ".diff-remove", containerNode);
 }
--- a/devtools/client/locales/en-US/changes.properties
+++ b/devtools/client/locales/en-US/changes.properties
@@ -19,8 +19,24 @@ changes.inlineStyleSheetLabel=Inline %S
 
 # LOCALIZATION NOTE (changes.elementStyleLabel): This label appears in the Changes
 # panel above changes done to element styles.
 changes.elementStyleLabel=Element
 
 # LOCALIZATION NOTE (changes.iframeLabel): This label appears next to URLs of stylesheets
 # and element inline styles hosted by iframes. Lowercase intentional.
 changes.iframeLabel=iframe
+
+# LOCALIZATION NOTE (changes.contextmenu.copy): Label for "Copy" option in Changes panel
+# context menu
+changes.contextmenu.copy=Copy
+
+# LOCALIZATION NOTE (changes.contextmenu.copy.accessKey): Access key for "Copy"
+# option in the Changes panel.
+changes.contextmenu.copy.accessKey=C
+
+# LOCALIZATION NOTE (changes.contextmenu.selectAll): Label for "Select All" option in the
+# Changes panel context menu to select all text content.
+changes.contextmenu.selectAll=Select All
+
+# LOCALIZATION NOTE (changes.contextmenu.selectAll.accessKey): Access key for "Select All"
+# option in the Changes panel.
+changes.contextmenu.selectAll.accessKey=A