diff --git a/jsscripts/ContextMenuHandler.js b/jsscripts/ContextMenuHandler.js index 2eec65a..722c24c 100644 --- a/jsscripts/ContextMenuHandler.js +++ b/jsscripts/ContextMenuHandler.js @@ -2,7 +2,10 @@ * 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/. */ -const kXLinkNamespace = "http://www.w3.org/1999/xlink"; +let Ci = Components.interfaces; +let Cc = Components.classes; + +this.kXLinkNamespace = "http://www.w3.org/1999/xlink"; dump("### ContextMenuHandler.js loaded\n"); @@ -102,6 +105,8 @@ var ContextMenuHandler = { if (Util.isTextInput(this._target)) { // select all text in the input control this._target.select(); + } else if (Util.isEditableContent(this._target)) { + this._target.ownerDocument.execCommand("selectAll", false); } else { // select the entire document content.getSelection().selectAllChildren(content.document); @@ -118,6 +123,14 @@ var ContextMenuHandler = { } else { Util.dumpLn("error: target element does not support nsIDOMNSEditableElement"); } + } else if (Util.isEditableContent(this._target)) { + try { + this._target.ownerDocument.execCommand("paste", + false, + Ci.nsIClipboard.kGlobalClipboard); + } catch (ex) { + dump("ContextMenuHandler: exception pasting into contentEditable: " + ex.message + "\n"); + } } this.reset(); }, @@ -134,6 +147,12 @@ var ContextMenuHandler = { } else { Util.dumpLn("error: target element does not support nsIDOMNSEditableElement"); } + } else if (Util.isEditableContent(this._target)) { + try { + this._target.ownerDocument.execCommand("cut", false); + } catch (ex) { + dump("ContextMenuHandler: exception cutting from contentEditable: " + ex.message + "\n"); + } } this.reset(); }, @@ -146,6 +165,13 @@ var ContextMenuHandler = { } else { Util.dumpLn("error: target element does not support nsIDOMNSEditableElement"); } + } else if (Util.isEditableContent(this._target)) { + try { + this._target.ownerDocument.execCommand("copy", false); + } catch (ex) { + dump("ContextMenuHandler: exception copying from contentEditable: " + + ex.message + "\n"); + } } else { let selectionText = this._previousState.string; @@ -188,12 +214,13 @@ var ContextMenuHandler = { contentDisposition: "", string: "", }; + let uniqueStateTypes = new Set(); // Do checks for nodes that never have children. if (popupNode.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) { // See if the user clicked on an image. if (popupNode instanceof Ci.nsIImageLoadingContent && popupNode.currentURI) { - state.types.push("image"); + uniqueStateTypes.add("image"); state.label = state.mediaURL = popupNode.currentURI.spec; imageUrl = state.mediaURL; this._target = popupNode; @@ -201,10 +228,9 @@ var ContextMenuHandler = { // Retrieve the type of image from the cache since the url can fail to // provide valuable informations try { - let tools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools); - let imageCache = tools.getImgCacheForDocument(content.document); - let props = imageCache.findEntryProperties(popupNode.currentURI); - + let imageCache = Cc["@mozilla.org/image/cache;1"].getService(Ci.imgICache); + let props = imageCache.findEntryProperties(popupNode.currentURI, + content.document.characterSet); if (props) { state.contentType = String(props.get("type", Ci.nsISupportsCString)); state.contentDisposition = String(props.get("content-disposition", @@ -219,6 +245,7 @@ var ContextMenuHandler = { let elem = popupNode; let isText = false; + let isEditableText = false; while (elem) { if (elem.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) { @@ -231,56 +258,70 @@ var ContextMenuHandler = { continue; } - state.types.push("link"); + uniqueStateTypes.add("link"); state.label = state.linkURL = this._getLinkURL(elem); state.linkTitle = popupNode.textContent || popupNode.title; state.linkProtocol = this._getProtocol(this._getURI(state.linkURL)); // mark as text so we can pickup on selection below isText = true; break; - } else if (Util.isTextInput(elem)) { - let selectionStart = elem.selectionStart; - let selectionEnd = elem.selectionEnd; + } + // is the target contentEditable (not just inheriting contentEditable) + // or the entire document in designer mode. + else if (elem.contentEditable == "true" || + Util.isOwnerDocumentInDesignMode(elem)) { + this._target = elem; + isEditableText = true; + isText = true; + uniqueStateTypes.add("input-text"); - state.types.push("input-text"); + if (elem.textContent.length) { + uniqueStateTypes.add("selectable"); + } else { + uniqueStateTypes.add("input-empty"); + } + break; + } + // is the target a text input + else if (Util.isTextInput(elem)) { this._target = elem; + isEditableText = true; + uniqueStateTypes.add("input-text"); + + let selectionStart = elem.selectionStart; + let selectionEnd = elem.selectionEnd; // Don't include "copy" for password fields. if (!(elem instanceof Ci.nsIDOMHTMLInputElement) || elem.mozIsTextField(true)) { // If there is a selection add cut and copy if (selectionStart != selectionEnd) { - state.types.push("cut"); - state.types.push("copy"); + uniqueStateTypes.add("cut"); + uniqueStateTypes.add("copy"); state.string = elem.value.slice(selectionStart, selectionEnd); } else if (elem.value && elem.textLength) { // There is text and it is not selected so add selectable items - state.types.push("selectable"); + uniqueStateTypes.add("selectable"); state.string = elem.value; } } if (!elem.textLength) { - state.types.push("input-empty"); - } - - let flavors = ["text/unicode"]; - let cb = Cc["@mozilla.org/widget/clipboard;1"].getService(Ci.nsIClipboard); - let hasData = cb.hasDataMatchingFlavors(flavors, - flavors.length, - Ci.nsIClipboard.kGlobalClipboard); - if (hasData && !elem.readOnly) { - state.types.push("paste"); + uniqueStateTypes.add("input-empty"); } break; - } else if (Util.isText(elem)) { + } + // is the target an element containing text content + else if (Util.isText(elem)) { isText = true; - } else if (elem instanceof Ci.nsIDOMHTMLMediaElement || + } + // is the target a media element + else if (elem instanceof Ci.nsIDOMHTMLMediaElement || elem instanceof targetWindow.HTMLVideoElement) { state.label = state.mediaURL = (elem.currentSrc || elem.src); - state.types.push((elem.paused || elem.ended) ? + uniqueStateTypes.add((elem.paused || elem.ended) ? "media-paused" : "media-playing"); if (elem instanceof targetWindow.HTMLVideoElement) { - state.types.push("video"); + uniqueStateTypes.add("video"); } } } @@ -293,22 +334,39 @@ var ContextMenuHandler = { // If this is text and has a selection, we want to bring // up the copy option on the context menu. let selection = targetWindow.getSelection(); - if (selection && selection.toString().length > 0) { + if (selection && this._tapInSelection(selection, aX, aY)) { state.string = targetWindow.getSelection().toString(); - state.types.push("copy"); - state.types.push("selected-text"); + uniqueStateTypes.add("copy"); + uniqueStateTypes.add("selected-text"); + if (isEditableText) { + uniqueStateTypes.add("cut"); + } } else { // Add general content text if this isn't anything specific - if (state.types.indexOf("image") == -1 && - state.types.indexOf("media") == -1 && - state.types.indexOf("video") == -1 && - state.types.indexOf("link") == -1 && - state.types.indexOf("input-text") == -1) { - state.types.push("content-text"); + if (!( + uniqueStateTypes.has("image") || + uniqueStateTypes.has("media") || + uniqueStateTypes.has("video") || + uniqueStateTypes.has("link") || + uniqueStateTypes.has("input-text") + )) { + uniqueStateTypes.add("content-text"); } } } + // Is paste applicable here? + if (isEditableText) { + let flavors = ["text/unicode"]; + let cb = Cc["@mozilla.org/widget/clipboard;1"].getService(Ci.nsIClipboard); + let hasData = cb.hasDataMatchingFlavors(flavors, + flavors.length, + Ci.nsIClipboard.kGlobalClipboard); + // add paste if there's data + if (hasData && !elem.readOnly) { + uniqueStateTypes.add("paste"); + } + } // populate position and event source state.xPos = offsetX + aX; state.yPos = offsetY + aY; @@ -316,13 +374,28 @@ var ContextMenuHandler = { for (let i = 0; i < this._types.length; i++) if (this._types[i].handler(state, popupNode)) - state.types.push(this._types[i].name); + uniqueStateTypes.add(this._types[i].name); + state.types = [type for (type of uniqueStateTypes)]; this._previousState = state; sendAsyncMessage("Content:ContextMenu", state); }, + _tapInSelection: function (aSelection, aX, aY) { + if (!aSelection || !aSelection.rangeCount) { + return false; + } + for (let idx = 0; idx < aSelection.rangeCount; idx++) { + let range = aSelection.getRangeAt(idx); + let rect = range.getBoundingClientRect(); + if (Util.pointWithinDOMRect(aX, aY, rect)) { + return true; + } + } + return false; + }, + _getLinkURL: function ch_getLinkURL(aLink) { let href = aLink.href; if (href) @@ -369,5 +442,6 @@ var ContextMenuHandler = { this._types = this._types.filter(function(type) type.name != aName); } }; +this.ContextMenuHandler = ContextMenuHandler; ContextMenuHandler.init(); diff --git a/jsscripts/Util.js b/jsscripts/Util.js index b1491e9..bd14d86 100644 --- a/jsscripts/Util.js +++ b/jsscripts/Util.js @@ -2,6 +2,9 @@ * 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/. */ +let Cc = Components.classes; +let Ci = Components.interfaces; + let Util = { /* * General purpose utilities @@ -11,26 +14,6 @@ let Util = { return aWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); }, - // Recursively find all documents, including root document. - getAllDocuments: function getAllDocuments(doc, resultSoFar) { - resultSoFar = resultSoFar || [doc]; - if (!doc.defaultView) - return resultSoFar; - let frames = doc.defaultView.frames; - if (!frames) - return resultSoFar; - - let i; - let currentDoc; - for (i = 0; i < frames.length; i++) { - currentDoc = frames[i].document; - resultSoFar.push(currentDoc); - this.getAllDocuments(currentDoc, resultSoFar); - } - - return resultSoFar; - }, - // Put the Mozilla networking code into a state that will kick the // auto-connection process. forceOnline: function forceOnline() { @@ -126,14 +109,6 @@ let Util = { * Element utilities */ - highlightElement: function highlightElement(aElement) { - if (aElement == null) { - this.dumpLn("aElement is null"); - return; - } - aElement.style.border = "2px solid red"; - }, - transitionElementVisibility: function(aNodes, aVisible) { // accept single node or a collection of nodes aNodes = aNodes.length ? aNodes : [aNodes]; @@ -161,30 +136,52 @@ let Util = { return defd.promise; }, - getHrefForElement: function getHrefForElement(target) { - let link = null; - while (target) { - if (target instanceof Ci.nsIDOMHTMLAnchorElement || - target instanceof Ci.nsIDOMHTMLAreaElement || - target instanceof Ci.nsIDOMHTMLLinkElement) { - if (target.hasAttribute("href")) - link = target; - } - target = target.parentNode; - } - - if (link && link.hasAttribute("href")) - return link.href; - else - return null; - }, - isTextInput: function isTextInput(aElement) { return ((aElement instanceof Ci.nsIDOMHTMLInputElement && aElement.mozIsTextField(false)) || aElement instanceof Ci.nsIDOMHTMLTextAreaElement); }, + /** + * Checks whether aElement's content can be edited either if it(or any of its + * parents) has "contenteditable" attribute set to "true" or aElement's + * ownerDocument is in design mode. + */ + isEditableContent: function isEditableContent(aElement) { + return !!aElement && (aElement.isContentEditable || + this.isOwnerDocumentInDesignMode(aElement)); + + }, + + isEditable: function isEditable(aElement) { + if (!aElement) { + return false; + } + + if (this.isTextInput(aElement) || this.isEditableContent(aElement)) { + return true; + } + + // If a body element is editable and the body is the child of an + // iframe or div we can assume this is an advanced HTML editor + if ((aElement instanceof Ci.nsIDOMHTMLIFrameElement || + aElement instanceof Ci.nsIDOMHTMLDivElement) && + aElement.contentDocument && + this.isEditableContent(aElement.contentDocument.body)) { + return true; + } + + return false; + }, + + /** + * Checks whether aElement's owner document has design mode turned on. + */ + isOwnerDocumentInDesignMode: function(aElement) { + return !!aElement && !!aElement.ownerDocument && + aElement.ownerDocument.designMode == "on"; + }, + isMultilineInput: function isMultilineInput(aElement) { return (aElement instanceof Ci.nsIDOMHTMLTextAreaElement); }, @@ -249,6 +246,18 @@ let Util = { } }, + /* + * DownloadUtils.convertByteUnits returns [size, localized-unit-string] + * so they are joined for a single download size string. + */ + getDownloadSize: function dv__getDownloadSize (aSize) { + let [size, units] = DownloadUtils.convertByteUnits(aSize); + if (aSize > 0) + return size + units; + else + return Strings.browser.GetStringFromName("downloadsUnknownSize"); + }, + /* * URIs and schemes */ @@ -270,23 +279,14 @@ let Util = { aURL.indexOf("chrome:") == 0); }, - isOpenableScheme: function isShareableScheme(aProtocol) { - let dontOpen = /^(mailto|javascript|news|snews)$/; - return (aProtocol && !dontOpen.test(aProtocol)); - }, - - isShareableScheme: function isShareableScheme(aProtocol) { - let dontShare = /^(chrome|about|file|javascript|resource)$/; - return (aProtocol && !dontShare.test(aProtocol)); - }, - // Don't display anything in the urlbar for these special URIs. isURLEmpty: function isURLEmpty(aURL) { return (!aURL || aURL == "about:blank" || aURL == "about:empty" || aURL == "about:home" || - aURL == "about:start"); + aURL == "about:newtab" || + aURL.startsWith("about:newtab")); }, // Title to use for emptyURL tabs. @@ -344,66 +344,41 @@ let Util = { return this.displayDPI = this.getWindowUtils(window).displayDPI; }, - isPortrait: function isPortrait() { - return (window.innerWidth <= window.innerHeight); - }, - - LOCALE_DIR_RTL: -1, - LOCALE_DIR_LTR: 1, - get localeDir() { - // determine browser dir first to know which direction to snap to - let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry); - return chromeReg.isLocaleRTL("global") ? this.LOCALE_DIR_RTL : this.LOCALE_DIR_LTR; - }, - /* - * Process utilities + * aViewHeight - the height of the viewable area in the browser + * aRect - a bounding rectangle of a selection or element. + * + * return - number of pixels for the browser to be shifted up by such + * that aRect is centered vertically within aViewHeight. */ + centerElementInView: function centerElementInView(aViewHeight, aRect) { + // If the bottom of the target bounds is higher than the new height, + // there's no need to adjust. It will be above the keyboard. + if (aRect.bottom <= aViewHeight) { + return 0; + } - isParentProcess: function isInParentProcess() { - let appInfo = Cc["@mozilla.org/xre/app-info;1"]; - return (!appInfo || appInfo.getService(Ci.nsIXULRuntime).processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT); - }, - - /* - * Event utilities - */ - - modifierMaskFromEvent: function modifierMaskFromEvent(aEvent) { - return (aEvent.altKey ? Ci.nsIDOMEvent.ALT_MASK : 0) | - (aEvent.ctrlKey ? Ci.nsIDOMEvent.CONTROL_MASK : 0) | - (aEvent.shiftKey ? Ci.nsIDOMEvent.SHIFT_MASK : 0) | - (aEvent.metaKey ? Ci.nsIDOMEvent.META_MASK : 0); - }, - - /* - * Download utilities - */ - - insertDownload: function insertDownload(aSrcUri, aFile) { - let dm = Cc["@mozilla.org/download-manager;1"].getService(Ci.nsIDownloadManager); - let db = dm.DBConnection; - - let stmt = db.createStatement( - "INSERT INTO moz_downloads (name, source, target, startTime, endTime, state, referrer) " + - "VALUES (:name, :source, :target, :startTime, :endTime, :state, :referrer)" - ); - - stmt.params.name = aFile.leafName; - stmt.params.source = aSrcUri.spec; - stmt.params.target = aFile.path; - stmt.params.startTime = Date.now() * 1000; - stmt.params.endTime = Date.now() * 1000; - stmt.params.state = Ci.nsIDownloadManager.DOWNLOAD_NOTSTARTED; - stmt.params.referrer = aSrcUri.spec; - - stmt.execute(); - stmt.finalize(); - - let newItemId = db.lastInsertRowID; - let download = dm.getDownload(newItemId); - //dm.resumeDownload(download); - //Services.obs.notifyObservers(download, "dl-start", null); + // height of the target element + let targetHeight = aRect.bottom - aRect.top; + // height of the browser view. + let viewBottom = content.innerHeight; + + // If the target is shorter than the new content height, we can go ahead + // and center it. + if (targetHeight <= aViewHeight) { + // Try to center the element vertically in the new content area, but + // don't position such that the bottom of the browser view moves above + // the top of the chrome. We purposely do not resize the browser window + // by making it taller when trying to center elements that are near the + // lower bounds. This would trigger reflow which can cause content to + // shift around. + let splitMargin = Math.round((aViewHeight - targetHeight) * .5); + let distanceToPageBounds = viewBottom - aRect.bottom; + let distanceFromChromeTop = aRect.bottom - aViewHeight; + let distanceToCenter = + distanceFromChromeTop + Math.min(distanceToPageBounds, splitMargin); + return distanceToCenter; + } }, /* @@ -502,3 +477,4 @@ Util.Timeout.prototype = { } }; +this.Util = Util;