1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/addon-sdk/source/lib/sdk/selection.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,467 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +module.metadata = { 1.11 + "stability": "stable", 1.12 + "engines": { 1.13 + "Firefox": "*" 1.14 + } 1.15 +}; 1.16 + 1.17 +const { Ci, Cc } = require("chrome"), 1.18 + { setTimeout } = require("./timers"), 1.19 + { emit, off } = require("./event/core"), 1.20 + { Class, obscure } = require("./core/heritage"), 1.21 + { EventTarget } = require("./event/target"), 1.22 + { ns } = require("./core/namespace"), 1.23 + { when: unload } = require("./system/unload"), 1.24 + { ignoreWindow } = require('./private-browsing/utils'), 1.25 + { getTabs, getTabContentWindow, getTabForContentWindow, 1.26 + getAllTabContentWindows } = require('./tabs/utils'), 1.27 + winUtils = require("./window/utils"), 1.28 + events = require("./system/events"), 1.29 + { iteratorSymbol, forInIterator } = require("./util/iteration"); 1.30 + 1.31 +// The selection types 1.32 +const HTML = 0x01, 1.33 + TEXT = 0x02, 1.34 + DOM = 0x03; // internal use only 1.35 + 1.36 +// A more developer-friendly message than the caught exception when is not 1.37 +// possible change a selection. 1.38 +const ERR_CANNOT_CHANGE_SELECTION = 1.39 + "It isn't possible to change the selection, as there isn't currently a selection"; 1.40 + 1.41 +const selections = ns(); 1.42 + 1.43 +const Selection = Class({ 1.44 + /** 1.45 + * Creates an object from which a selection can be set, get, etc. Each 1.46 + * object has an associated with a range number. Range numbers are the 1.47 + * 0-indexed counter of selection ranges as explained at 1.48 + * https://developer.mozilla.org/en/DOM/Selection. 1.49 + * 1.50 + * @param rangeNumber 1.51 + * The zero-based range index into the selection 1.52 + */ 1.53 + initialize: function initialize(rangeNumber) { 1.54 + // In order to hide the private `rangeNumber` argument from API consumers 1.55 + // while still enabling Selection getters/setters to access it, we define 1.56 + // it as non enumerable, non configurable property. While consumers still 1.57 + // may discover it they won't be able to do any harm which is good enough 1.58 + // in this case. 1.59 + Object.defineProperties(this, { 1.60 + rangeNumber: { 1.61 + enumerable: false, 1.62 + configurable: false, 1.63 + value: rangeNumber 1.64 + } 1.65 + }); 1.66 + }, 1.67 + get text() { return getSelection(TEXT, this.rangeNumber); }, 1.68 + set text(value) { setSelection(TEXT, value, this.rangeNumber); }, 1.69 + get html() { return getSelection(HTML, this.rangeNumber); }, 1.70 + set html(value) { setSelection(HTML, value, this.rangeNumber); }, 1.71 + get isContiguous() { 1.72 + 1.73 + // If there are multiple non empty ranges, the selection is definitely 1.74 + // discontiguous. It returns `false` also if there are no valid selection. 1.75 + let count = 0; 1.76 + for (let sel in selectionIterator) 1.77 + if (++count > 1) 1.78 + break; 1.79 + 1.80 + return count === 1; 1.81 + } 1.82 +}); 1.83 + 1.84 +const selectionListener = { 1.85 + notifySelectionChanged: function (document, selection, reason) { 1.86 + if (!["SELECTALL", "KEYPRESS", "MOUSEUP"].some(function(type) reason & 1.87 + Ci.nsISelectionListener[type + "_REASON"]) || selection.toString() == "") 1.88 + return; 1.89 + 1.90 + this.onSelect(); 1.91 + }, 1.92 + 1.93 + onSelect: function() { 1.94 + emit(module.exports, "select"); 1.95 + } 1.96 +} 1.97 + 1.98 +/** 1.99 + * Defines iterators so that discontiguous selections can be iterated. 1.100 + * Empty selections are skipped - see `safeGetRange` for further details. 1.101 + * 1.102 + * If discontiguous selections are in a text field, only the first one 1.103 + * is returned because the text field selection APIs doesn't support 1.104 + * multiple selections. 1.105 + */ 1.106 +function* forOfIterator() { 1.107 + let selection = getSelection(DOM); 1.108 + let count = 0; 1.109 + 1.110 + if (selection) 1.111 + count = selection.rangeCount || (getElementWithSelection() ? 1 : 0); 1.112 + 1.113 + for (let i = 0; i < count; i++) { 1.114 + let sel = Selection(i); 1.115 + 1.116 + if (sel.text) 1.117 + yield Selection(i); 1.118 + } 1.119 +} 1.120 + 1.121 +const selectionIteratorOptions = { 1.122 + __iterator__: forInIterator 1.123 +} 1.124 +selectionIteratorOptions[iteratorSymbol] = forOfIterator; 1.125 +const selectionIterator = obscure(selectionIteratorOptions); 1.126 + 1.127 +/** 1.128 + * Returns the most recent focused window. 1.129 + * if private browsing window is most recent and not supported, 1.130 + * then ignore it and return `null`, because the focused window 1.131 + * can't be targeted. 1.132 + */ 1.133 +function getFocusedWindow() { 1.134 + let window = winUtils.getFocusedWindow(); 1.135 + 1.136 + return ignoreWindow(window) ? null : window; 1.137 +} 1.138 + 1.139 +/** 1.140 + * Returns the focused element in the most recent focused window 1.141 + * if private browsing window is most recent and not supported, 1.142 + * then ignore it and return `null`, because the focused element 1.143 + * can't be targeted. 1.144 + */ 1.145 +function getFocusedElement() { 1.146 + let element = winUtils.getFocusedElement(); 1.147 + 1.148 + if (!element || ignoreWindow(element.ownerDocument.defaultView)) 1.149 + return null; 1.150 + 1.151 + return element; 1.152 +} 1.153 + 1.154 +/** 1.155 + * Returns the current selection from most recent content window. Depending on 1.156 + * the specified |type|, the value returned can be a string of text, stringified 1.157 + * HTML, or a DOM selection object as described at 1.158 + * https://developer.mozilla.org/en/DOM/Selection. 1.159 + * 1.160 + * @param type 1.161 + * Specifies the return type of the selection. Valid values are the one 1.162 + * of the constants HTML, TEXT, or DOM. 1.163 + * 1.164 + * @param rangeNumber 1.165 + * Specifies the zero-based range index of the returned selection. 1.166 + */ 1.167 +function getSelection(type, rangeNumber) { 1.168 + let window, selection; 1.169 + try { 1.170 + window = getFocusedWindow(); 1.171 + selection = window.getSelection(); 1.172 + } 1.173 + catch (e) { 1.174 + return null; 1.175 + } 1.176 + 1.177 + // Get the selected content as the specified type 1.178 + if (type == DOM) { 1.179 + return selection; 1.180 + } 1.181 + else if (type == TEXT) { 1.182 + let range = safeGetRange(selection, rangeNumber); 1.183 + 1.184 + if (range) 1.185 + return range.toString(); 1.186 + 1.187 + let node = getElementWithSelection(); 1.188 + 1.189 + if (!node) 1.190 + return null; 1.191 + 1.192 + return node.value.substring(node.selectionStart, node.selectionEnd); 1.193 + } 1.194 + else if (type == HTML) { 1.195 + let range = safeGetRange(selection, rangeNumber); 1.196 + // Another way, but this includes the xmlns attribute for all elements in 1.197 + // Gecko 1.9.2+ : 1.198 + // return Cc["@mozilla.org/xmlextras/xmlserializer;1"]. 1.199 + // createInstance(Ci.nsIDOMSerializer).serializeToSTring(range. 1.200 + // cloneContents()); 1.201 + if (!range) 1.202 + return null; 1.203 + 1.204 + let node = window.document.createElement("span"); 1.205 + node.appendChild(range.cloneContents()); 1.206 + return node.innerHTML; 1.207 + } 1.208 + 1.209 + throw new Error("Type " + type + " is unrecognized."); 1.210 +} 1.211 + 1.212 +/** 1.213 + * Sets the current selection of the most recent content document by changing 1.214 + * the existing selected text/HTML range to the specified value. 1.215 + * 1.216 + * @param val 1.217 + * The value for the new selection 1.218 + * 1.219 + * @param rangeNumber 1.220 + * The zero-based range index of the selection to be set 1.221 + * 1.222 + */ 1.223 +function setSelection(type, val, rangeNumber) { 1.224 + // Make sure we have a window context & that there is a current selection. 1.225 + // Selection cannot be set unless there is an existing selection. 1.226 + let window, selection; 1.227 + 1.228 + try { 1.229 + window = getFocusedWindow(); 1.230 + selection = window.getSelection(); 1.231 + } 1.232 + catch (e) { 1.233 + throw new Error(ERR_CANNOT_CHANGE_SELECTION); 1.234 + } 1.235 + 1.236 + let range = safeGetRange(selection, rangeNumber); 1.237 + 1.238 + if (range) { 1.239 + let fragment; 1.240 + 1.241 + if (type === HTML) 1.242 + fragment = range.createContextualFragment(val); 1.243 + else { 1.244 + fragment = range.createContextualFragment(""); 1.245 + fragment.textContent = val; 1.246 + } 1.247 + 1.248 + range.deleteContents(); 1.249 + range.insertNode(fragment); 1.250 + } 1.251 + else { 1.252 + let node = getElementWithSelection(); 1.253 + 1.254 + if (!node) 1.255 + throw new Error(ERR_CANNOT_CHANGE_SELECTION); 1.256 + 1.257 + let { value, selectionStart, selectionEnd } = node; 1.258 + 1.259 + let newSelectionEnd = selectionStart + val.length; 1.260 + 1.261 + node.value = value.substring(0, selectionStart) + 1.262 + val + 1.263 + value.substring(selectionEnd, value.length); 1.264 + 1.265 + node.setSelectionRange(selectionStart, newSelectionEnd); 1.266 + } 1.267 +} 1.268 + 1.269 +/** 1.270 + * Returns the specified range in a selection without throwing an exception. 1.271 + * 1.272 + * @param selection 1.273 + * A selection object as described at 1.274 + * https://developer.mozilla.org/en/DOM/Selection 1.275 + * 1.276 + * @param [rangeNumber] 1.277 + * Specifies the zero-based range index of the returned selection. 1.278 + * If it's not provided the function will return the first non empty 1.279 + * range, if any. 1.280 + */ 1.281 +function safeGetRange(selection, rangeNumber) { 1.282 + try { 1.283 + let { rangeCount } = selection; 1.284 + let range = null; 1.285 + 1.286 + if (typeof rangeNumber === "undefined") 1.287 + rangeNumber = 0; 1.288 + else 1.289 + rangeCount = rangeNumber + 1; 1.290 + 1.291 + for (; rangeNumber < rangeCount; rangeNumber++ ) { 1.292 + range = selection.getRangeAt(rangeNumber); 1.293 + 1.294 + if (range && range.toString()) 1.295 + break; 1.296 + 1.297 + range = null; 1.298 + } 1.299 + 1.300 + return range; 1.301 + } 1.302 + catch (e) { 1.303 + return null; 1.304 + } 1.305 +} 1.306 + 1.307 +/** 1.308 + * Returns a reference of the DOM's active element for the window given, if it 1.309 + * supports the text field selection API and has a text selected. 1.310 + * 1.311 + * Note: 1.312 + * we need this method because window.getSelection doesn't return a selection 1.313 + * for text selected in a form field (see bug 85686) 1.314 + */ 1.315 +function getElementWithSelection() { 1.316 + let element = getFocusedElement(); 1.317 + 1.318 + if (!element) 1.319 + return null; 1.320 + 1.321 + try { 1.322 + // Accessing selectionStart and selectionEnd on e.g. a button 1.323 + // results in an exception thrown as per the HTML5 spec. See 1.324 + // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection 1.325 + 1.326 + let { value, selectionStart, selectionEnd } = element; 1.327 + 1.328 + let hasSelection = typeof value === "string" && 1.329 + !isNaN(selectionStart) && 1.330 + !isNaN(selectionEnd) && 1.331 + selectionStart !== selectionEnd; 1.332 + 1.333 + return hasSelection ? element : null; 1.334 + } 1.335 + catch (err) { 1.336 + return null; 1.337 + } 1.338 + 1.339 +} 1.340 + 1.341 +/** 1.342 + * Adds the Selection Listener to the content's window given 1.343 + */ 1.344 +function addSelectionListener(window) { 1.345 + let selection = window.getSelection(); 1.346 + 1.347 + // Don't add the selection's listener more than once to the same window, 1.348 + // if the selection object is the same 1.349 + if ("selection" in selections(window) && selections(window).selection === selection) 1.350 + return; 1.351 + 1.352 + // We ensure that the current selection is an instance of 1.353 + // `nsISelectionPrivate` before working on it, in case is `null`. 1.354 + // 1.355 + // If it's `null` it's likely too early to add the listener, and we demand 1.356 + // that operation to `document-shown` - it can easily happens for frames 1.357 + if (selection instanceof Ci.nsISelectionPrivate) 1.358 + selection.addSelectionListener(selectionListener); 1.359 + 1.360 + // nsISelectionListener implementation seems not fire a notification if 1.361 + // a selection is in a text field, therefore we need to add a listener to 1.362 + // window.onselect, that is fired only for text fields. 1.363 + // For consistency, we add it only when the nsISelectionListener is added. 1.364 + // 1.365 + // https://developer.mozilla.org/en/DOM/window.onselect 1.366 + window.addEventListener("select", selectionListener.onSelect, true); 1.367 + 1.368 + selections(window).selection = selection; 1.369 +}; 1.370 + 1.371 +/** 1.372 + * Removes the Selection Listener to the content's window given 1.373 + */ 1.374 +function removeSelectionListener(window) { 1.375 + // Don't remove the selection's listener to a window that wasn't handled. 1.376 + if (!("selection" in selections(window))) 1.377 + return; 1.378 + 1.379 + let selection = window.getSelection(); 1.380 + let isSameSelection = selection === selections(window).selection; 1.381 + 1.382 + // Before remove the listener, we ensure that the current selection is an 1.383 + // instance of `nsISelectionPrivate` (it could be `null`), and that is still 1.384 + // the selection we managed for this window (it could be detached). 1.385 + if (selection instanceof Ci.nsISelectionPrivate && isSameSelection) 1.386 + selection.removeSelectionListener(selectionListener); 1.387 + 1.388 + window.removeEventListener("select", selectionListener.onSelect, true); 1.389 + 1.390 + delete selections(window).selection; 1.391 +}; 1.392 + 1.393 +function onContent(event) { 1.394 + let window = event.subject.defaultView; 1.395 + 1.396 + // We are not interested in documents without valid defaultView (e.g. XML) 1.397 + // that aren't in a tab (e.g. Panel); or in private windows 1.398 + if (window && getTabForContentWindow(window) && !ignoreWindow(window)) { 1.399 + addSelectionListener(window); 1.400 + } 1.401 +} 1.402 + 1.403 +// Adds Selection listener to new documents 1.404 +// Note that strong reference is needed for documents that are loading slowly or 1.405 +// where the server didn't close the connection (e.g. "comet"). 1.406 +events.on("document-element-inserted", onContent, true); 1.407 + 1.408 +// Adds Selection listeners to existing documents 1.409 +getAllTabContentWindows().forEach(addSelectionListener); 1.410 + 1.411 +// When a document is not visible anymore the selection object is detached, and 1.412 +// a new selection object is created when it becomes visible again. 1.413 +// That makes the previous selection's listeners added previously totally 1.414 +// useless – the listeners are not notified anymore. 1.415 +// To fix that we're listening for `document-shown` event in order to add 1.416 +// the listeners to the new selection object created. 1.417 +// 1.418 +// See bug 665386 for further details. 1.419 + 1.420 +function onShown(event) { 1.421 + let window = event.subject.defaultView; 1.422 + 1.423 + // We are not interested in documents without valid defaultView. 1.424 + // For example XML documents don't have windows and we don't yet support them. 1.425 + if (!window) 1.426 + return; 1.427 + 1.428 + // We want to handle only the windows where we added selection's listeners 1.429 + if ("selection" in selections(window)) { 1.430 + let currentSelection = window.getSelection(); 1.431 + let { selection } = selections(window); 1.432 + 1.433 + // If the current selection for the window given is different from the one 1.434 + // stored in the namespace, we need to add the listeners again, and replace 1.435 + // the previous selection in our list with the new one. 1.436 + // 1.437 + // Notice that we don't have to remove the listeners from the old selection, 1.438 + // because is detached. An attempt to remove the listener, will raise an 1.439 + // error (see http://mxr.mozilla.org/mozilla-central/source/layout/generic/nsSelection.cpp#5343 ) 1.440 + // 1.441 + // We ensure that the current selection is an instance of 1.442 + // `nsISelectionPrivate` before working on it, in case is `null`. 1.443 + if (currentSelection instanceof Ci.nsISelectionPrivate && 1.444 + currentSelection !== selection) { 1.445 + 1.446 + window.addEventListener("select", selectionListener.onSelect, true); 1.447 + currentSelection.addSelectionListener(selectionListener); 1.448 + selections(window).selection = currentSelection; 1.449 + } 1.450 + } 1.451 +} 1.452 + 1.453 +events.on("document-shown", onShown, true); 1.454 + 1.455 +// Removes Selection listeners when the add-on is unloaded 1.456 +unload(function(){ 1.457 + getAllTabContentWindows().forEach(removeSelectionListener); 1.458 + 1.459 + events.off("document-element-inserted", onContent); 1.460 + events.off("document-shown", onShown); 1.461 + 1.462 + off(exports); 1.463 +}); 1.464 + 1.465 +const selection = Class({ 1.466 + extends: EventTarget, 1.467 + implements: [ Selection, selectionIterator ] 1.468 +})(); 1.469 + 1.470 +module.exports = selection;