Mypal/toolkit/jetpack/sdk/selection.js
2019-03-11 13:26:37 +03:00

472 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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": {
"Palemoon": "*",
"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;