mobile/android/chrome/content/SelectionHandler.js

branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
equal deleted inserted replaced
-1:000000000000 0:951b3818465a
1 // -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*-
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
4 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
6
7 var SelectionHandler = {
8 HANDLE_TYPE_START: "START",
9 HANDLE_TYPE_MIDDLE: "MIDDLE",
10 HANDLE_TYPE_END: "END",
11
12 TYPE_NONE: 0,
13 TYPE_CURSOR: 1,
14 TYPE_SELECTION: 2,
15
16 SELECT_ALL: 0,
17 SELECT_AT_POINT: 1,
18
19 // Keeps track of data about the dimensions of the selection. Coordinates
20 // stored here are relative to the _contentWindow window.
21 _cache: null,
22 _activeType: 0, // TYPE_NONE
23 _draggingHandles: false, // True while user drags text selection handles
24 _ignoreCompositionChanges: false, // Persist caret during IME composition updates
25 _prevHandlePositions: [], // Avoid issuing duplicate "TextSelection:Position" messages
26
27 // TargetElement changes (text <--> no text) trigger actionbar UI update
28 _prevTargetElementHasText: null,
29
30 // The window that holds the selection (can be a sub-frame)
31 get _contentWindow() {
32 if (this._contentWindowRef)
33 return this._contentWindowRef.get();
34 return null;
35 },
36
37 set _contentWindow(aContentWindow) {
38 this._contentWindowRef = Cu.getWeakReference(aContentWindow);
39 },
40
41 get _targetElement() {
42 if (this._targetElementRef)
43 return this._targetElementRef.get();
44 return null;
45 },
46
47 set _targetElement(aTargetElement) {
48 this._targetElementRef = Cu.getWeakReference(aTargetElement);
49 },
50
51 get _domWinUtils() {
52 return BrowserApp.selectedBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).
53 getInterface(Ci.nsIDOMWindowUtils);
54 },
55
56 _isRTL: false,
57
58 _addObservers: function sh_addObservers() {
59 Services.obs.addObserver(this, "Gesture:SingleTap", false);
60 Services.obs.addObserver(this, "Tab:Selected", false);
61 Services.obs.addObserver(this, "after-viewport-change", false);
62 Services.obs.addObserver(this, "TextSelection:Move", false);
63 Services.obs.addObserver(this, "TextSelection:Position", false);
64 Services.obs.addObserver(this, "TextSelection:End", false);
65 Services.obs.addObserver(this, "TextSelection:Action", false);
66 Services.obs.addObserver(this, "TextSelection:LayerReflow", false);
67
68 BrowserApp.deck.addEventListener("pagehide", this, false);
69 BrowserApp.deck.addEventListener("blur", this, true);
70 BrowserApp.deck.addEventListener("scroll", this, true);
71 },
72
73 _removeObservers: function sh_removeObservers() {
74 Services.obs.removeObserver(this, "Gesture:SingleTap");
75 Services.obs.removeObserver(this, "Tab:Selected");
76 Services.obs.removeObserver(this, "after-viewport-change");
77 Services.obs.removeObserver(this, "TextSelection:Move");
78 Services.obs.removeObserver(this, "TextSelection:Position");
79 Services.obs.removeObserver(this, "TextSelection:End");
80 Services.obs.removeObserver(this, "TextSelection:Action");
81 Services.obs.removeObserver(this, "TextSelection:LayerReflow");
82
83 BrowserApp.deck.removeEventListener("pagehide", this, false);
84 BrowserApp.deck.removeEventListener("blur", this, true);
85 BrowserApp.deck.removeEventListener("scroll", this, true);
86 },
87
88 observe: function sh_observe(aSubject, aTopic, aData) {
89 switch (aTopic) {
90 // Update handle/caret position on page reflow (keyboard open/close,
91 // dynamic DOM changes, orientation updates, etc).
92 case "TextSelection:LayerReflow": {
93 if (this._activeType == this.TYPE_SELECTION) {
94 this._updateCacheForSelection();
95 }
96 if (this._activeType != this.TYPE_NONE) {
97 this._positionHandlesOnChange();
98 }
99 break;
100 }
101
102 // Update caret position on keyboard activity
103 case "TextSelection:UpdateCaretPos":
104 // Generated by IME close, autoCorrection / styling
105 this._positionHandles();
106 break;
107
108 case "Gesture:SingleTap": {
109 if (this._activeType == this.TYPE_SELECTION) {
110 let data = JSON.parse(aData);
111 if (!this._pointInSelection(data.x, data.y))
112 this._closeSelection();
113 } else if (this._activeType == this.TYPE_CURSOR) {
114 // attachCaret() is called in the "Gesture:SingleTap" handler in BrowserEventHandler
115 // We're guaranteed to call this first, because this observer was added last
116 this._deactivate();
117 }
118 break;
119 }
120 case "Tab:Selected":
121 case "TextSelection:End":
122 this._closeSelection();
123 break;
124 case "TextSelection:Action":
125 for (let type in this.actions) {
126 if (this.actions[type].id == aData) {
127 this.actions[type].action(this._targetElement);
128 break;
129 }
130 }
131 break;
132 case "after-viewport-change": {
133 if (this._activeType == this.TYPE_SELECTION) {
134 // Update the cache after the viewport changes (e.g. panning, zooming).
135 this._updateCacheForSelection();
136 }
137 break;
138 }
139 case "TextSelection:Move": {
140 let data = JSON.parse(aData);
141 if (this._activeType == this.TYPE_SELECTION) {
142 this._startDraggingHandles();
143 this._moveSelection(data.handleType == this.HANDLE_TYPE_START, data.x, data.y);
144
145 } else if (this._activeType == this.TYPE_CURSOR) {
146 this._startDraggingHandles();
147
148 // Ignore IMM composition notifications when caret movement starts
149 this._ignoreCompositionChanges = true;
150
151 // Send a click event to the text box, which positions the caret
152 this._sendMouseEvents(data.x, data.y);
153
154 // Move the handle directly under the caret
155 this._positionHandles();
156 }
157 break;
158 }
159 case "TextSelection:Position": {
160 if (this._activeType == this.TYPE_SELECTION) {
161 this._startDraggingHandles();
162
163 // Check to see if the handles should be reversed.
164 let isStartHandle = JSON.parse(aData).handleType == this.HANDLE_TYPE_START;
165 try {
166 let selectionReversed = this._updateCacheForSelection(isStartHandle);
167 if (selectionReversed) {
168 // Reverse the anchor and focus to correspond to the new start and end handles.
169 let selection = this._getSelection();
170 let anchorNode = selection.anchorNode;
171 let anchorOffset = selection.anchorOffset;
172 selection.collapse(selection.focusNode, selection.focusOffset);
173 selection.extend(anchorNode, anchorOffset);
174 }
175 } catch (e) {
176 // User finished handle positioning with one end off the screen
177 this._closeSelection();
178 break;
179 }
180
181 this._stopDraggingHandles();
182 this._positionHandles();
183 // Changes to handle position can affect selection context and actionbar display
184 this._updateMenu();
185
186 } else if (this._activeType == this.TYPE_CURSOR) {
187 // Act on IMM composition notifications after caret movement ends
188 this._ignoreCompositionChanges = false;
189 this._stopDraggingHandles();
190 this._positionHandles();
191
192 } else {
193 Cu.reportError("Ignored \"TextSelection:Position\" message during invalid selection status");
194 }
195
196 break;
197 }
198
199 case "TextSelection:Get":
200 sendMessageToJava({
201 type: "TextSelection:Data",
202 requestId: aData,
203 text: this._getSelectedText()
204 });
205 break;
206 }
207 },
208
209 // Ignore selectionChange notifications during handle dragging, disable dynamic
210 // IME text compositions (autoSuggest, autoCorrect, etc)
211 _startDraggingHandles: function sh_startDraggingHandles() {
212 if (!this._draggingHandles) {
213 this._draggingHandles = true;
214 sendMessageToJava({ type: "TextSelection:DraggingHandle", dragging: true });
215 }
216 },
217
218 // Act on selectionChange notifications when not dragging handles, allow dynamic
219 // IME text compositions (autoSuggest, autoCorrect, etc)
220 _stopDraggingHandles: function sh_stopDraggingHandles() {
221 if (this._draggingHandles) {
222 this._draggingHandles = false;
223 sendMessageToJava({ type: "TextSelection:DraggingHandle", dragging: false });
224 }
225 },
226
227 handleEvent: function sh_handleEvent(aEvent) {
228 switch (aEvent.type) {
229 case "scroll":
230 // Maintain position when top-level document is scrolled
231 this._positionHandlesOnChange();
232 break;
233
234 case "pagehide":
235 case "blur":
236 this._closeSelection();
237 break;
238
239 // Update caret position on keyboard activity
240 case "keyup":
241 // Not generated by Swiftkeyboard
242 case "compositionupdate":
243 case "compositionend":
244 // Generated by SwiftKeyboard, et. al.
245 if (!this._ignoreCompositionChanges) {
246 this._positionHandles();
247 }
248 break;
249 }
250 },
251
252 /** Returns true if the provided element can be selected in text selection, false otherwise. */
253 canSelect: function sh_canSelect(aElement) {
254 return !(aElement instanceof Ci.nsIDOMHTMLButtonElement ||
255 aElement instanceof Ci.nsIDOMHTMLEmbedElement ||
256 aElement instanceof Ci.nsIDOMHTMLImageElement ||
257 aElement instanceof Ci.nsIDOMHTMLMediaElement) &&
258 aElement.style.MozUserSelect != 'none';
259 },
260
261 _getScrollPos: function sh_getScrollPos() {
262 // Get the current display position
263 let scrollX = {}, scrollY = {};
264 this._contentWindow.top.QueryInterface(Ci.nsIInterfaceRequestor).
265 getInterface(Ci.nsIDOMWindowUtils).getScrollXY(false, scrollX, scrollY);
266 return {
267 X: scrollX.value,
268 Y: scrollY.value
269 };
270 },
271
272 notifySelectionChanged: function sh_notifySelectionChanged(aDocument, aSelection, aReason) {
273 // Ignore selectionChange notifications during handle movements
274 if (this._draggingHandles) {
275 return;
276 }
277
278 // If the selection was collapsed to Start or to End, always close it
279 if ((aReason & Ci.nsISelectionListener.COLLAPSETOSTART_REASON) ||
280 (aReason & Ci.nsISelectionListener.COLLAPSETOEND_REASON)) {
281 this._closeSelection();
282 return;
283 }
284
285 // If selected text no longer exists, close
286 if (!aSelection.toString()) {
287 this._closeSelection();
288 }
289 },
290
291 /*
292 * Called from browser.js when the user long taps on text or chooses
293 * the "Select Word" context menu item. Initializes SelectionHandler,
294 * starts a selection, and positions the text selection handles.
295 *
296 * @param aOptions list of options describing how to start selection
297 * Options include:
298 * mode - SELECT_ALL to select everything in the target
299 * element, or SELECT_AT_POINT to select a word.
300 * x - The x-coordinate for SELECT_AT_POINT.
301 * y - The y-coordinate for SELECT_AT_POINT.
302 */
303 startSelection: function sh_startSelection(aElement, aOptions = { mode: SelectionHandler.SELECT_ALL }) {
304 // Clear out any existing active selection
305 this._closeSelection();
306
307 this._initTargetInfo(aElement, this.TYPE_SELECTION);
308
309 // Clear any existing selection from the document
310 this._contentWindow.getSelection().removeAllRanges();
311
312 // Perform the appropriate selection method, if we can't determine method, or it fails, return
313 if (!this._performSelection(aOptions)) {
314 this._deactivate();
315 return false;
316 }
317
318 // Double check results of successful selection operation
319 let selection = this._getSelection();
320 if (!selection || selection.rangeCount == 0 || selection.getRangeAt(0).collapsed) {
321 this._deactivate();
322 return false;
323 }
324
325 if (this._isPhoneNumber(selection.toString())) {
326 let anchorNode = selection.anchorNode;
327 let anchorOffset = selection.anchorOffset;
328 let focusNode = null;
329 let focusOffset = null;
330 while (this._isPhoneNumber(selection.toString().trim())) {
331 focusNode = selection.focusNode;
332 focusOffset = selection.focusOffset;
333 selection.modify("extend", "forward", "word");
334 // if we hit the end of the text on the page, we can't advance the selection
335 if (focusNode == selection.focusNode && focusOffset == selection.focusOffset) {
336 break;
337 }
338 }
339
340 // reverse selection
341 selection.collapse(focusNode, focusOffset);
342 selection.extend(anchorNode, anchorOffset);
343
344 anchorNode = focusNode;
345 anchorOffset = focusOffset
346
347 while (this._isPhoneNumber(selection.toString().trim())) {
348 focusNode = selection.focusNode;
349 focusOffset = selection.focusOffset;
350 selection.modify("extend", "backward", "word");
351 // if we hit the end of the text on the page, we can't advance the selection
352 if (focusNode == selection.focusNode && focusOffset == selection.focusOffset) {
353 break;
354 }
355 }
356
357 selection.collapse(focusNode, focusOffset);
358 selection.extend(anchorNode, anchorOffset);
359 }
360
361 // Add a listener to end the selection if it's removed programatically
362 selection.QueryInterface(Ci.nsISelectionPrivate).addSelectionListener(this);
363 this._activeType = this.TYPE_SELECTION;
364
365 // Initialize the cache
366 this._cache = { start: {}, end: {}};
367 this._updateCacheForSelection();
368
369 let scroll = this._getScrollPos();
370 // Figure out the distance between the selection and the click
371 let positions = this._getHandlePositions(scroll);
372
373 if (aOptions.mode == this.SELECT_AT_POINT && !this._selectionNearClick(scroll.X + aOptions.x,
374 scroll.Y + aOptions.y,
375 positions)) {
376 this._closeSelection();
377 return false;
378 }
379
380 // Determine position and show handles, open actionbar
381 this._positionHandles(positions);
382 sendMessageToJava({
383 type: "TextSelection:ShowHandles",
384 handles: [this.HANDLE_TYPE_START, this.HANDLE_TYPE_END]
385 });
386 this._updateMenu();
387 return true;
388 },
389
390 /*
391 * Called to perform a selection operation, given a target element, selection method, starting point etc.
392 */
393 _performSelection: function sh_performSelection(aOptions) {
394 if (aOptions.mode == this.SELECT_AT_POINT) {
395 return this._domWinUtils.selectAtPoint(aOptions.x, aOptions.y, Ci.nsIDOMWindowUtils.SELECT_WORDNOSPACE);
396 }
397
398 if (aOptions.mode != this.SELECT_ALL) {
399 Cu.reportError("SelectionHandler.js: _performSelection() Invalid selection mode " + aOptions.mode);
400 return false;
401 }
402
403 // HTMLPreElement is a #text node, SELECT_ALL implies entire paragraph
404 if (this._targetElement instanceof HTMLPreElement) {
405 return this._domWinUtils.selectAtPoint(1, 1, Ci.nsIDOMWindowUtils.SELECT_PARAGRAPH);
406 }
407
408 // Else default to selectALL Document
409 this._getSelectionController().selectAll();
410
411 // Selection is entire HTMLHtmlElement, remove any trailing document whitespace
412 let selection = this._getSelection();
413 let lastNode = selection.focusNode;
414 while (lastNode && lastNode.lastChild) {
415 lastNode = lastNode.lastChild;
416 }
417
418 if (lastNode instanceof Text) {
419 try {
420 selection.extend(lastNode, lastNode.length);
421 } catch (e) {
422 Cu.reportError("SelectionHandler.js: _performSelection() whitespace trim fails: lastNode[" + lastNode +
423 "] lastNode.length[" + lastNode.length + "]");
424 }
425 }
426
427 return true;
428 },
429
430 /* Return true if the current selection (given by aPositions) is near to where the coordinates passed in */
431 _selectionNearClick: function(aX, aY, aPositions) {
432 let distance = 0;
433
434 // Check if the click was in the bounding box of the selection handles
435 if (aPositions[0].left < aX && aX < aPositions[1].left
436 && aPositions[0].top < aY && aY < aPositions[1].top) {
437 distance = 0;
438 } else {
439 // If it was outside, check the distance to the center of the selection
440 let selectposX = (aPositions[0].left + aPositions[1].left) / 2;
441 let selectposY = (aPositions[0].top + aPositions[1].top) / 2;
442
443 let dx = Math.abs(selectposX - aX);
444 let dy = Math.abs(selectposY - aY);
445 distance = dx + dy;
446 }
447
448 let maxSelectionDistance = Services.prefs.getIntPref("browser.ui.selection.distance");
449 return (distance < maxSelectionDistance);
450 },
451
452 /* Reads a value from an action. If the action defines the value as a function, will return the result of calling
453 the function. Otherwise, will return the value itself. If the value isn't defined for this action, will return a default */
454 _getValue: function(obj, name, defaultValue) {
455 if (!(name in obj))
456 return defaultValue;
457
458 if (typeof obj[name] == "function")
459 return obj[name](this._targetElement);
460
461 return obj[name];
462 },
463
464 addAction: function(action) {
465 if (!action.id)
466 action.id = uuidgen.generateUUID().toString()
467
468 if (this.actions[action.id])
469 throw "Action with id " + action.id + " already added";
470
471 // Update actions list and actionbar UI if active.
472 this.actions[action.id] = action;
473 this._updateMenu();
474 return action.id;
475 },
476
477 removeAction: function(id) {
478 // Update actions list and actionbar UI if active.
479 delete this.actions[id];
480 this._updateMenu();
481 },
482
483 _updateMenu: function() {
484 if (this._activeType == this.TYPE_NONE) {
485 return;
486 }
487
488 // Update actionbar UI.
489 let actions = [];
490 for (let type in this.actions) {
491 let action = this.actions[type];
492 if (action.selector.matches(this._targetElement)) {
493 let a = {
494 id: action.id,
495 label: this._getValue(action, "label", ""),
496 icon: this._getValue(action, "icon", "drawable://ic_status_logo"),
497 showAsAction: this._getValue(action, "showAsAction", true),
498 order: this._getValue(action, "order", 0)
499 };
500 actions.push(a);
501 }
502 }
503
504 actions.sort((a, b) => b.order - a.order);
505
506 sendMessageToJava({
507 type: "TextSelection:Update",
508 actions: actions
509 });
510 },
511
512 /*
513 * Actionbar methods.
514 */
515 actions: {
516 SELECT_ALL: {
517 label: Strings.browser.GetStringFromName("contextmenu.selectAll"),
518 id: "selectall_action",
519 icon: "drawable://ab_select_all",
520 action: function(aElement) {
521 SelectionHandler.startSelection(aElement);
522 UITelemetry.addEvent("action.1", "actionbar", null, "select_all");
523 },
524 order: 5,
525 selector: {
526 matches: function(aElement) {
527 return (aElement.textLength != 0);
528 }
529 }
530 },
531
532 CUT: {
533 label: Strings.browser.GetStringFromName("contextmenu.cut"),
534 id: "cut_action",
535 icon: "drawable://ab_cut",
536 action: function(aElement) {
537 let start = aElement.selectionStart;
538 let end = aElement.selectionEnd;
539
540 SelectionHandler.copySelection();
541 aElement.value = aElement.value.substring(0, start) + aElement.value.substring(end)
542
543 // copySelection closes the selection. Show a caret where we just cut the text.
544 SelectionHandler.attachCaret(aElement);
545 UITelemetry.addEvent("action.1", "actionbar", null, "cut");
546 },
547 order: 4,
548 selector: {
549 matches: function(aElement) {
550 return SelectionHandler.isElementEditableText(aElement) ?
551 SelectionHandler.isSelectionActive() : false;
552 }
553 }
554 },
555
556 COPY: {
557 label: Strings.browser.GetStringFromName("contextmenu.copy"),
558 id: "copy_action",
559 icon: "drawable://ab_copy",
560 action: function() {
561 SelectionHandler.copySelection();
562 UITelemetry.addEvent("action.1", "actionbar", null, "copy");
563 },
564 order: 3,
565 selector: {
566 matches: function(aElement) {
567 // Don't include "copy" for password fields.
568 // mozIsTextField(true) tests for only non-password fields.
569 if (aElement instanceof Ci.nsIDOMHTMLInputElement && !aElement.mozIsTextField(true)) {
570 return false;
571 }
572 return SelectionHandler.isSelectionActive();
573 }
574 }
575 },
576
577 PASTE: {
578 label: Strings.browser.GetStringFromName("contextmenu.paste"),
579 id: "paste_action",
580 icon: "drawable://ab_paste",
581 action: function(aElement) {
582 if (aElement && (aElement instanceof Ci.nsIDOMNSEditableElement)) {
583 let target = aElement.QueryInterface(Ci.nsIDOMNSEditableElement);
584 target.editor.paste(Ci.nsIClipboard.kGlobalClipboard);
585 target.focus();
586 SelectionHandler._closeSelection();
587 UITelemetry.addEvent("action.1", "actionbar", null, "paste");
588 }
589 },
590 order: 2,
591 selector: {
592 matches: function(aElement) {
593 if (SelectionHandler.isElementEditableText(aElement)) {
594 let flavors = ["text/unicode"];
595 return Services.clipboard.hasDataMatchingFlavors(flavors, flavors.length, Ci.nsIClipboard.kGlobalClipboard);
596 }
597 return false;
598 }
599 }
600 },
601
602 SHARE: {
603 label: Strings.browser.GetStringFromName("contextmenu.share"),
604 id: "share_action",
605 icon: "drawable://ic_menu_share",
606 action: function() {
607 SelectionHandler.shareSelection();
608 UITelemetry.addEvent("action.1", "actionbar", null, "share");
609 },
610 selector: {
611 matches: function() {
612 return SelectionHandler.isSelectionActive();
613 }
614 }
615 },
616
617 SEARCH: {
618 label: function() {
619 return Strings.browser.formatStringFromName("contextmenu.search", [Services.search.defaultEngine.name], 1);
620 },
621 id: "search_action",
622 icon: "drawable://ab_search",
623 action: function() {
624 SelectionHandler.searchSelection();
625 SelectionHandler._closeSelection();
626 UITelemetry.addEvent("action.1", "actionbar", null, "search");
627 },
628 order: 1,
629 selector: {
630 matches: function() {
631 return SelectionHandler.isSelectionActive();
632 }
633 }
634 },
635
636 CALL: {
637 label: Strings.browser.GetStringFromName("contextmenu.call"),
638 id: "call_action",
639 icon: "drawable://phone",
640 action: function() {
641 SelectionHandler.callSelection();
642 UITelemetry.addEvent("action.1", "actionbar", null, "call");
643 },
644 order: 1,
645 selector: {
646 matches: function () {
647 return SelectionHandler._getSelectedPhoneNumber() != null;
648 }
649 }
650 }
651 },
652
653 /*
654 * Called by BrowserEventHandler when the user taps in a form input.
655 * Initializes SelectionHandler and positions the caret handle.
656 *
657 * @param aX, aY tap location in client coordinates.
658 */
659 attachCaret: function sh_attachCaret(aElement) {
660 // Ensure it isn't disabled, isn't handled by Android native dialog, and is editable text element
661 if (aElement.disabled || InputWidgetHelper.hasInputWidget(aElement) || !this.isElementEditableText(aElement)) {
662 return;
663 }
664
665 this._initTargetInfo(aElement, this.TYPE_CURSOR);
666
667 // Caret-specific observer/listeners
668 Services.obs.addObserver(this, "TextSelection:UpdateCaretPos", false);
669 BrowserApp.deck.addEventListener("keyup", this, false);
670 BrowserApp.deck.addEventListener("compositionupdate", this, false);
671 BrowserApp.deck.addEventListener("compositionend", this, false);
672
673 this._activeType = this.TYPE_CURSOR;
674
675 // Determine position and show caret, open actionbar
676 this._positionHandles();
677 sendMessageToJava({
678 type: "TextSelection:ShowHandles",
679 handles: [this.HANDLE_TYPE_MIDDLE]
680 });
681 this._updateMenu();
682 },
683
684 // Target initialization for both TYPE_CURSOR and TYPE_SELECTION
685 _initTargetInfo: function sh_initTargetInfo(aElement, aSelectionType) {
686 this._targetElement = aElement;
687 if (aElement instanceof Ci.nsIDOMNSEditableElement) {
688 if (aSelectionType === this.TYPE_SELECTION) {
689 // Blur the targetElement to force IME code to undo previous style compositions
690 // (visible underlines / etc generated by autoCorrection, autoSuggestion)
691 aElement.blur();
692 }
693 // Ensure targetElement is now focused normally
694 aElement.focus();
695 }
696
697 this._stopDraggingHandles();
698 this._contentWindow = aElement.ownerDocument.defaultView;
699 this._isRTL = (this._contentWindow.getComputedStyle(aElement, "").direction == "rtl");
700
701 this._addObservers();
702 },
703
704 _getSelection: function sh_getSelection() {
705 if (this._targetElement instanceof Ci.nsIDOMNSEditableElement)
706 return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selection;
707 else
708 return this._contentWindow.getSelection();
709 },
710
711 _getSelectedText: function sh_getSelectedText() {
712 if (!this._contentWindow)
713 return "";
714
715 let selection = this._getSelection();
716 if (!selection)
717 return "";
718
719 if (this._targetElement instanceof Ci.nsIDOMHTMLTextAreaElement) {
720 return selection.QueryInterface(Ci.nsISelectionPrivate).
721 toStringWithFormat("text/plain", Ci.nsIDocumentEncoder.OutputPreformatted | Ci.nsIDocumentEncoder.OutputRaw, 0);
722 }
723
724 return selection.toString().trim();
725 },
726
727 _getSelectionController: function sh_getSelectionController() {
728 if (this._targetElement instanceof Ci.nsIDOMNSEditableElement)
729 return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selectionController;
730 else
731 return this._contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).
732 getInterface(Ci.nsIWebNavigation).
733 QueryInterface(Ci.nsIInterfaceRequestor).
734 getInterface(Ci.nsISelectionDisplay).
735 QueryInterface(Ci.nsISelectionController);
736 },
737
738 // Used by the contextmenu "matches" functions in ClipboardHelper
739 isSelectionActive: function sh_isSelectionActive() {
740 return (this._activeType == this.TYPE_SELECTION);
741 },
742
743 isElementEditableText: function (aElement) {
744 return ((aElement instanceof HTMLInputElement && aElement.mozIsTextField(false)) ||
745 (aElement instanceof HTMLTextAreaElement));
746 },
747
748 /*
749 * Helper function for moving the selection inside an editable element.
750 *
751 * @param aAnchorX the stationary handle's x-coordinate in client coordinates
752 * @param aX the moved handle's x-coordinate in client coordinates
753 * @param aCaretPos the current position of the caret
754 */
755 _moveSelectionInEditable: function sh_moveSelectionInEditable(aAnchorX, aX, aCaretPos) {
756 let anchorOffset = aX < aAnchorX ? this._targetElement.selectionEnd
757 : this._targetElement.selectionStart;
758 let newOffset = aCaretPos.offset;
759 let [start, end] = anchorOffset <= newOffset ?
760 [anchorOffset, newOffset] :
761 [newOffset, anchorOffset];
762 this._targetElement.setSelectionRange(start, end);
763 },
764
765 /*
766 * Moves the selection as the user drags a selection handle.
767 *
768 * @param aIsStartHandle whether the user is moving the start handle (as opposed to the end handle)
769 * @param aX, aY selection point in client coordinates
770 */
771 _moveSelection: function sh_moveSelection(aIsStartHandle, aX, aY) {
772 // XXX We should be smarter about the coordinates we pass to caretPositionFromPoint, especially
773 // in editable targets. We should factor out the logic that's currently in _sendMouseEvents.
774 let viewOffset = this._getViewOffset();
775 let caretPos = this._contentWindow.document.caretPositionFromPoint(aX - viewOffset.x, aY - viewOffset.y);
776 if (!caretPos) {
777 // User moves handle offscreen while positioning
778 return;
779 }
780
781 // Constrain text selection within editable elements.
782 let targetIsEditable = this._targetElement instanceof Ci.nsIDOMNSEditableElement;
783 if (targetIsEditable && (caretPos.offsetNode != this._targetElement)) {
784 return;
785 }
786
787 // Update the cache as the handle is dragged (keep the cache in client coordinates).
788 if (aIsStartHandle) {
789 this._cache.start.x = aX;
790 this._cache.start.y = aY;
791 } else {
792 this._cache.end.x = aX;
793 this._cache.end.y = aY;
794 }
795
796 let selection = this._getSelection();
797
798 // The handles work the same on both LTR and RTL pages, but the anchor/focus nodes
799 // are reversed, so we need to reverse the logic to extend the selection.
800 if ((aIsStartHandle && !this._isRTL) || (!aIsStartHandle && this._isRTL)) {
801 if (targetIsEditable) {
802 let anchorX = this._isRTL ? this._cache.start.x : this._cache.end.x;
803 this._moveSelectionInEditable(anchorX, aX, caretPos);
804 } else {
805 let focusNode = selection.focusNode;
806 let focusOffset = selection.focusOffset;
807 selection.collapse(caretPos.offsetNode, caretPos.offset);
808 selection.extend(focusNode, focusOffset);
809 }
810 } else {
811 if (targetIsEditable) {
812 let anchorX = this._isRTL ? this._cache.end.x : this._cache.start.x;
813 this._moveSelectionInEditable(anchorX, aX, caretPos);
814 } else {
815 selection.extend(caretPos.offsetNode, caretPos.offset);
816 }
817 }
818 },
819
820 _sendMouseEvents: function sh_sendMouseEvents(aX, aY, useShift) {
821 // If we're positioning a cursor in an input field, make sure the handle
822 // stays within the bounds of the field
823 if (this._activeType == this.TYPE_CURSOR) {
824 // Get rect of text inside element
825 let range = document.createRange();
826 range.selectNodeContents(this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.rootElement);
827 let textBounds = range.getBoundingClientRect();
828
829 // Get rect of editor
830 let editorBounds = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_EDITOR_RECT, 0, 0, 0, 0,
831 this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
832 // the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so
833 // divide by the pixel ratio
834 let editorRect = new Rect(editorBounds.left / window.devicePixelRatio,
835 editorBounds.top / window.devicePixelRatio,
836 editorBounds.width / window.devicePixelRatio,
837 editorBounds.height / window.devicePixelRatio);
838
839 // Use intersection of the text rect and the editor rect
840 let rect = new Rect(textBounds.left, textBounds.top, textBounds.width, textBounds.height);
841 rect.restrictTo(editorRect);
842
843 // Clamp vertically and scroll if handle is at bounds. The top and bottom
844 // must be restricted by an additional pixel since clicking on the top
845 // edge of an input field moves the cursor to the beginning of that
846 // field's text (and clicking the bottom moves the cursor to the end).
847 if (aY < rect.y + 1) {
848 aY = rect.y + 1;
849 this._getSelectionController().scrollLine(false);
850 } else if (aY > rect.y + rect.height - 1) {
851 aY = rect.y + rect.height - 1;
852 this._getSelectionController().scrollLine(true);
853 }
854
855 // Clamp horizontally and scroll if handle is at bounds
856 if (aX < rect.x) {
857 aX = rect.x;
858 this._getSelectionController().scrollCharacter(false);
859 } else if (aX > rect.x + rect.width) {
860 aX = rect.x + rect.width;
861 this._getSelectionController().scrollCharacter(true);
862 }
863 } else if (this._activeType == this.TYPE_SELECTION) {
864 // Send mouse event 1px too high to prevent selection from entering the line below where it should be
865 aY -= 1;
866 }
867
868 this._domWinUtils.sendMouseEventToWindow("mousedown", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 0, true);
869 this._domWinUtils.sendMouseEventToWindow("mouseup", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 0, true);
870 },
871
872 copySelection: function sh_copySelection() {
873 let selectedText = this._getSelectedText();
874 if (selectedText.length) {
875 let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
876 clipboard.copyString(selectedText, this._contentWindow.document);
877 NativeWindow.toast.show(Strings.browser.GetStringFromName("selectionHelper.textCopied"), "short");
878 }
879 this._closeSelection();
880 },
881
882 shareSelection: function sh_shareSelection() {
883 let selectedText = this._getSelectedText();
884 if (selectedText.length) {
885 sendMessageToJava({
886 type: "Share:Text",
887 text: selectedText
888 });
889 }
890 this._closeSelection();
891 },
892
893 searchSelection: function sh_searchSelection() {
894 let selectedText = this._getSelectedText();
895 if (selectedText.length) {
896 let req = Services.search.defaultEngine.getSubmission(selectedText);
897 let parent = BrowserApp.selectedTab;
898 let isPrivate = PrivateBrowsingUtils.isWindowPrivate(parent.browser.contentWindow);
899 // Set current tab as parent of new tab, and set new tab as private if the parent is.
900 BrowserApp.addTab(req.uri.spec, {parentId: parent.id,
901 selected: true,
902 isPrivate: isPrivate});
903 }
904 this._closeSelection();
905 },
906
907 _phoneRegex: /^\+?[0-9\s,-.\(\)*#pw]{1,30}$/,
908
909 _getSelectedPhoneNumber: function sh_getSelectedPhoneNumber() {
910 return this._isPhoneNumber(this._getSelectedText().trim());
911 },
912
913 _isPhoneNumber: function sh_isPhoneNumber(selectedText) {
914 return (this._phoneRegex.test(selectedText) ? selectedText : null);
915 },
916
917 callSelection: function sh_callSelection() {
918 let selectedText = this._getSelectedPhoneNumber();
919 if (selectedText) {
920 BrowserApp.loadURI("tel:" + selectedText);
921 }
922 this._closeSelection();
923 },
924
925 /*
926 * Shuts SelectionHandler down.
927 */
928 _closeSelection: function sh_closeSelection() {
929 // Bail if there's no active selection
930 if (this._activeType == this.TYPE_NONE)
931 return;
932
933 if (this._activeType == this.TYPE_SELECTION)
934 this._clearSelection();
935
936 this._deactivate();
937 },
938
939 _clearSelection: function sh_clearSelection() {
940 let selection = this._getSelection();
941 if (selection) {
942 // Remove our listener before we clear the selection
943 selection.QueryInterface(Ci.nsISelectionPrivate).removeSelectionListener(this);
944 // Clear selection without clearing the anchorNode or focusNode
945 if (selection.rangeCount != 0) {
946 selection.collapseToStart();
947 }
948 }
949 },
950
951 _deactivate: function sh_deactivate() {
952 this._stopDraggingHandles();
953 // Hide handle/caret, close actionbar
954 sendMessageToJava({ type: "TextSelection:HideHandles" });
955
956 this._removeObservers();
957
958 // Only observed for caret positioning
959 if (this._activeType == this.TYPE_CURSOR) {
960 Services.obs.removeObserver(this, "TextSelection:UpdateCaretPos");
961 BrowserApp.deck.removeEventListener("keyup", this);
962 BrowserApp.deck.removeEventListener("compositionupdate", this);
963 BrowserApp.deck.removeEventListener("compositionend", this);
964 }
965
966 this._contentWindow = null;
967 this._targetElement = null;
968 this._isRTL = false;
969 this._cache = null;
970 this._ignoreCompositionChanges = false;
971 this._prevHandlePositions = [];
972 this._prevTargetElementHasText = null;
973
974 this._activeType = this.TYPE_NONE;
975 },
976
977 _getViewOffset: function sh_getViewOffset() {
978 let offset = { x: 0, y: 0 };
979 let win = this._contentWindow;
980
981 // Recursively look through frames to compute the total position offset.
982 while (win.frameElement) {
983 let rect = win.frameElement.getBoundingClientRect();
984 offset.x += rect.left;
985 offset.y += rect.top;
986
987 win = win.parent;
988 }
989
990 return offset;
991 },
992
993 _pointInSelection: function sh_pointInSelection(aX, aY) {
994 let offset = this._getViewOffset();
995 let rangeRect = this._getSelection().getRangeAt(0).getBoundingClientRect();
996 let radius = ElementTouchHelper.getTouchRadius();
997 return (aX - offset.x > rangeRect.left - radius.left &&
998 aX - offset.x < rangeRect.right + radius.right &&
999 aY - offset.y > rangeRect.top - radius.top &&
1000 aY - offset.y < rangeRect.bottom + radius.bottom);
1001 },
1002
1003 // Returns true if the selection has been reversed. Takes optional aIsStartHandle
1004 // param to decide whether the selection has been reversed.
1005 _updateCacheForSelection: function sh_updateCacheForSelection(aIsStartHandle) {
1006 let rects = this._getSelection().getRangeAt(0).getClientRects();
1007 if (!rects[0]) {
1008 // nsISelection object exists, but there's nothing actually selected
1009 throw "Failed to update cache for invalid selection";
1010 }
1011
1012 let start = { x: this._isRTL ? rects[0].right : rects[0].left, y: rects[0].bottom };
1013 let end = { x: this._isRTL ? rects[rects.length - 1].left : rects[rects.length - 1].right, y: rects[rects.length - 1].bottom };
1014
1015 let selectionReversed = false;
1016 if (this._cache.start) {
1017 // If the end moved past the old end, but we're dragging the start handle, then that handle should become the end handle (and vice versa)
1018 selectionReversed = (aIsStartHandle && (end.y > this._cache.end.y || (end.y == this._cache.end.y && end.x > this._cache.end.x))) ||
1019 (!aIsStartHandle && (start.y < this._cache.start.y || (start.y == this._cache.start.y && start.x < this._cache.start.x)));
1020 }
1021
1022 this._cache.start = start;
1023 this._cache.end = end;
1024
1025 return selectionReversed;
1026 },
1027
1028 _getHandlePositions: function sh_getHandlePositions(scroll) {
1029 // the checkHidden function tests to see if the given point is hidden inside an
1030 // iframe/subdocument. this is so that if we select some text inside an iframe and
1031 // scroll the iframe so the selection is out of view, we hide the handles rather
1032 // than having them float on top of the main page content.
1033 let checkHidden = function(x, y) {
1034 return false;
1035 };
1036 if (this._contentWindow.frameElement) {
1037 let bounds = this._contentWindow.frameElement.getBoundingClientRect();
1038 checkHidden = function(x, y) {
1039 return x < 0 || y < 0 || x > bounds.width || y > bounds.height;
1040 };
1041 }
1042
1043 let positions = null;
1044 if (this._activeType == this.TYPE_CURSOR) {
1045 // The left and top properties returned are relative to the client area
1046 // of the window, so we don't need to account for a sub-frame offset.
1047 let cursor = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_CARET_RECT, this._targetElement.selectionEnd, 0, 0, 0,
1048 this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK);
1049 // the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so
1050 // divide by the pixel ratio
1051 let x = cursor.left / window.devicePixelRatio;
1052 let y = (cursor.top + cursor.height) / window.devicePixelRatio;
1053 return [{ handle: this.HANDLE_TYPE_MIDDLE,
1054 left: x + scroll.X,
1055 top: y + scroll.Y,
1056 hidden: checkHidden(x, y) }];
1057 } else {
1058 let sx = this._cache.start.x;
1059 let sy = this._cache.start.y;
1060 let ex = this._cache.end.x;
1061 let ey = this._cache.end.y;
1062
1063 // Translate coordinates to account for selections in sub-frames. We can't cache
1064 // this because the top-level page may have scrolled since selection started.
1065 let offset = this._getViewOffset();
1066
1067 return [{ handle: this.HANDLE_TYPE_START,
1068 left: sx + offset.x + scroll.X,
1069 top: sy + offset.y + scroll.Y,
1070 hidden: checkHidden(sx, sy) },
1071 { handle: this.HANDLE_TYPE_END,
1072 left: ex + offset.x + scroll.X,
1073 top: ey + offset.y + scroll.Y,
1074 hidden: checkHidden(ex, ey) }];
1075 }
1076 },
1077
1078 // Position handles, but avoid superfluous re-positioning (helps during
1079 // "TextSelection:LayerReflow", "scroll" of top-level document, etc).
1080 _positionHandlesOnChange: function() {
1081 // Helper function to compare position messages
1082 let samePositions = function(aPrev, aCurr) {
1083 if (aPrev.length != aCurr.length) {
1084 return false;
1085 }
1086 for (let i = 0; i < aPrev.length; i++) {
1087 if (aPrev[i].left != aCurr[i].left ||
1088 aPrev[i].top != aCurr[i].top ||
1089 aPrev[i].hidden != aCurr[i].hidden) {
1090 return false;
1091 }
1092 }
1093 return true;
1094 }
1095
1096 let positions = this._getHandlePositions(this._getScrollPos());
1097 if (!samePositions(this._prevHandlePositions, positions)) {
1098 this._positionHandles(positions);
1099 }
1100 },
1101
1102 // Position handles, allow for re-position, in case user drags handle
1103 // to invalid position, then releases, we can put it back where it started
1104 // positions is an array of objects with data about handle positions,
1105 // which we get from _getHandlePositions.
1106 _positionHandles: function sh_positionHandles(positions) {
1107 if (!positions) {
1108 positions = this._getHandlePositions(this._getScrollPos());
1109 }
1110 sendMessageToJava({
1111 type: "TextSelection:PositionHandles",
1112 positions: positions,
1113 rtl: this._isRTL
1114 });
1115 this._prevHandlePositions = positions;
1116
1117 // Text state transitions (text <--> no text) will affect selection context and actionbar display
1118 let currTargetElementHasText = (this._targetElement.textLength > 0);
1119 if (currTargetElementHasText != this._prevTargetElementHasText) {
1120 this._prevTargetElementHasText = currTargetElementHasText;
1121 this._updateMenu();
1122 }
1123 },
1124
1125 subdocumentScrolled: function sh_subdocumentScrolled(aElement) {
1126 if (this._activeType == this.TYPE_NONE) {
1127 return;
1128 }
1129 let scrollView = aElement.ownerDocument.defaultView;
1130 let view = this._contentWindow;
1131 while (true) {
1132 if (view == scrollView) {
1133 // The selection is in a view (or sub-view) of the view that scrolled.
1134 // So we need to reposition the handles.
1135 if (this._activeType == this.TYPE_SELECTION) {
1136 this._updateCacheForSelection();
1137 }
1138 this._positionHandles();
1139 break;
1140 }
1141 if (view == view.parent) {
1142 break;
1143 }
1144 view = view.parent;
1145 }
1146 }
1147 };

mercurial