summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/selection.js
diff options
context:
space:
mode:
authorMatt A. Tobin <email@mattatobin.com>2018-02-10 02:51:36 -0500
committerMatt A. Tobin <email@mattatobin.com>2018-02-10 02:51:36 -0500
commit37d5300335d81cecbecc99812747a657588c63eb (patch)
tree765efa3b6a56bb715d9813a8697473e120436278 /toolkit/jetpack/sdk/selection.js
parentb2bdac20c02b12f2057b9ef70b0a946113a00e00 (diff)
parent4fb11cd5966461bccc3ed1599b808237be6b0de9 (diff)
downloadUXP-37d5300335d81cecbecc99812747a657588c63eb.tar
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.gz
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.lz
UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.xz
UXP-37d5300335d81cecbecc99812747a657588c63eb.zip
Merge branch 'ext-work'
Diffstat (limited to 'toolkit/jetpack/sdk/selection.js')
-rw-r--r--toolkit/jetpack/sdk/selection.js470
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;