Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
michael@0 | 1 | /* This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
michael@0 | 4 | |
michael@0 | 5 | let Ci = Components.interfaces; |
michael@0 | 6 | let Cc = Components.classes; |
michael@0 | 7 | |
michael@0 | 8 | this.kXLinkNamespace = "http://www.w3.org/1999/xlink"; |
michael@0 | 9 | |
michael@0 | 10 | dump("### ContextMenuHandler.js loaded\n"); |
michael@0 | 11 | |
michael@0 | 12 | var ContextMenuHandler = { |
michael@0 | 13 | _types: [], |
michael@0 | 14 | _previousState: null, |
michael@0 | 15 | |
michael@0 | 16 | init: function ch_init() { |
michael@0 | 17 | // Events we catch from content during the bubbling phase |
michael@0 | 18 | addEventListener("contextmenu", this, false); |
michael@0 | 19 | addEventListener("pagehide", this, false); |
michael@0 | 20 | |
michael@0 | 21 | // Messages we receive from browser |
michael@0 | 22 | // Command sent over from browser that only we can handle. |
michael@0 | 23 | addMessageListener("Browser:ContextCommand", this, false); |
michael@0 | 24 | |
michael@0 | 25 | this.popupNode = null; |
michael@0 | 26 | }, |
michael@0 | 27 | |
michael@0 | 28 | handleEvent: function ch_handleEvent(aEvent) { |
michael@0 | 29 | switch (aEvent.type) { |
michael@0 | 30 | case "contextmenu": |
michael@0 | 31 | this._onContentContextMenu(aEvent); |
michael@0 | 32 | break; |
michael@0 | 33 | case "pagehide": |
michael@0 | 34 | this.reset(); |
michael@0 | 35 | break; |
michael@0 | 36 | } |
michael@0 | 37 | }, |
michael@0 | 38 | |
michael@0 | 39 | receiveMessage: function ch_receiveMessage(aMessage) { |
michael@0 | 40 | switch (aMessage.name) { |
michael@0 | 41 | case "Browser:ContextCommand": |
michael@0 | 42 | this._onContextCommand(aMessage); |
michael@0 | 43 | break; |
michael@0 | 44 | } |
michael@0 | 45 | }, |
michael@0 | 46 | |
michael@0 | 47 | /* |
michael@0 | 48 | * Handler for commands send over from browser's ContextCommands.js |
michael@0 | 49 | * in response to certain context menu actions only we can handle. |
michael@0 | 50 | */ |
michael@0 | 51 | _onContextCommand: function _onContextCommand(aMessage) { |
michael@0 | 52 | let node = this.popupNode; |
michael@0 | 53 | let command = aMessage.json.command; |
michael@0 | 54 | |
michael@0 | 55 | switch (command) { |
michael@0 | 56 | case "cut": |
michael@0 | 57 | this._onCut(); |
michael@0 | 58 | break; |
michael@0 | 59 | |
michael@0 | 60 | case "copy": |
michael@0 | 61 | this._onCopy(); |
michael@0 | 62 | break; |
michael@0 | 63 | |
michael@0 | 64 | case "paste": |
michael@0 | 65 | this._onPaste(); |
michael@0 | 66 | break; |
michael@0 | 67 | |
michael@0 | 68 | case "select-all": |
michael@0 | 69 | this._onSelectAll(); |
michael@0 | 70 | break; |
michael@0 | 71 | |
michael@0 | 72 | case "copy-image-contents": |
michael@0 | 73 | this._onCopyImage(); |
michael@0 | 74 | break; |
michael@0 | 75 | } |
michael@0 | 76 | }, |
michael@0 | 77 | |
michael@0 | 78 | /****************************************************** |
michael@0 | 79 | * Event handlers |
michael@0 | 80 | */ |
michael@0 | 81 | |
michael@0 | 82 | reset: function ch_reset() { |
michael@0 | 83 | this.popupNode = null; |
michael@0 | 84 | this._target = null; |
michael@0 | 85 | }, |
michael@0 | 86 | |
michael@0 | 87 | // content contextmenu handler |
michael@0 | 88 | _onContentContextMenu: function _onContentContextMenu(aEvent) { |
michael@0 | 89 | if (aEvent.defaultPrevented) |
michael@0 | 90 | return; |
michael@0 | 91 | |
michael@0 | 92 | // Don't let these bubble up to input.js |
michael@0 | 93 | aEvent.stopPropagation(); |
michael@0 | 94 | aEvent.preventDefault(); |
michael@0 | 95 | |
michael@0 | 96 | this._processPopupNode(aEvent.originalTarget, aEvent.clientX, |
michael@0 | 97 | aEvent.clientY, aEvent.mozInputSource); |
michael@0 | 98 | }, |
michael@0 | 99 | |
michael@0 | 100 | /****************************************************** |
michael@0 | 101 | * ContextCommand handlers |
michael@0 | 102 | */ |
michael@0 | 103 | |
michael@0 | 104 | _onSelectAll: function _onSelectAll() { |
michael@0 | 105 | if (Util.isTextInput(this._target)) { |
michael@0 | 106 | // select all text in the input control |
michael@0 | 107 | this._target.select(); |
michael@0 | 108 | } else if (Util.isEditableContent(this._target)) { |
michael@0 | 109 | this._target.ownerDocument.execCommand("selectAll", false); |
michael@0 | 110 | } else { |
michael@0 | 111 | // select the entire document |
michael@0 | 112 | content.getSelection().selectAllChildren(content.document); |
michael@0 | 113 | } |
michael@0 | 114 | this.reset(); |
michael@0 | 115 | }, |
michael@0 | 116 | |
michael@0 | 117 | _onPaste: function _onPaste() { |
michael@0 | 118 | // paste text if this is an input control |
michael@0 | 119 | if (Util.isTextInput(this._target)) { |
michael@0 | 120 | let edit = this._target.QueryInterface(Ci.nsIDOMNSEditableElement); |
michael@0 | 121 | if (edit) { |
michael@0 | 122 | edit.editor.paste(Ci.nsIClipboard.kGlobalClipboard); |
michael@0 | 123 | } else { |
michael@0 | 124 | Util.dumpLn("error: target element does not support nsIDOMNSEditableElement"); |
michael@0 | 125 | } |
michael@0 | 126 | } else if (Util.isEditableContent(this._target)) { |
michael@0 | 127 | try { |
michael@0 | 128 | this._target.ownerDocument.execCommand("paste", |
michael@0 | 129 | false, |
michael@0 | 130 | Ci.nsIClipboard.kGlobalClipboard); |
michael@0 | 131 | } catch (ex) { |
michael@0 | 132 | dump("ContextMenuHandler: exception pasting into contentEditable: " + ex.message + "\n"); |
michael@0 | 133 | } |
michael@0 | 134 | } |
michael@0 | 135 | this.reset(); |
michael@0 | 136 | }, |
michael@0 | 137 | |
michael@0 | 138 | _onCopyImage: function _onCopyImage() { |
michael@0 | 139 | Util.copyImageToClipboard(this._target); |
michael@0 | 140 | }, |
michael@0 | 141 | |
michael@0 | 142 | _onCut: function _onCut() { |
michael@0 | 143 | if (Util.isTextInput(this._target)) { |
michael@0 | 144 | let edit = this._target.QueryInterface(Ci.nsIDOMNSEditableElement); |
michael@0 | 145 | if (edit) { |
michael@0 | 146 | edit.editor.cut(); |
michael@0 | 147 | } else { |
michael@0 | 148 | Util.dumpLn("error: target element does not support nsIDOMNSEditableElement"); |
michael@0 | 149 | } |
michael@0 | 150 | } else if (Util.isEditableContent(this._target)) { |
michael@0 | 151 | try { |
michael@0 | 152 | this._target.ownerDocument.execCommand("cut", false); |
michael@0 | 153 | } catch (ex) { |
michael@0 | 154 | dump("ContextMenuHandler: exception cutting from contentEditable: " + ex.message + "\n"); |
michael@0 | 155 | } |
michael@0 | 156 | } |
michael@0 | 157 | this.reset(); |
michael@0 | 158 | }, |
michael@0 | 159 | |
michael@0 | 160 | _onCopy: function _onCopy() { |
michael@0 | 161 | if (Util.isTextInput(this._target)) { |
michael@0 | 162 | let edit = this._target.QueryInterface(Ci.nsIDOMNSEditableElement); |
michael@0 | 163 | if (edit) { |
michael@0 | 164 | edit.editor.copy(); |
michael@0 | 165 | } else { |
michael@0 | 166 | Util.dumpLn("error: target element does not support nsIDOMNSEditableElement"); |
michael@0 | 167 | } |
michael@0 | 168 | } else if (Util.isEditableContent(this._target)) { |
michael@0 | 169 | try { |
michael@0 | 170 | this._target.ownerDocument.execCommand("copy", false); |
michael@0 | 171 | } catch (ex) { |
michael@0 | 172 | dump("ContextMenuHandler: exception copying from contentEditable: " + |
michael@0 | 173 | ex.message + "\n"); |
michael@0 | 174 | } |
michael@0 | 175 | } else { |
michael@0 | 176 | let selectionText = this._previousState.string; |
michael@0 | 177 | |
michael@0 | 178 | Cc["@mozilla.org/widget/clipboardhelper;1"] |
michael@0 | 179 | .getService(Ci.nsIClipboardHelper).copyString(selectionText); |
michael@0 | 180 | } |
michael@0 | 181 | this.reset(); |
michael@0 | 182 | }, |
michael@0 | 183 | |
michael@0 | 184 | /****************************************************** |
michael@0 | 185 | * Utility routines |
michael@0 | 186 | */ |
michael@0 | 187 | |
michael@0 | 188 | /* |
michael@0 | 189 | * _processPopupNode - Generate and send a Content:ContextMenu message |
michael@0 | 190 | * to browser detailing the underlying content types at this.popupNode. |
michael@0 | 191 | * Note the event we receive targets the sub frame (if there is one) of |
michael@0 | 192 | * the page. |
michael@0 | 193 | */ |
michael@0 | 194 | _processPopupNode: function _processPopupNode(aPopupNode, aX, aY, aInputSrc) { |
michael@0 | 195 | if (!aPopupNode) |
michael@0 | 196 | return; |
michael@0 | 197 | |
michael@0 | 198 | let { targetWindow: targetWindow, |
michael@0 | 199 | offsetX: offsetX, |
michael@0 | 200 | offsetY: offsetY } = |
michael@0 | 201 | Util.translateToTopLevelWindow(aPopupNode); |
michael@0 | 202 | |
michael@0 | 203 | let popupNode = this.popupNode = aPopupNode; |
michael@0 | 204 | let imageUrl = ""; |
michael@0 | 205 | |
michael@0 | 206 | let state = { |
michael@0 | 207 | types: [], |
michael@0 | 208 | label: "", |
michael@0 | 209 | linkURL: "", |
michael@0 | 210 | linkTitle: "", |
michael@0 | 211 | linkProtocol: null, |
michael@0 | 212 | mediaURL: "", |
michael@0 | 213 | contentType: "", |
michael@0 | 214 | contentDisposition: "", |
michael@0 | 215 | string: "", |
michael@0 | 216 | }; |
michael@0 | 217 | let uniqueStateTypes = new Set(); |
michael@0 | 218 | |
michael@0 | 219 | // Do checks for nodes that never have children. |
michael@0 | 220 | if (popupNode.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) { |
michael@0 | 221 | // See if the user clicked on an image. |
michael@0 | 222 | if (popupNode instanceof Ci.nsIImageLoadingContent && popupNode.currentURI) { |
michael@0 | 223 | uniqueStateTypes.add("image"); |
michael@0 | 224 | state.label = state.mediaURL = popupNode.currentURI.spec; |
michael@0 | 225 | imageUrl = state.mediaURL; |
michael@0 | 226 | this._target = popupNode; |
michael@0 | 227 | |
michael@0 | 228 | // Retrieve the type of image from the cache since the url can fail to |
michael@0 | 229 | // provide valuable informations |
michael@0 | 230 | try { |
michael@0 | 231 | let imageCache = Cc["@mozilla.org/image/cache;1"].getService(Ci.imgICache); |
michael@0 | 232 | let props = imageCache.findEntryProperties(popupNode.currentURI, |
michael@0 | 233 | content.document.characterSet); |
michael@0 | 234 | if (props) { |
michael@0 | 235 | state.contentType = String(props.get("type", Ci.nsISupportsCString)); |
michael@0 | 236 | state.contentDisposition = String(props.get("content-disposition", |
michael@0 | 237 | Ci.nsISupportsCString)); |
michael@0 | 238 | } |
michael@0 | 239 | } catch (ex) { |
michael@0 | 240 | Util.dumpLn(ex.message); |
michael@0 | 241 | // Failure to get type and content-disposition off the image is non-fatal |
michael@0 | 242 | } |
michael@0 | 243 | } |
michael@0 | 244 | } |
michael@0 | 245 | |
michael@0 | 246 | let elem = popupNode; |
michael@0 | 247 | let isText = false; |
michael@0 | 248 | let isEditableText = false; |
michael@0 | 249 | |
michael@0 | 250 | while (elem) { |
michael@0 | 251 | if (elem.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) { |
michael@0 | 252 | // is the target a link or a descendant of a link? |
michael@0 | 253 | if (Util.isLink(elem)) { |
michael@0 | 254 | // If this is an image that links to itself, don't include both link and |
michael@0 | 255 | // image otpions. |
michael@0 | 256 | if (imageUrl == this._getLinkURL(elem)) { |
michael@0 | 257 | elem = elem.parentNode; |
michael@0 | 258 | continue; |
michael@0 | 259 | } |
michael@0 | 260 | |
michael@0 | 261 | uniqueStateTypes.add("link"); |
michael@0 | 262 | state.label = state.linkURL = this._getLinkURL(elem); |
michael@0 | 263 | linkUrl = state.linkURL; |
michael@0 | 264 | state.linkTitle = popupNode.textContent || popupNode.title; |
michael@0 | 265 | state.linkProtocol = this._getProtocol(this._getURI(state.linkURL)); |
michael@0 | 266 | // mark as text so we can pickup on selection below |
michael@0 | 267 | isText = true; |
michael@0 | 268 | break; |
michael@0 | 269 | } |
michael@0 | 270 | // is the target contentEditable (not just inheriting contentEditable) |
michael@0 | 271 | // or the entire document in designer mode. |
michael@0 | 272 | else if (elem.contentEditable == "true" || |
michael@0 | 273 | Util.isOwnerDocumentInDesignMode(elem)) { |
michael@0 | 274 | this._target = elem; |
michael@0 | 275 | isEditableText = true; |
michael@0 | 276 | isText = true; |
michael@0 | 277 | uniqueStateTypes.add("input-text"); |
michael@0 | 278 | |
michael@0 | 279 | if (elem.textContent.length) { |
michael@0 | 280 | uniqueStateTypes.add("selectable"); |
michael@0 | 281 | } else { |
michael@0 | 282 | uniqueStateTypes.add("input-empty"); |
michael@0 | 283 | } |
michael@0 | 284 | break; |
michael@0 | 285 | } |
michael@0 | 286 | // is the target a text input |
michael@0 | 287 | else if (Util.isTextInput(elem)) { |
michael@0 | 288 | this._target = elem; |
michael@0 | 289 | isEditableText = true; |
michael@0 | 290 | uniqueStateTypes.add("input-text"); |
michael@0 | 291 | |
michael@0 | 292 | let selectionStart = elem.selectionStart; |
michael@0 | 293 | let selectionEnd = elem.selectionEnd; |
michael@0 | 294 | |
michael@0 | 295 | // Don't include "copy" for password fields. |
michael@0 | 296 | if (!(elem instanceof Ci.nsIDOMHTMLInputElement) || elem.mozIsTextField(true)) { |
michael@0 | 297 | // If there is a selection add cut and copy |
michael@0 | 298 | if (selectionStart != selectionEnd) { |
michael@0 | 299 | uniqueStateTypes.add("cut"); |
michael@0 | 300 | uniqueStateTypes.add("copy"); |
michael@0 | 301 | state.string = elem.value.slice(selectionStart, selectionEnd); |
michael@0 | 302 | } else if (elem.value && elem.textLength) { |
michael@0 | 303 | // There is text and it is not selected so add selectable items |
michael@0 | 304 | uniqueStateTypes.add("selectable"); |
michael@0 | 305 | state.string = elem.value; |
michael@0 | 306 | } |
michael@0 | 307 | } |
michael@0 | 308 | |
michael@0 | 309 | if (!elem.textLength) { |
michael@0 | 310 | uniqueStateTypes.add("input-empty"); |
michael@0 | 311 | } |
michael@0 | 312 | break; |
michael@0 | 313 | } |
michael@0 | 314 | // is the target an element containing text content |
michael@0 | 315 | else if (Util.isText(elem)) { |
michael@0 | 316 | isText = true; |
michael@0 | 317 | } |
michael@0 | 318 | // is the target a media element |
michael@0 | 319 | else if (elem instanceof Ci.nsIDOMHTMLMediaElement || |
michael@0 | 320 | elem instanceof Ci.nsIDOMHTMLVideoElement) { |
michael@0 | 321 | state.label = state.mediaURL = (elem.currentSrc || elem.src); |
michael@0 | 322 | uniqueStateTypes.add((elem.paused || elem.ended) ? |
michael@0 | 323 | "media-paused" : "media-playing"); |
michael@0 | 324 | if (elem instanceof Ci.nsIDOMHTMLVideoElement) { |
michael@0 | 325 | uniqueStateTypes.add("video"); |
michael@0 | 326 | } |
michael@0 | 327 | } |
michael@0 | 328 | } |
michael@0 | 329 | |
michael@0 | 330 | elem = elem.parentNode; |
michael@0 | 331 | } |
michael@0 | 332 | |
michael@0 | 333 | // Over arching text tests |
michael@0 | 334 | if (isText) { |
michael@0 | 335 | // If this is text and has a selection, we want to bring |
michael@0 | 336 | // up the copy option on the context menu. |
michael@0 | 337 | let selection = targetWindow.getSelection(); |
michael@0 | 338 | if (selection && this._tapInSelection(selection, aX, aY)) { |
michael@0 | 339 | state.string = targetWindow.getSelection().toString(); |
michael@0 | 340 | uniqueStateTypes.add("copy"); |
michael@0 | 341 | uniqueStateTypes.add("selected-text"); |
michael@0 | 342 | if (isEditableText) { |
michael@0 | 343 | uniqueStateTypes.add("cut"); |
michael@0 | 344 | } |
michael@0 | 345 | } else { |
michael@0 | 346 | // Add general content text if this isn't anything specific |
michael@0 | 347 | if (!( |
michael@0 | 348 | uniqueStateTypes.has("image") || |
michael@0 | 349 | uniqueStateTypes.has("media") || |
michael@0 | 350 | uniqueStateTypes.has("video") || |
michael@0 | 351 | uniqueStateTypes.has("link") || |
michael@0 | 352 | uniqueStateTypes.has("input-text") |
michael@0 | 353 | )) { |
michael@0 | 354 | uniqueStateTypes.add("content-text"); |
michael@0 | 355 | } |
michael@0 | 356 | } |
michael@0 | 357 | } |
michael@0 | 358 | |
michael@0 | 359 | // Is paste applicable here? |
michael@0 | 360 | if (isEditableText) { |
michael@0 | 361 | let flavors = ["text/unicode"]; |
michael@0 | 362 | let cb = Cc["@mozilla.org/widget/clipboard;1"].getService(Ci.nsIClipboard); |
michael@0 | 363 | let hasData = cb.hasDataMatchingFlavors(flavors, |
michael@0 | 364 | flavors.length, |
michael@0 | 365 | Ci.nsIClipboard.kGlobalClipboard); |
michael@0 | 366 | // add paste if there's data |
michael@0 | 367 | if (hasData && !elem.readOnly) { |
michael@0 | 368 | uniqueStateTypes.add("paste"); |
michael@0 | 369 | } |
michael@0 | 370 | } |
michael@0 | 371 | // populate position and event source |
michael@0 | 372 | state.xPos = offsetX + aX; |
michael@0 | 373 | state.yPos = offsetY + aY; |
michael@0 | 374 | state.source = aInputSrc; |
michael@0 | 375 | |
michael@0 | 376 | for (let i = 0; i < this._types.length; i++) |
michael@0 | 377 | if (this._types[i].handler(state, popupNode)) |
michael@0 | 378 | uniqueStateTypes.add(this._types[i].name); |
michael@0 | 379 | |
michael@0 | 380 | state.types = [type for (type of uniqueStateTypes)]; |
michael@0 | 381 | this._previousState = state; |
michael@0 | 382 | |
michael@0 | 383 | sendAsyncMessage("Content:ContextMenu", state); |
michael@0 | 384 | }, |
michael@0 | 385 | |
michael@0 | 386 | _tapInSelection: function (aSelection, aX, aY) { |
michael@0 | 387 | if (!aSelection || !aSelection.rangeCount) { |
michael@0 | 388 | return false; |
michael@0 | 389 | } |
michael@0 | 390 | for (let idx = 0; idx < aSelection.rangeCount; idx++) { |
michael@0 | 391 | let range = aSelection.getRangeAt(idx); |
michael@0 | 392 | let rect = range.getBoundingClientRect(); |
michael@0 | 393 | if (Util.pointWithinDOMRect(aX, aY, rect)) { |
michael@0 | 394 | return true; |
michael@0 | 395 | } |
michael@0 | 396 | } |
michael@0 | 397 | return false; |
michael@0 | 398 | }, |
michael@0 | 399 | |
michael@0 | 400 | _getLinkURL: function ch_getLinkURL(aLink) { |
michael@0 | 401 | let href = aLink.href; |
michael@0 | 402 | if (href) |
michael@0 | 403 | return href; |
michael@0 | 404 | |
michael@0 | 405 | href = aLink.getAttributeNS(kXLinkNamespace, "href"); |
michael@0 | 406 | if (!href || !href.match(/\S/)) { |
michael@0 | 407 | // Without this we try to save as the current doc, |
michael@0 | 408 | // for example, HTML case also throws if empty |
michael@0 | 409 | throw "Empty href"; |
michael@0 | 410 | } |
michael@0 | 411 | |
michael@0 | 412 | return Util.makeURLAbsolute(aLink.baseURI, href); |
michael@0 | 413 | }, |
michael@0 | 414 | |
michael@0 | 415 | _getURI: function ch_getURI(aURL) { |
michael@0 | 416 | try { |
michael@0 | 417 | return Util.makeURI(aURL); |
michael@0 | 418 | } catch (ex) { } |
michael@0 | 419 | |
michael@0 | 420 | return null; |
michael@0 | 421 | }, |
michael@0 | 422 | |
michael@0 | 423 | _getProtocol: function ch_getProtocol(aURI) { |
michael@0 | 424 | if (aURI) |
michael@0 | 425 | return aURI.scheme; |
michael@0 | 426 | return null; |
michael@0 | 427 | }, |
michael@0 | 428 | |
michael@0 | 429 | /** |
michael@0 | 430 | * For add-ons to add new types and data to the ContextMenu message. |
michael@0 | 431 | * |
michael@0 | 432 | * @param aName A string to identify the new type. |
michael@0 | 433 | * @param aHandler A function that takes a state object and a target element. |
michael@0 | 434 | * If aHandler returns true, then aName will be added to the list of types. |
michael@0 | 435 | * The function may also modify the state object. |
michael@0 | 436 | */ |
michael@0 | 437 | registerType: function registerType(aName, aHandler) { |
michael@0 | 438 | this._types.push({name: aName, handler: aHandler}); |
michael@0 | 439 | }, |
michael@0 | 440 | |
michael@0 | 441 | /** Remove all handlers registered for a given type. */ |
michael@0 | 442 | unregisterType: function unregisterType(aName) { |
michael@0 | 443 | this._types = this._types.filter(function(type) type.name != aName); |
michael@0 | 444 | } |
michael@0 | 445 | }; |
michael@0 | 446 | this.ContextMenuHandler = ContextMenuHandler; |
michael@0 | 447 | |
michael@0 | 448 | ContextMenuHandler.init(); |