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