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