selection.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. module.metadata = {
  6. "stability": "stable",
  7. "engines": {
  8. "Firefox": "*"
  9. }
  10. };
  11. const { Ci, Cc } = require("chrome"),
  12. { setTimeout } = require("./timers"),
  13. { emit, off } = require("./event/core"),
  14. { Class, obscure } = require("./core/heritage"),
  15. { EventTarget } = require("./event/target"),
  16. { ns } = require("./core/namespace"),
  17. { when: unload } = require("./system/unload"),
  18. { ignoreWindow } = require('./private-browsing/utils'),
  19. { getTabs, getTabContentWindow, getTabForContentWindow,
  20. getAllTabContentWindows } = require('./tabs/utils'),
  21. winUtils = require("./window/utils"),
  22. events = require("./system/events"),
  23. { iteratorSymbol, forInIterator } = require("./util/iteration");
  24. // The selection types
  25. const HTML = 0x01,
  26. TEXT = 0x02,
  27. DOM = 0x03; // internal use only
  28. // A more developer-friendly message than the caught exception when is not
  29. // possible change a selection.
  30. const ERR_CANNOT_CHANGE_SELECTION =
  31. "It isn't possible to change the selection, as there isn't currently a selection";
  32. const selections = ns();
  33. const Selection = Class({
  34. /**
  35. * Creates an object from which a selection can be set, get, etc. Each
  36. * object has an associated with a range number. Range numbers are the
  37. * 0-indexed counter of selection ranges as explained at
  38. * https://developer.mozilla.org/en/DOM/Selection.
  39. *
  40. * @param rangeNumber
  41. * The zero-based range index into the selection
  42. */
  43. initialize: function initialize(rangeNumber) {
  44. // In order to hide the private `rangeNumber` argument from API consumers
  45. // while still enabling Selection getters/setters to access it, we define
  46. // it as non enumerable, non configurable property. While consumers still
  47. // may discover it they won't be able to do any harm which is good enough
  48. // in this case.
  49. Object.defineProperties(this, {
  50. rangeNumber: {
  51. enumerable: false,
  52. configurable: false,
  53. value: rangeNumber
  54. }
  55. });
  56. },
  57. get text() { return getSelection(TEXT, this.rangeNumber); },
  58. set text(value) { setSelection(TEXT, value, this.rangeNumber); },
  59. get html() { return getSelection(HTML, this.rangeNumber); },
  60. set html(value) { setSelection(HTML, value, this.rangeNumber); },
  61. get isContiguous() {
  62. // If there are multiple non empty ranges, the selection is definitely
  63. // discontiguous. It returns `false` also if there are no valid selection.
  64. let count = 0;
  65. for (let sel in selectionIterator)
  66. if (++count > 1)
  67. break;
  68. return count === 1;
  69. }
  70. });
  71. const selectionListener = {
  72. notifySelectionChanged: function (document, selection, reason) {
  73. if (!["SELECTALL", "KEYPRESS", "MOUSEUP"].some(function(type) reason &
  74. Ci.nsISelectionListener[type + "_REASON"]) || selection.toString() == "")
  75. return;
  76. this.onSelect();
  77. },
  78. onSelect: function() {
  79. emit(module.exports, "select");
  80. }
  81. }
  82. /**
  83. * Defines iterators so that discontiguous selections can be iterated.
  84. * Empty selections are skipped - see `safeGetRange` for further details.
  85. *
  86. * If discontiguous selections are in a text field, only the first one
  87. * is returned because the text field selection APIs doesn't support
  88. * multiple selections.
  89. */
  90. function* forOfIterator() {
  91. let selection = getSelection(DOM);
  92. let count = 0;
  93. if (selection)
  94. count = selection.rangeCount || (getElementWithSelection() ? 1 : 0);
  95. for (let i = 0; i < count; i++) {
  96. let sel = Selection(i);
  97. if (sel.text)
  98. yield Selection(i);
  99. }
  100. }
  101. const selectionIteratorOptions = {
  102. __iterator__: forInIterator
  103. }
  104. selectionIteratorOptions[iteratorSymbol] = forOfIterator;
  105. const selectionIterator = obscure(selectionIteratorOptions);
  106. /**
  107. * Returns the most recent focused window.
  108. * if private browsing window is most recent and not supported,
  109. * then ignore it and return `null`, because the focused window
  110. * can't be targeted.
  111. */
  112. function getFocusedWindow() {
  113. let window = winUtils.getFocusedWindow();
  114. return ignoreWindow(window) ? null : window;
  115. }
  116. /**
  117. * Returns the focused element in the most recent focused window
  118. * if private browsing window is most recent and not supported,
  119. * then ignore it and return `null`, because the focused element
  120. * can't be targeted.
  121. */
  122. function getFocusedElement() {
  123. let element = winUtils.getFocusedElement();
  124. if (!element || ignoreWindow(element.ownerDocument.defaultView))
  125. return null;
  126. return element;
  127. }
  128. /**
  129. * Returns the current selection from most recent content window. Depending on
  130. * the specified |type|, the value returned can be a string of text, stringified
  131. * HTML, or a DOM selection object as described at
  132. * https://developer.mozilla.org/en/DOM/Selection.
  133. *
  134. * @param type
  135. * Specifies the return type of the selection. Valid values are the one
  136. * of the constants HTML, TEXT, or DOM.
  137. *
  138. * @param rangeNumber
  139. * Specifies the zero-based range index of the returned selection.
  140. */
  141. function getSelection(type, rangeNumber) {
  142. let window, selection;
  143. try {
  144. window = getFocusedWindow();
  145. selection = window.getSelection();
  146. }
  147. catch (e) {
  148. return null;
  149. }
  150. // Get the selected content as the specified type
  151. if (type == DOM) {
  152. return selection;
  153. }
  154. else if (type == TEXT) {
  155. let range = safeGetRange(selection, rangeNumber);
  156. if (range)
  157. return range.toString();
  158. let node = getElementWithSelection();
  159. if (!node)
  160. return null;
  161. return node.value.substring(node.selectionStart, node.selectionEnd);
  162. }
  163. else if (type == HTML) {
  164. let range = safeGetRange(selection, rangeNumber);
  165. // Another way, but this includes the xmlns attribute for all elements in
  166. // Gecko 1.9.2+ :
  167. // return Cc["@mozilla.org/xmlextras/xmlserializer;1"].
  168. // createInstance(Ci.nsIDOMSerializer).serializeToSTring(range.
  169. // cloneContents());
  170. if (!range)
  171. return null;
  172. let node = window.document.createElement("span");
  173. node.appendChild(range.cloneContents());
  174. return node.innerHTML;
  175. }
  176. throw new Error("Type " + type + " is unrecognized.");
  177. }
  178. /**
  179. * Sets the current selection of the most recent content document by changing
  180. * the existing selected text/HTML range to the specified value.
  181. *
  182. * @param val
  183. * The value for the new selection
  184. *
  185. * @param rangeNumber
  186. * The zero-based range index of the selection to be set
  187. *
  188. */
  189. function setSelection(type, val, rangeNumber) {
  190. // Make sure we have a window context & that there is a current selection.
  191. // Selection cannot be set unless there is an existing selection.
  192. let window, selection;
  193. try {
  194. window = getFocusedWindow();
  195. selection = window.getSelection();
  196. }
  197. catch (e) {
  198. throw new Error(ERR_CANNOT_CHANGE_SELECTION);
  199. }
  200. let range = safeGetRange(selection, rangeNumber);
  201. if (range) {
  202. let fragment;
  203. if (type === HTML)
  204. fragment = range.createContextualFragment(val);
  205. else {
  206. fragment = range.createContextualFragment("");
  207. fragment.textContent = val;
  208. }
  209. range.deleteContents();
  210. range.insertNode(fragment);
  211. }
  212. else {
  213. let node = getElementWithSelection();
  214. if (!node)
  215. throw new Error(ERR_CANNOT_CHANGE_SELECTION);
  216. let { value, selectionStart, selectionEnd } = node;
  217. let newSelectionEnd = selectionStart + val.length;
  218. node.value = value.substring(0, selectionStart) +
  219. val +
  220. value.substring(selectionEnd, value.length);
  221. node.setSelectionRange(selectionStart, newSelectionEnd);
  222. }
  223. }
  224. /**
  225. * Returns the specified range in a selection without throwing an exception.
  226. *
  227. * @param selection
  228. * A selection object as described at
  229. * https://developer.mozilla.org/en/DOM/Selection
  230. *
  231. * @param [rangeNumber]
  232. * Specifies the zero-based range index of the returned selection.
  233. * If it's not provided the function will return the first non empty
  234. * range, if any.
  235. */
  236. function safeGetRange(selection, rangeNumber) {
  237. try {
  238. let { rangeCount } = selection;
  239. let range = null;
  240. if (typeof rangeNumber === "undefined")
  241. rangeNumber = 0;
  242. else
  243. rangeCount = rangeNumber + 1;
  244. for (; rangeNumber < rangeCount; rangeNumber++ ) {
  245. range = selection.getRangeAt(rangeNumber);
  246. if (range && range.toString())
  247. break;
  248. range = null;
  249. }
  250. return range;
  251. }
  252. catch (e) {
  253. return null;
  254. }
  255. }
  256. /**
  257. * Returns a reference of the DOM's active element for the window given, if it
  258. * supports the text field selection API and has a text selected.
  259. *
  260. * Note:
  261. * we need this method because window.getSelection doesn't return a selection
  262. * for text selected in a form field (see bug 85686)
  263. */
  264. function getElementWithSelection() {
  265. let element = getFocusedElement();
  266. if (!element)
  267. return null;
  268. try {
  269. // Accessing selectionStart and selectionEnd on e.g. a button
  270. // results in an exception thrown as per the HTML5 spec. See
  271. // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection
  272. let { value, selectionStart, selectionEnd } = element;
  273. let hasSelection = typeof value === "string" &&
  274. !isNaN(selectionStart) &&
  275. !isNaN(selectionEnd) &&
  276. selectionStart !== selectionEnd;
  277. return hasSelection ? element : null;
  278. }
  279. catch (err) {
  280. return null;
  281. }
  282. }
  283. /**
  284. * Adds the Selection Listener to the content's window given
  285. */
  286. function addSelectionListener(window) {
  287. let selection = window.getSelection();
  288. // Don't add the selection's listener more than once to the same window,
  289. // if the selection object is the same
  290. if ("selection" in selections(window) && selections(window).selection === selection)
  291. return;
  292. // We ensure that the current selection is an instance of
  293. // `nsISelectionPrivate` before working on it, in case is `null`.
  294. //
  295. // If it's `null` it's likely too early to add the listener, and we demand
  296. // that operation to `document-shown` - it can easily happens for frames
  297. if (selection instanceof Ci.nsISelectionPrivate)
  298. selection.addSelectionListener(selectionListener);
  299. // nsISelectionListener implementation seems not fire a notification if
  300. // a selection is in a text field, therefore we need to add a listener to
  301. // window.onselect, that is fired only for text fields.
  302. // For consistency, we add it only when the nsISelectionListener is added.
  303. //
  304. // https://developer.mozilla.org/en/DOM/window.onselect
  305. window.addEventListener("select", selectionListener.onSelect, true);
  306. selections(window).selection = selection;
  307. };
  308. /**
  309. * Removes the Selection Listener to the content's window given
  310. */
  311. function removeSelectionListener(window) {
  312. // Don't remove the selection's listener to a window that wasn't handled.
  313. if (!("selection" in selections(window)))
  314. return;
  315. let selection = window.getSelection();
  316. let isSameSelection = selection === selections(window).selection;
  317. // Before remove the listener, we ensure that the current selection is an
  318. // instance of `nsISelectionPrivate` (it could be `null`), and that is still
  319. // the selection we managed for this window (it could be detached).
  320. if (selection instanceof Ci.nsISelectionPrivate && isSameSelection)
  321. selection.removeSelectionListener(selectionListener);
  322. window.removeEventListener("select", selectionListener.onSelect, true);
  323. delete selections(window).selection;
  324. };
  325. function onContent(event) {
  326. let window = event.subject.defaultView;
  327. // We are not interested in documents without valid defaultView (e.g. XML)
  328. // that aren't in a tab (e.g. Panel); or in private windows
  329. if (window && getTabForContentWindow(window) && !ignoreWindow(window)) {
  330. addSelectionListener(window);
  331. }
  332. }
  333. // Adds Selection listener to new documents
  334. // Note that strong reference is needed for documents that are loading slowly or
  335. // where the server didn't close the connection (e.g. "comet").
  336. events.on("document-element-inserted", onContent, true);
  337. // Adds Selection listeners to existing documents
  338. getAllTabContentWindows().forEach(addSelectionListener);
  339. // When a document is not visible anymore the selection object is detached, and
  340. // a new selection object is created when it becomes visible again.
  341. // That makes the previous selection's listeners added previously totally
  342. // useless – the listeners are not notified anymore.
  343. // To fix that we're listening for `document-shown` event in order to add
  344. // the listeners to the new selection object created.
  345. //
  346. // See bug 665386 for further details.
  347. function onShown(event) {
  348. let window = event.subject.defaultView;
  349. // We are not interested in documents without valid defaultView.
  350. // For example XML documents don't have windows and we don't yet support them.
  351. if (!window)
  352. return;
  353. // We want to handle only the windows where we added selection's listeners
  354. if ("selection" in selections(window)) {
  355. let currentSelection = window.getSelection();
  356. let { selection } = selections(window);
  357. // If the current selection for the window given is different from the one
  358. // stored in the namespace, we need to add the listeners again, and replace
  359. // the previous selection in our list with the new one.
  360. //
  361. // Notice that we don't have to remove the listeners from the old selection,
  362. // because is detached. An attempt to remove the listener, will raise an
  363. // error (see http://mxr.mozilla.org/mozilla-central/source/layout/generic/nsSelection.cpp#5343 )
  364. //
  365. // We ensure that the current selection is an instance of
  366. // `nsISelectionPrivate` before working on it, in case is `null`.
  367. if (currentSelection instanceof Ci.nsISelectionPrivate &&
  368. currentSelection !== selection) {
  369. window.addEventListener("select", selectionListener.onSelect, true);
  370. currentSelection.addSelectionListener(selectionListener);
  371. selections(window).selection = currentSelection;
  372. }
  373. }
  374. }
  375. events.on("document-shown", onShown, true);
  376. // Removes Selection listeners when the add-on is unloaded
  377. unload(function(){
  378. getAllTabContentWindows().forEach(removeSelectionListener);
  379. events.off("document-element-inserted", onContent);
  380. events.off("document-shown", onShown);
  381. off(exports);
  382. });
  383. const selection = Class({
  384. extends: EventTarget,
  385. implements: [ Selection, selectionIterator ]
  386. })();
  387. module.exports = selection;