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