michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: module.metadata = { michael@0: "stability": "stable", michael@0: "engines": { michael@0: "Firefox": "*" michael@0: } michael@0: }; michael@0: michael@0: const { Ci, Cc } = require("chrome"), michael@0: { setTimeout } = require("./timers"), michael@0: { emit, off } = require("./event/core"), michael@0: { Class, obscure } = require("./core/heritage"), michael@0: { EventTarget } = require("./event/target"), michael@0: { ns } = require("./core/namespace"), michael@0: { when: unload } = require("./system/unload"), michael@0: { ignoreWindow } = require('./private-browsing/utils'), michael@0: { getTabs, getTabContentWindow, getTabForContentWindow, michael@0: getAllTabContentWindows } = require('./tabs/utils'), michael@0: winUtils = require("./window/utils"), michael@0: events = require("./system/events"), michael@0: { iteratorSymbol, forInIterator } = require("./util/iteration"); michael@0: michael@0: // The selection types michael@0: const HTML = 0x01, michael@0: TEXT = 0x02, michael@0: DOM = 0x03; // internal use only michael@0: michael@0: // A more developer-friendly message than the caught exception when is not michael@0: // possible change a selection. michael@0: const ERR_CANNOT_CHANGE_SELECTION = michael@0: "It isn't possible to change the selection, as there isn't currently a selection"; michael@0: michael@0: const selections = ns(); michael@0: michael@0: const Selection = Class({ michael@0: /** michael@0: * Creates an object from which a selection can be set, get, etc. Each michael@0: * object has an associated with a range number. Range numbers are the michael@0: * 0-indexed counter of selection ranges as explained at michael@0: * https://developer.mozilla.org/en/DOM/Selection. michael@0: * michael@0: * @param rangeNumber michael@0: * The zero-based range index into the selection michael@0: */ michael@0: initialize: function initialize(rangeNumber) { michael@0: // In order to hide the private `rangeNumber` argument from API consumers michael@0: // while still enabling Selection getters/setters to access it, we define michael@0: // it as non enumerable, non configurable property. While consumers still michael@0: // may discover it they won't be able to do any harm which is good enough michael@0: // in this case. michael@0: Object.defineProperties(this, { michael@0: rangeNumber: { michael@0: enumerable: false, michael@0: configurable: false, michael@0: value: rangeNumber michael@0: } michael@0: }); michael@0: }, michael@0: get text() { return getSelection(TEXT, this.rangeNumber); }, michael@0: set text(value) { setSelection(TEXT, value, this.rangeNumber); }, michael@0: get html() { return getSelection(HTML, this.rangeNumber); }, michael@0: set html(value) { setSelection(HTML, value, this.rangeNumber); }, michael@0: get isContiguous() { michael@0: michael@0: // If there are multiple non empty ranges, the selection is definitely michael@0: // discontiguous. It returns `false` also if there are no valid selection. michael@0: let count = 0; michael@0: for (let sel in selectionIterator) michael@0: if (++count > 1) michael@0: break; michael@0: michael@0: return count === 1; michael@0: } michael@0: }); michael@0: michael@0: const selectionListener = { michael@0: notifySelectionChanged: function (document, selection, reason) { michael@0: if (!["SELECTALL", "KEYPRESS", "MOUSEUP"].some(function(type) reason & michael@0: Ci.nsISelectionListener[type + "_REASON"]) || selection.toString() == "") michael@0: return; michael@0: michael@0: this.onSelect(); michael@0: }, michael@0: michael@0: onSelect: function() { michael@0: emit(module.exports, "select"); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Defines iterators so that discontiguous selections can be iterated. michael@0: * Empty selections are skipped - see `safeGetRange` for further details. michael@0: * michael@0: * If discontiguous selections are in a text field, only the first one michael@0: * is returned because the text field selection APIs doesn't support michael@0: * multiple selections. michael@0: */ michael@0: function* forOfIterator() { michael@0: let selection = getSelection(DOM); michael@0: let count = 0; michael@0: michael@0: if (selection) michael@0: count = selection.rangeCount || (getElementWithSelection() ? 1 : 0); michael@0: michael@0: for (let i = 0; i < count; i++) { michael@0: let sel = Selection(i); michael@0: michael@0: if (sel.text) michael@0: yield Selection(i); michael@0: } michael@0: } michael@0: michael@0: const selectionIteratorOptions = { michael@0: __iterator__: forInIterator michael@0: } michael@0: selectionIteratorOptions[iteratorSymbol] = forOfIterator; michael@0: const selectionIterator = obscure(selectionIteratorOptions); michael@0: michael@0: /** michael@0: * Returns the most recent focused window. michael@0: * if private browsing window is most recent and not supported, michael@0: * then ignore it and return `null`, because the focused window michael@0: * can't be targeted. michael@0: */ michael@0: function getFocusedWindow() { michael@0: let window = winUtils.getFocusedWindow(); michael@0: michael@0: return ignoreWindow(window) ? null : window; michael@0: } michael@0: michael@0: /** michael@0: * Returns the focused element in the most recent focused window michael@0: * if private browsing window is most recent and not supported, michael@0: * then ignore it and return `null`, because the focused element michael@0: * can't be targeted. michael@0: */ michael@0: function getFocusedElement() { michael@0: let element = winUtils.getFocusedElement(); michael@0: michael@0: if (!element || ignoreWindow(element.ownerDocument.defaultView)) michael@0: return null; michael@0: michael@0: return element; michael@0: } michael@0: michael@0: /** michael@0: * Returns the current selection from most recent content window. Depending on michael@0: * the specified |type|, the value returned can be a string of text, stringified michael@0: * HTML, or a DOM selection object as described at michael@0: * https://developer.mozilla.org/en/DOM/Selection. michael@0: * michael@0: * @param type michael@0: * Specifies the return type of the selection. Valid values are the one michael@0: * of the constants HTML, TEXT, or DOM. michael@0: * michael@0: * @param rangeNumber michael@0: * Specifies the zero-based range index of the returned selection. michael@0: */ michael@0: function getSelection(type, rangeNumber) { michael@0: let window, selection; michael@0: try { michael@0: window = getFocusedWindow(); michael@0: selection = window.getSelection(); michael@0: } michael@0: catch (e) { michael@0: return null; michael@0: } michael@0: michael@0: // Get the selected content as the specified type michael@0: if (type == DOM) { michael@0: return selection; michael@0: } michael@0: else if (type == TEXT) { michael@0: let range = safeGetRange(selection, rangeNumber); michael@0: michael@0: if (range) michael@0: return range.toString(); michael@0: michael@0: let node = getElementWithSelection(); michael@0: michael@0: if (!node) michael@0: return null; michael@0: michael@0: return node.value.substring(node.selectionStart, node.selectionEnd); michael@0: } michael@0: else if (type == HTML) { michael@0: let range = safeGetRange(selection, rangeNumber); michael@0: // Another way, but this includes the xmlns attribute for all elements in michael@0: // Gecko 1.9.2+ : michael@0: // return Cc["@mozilla.org/xmlextras/xmlserializer;1"]. michael@0: // createInstance(Ci.nsIDOMSerializer).serializeToSTring(range. michael@0: // cloneContents()); michael@0: if (!range) michael@0: return null; michael@0: michael@0: let node = window.document.createElement("span"); michael@0: node.appendChild(range.cloneContents()); michael@0: return node.innerHTML; michael@0: } michael@0: michael@0: throw new Error("Type " + type + " is unrecognized."); michael@0: } michael@0: michael@0: /** michael@0: * Sets the current selection of the most recent content document by changing michael@0: * the existing selected text/HTML range to the specified value. michael@0: * michael@0: * @param val michael@0: * The value for the new selection michael@0: * michael@0: * @param rangeNumber michael@0: * The zero-based range index of the selection to be set michael@0: * michael@0: */ michael@0: function setSelection(type, val, rangeNumber) { michael@0: // Make sure we have a window context & that there is a current selection. michael@0: // Selection cannot be set unless there is an existing selection. michael@0: let window, selection; michael@0: michael@0: try { michael@0: window = getFocusedWindow(); michael@0: selection = window.getSelection(); michael@0: } michael@0: catch (e) { michael@0: throw new Error(ERR_CANNOT_CHANGE_SELECTION); michael@0: } michael@0: michael@0: let range = safeGetRange(selection, rangeNumber); michael@0: michael@0: if (range) { michael@0: let fragment; michael@0: michael@0: if (type === HTML) michael@0: fragment = range.createContextualFragment(val); michael@0: else { michael@0: fragment = range.createContextualFragment(""); michael@0: fragment.textContent = val; michael@0: } michael@0: michael@0: range.deleteContents(); michael@0: range.insertNode(fragment); michael@0: } michael@0: else { michael@0: let node = getElementWithSelection(); michael@0: michael@0: if (!node) michael@0: throw new Error(ERR_CANNOT_CHANGE_SELECTION); michael@0: michael@0: let { value, selectionStart, selectionEnd } = node; michael@0: michael@0: let newSelectionEnd = selectionStart + val.length; michael@0: michael@0: node.value = value.substring(0, selectionStart) + michael@0: val + michael@0: value.substring(selectionEnd, value.length); michael@0: michael@0: node.setSelectionRange(selectionStart, newSelectionEnd); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Returns the specified range in a selection without throwing an exception. michael@0: * michael@0: * @param selection michael@0: * A selection object as described at michael@0: * https://developer.mozilla.org/en/DOM/Selection michael@0: * michael@0: * @param [rangeNumber] michael@0: * Specifies the zero-based range index of the returned selection. michael@0: * If it's not provided the function will return the first non empty michael@0: * range, if any. michael@0: */ michael@0: function safeGetRange(selection, rangeNumber) { michael@0: try { michael@0: let { rangeCount } = selection; michael@0: let range = null; michael@0: michael@0: if (typeof rangeNumber === "undefined") michael@0: rangeNumber = 0; michael@0: else michael@0: rangeCount = rangeNumber + 1; michael@0: michael@0: for (; rangeNumber < rangeCount; rangeNumber++ ) { michael@0: range = selection.getRangeAt(rangeNumber); michael@0: michael@0: if (range && range.toString()) michael@0: break; michael@0: michael@0: range = null; michael@0: } michael@0: michael@0: return range; michael@0: } michael@0: catch (e) { michael@0: return null; michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Returns a reference of the DOM's active element for the window given, if it michael@0: * supports the text field selection API and has a text selected. michael@0: * michael@0: * Note: michael@0: * we need this method because window.getSelection doesn't return a selection michael@0: * for text selected in a form field (see bug 85686) michael@0: */ michael@0: function getElementWithSelection() { michael@0: let element = getFocusedElement(); michael@0: michael@0: if (!element) michael@0: return null; michael@0: michael@0: try { michael@0: // Accessing selectionStart and selectionEnd on e.g. a button michael@0: // results in an exception thrown as per the HTML5 spec. See michael@0: // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection michael@0: michael@0: let { value, selectionStart, selectionEnd } = element; michael@0: michael@0: let hasSelection = typeof value === "string" && michael@0: !isNaN(selectionStart) && michael@0: !isNaN(selectionEnd) && michael@0: selectionStart !== selectionEnd; michael@0: michael@0: return hasSelection ? element : null; michael@0: } michael@0: catch (err) { michael@0: return null; michael@0: } michael@0: michael@0: } michael@0: michael@0: /** michael@0: * Adds the Selection Listener to the content's window given michael@0: */ michael@0: function addSelectionListener(window) { michael@0: let selection = window.getSelection(); michael@0: michael@0: // Don't add the selection's listener more than once to the same window, michael@0: // if the selection object is the same michael@0: if ("selection" in selections(window) && selections(window).selection === selection) michael@0: return; michael@0: michael@0: // We ensure that the current selection is an instance of michael@0: // `nsISelectionPrivate` before working on it, in case is `null`. michael@0: // michael@0: // If it's `null` it's likely too early to add the listener, and we demand michael@0: // that operation to `document-shown` - it can easily happens for frames michael@0: if (selection instanceof Ci.nsISelectionPrivate) michael@0: selection.addSelectionListener(selectionListener); michael@0: michael@0: // nsISelectionListener implementation seems not fire a notification if michael@0: // a selection is in a text field, therefore we need to add a listener to michael@0: // window.onselect, that is fired only for text fields. michael@0: // For consistency, we add it only when the nsISelectionListener is added. michael@0: // michael@0: // https://developer.mozilla.org/en/DOM/window.onselect michael@0: window.addEventListener("select", selectionListener.onSelect, true); michael@0: michael@0: selections(window).selection = selection; michael@0: }; michael@0: michael@0: /** michael@0: * Removes the Selection Listener to the content's window given michael@0: */ michael@0: function removeSelectionListener(window) { michael@0: // Don't remove the selection's listener to a window that wasn't handled. michael@0: if (!("selection" in selections(window))) michael@0: return; michael@0: michael@0: let selection = window.getSelection(); michael@0: let isSameSelection = selection === selections(window).selection; michael@0: michael@0: // Before remove the listener, we ensure that the current selection is an michael@0: // instance of `nsISelectionPrivate` (it could be `null`), and that is still michael@0: // the selection we managed for this window (it could be detached). michael@0: if (selection instanceof Ci.nsISelectionPrivate && isSameSelection) michael@0: selection.removeSelectionListener(selectionListener); michael@0: michael@0: window.removeEventListener("select", selectionListener.onSelect, true); michael@0: michael@0: delete selections(window).selection; michael@0: }; michael@0: michael@0: function onContent(event) { michael@0: let window = event.subject.defaultView; michael@0: michael@0: // We are not interested in documents without valid defaultView (e.g. XML) michael@0: // that aren't in a tab (e.g. Panel); or in private windows michael@0: if (window && getTabForContentWindow(window) && !ignoreWindow(window)) { michael@0: addSelectionListener(window); michael@0: } michael@0: } michael@0: michael@0: // Adds Selection listener to new documents michael@0: // Note that strong reference is needed for documents that are loading slowly or michael@0: // where the server didn't close the connection (e.g. "comet"). michael@0: events.on("document-element-inserted", onContent, true); michael@0: michael@0: // Adds Selection listeners to existing documents michael@0: getAllTabContentWindows().forEach(addSelectionListener); michael@0: michael@0: // When a document is not visible anymore the selection object is detached, and michael@0: // a new selection object is created when it becomes visible again. michael@0: // That makes the previous selection's listeners added previously totally michael@0: // useless – the listeners are not notified anymore. michael@0: // To fix that we're listening for `document-shown` event in order to add michael@0: // the listeners to the new selection object created. michael@0: // michael@0: // See bug 665386 for further details. michael@0: michael@0: function onShown(event) { michael@0: let window = event.subject.defaultView; michael@0: michael@0: // We are not interested in documents without valid defaultView. michael@0: // For example XML documents don't have windows and we don't yet support them. michael@0: if (!window) michael@0: return; michael@0: michael@0: // We want to handle only the windows where we added selection's listeners michael@0: if ("selection" in selections(window)) { michael@0: let currentSelection = window.getSelection(); michael@0: let { selection } = selections(window); michael@0: michael@0: // If the current selection for the window given is different from the one michael@0: // stored in the namespace, we need to add the listeners again, and replace michael@0: // the previous selection in our list with the new one. michael@0: // michael@0: // Notice that we don't have to remove the listeners from the old selection, michael@0: // because is detached. An attempt to remove the listener, will raise an michael@0: // error (see http://mxr.mozilla.org/mozilla-central/source/layout/generic/nsSelection.cpp#5343 ) michael@0: // michael@0: // We ensure that the current selection is an instance of michael@0: // `nsISelectionPrivate` before working on it, in case is `null`. michael@0: if (currentSelection instanceof Ci.nsISelectionPrivate && michael@0: currentSelection !== selection) { michael@0: michael@0: window.addEventListener("select", selectionListener.onSelect, true); michael@0: currentSelection.addSelectionListener(selectionListener); michael@0: selections(window).selection = currentSelection; michael@0: } michael@0: } michael@0: } michael@0: michael@0: events.on("document-shown", onShown, true); michael@0: michael@0: // Removes Selection listeners when the add-on is unloaded michael@0: unload(function(){ michael@0: getAllTabContentWindows().forEach(removeSelectionListener); michael@0: michael@0: events.off("document-element-inserted", onContent); michael@0: events.off("document-shown", onShown); michael@0: michael@0: off(exports); michael@0: }); michael@0: michael@0: const selection = Class({ michael@0: extends: EventTarget, michael@0: implements: [ Selection, selectionIterator ] michael@0: })(); michael@0: michael@0: module.exports = selection;