Bug 1067325 - Context menu actions for view source tabs. r=mconley
authorJ. Ryan Stinnett <jryans@gmail.com>
Mon, 18 May 2015 11:10:41 -0500
changeset 265171 4f686a4f83ddd276e7a5ebd391a7b120171769aa
parent 265170 7feb1cfb0182b53725d57fc8eb3780dde251f3cb
child 265172 732494b7a816f351531cf94a3238c24f5c06b447
push id2101
push userjryans@gmail.com
push dateMon, 18 May 2015 16:11:00 +0000
reviewersmconley
bugs1067325
milestone41.0a1
Bug 1067325 - Context menu actions for view source tabs. r=mconley
toolkit/components/viewsource/ViewSourceBrowser.jsm
toolkit/components/viewsource/content/viewSource-content.js
toolkit/components/viewsource/content/viewSource.js
toolkit/components/viewsource/test/browser/browser_viewsourceprefs.js
toolkit/locales/en-US/chrome/global/viewSource.properties
--- a/toolkit/components/viewsource/ViewSourceBrowser.jsm
+++ b/toolkit/components/viewsource/ViewSourceBrowser.jsm
@@ -50,23 +50,32 @@ ViewSourceBrowser.prototype = {
   /**
    * The <browser> that will be displaying the view source content.
    */
   get browser() {
     return this._browser;
   },
 
   /**
+   * Holds the value of the last line found via the "Go to line"
+   * command, to pre-populate the prompt the next time it is
+   * opened.
+   */
+  lastLineFound: null,
+
+  /**
    * These are the messages that ViewSourceBrowser will listen for
    * from the frame script it injects. Any message names added here
    * will automatically have ViewSourceBrowser listen for those messages,
    * and remove the listeners on teardown.
    */
-  // TODO: Some messages will appear here in a later patch
   messages: [
+    "ViewSource:PromptAndGoToLine",
+    "ViewSource:GoToLine:Success",
+    "ViewSource:GoToLine:Failed",
   ],
 
   /**
    * This should be called as soon as the script loads. When this function
    * executes, we can assume the DOM content has not yet loaded.
    */
   init() {
     this.messages.forEach((msgName) => {
@@ -86,18 +95,26 @@ ViewSourceBrowser.prototype = {
 
   /**
    * Anything added to the messages array will get handled here, and should
    * get dispatched to a specific function for the message name.
    */
   receiveMessage(message) {
     let data = message.data;
 
-    // TODO: Some messages will appear here in a later patch
     switch(message.name) {
+      case "ViewSource:PromptAndGoToLine":
+        this.promptAndGoToLine();
+        break;
+      case "ViewSource:GoToLine:Success":
+        this.onGoToLineSuccess(data.lineNumber);
+        break;
+      case "ViewSource:GoToLine:Failed":
+        this.onGoToLineFailed();
+        break;
       default:
         // Unhandled
         return false;
     }
   },
 
   /**
    * Getter for the message manager of the view source browser.
@@ -537,9 +554,77 @@ ViewSourceBrowser.prototype = {
     // replace chars in our charTable
     str = str.replace(/[<>&"]/g, charTableLookup);
 
     // replace chars > 0x7f via nsIEntityConverter
     str = str.replace(/[^\0-\u007f]/g, convertEntity);
 
     return str;
   },
+
+  /**
+   * Opens the "Go to line" prompt for a user to hop to a particular line
+   * of the source code they're viewing. This will keep prompting until the
+   * user either cancels out of the prompt, or enters a valid line number.
+   */
+  promptAndGoToLine() {
+    let input = { value: this.lastLineFound };
+    let window = Services.wm.getMostRecentWindow(null);
+
+    let ok = Services.prompt.prompt(
+        window,
+        this.bundle.GetStringFromName("goToLineTitle"),
+        this.bundle.GetStringFromName("goToLineText"),
+        input,
+        null,
+        {value:0});
+
+    if (!ok)
+      return;
+
+    let line = parseInt(input.value, 10);
+
+    if (!(line > 0)) {
+      Services.prompt.alert(window,
+                            this.bundle.GetStringFromName("invalidInputTitle"),
+                            this.bundle.GetStringFromName("invalidInputText"));
+      this.promptAndGoToLine();
+    } else {
+      this.goToLine(line);
+    }
+  },
+
+  /**
+   * Go to a particular line of the source code. This act is asynchronous.
+   *
+   * @param lineNumber
+   *        The line number to try to go to to.
+   */
+  goToLine(lineNumber) {
+    this.sendAsyncMessage("ViewSource:GoToLine", { lineNumber });
+  },
+
+  /**
+   * Called when the frame script reports that a line was successfully gotten
+   * to.
+   *
+   * @param lineNumber
+   *        The line number that we successfully got to.
+   */
+  onGoToLineSuccess(lineNumber) {
+    // We'll pre-populate the "Go to line" prompt with this value the next
+    // time it comes up.
+    this.lastLineFound = lineNumber;
+  },
+
+  /**
+   * Called when the frame script reports that we failed to go to a particular
+   * line. This informs the user that their selection was likely out of range,
+   * and then reprompts the user to try again.
+   */
+  onGoToLineFailed() {
+    let window = Services.wm.getMostRecentWindow(null);
+    Services.prompt.alert(window,
+                          this.bundle.GetStringFromName("outOfRangeTitle"),
+                          this.bundle.GetStringFromName("outOfRangeText"));
+    this.promptAndGoToLine();
+  },
 };
--- a/toolkit/components/viewsource/content/viewSource-content.js
+++ b/toolkit/components/viewsource/content/viewSource-content.js
@@ -7,16 +7,17 @@ const { utils: Cu, interfaces: Ci, class
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
   "resource://gre/modules/BrowserUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "DeferredTask",
   "resource://gre/modules/DeferredTask.jsm");
 
+const NS_XHTML = "http://www.w3.org/1999/xhtml";
 const BUNDLE_URL = "chrome://global/locale/viewSource.properties";
 
 // These are markers used to delimit the selection during processing. They
 // are removed from the final rendering.
 // We use noncharacter Unicode codepoints to minimize the risk of clashing
 // with anything that might legitimately be present in the document.
 // U+FDD0..FDEF <noncharacters>
 const MARK_SELECTION_START = "\uFDD0";
@@ -339,26 +340,38 @@ let ViewSourceContent = {
    */
   loadSourceFromURL(URL) {
     let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
     let webNav = docShell.QueryInterface(Ci.nsIWebNavigation);
     webNav.loadURI(URL, loadFlags, null, null, null);
   },
 
   /**
-   * This handler is specifically for click events bubbling up from
-   * error page content, which can show up if the user attempts to
-   * view the source of an attack page.
+   * This handler is for click events from:
+   *   * error page content, which can show up if the user attempts to view the
+   *     source of an attack page.
+   *   * in-page context menu actions
    */
   onClick(event) {
+    let target = event.originalTarget;
+    // Check for content menu actions
+    if (target.id) {
+      this.contextMenuItems.forEach(itemSpec => {
+        if (itemSpec.id !== target.id) {
+          return;
+        }
+        itemSpec.handler.call(this, event);
+        event.stopPropagation();
+      });
+    }
+
     // Don't trust synthetic events
     if (!event.isTrusted || event.target.localName != "button")
       return;
 
-    let target = event.originalTarget;
     let errorDoc = target.ownerDocument;
 
     if (/^about:blocked/.test(errorDoc.documentURI)) {
       // The event came from a button on a malware/phishing block page
 
       if (target == errorDoc.getElementById("getMeOutButton")) {
         // Instead of loading some safe page, just close the window
         sendAsyncMessage("ViewSource:Close");
@@ -396,16 +409,18 @@ let ViewSourceContent = {
     // If we need to draw the selection, wait until an actual view source page
     // has loaded, instead of about:blank.
     if (this.needsDrawSelection &&
         content.document.documentURI.startsWith("view-source:")) {
       this.needsDrawSelection = false;
       this.drawSelection();
     }
 
+    this.injectContextMenu();
+
     sendAsyncMessage("ViewSource:SourceLoaded");
   },
 
   /**
    * Handler for the pagehide event.
    *
    * @param event
    *        The pagehide event being handled.
@@ -653,23 +668,22 @@ let ViewSourceContent = {
    * or not long lines are wrapped.
    */
   toggleWrapping() {
     let body = content.document.body;
     body.classList.toggle("wrap");
   },
 
   /**
-   * Called when the parent has changed the syntax highlighting pref.
+   * Toggles the "highlight" class on the document body, which sets whether
+   * or not syntax highlighting is displayed.
    */
   toggleSyntaxHighlighting() {
-    // The parent process should have set the view_source.syntax_highlight
-    // pref to the desired value. The reload brings that setting into
-    // effect.
-    this.reload();
+    let body = content.document.body;
+    body.classList.toggle("highlight");
   },
 
   /**
    * Called when the parent has changed the character set to view the
    * source with.
    *
    * @param charset
    *        The character set to use.
@@ -852,10 +866,85 @@ let ViewSourceContent = {
     findService.replaceString = replaceString;
 
     findInst.matchCase     = matchCase;
     findInst.entireWord    = entireWord;
     findInst.wrapFind      = wrapFind;
     findInst.findBackwards = findBackwards;
     findInst.searchString  = searchString;
   },
+
+  /**
+   * In-page context menu items that are injected after page load.
+   */
+  contextMenuItems: [
+    {
+      id: "goToLine",
+      handler() {
+        sendAsyncMessage("ViewSource:PromptAndGoToLine");
+      }
+    },
+    {
+      id: "wrapLongLines",
+      get checked() {
+        return Services.prefs.getBoolPref("view_source.wrap_long_lines");
+      },
+      handler() {
+        this.toggleWrapping();
+      }
+    },
+    {
+      id: "highlightSyntax",
+      get checked() {
+        return Services.prefs.getBoolPref("view_source.syntax_highlight");
+      },
+      handler() {
+        this.toggleSyntaxHighlighting();
+      }
+    },
+  ],
+
+  /**
+   * Add context menu items for view source specific actions.
+   */
+  injectContextMenu() {
+    let doc = content.document;
+
+    let menu = doc.createElementNS(NS_XHTML, "menu");
+    menu.setAttribute("type", "context");
+    menu.setAttribute("id", "actions");
+    doc.body.appendChild(menu);
+    doc.body.setAttribute("contextmenu", "actions");
+
+    this.contextMenuItems.forEach(itemSpec => {
+      let item = doc.createElementNS(NS_XHTML, "menuitem");
+      item.setAttribute("id", itemSpec.id);
+      let labelName = `context_${itemSpec.id}_label`;
+      let label = this.bundle.GetStringFromName(labelName);
+      item.setAttribute("label", label);
+      if ("checked" in itemSpec) {
+        item.setAttribute("type", "checkbox");
+      }
+      menu.appendChild(item);
+    });
+
+    this.updateContextMenu();
+  },
+
+  /**
+   * Update state of checkbox-style context menu items.
+   */
+  updateContextMenu() {
+    let doc = content.document;
+    this.contextMenuItems.forEach(itemSpec => {
+      if (!("checked" in itemSpec)) {
+        return;
+      }
+      let item = doc.getElementById(itemSpec.id);
+      if (itemSpec.checked) {
+        item.setAttribute("checked", true);
+      } else {
+        item.removeAttribute("checked");
+      }
+    });
+  },
 };
 ViewSourceContent.init();
--- a/toolkit/components/viewsource/content/viewSource.js
+++ b/toolkit/components/viewsource/content/viewSource.js
@@ -47,23 +47,16 @@ ViewSourceChrome.prototype = {
   /**
    * The <browser> that will be displaying the view source content.
    */
   get browser() {
     return gBrowser;
   },
 
   /**
-   * Holds the value of the last line found via the "Go to line"
-   * command, to pre-populate the prompt the next time it is
-   * opened.
-   */
-  lastLineFound: null,
-
-  /**
    * The context menu, when opened from the content process, sends
    * up a chunk of serialized data describing the items that the
    * context menu is being opened on. This allows us to avoid using
    * CPOWs.
    */
   contextMenuData: {},
 
   /**
@@ -72,18 +65,16 @@ ViewSourceChrome.prototype = {
    * will automatically have ViewSourceChrome listen for those messages,
    * and remove the listeners on teardown.
    */
   messages: ViewSourceBrowser.prototype.messages.concat([
     "ViewSource:SourceLoaded",
     "ViewSource:SourceUnloaded",
     "ViewSource:Close",
     "ViewSource:OpenURL",
-    "ViewSource:GoToLine:Success",
-    "ViewSource:GoToLine:Failed",
     "ViewSource:UpdateStatus",
     "ViewSource:ContextMenuOpening",
   ]),
 
   /**
    * This called via ViewSourceBrowser's constructor.  This should be called as
    * soon as the script loads.  When this function executes, we can assume the
    * DOM content has not yet loaded.
@@ -142,22 +133,16 @@ ViewSourceChrome.prototype = {
         this.onSourceUnloaded();
         break;
       case "ViewSource:Close":
         this.close();
         break;
       case "ViewSource:OpenURL":
         this.openURL(data.URL);
         break;
-      case "ViewSource:GoToLine:Failed":
-        this.onGoToLineFailed();
-        break;
-      case "ViewSource:GoToLine:Success":
-        this.onGoToLineSuccess(data.lineNumber);
-        break;
       case "ViewSource:UpdateStatus":
         this.updateStatus(data.label);
         break;
       case "ViewSource:ContextMenuOpening":
         this.onContextMenuOpening(data.isLink, data.isEmail, data.href);
         if (this.browser.isRemoteBrowser) {
           this.openContextMenu(data.screenX, data.screenY);
         }
@@ -611,84 +596,29 @@ ViewSourceChrome.prototype = {
   updateStatus(label) {
     let statusBarField = document.getElementById("statusbar-line-col");
     if (statusBarField) {
       statusBarField.label = label;
     }
   },
 
   /**
-   * Opens the "Go to line" prompt for a user to hop to a particular line
-   * of the source code they're viewing. This will keep prompting until the
-   * user either cancels out of the prompt, or enters a valid line number.
-   */
-  promptAndGoToLine() {
-    let input = { value: this.lastLineFound };
-
-    let ok = Services.prompt.prompt(
-        window,
-        gViewSourceBundle.getString("goToLineTitle"),
-        gViewSourceBundle.getString("goToLineText"),
-        input,
-        null,
-        {value:0});
-
-    if (!ok)
-      return;
-
-    let line = parseInt(input.value, 10);
-
-    if (!(line > 0)) {
-      Services.prompt.alert(window,
-                            gViewSourceBundle.getString("invalidInputTitle"),
-                            gViewSourceBundle.getString("invalidInputText"));
-      this.promptAndGoToLine();
-    } else {
-      this.goToLine(line);
-    }
-  },
-
-  /**
-   * Go to a particular line of the source code. This act is asynchronous.
-   *
-   * @param lineNumber
-   *        The line number to try to go to to.
-   */
-  goToLine(lineNumber) {
-    this.sendAsyncMessage("ViewSource:GoToLine", { lineNumber });
-  },
-
-  /**
    * Called when the frame script reports that a line was successfully gotten
    * to.
    *
    * @param lineNumber
    *        The line number that we successfully got to.
    */
   onGoToLineSuccess(lineNumber) {
-    // We'll pre-populate the "Go to line" prompt with this value the next
-    // time it comes up.
-    this.lastLineFound = lineNumber;
+    ViewSourceBrowser.prototype.onGoToLineSuccess.call(this, lineNumber);
     document.getElementById("statusbar-line-col").label =
       gViewSourceBundle.getFormattedString("statusBarLineCol", [lineNumber, 1]);
   },
 
   /**
-   * Called when the frame script reports that we failed to go to a particular
-   * line. This informs the user that their selection was likely out of range,
-   * and then reprompts the user to try again.
-   */
-  onGoToLineFailed() {
-    Services.prompt.alert(window,
-                          gViewSourceBundle.getString("outOfRangeTitle"),
-                          gViewSourceBundle.getString("outOfRangeText"));
-    this.promptAndGoToLine();
-  },
-
-  /**
    * Reloads the browser, bypassing the network cache.
    */
   reload() {
     this.browser.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY |
                                  Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
   },
 
   /**
@@ -713,18 +643,17 @@ ViewSourceChrome.prototype = {
   /**
    * Called when the user clicks on the "Syntax Highlighting" menu item, and
    * flips the user preference for wrapping long lines in the view source
    * browser.
    */
   toggleSyntaxHighlighting() {
     this.shouldHighlight = !this.shouldHighlight;
     // We can't flip this value in the child, since prefs are read-only there.
-    // We flip it here, and then cause a reload in the child to make the change
-    // occur.
+    // We flip it here, and then toggle a class in the child.
     Services.prefs.setBoolPref("view_source.syntax_highlight",
                                this.shouldHighlight);
     this.sendAsyncMessage("ViewSource:ToggleSyntaxHighlighting");
   },
 
   /**
    * Updates the "remote" attribute of the view source browser. This
    * will remove the browser from the DOM, and then re-add it in the
--- a/toolkit/components/viewsource/test/browser/browser_viewsourceprefs.js
+++ b/toolkit/components/viewsource/test/browser/browser_viewsourceprefs.js
@@ -48,31 +48,24 @@ let exercisePrefs = Task.async(function*
   yield checkStyle(win, "white-space", "pre-wrap");
 
   simulateClick(wrapMenuItem);
   is(wrapMenuItem.hasAttribute("checked"), false, "Wrap menu item unchecked");
   is(SpecialPowers.getBoolPref("view_source.wrap_long_lines"), false, "Wrap pref set");
   yield checkStyle(win, "white-space", "pre");
 
   // Check that the Syntax Highlighting menu item works.
-  let pageShowPromise = BrowserTestUtils.waitForEvent(win.gBrowser, "pageshow");
   simulateClick(syntaxMenuItem);
-  yield pageShowPromise;
-
   is(syntaxMenuItem.hasAttribute("checked"), false, "Syntax menu item unchecked");
   is(SpecialPowers.getBoolPref("view_source.syntax_highlight"), false, "Syntax highlighting pref set");
   yield checkHighlight(win, false);
 
-  pageShowPromise = BrowserTestUtils.waitForEvent(win.gBrowser, "pageshow");
   simulateClick(syntaxMenuItem);
-  yield pageShowPromise;
-
   is(syntaxMenuItem.hasAttribute("checked"), true, "Syntax menu item checked");
   is(SpecialPowers.getBoolPref("view_source.syntax_highlight"), true, "Syntax highlighting pref set");
-
   yield checkHighlight(win, highlightable);
   yield BrowserTestUtils.closeWindow(win);
 
   // Open a new view-source window to check that the prefs are obeyed.
   SpecialPowers.setIntPref("view_source.tab_size", 2);
   SpecialPowers.setBoolPref("view_source.wrap_long_lines", true);
   SpecialPowers.setBoolPref("view_source.syntax_highlight", false);
 
@@ -119,16 +112,15 @@ let checkStyle = Task.async(function* (w
     let style = content.getComputedStyle(content.document.body, null);
     return style.getPropertyValue(styleProperty);
   });
   is(value, expected, "Correct value of " + styleProperty);
 });
 
 let checkHighlight = Task.async(function* (win, expected) {
   let browser = win.gBrowser;
-  let highlighted = yield ContentTask.spawn(browser, {}, function* () {
     let spans = content.document.getElementsByTagName("span");
     return Array.some(spans, (span) => {
-      return span.className != "";
+      let style = content.getComputedStyle(span, null);
+      return style.getPropertyValue("color") !== "rgb(0, 0, 0)";
     });
-  });
   is(highlighted, expected, "Syntax highlighting " + (expected ? "on" : "off"));
 });
--- a/toolkit/locales/en-US/chrome/global/viewSource.properties
+++ b/toolkit/locales/en-US/chrome/global/viewSource.properties
@@ -6,8 +6,12 @@ goToLineTitle     = Go to line
 goToLineText      = Enter line number
 invalidInputTitle = Invalid input
 invalidInputText  = The line number entered is invalid.
 outOfRangeTitle   = Line not found
 outOfRangeText    = The specified line was not found.
 statusBarLineCol  = Line %1$S, Col %2$S
 viewSelectionSourceTitle = DOM Source of Selection
 viewMathMLSourceTitle    = DOM Source of MathML
+
+context_goToLine_label        = Go to Lineā€¦
+context_wrapLongLines_label   = Wrap Long Lines
+context_highlightSyntax_label = Syntax Highlighting