michael@0: // -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: "use strict"; michael@0: michael@0: var SelectionHandler = { michael@0: HANDLE_TYPE_START: "START", michael@0: HANDLE_TYPE_MIDDLE: "MIDDLE", michael@0: HANDLE_TYPE_END: "END", michael@0: michael@0: TYPE_NONE: 0, michael@0: TYPE_CURSOR: 1, michael@0: TYPE_SELECTION: 2, michael@0: michael@0: SELECT_ALL: 0, michael@0: SELECT_AT_POINT: 1, michael@0: michael@0: // Keeps track of data about the dimensions of the selection. Coordinates michael@0: // stored here are relative to the _contentWindow window. michael@0: _cache: null, michael@0: _activeType: 0, // TYPE_NONE michael@0: _draggingHandles: false, // True while user drags text selection handles michael@0: _ignoreCompositionChanges: false, // Persist caret during IME composition updates michael@0: _prevHandlePositions: [], // Avoid issuing duplicate "TextSelection:Position" messages michael@0: michael@0: // TargetElement changes (text <--> no text) trigger actionbar UI update michael@0: _prevTargetElementHasText: null, michael@0: michael@0: // The window that holds the selection (can be a sub-frame) michael@0: get _contentWindow() { michael@0: if (this._contentWindowRef) michael@0: return this._contentWindowRef.get(); michael@0: return null; michael@0: }, michael@0: michael@0: set _contentWindow(aContentWindow) { michael@0: this._contentWindowRef = Cu.getWeakReference(aContentWindow); michael@0: }, michael@0: michael@0: get _targetElement() { michael@0: if (this._targetElementRef) michael@0: return this._targetElementRef.get(); michael@0: return null; michael@0: }, michael@0: michael@0: set _targetElement(aTargetElement) { michael@0: this._targetElementRef = Cu.getWeakReference(aTargetElement); michael@0: }, michael@0: michael@0: get _domWinUtils() { michael@0: return BrowserApp.selectedBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor). michael@0: getInterface(Ci.nsIDOMWindowUtils); michael@0: }, michael@0: michael@0: _isRTL: false, michael@0: michael@0: _addObservers: function sh_addObservers() { michael@0: Services.obs.addObserver(this, "Gesture:SingleTap", false); michael@0: Services.obs.addObserver(this, "Tab:Selected", false); michael@0: Services.obs.addObserver(this, "after-viewport-change", false); michael@0: Services.obs.addObserver(this, "TextSelection:Move", false); michael@0: Services.obs.addObserver(this, "TextSelection:Position", false); michael@0: Services.obs.addObserver(this, "TextSelection:End", false); michael@0: Services.obs.addObserver(this, "TextSelection:Action", false); michael@0: Services.obs.addObserver(this, "TextSelection:LayerReflow", false); michael@0: michael@0: BrowserApp.deck.addEventListener("pagehide", this, false); michael@0: BrowserApp.deck.addEventListener("blur", this, true); michael@0: BrowserApp.deck.addEventListener("scroll", this, true); michael@0: }, michael@0: michael@0: _removeObservers: function sh_removeObservers() { michael@0: Services.obs.removeObserver(this, "Gesture:SingleTap"); michael@0: Services.obs.removeObserver(this, "Tab:Selected"); michael@0: Services.obs.removeObserver(this, "after-viewport-change"); michael@0: Services.obs.removeObserver(this, "TextSelection:Move"); michael@0: Services.obs.removeObserver(this, "TextSelection:Position"); michael@0: Services.obs.removeObserver(this, "TextSelection:End"); michael@0: Services.obs.removeObserver(this, "TextSelection:Action"); michael@0: Services.obs.removeObserver(this, "TextSelection:LayerReflow"); michael@0: michael@0: BrowserApp.deck.removeEventListener("pagehide", this, false); michael@0: BrowserApp.deck.removeEventListener("blur", this, true); michael@0: BrowserApp.deck.removeEventListener("scroll", this, true); michael@0: }, michael@0: michael@0: observe: function sh_observe(aSubject, aTopic, aData) { michael@0: switch (aTopic) { michael@0: // Update handle/caret position on page reflow (keyboard open/close, michael@0: // dynamic DOM changes, orientation updates, etc). michael@0: case "TextSelection:LayerReflow": { michael@0: if (this._activeType == this.TYPE_SELECTION) { michael@0: this._updateCacheForSelection(); michael@0: } michael@0: if (this._activeType != this.TYPE_NONE) { michael@0: this._positionHandlesOnChange(); michael@0: } michael@0: break; michael@0: } michael@0: michael@0: // Update caret position on keyboard activity michael@0: case "TextSelection:UpdateCaretPos": michael@0: // Generated by IME close, autoCorrection / styling michael@0: this._positionHandles(); michael@0: break; michael@0: michael@0: case "Gesture:SingleTap": { michael@0: if (this._activeType == this.TYPE_SELECTION) { michael@0: let data = JSON.parse(aData); michael@0: if (!this._pointInSelection(data.x, data.y)) michael@0: this._closeSelection(); michael@0: } else if (this._activeType == this.TYPE_CURSOR) { michael@0: // attachCaret() is called in the "Gesture:SingleTap" handler in BrowserEventHandler michael@0: // We're guaranteed to call this first, because this observer was added last michael@0: this._deactivate(); michael@0: } michael@0: break; michael@0: } michael@0: case "Tab:Selected": michael@0: case "TextSelection:End": michael@0: this._closeSelection(); michael@0: break; michael@0: case "TextSelection:Action": michael@0: for (let type in this.actions) { michael@0: if (this.actions[type].id == aData) { michael@0: this.actions[type].action(this._targetElement); michael@0: break; michael@0: } michael@0: } michael@0: break; michael@0: case "after-viewport-change": { michael@0: if (this._activeType == this.TYPE_SELECTION) { michael@0: // Update the cache after the viewport changes (e.g. panning, zooming). michael@0: this._updateCacheForSelection(); michael@0: } michael@0: break; michael@0: } michael@0: case "TextSelection:Move": { michael@0: let data = JSON.parse(aData); michael@0: if (this._activeType == this.TYPE_SELECTION) { michael@0: this._startDraggingHandles(); michael@0: this._moveSelection(data.handleType == this.HANDLE_TYPE_START, data.x, data.y); michael@0: michael@0: } else if (this._activeType == this.TYPE_CURSOR) { michael@0: this._startDraggingHandles(); michael@0: michael@0: // Ignore IMM composition notifications when caret movement starts michael@0: this._ignoreCompositionChanges = true; michael@0: michael@0: // Send a click event to the text box, which positions the caret michael@0: this._sendMouseEvents(data.x, data.y); michael@0: michael@0: // Move the handle directly under the caret michael@0: this._positionHandles(); michael@0: } michael@0: break; michael@0: } michael@0: case "TextSelection:Position": { michael@0: if (this._activeType == this.TYPE_SELECTION) { michael@0: this._startDraggingHandles(); michael@0: michael@0: // Check to see if the handles should be reversed. michael@0: let isStartHandle = JSON.parse(aData).handleType == this.HANDLE_TYPE_START; michael@0: try { michael@0: let selectionReversed = this._updateCacheForSelection(isStartHandle); michael@0: if (selectionReversed) { michael@0: // Reverse the anchor and focus to correspond to the new start and end handles. michael@0: let selection = this._getSelection(); michael@0: let anchorNode = selection.anchorNode; michael@0: let anchorOffset = selection.anchorOffset; michael@0: selection.collapse(selection.focusNode, selection.focusOffset); michael@0: selection.extend(anchorNode, anchorOffset); michael@0: } michael@0: } catch (e) { michael@0: // User finished handle positioning with one end off the screen michael@0: this._closeSelection(); michael@0: break; michael@0: } michael@0: michael@0: this._stopDraggingHandles(); michael@0: this._positionHandles(); michael@0: // Changes to handle position can affect selection context and actionbar display michael@0: this._updateMenu(); michael@0: michael@0: } else if (this._activeType == this.TYPE_CURSOR) { michael@0: // Act on IMM composition notifications after caret movement ends michael@0: this._ignoreCompositionChanges = false; michael@0: this._stopDraggingHandles(); michael@0: this._positionHandles(); michael@0: michael@0: } else { michael@0: Cu.reportError("Ignored \"TextSelection:Position\" message during invalid selection status"); michael@0: } michael@0: michael@0: break; michael@0: } michael@0: michael@0: case "TextSelection:Get": michael@0: sendMessageToJava({ michael@0: type: "TextSelection:Data", michael@0: requestId: aData, michael@0: text: this._getSelectedText() michael@0: }); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: // Ignore selectionChange notifications during handle dragging, disable dynamic michael@0: // IME text compositions (autoSuggest, autoCorrect, etc) michael@0: _startDraggingHandles: function sh_startDraggingHandles() { michael@0: if (!this._draggingHandles) { michael@0: this._draggingHandles = true; michael@0: sendMessageToJava({ type: "TextSelection:DraggingHandle", dragging: true }); michael@0: } michael@0: }, michael@0: michael@0: // Act on selectionChange notifications when not dragging handles, allow dynamic michael@0: // IME text compositions (autoSuggest, autoCorrect, etc) michael@0: _stopDraggingHandles: function sh_stopDraggingHandles() { michael@0: if (this._draggingHandles) { michael@0: this._draggingHandles = false; michael@0: sendMessageToJava({ type: "TextSelection:DraggingHandle", dragging: false }); michael@0: } michael@0: }, michael@0: michael@0: handleEvent: function sh_handleEvent(aEvent) { michael@0: switch (aEvent.type) { michael@0: case "scroll": michael@0: // Maintain position when top-level document is scrolled michael@0: this._positionHandlesOnChange(); michael@0: break; michael@0: michael@0: case "pagehide": michael@0: case "blur": michael@0: this._closeSelection(); michael@0: break; michael@0: michael@0: // Update caret position on keyboard activity michael@0: case "keyup": michael@0: // Not generated by Swiftkeyboard michael@0: case "compositionupdate": michael@0: case "compositionend": michael@0: // Generated by SwiftKeyboard, et. al. michael@0: if (!this._ignoreCompositionChanges) { michael@0: this._positionHandles(); michael@0: } michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: /** Returns true if the provided element can be selected in text selection, false otherwise. */ michael@0: canSelect: function sh_canSelect(aElement) { michael@0: return !(aElement instanceof Ci.nsIDOMHTMLButtonElement || michael@0: aElement instanceof Ci.nsIDOMHTMLEmbedElement || michael@0: aElement instanceof Ci.nsIDOMHTMLImageElement || michael@0: aElement instanceof Ci.nsIDOMHTMLMediaElement) && michael@0: aElement.style.MozUserSelect != 'none'; michael@0: }, michael@0: michael@0: _getScrollPos: function sh_getScrollPos() { michael@0: // Get the current display position michael@0: let scrollX = {}, scrollY = {}; michael@0: this._contentWindow.top.QueryInterface(Ci.nsIInterfaceRequestor). michael@0: getInterface(Ci.nsIDOMWindowUtils).getScrollXY(false, scrollX, scrollY); michael@0: return { michael@0: X: scrollX.value, michael@0: Y: scrollY.value michael@0: }; michael@0: }, michael@0: michael@0: notifySelectionChanged: function sh_notifySelectionChanged(aDocument, aSelection, aReason) { michael@0: // Ignore selectionChange notifications during handle movements michael@0: if (this._draggingHandles) { michael@0: return; michael@0: } michael@0: michael@0: // If the selection was collapsed to Start or to End, always close it michael@0: if ((aReason & Ci.nsISelectionListener.COLLAPSETOSTART_REASON) || michael@0: (aReason & Ci.nsISelectionListener.COLLAPSETOEND_REASON)) { michael@0: this._closeSelection(); michael@0: return; michael@0: } michael@0: michael@0: // If selected text no longer exists, close michael@0: if (!aSelection.toString()) { michael@0: this._closeSelection(); michael@0: } michael@0: }, michael@0: michael@0: /* michael@0: * Called from browser.js when the user long taps on text or chooses michael@0: * the "Select Word" context menu item. Initializes SelectionHandler, michael@0: * starts a selection, and positions the text selection handles. michael@0: * michael@0: * @param aOptions list of options describing how to start selection michael@0: * Options include: michael@0: * mode - SELECT_ALL to select everything in the target michael@0: * element, or SELECT_AT_POINT to select a word. michael@0: * x - The x-coordinate for SELECT_AT_POINT. michael@0: * y - The y-coordinate for SELECT_AT_POINT. michael@0: */ michael@0: startSelection: function sh_startSelection(aElement, aOptions = { mode: SelectionHandler.SELECT_ALL }) { michael@0: // Clear out any existing active selection michael@0: this._closeSelection(); michael@0: michael@0: this._initTargetInfo(aElement, this.TYPE_SELECTION); michael@0: michael@0: // Clear any existing selection from the document michael@0: this._contentWindow.getSelection().removeAllRanges(); michael@0: michael@0: // Perform the appropriate selection method, if we can't determine method, or it fails, return michael@0: if (!this._performSelection(aOptions)) { michael@0: this._deactivate(); michael@0: return false; michael@0: } michael@0: michael@0: // Double check results of successful selection operation michael@0: let selection = this._getSelection(); michael@0: if (!selection || selection.rangeCount == 0 || selection.getRangeAt(0).collapsed) { michael@0: this._deactivate(); michael@0: return false; michael@0: } michael@0: michael@0: if (this._isPhoneNumber(selection.toString())) { michael@0: let anchorNode = selection.anchorNode; michael@0: let anchorOffset = selection.anchorOffset; michael@0: let focusNode = null; michael@0: let focusOffset = null; michael@0: while (this._isPhoneNumber(selection.toString().trim())) { michael@0: focusNode = selection.focusNode; michael@0: focusOffset = selection.focusOffset; michael@0: selection.modify("extend", "forward", "word"); michael@0: // if we hit the end of the text on the page, we can't advance the selection michael@0: if (focusNode == selection.focusNode && focusOffset == selection.focusOffset) { michael@0: break; michael@0: } michael@0: } michael@0: michael@0: // reverse selection michael@0: selection.collapse(focusNode, focusOffset); michael@0: selection.extend(anchorNode, anchorOffset); michael@0: michael@0: anchorNode = focusNode; michael@0: anchorOffset = focusOffset michael@0: michael@0: while (this._isPhoneNumber(selection.toString().trim())) { michael@0: focusNode = selection.focusNode; michael@0: focusOffset = selection.focusOffset; michael@0: selection.modify("extend", "backward", "word"); michael@0: // if we hit the end of the text on the page, we can't advance the selection michael@0: if (focusNode == selection.focusNode && focusOffset == selection.focusOffset) { michael@0: break; michael@0: } michael@0: } michael@0: michael@0: selection.collapse(focusNode, focusOffset); michael@0: selection.extend(anchorNode, anchorOffset); michael@0: } michael@0: michael@0: // Add a listener to end the selection if it's removed programatically michael@0: selection.QueryInterface(Ci.nsISelectionPrivate).addSelectionListener(this); michael@0: this._activeType = this.TYPE_SELECTION; michael@0: michael@0: // Initialize the cache michael@0: this._cache = { start: {}, end: {}}; michael@0: this._updateCacheForSelection(); michael@0: michael@0: let scroll = this._getScrollPos(); michael@0: // Figure out the distance between the selection and the click michael@0: let positions = this._getHandlePositions(scroll); michael@0: michael@0: if (aOptions.mode == this.SELECT_AT_POINT && !this._selectionNearClick(scroll.X + aOptions.x, michael@0: scroll.Y + aOptions.y, michael@0: positions)) { michael@0: this._closeSelection(); michael@0: return false; michael@0: } michael@0: michael@0: // Determine position and show handles, open actionbar michael@0: this._positionHandles(positions); michael@0: sendMessageToJava({ michael@0: type: "TextSelection:ShowHandles", michael@0: handles: [this.HANDLE_TYPE_START, this.HANDLE_TYPE_END] michael@0: }); michael@0: this._updateMenu(); michael@0: return true; michael@0: }, michael@0: michael@0: /* michael@0: * Called to perform a selection operation, given a target element, selection method, starting point etc. michael@0: */ michael@0: _performSelection: function sh_performSelection(aOptions) { michael@0: if (aOptions.mode == this.SELECT_AT_POINT) { michael@0: return this._domWinUtils.selectAtPoint(aOptions.x, aOptions.y, Ci.nsIDOMWindowUtils.SELECT_WORDNOSPACE); michael@0: } michael@0: michael@0: if (aOptions.mode != this.SELECT_ALL) { michael@0: Cu.reportError("SelectionHandler.js: _performSelection() Invalid selection mode " + aOptions.mode); michael@0: return false; michael@0: } michael@0: michael@0: // HTMLPreElement is a #text node, SELECT_ALL implies entire paragraph michael@0: if (this._targetElement instanceof HTMLPreElement) { michael@0: return this._domWinUtils.selectAtPoint(1, 1, Ci.nsIDOMWindowUtils.SELECT_PARAGRAPH); michael@0: } michael@0: michael@0: // Else default to selectALL Document michael@0: this._getSelectionController().selectAll(); michael@0: michael@0: // Selection is entire HTMLHtmlElement, remove any trailing document whitespace michael@0: let selection = this._getSelection(); michael@0: let lastNode = selection.focusNode; michael@0: while (lastNode && lastNode.lastChild) { michael@0: lastNode = lastNode.lastChild; michael@0: } michael@0: michael@0: if (lastNode instanceof Text) { michael@0: try { michael@0: selection.extend(lastNode, lastNode.length); michael@0: } catch (e) { michael@0: Cu.reportError("SelectionHandler.js: _performSelection() whitespace trim fails: lastNode[" + lastNode + michael@0: "] lastNode.length[" + lastNode.length + "]"); michael@0: } michael@0: } michael@0: michael@0: return true; michael@0: }, michael@0: michael@0: /* Return true if the current selection (given by aPositions) is near to where the coordinates passed in */ michael@0: _selectionNearClick: function(aX, aY, aPositions) { michael@0: let distance = 0; michael@0: michael@0: // Check if the click was in the bounding box of the selection handles michael@0: if (aPositions[0].left < aX && aX < aPositions[1].left michael@0: && aPositions[0].top < aY && aY < aPositions[1].top) { michael@0: distance = 0; michael@0: } else { michael@0: // If it was outside, check the distance to the center of the selection michael@0: let selectposX = (aPositions[0].left + aPositions[1].left) / 2; michael@0: let selectposY = (aPositions[0].top + aPositions[1].top) / 2; michael@0: michael@0: let dx = Math.abs(selectposX - aX); michael@0: let dy = Math.abs(selectposY - aY); michael@0: distance = dx + dy; michael@0: } michael@0: michael@0: let maxSelectionDistance = Services.prefs.getIntPref("browser.ui.selection.distance"); michael@0: return (distance < maxSelectionDistance); michael@0: }, michael@0: michael@0: /* Reads a value from an action. If the action defines the value as a function, will return the result of calling michael@0: the function. Otherwise, will return the value itself. If the value isn't defined for this action, will return a default */ michael@0: _getValue: function(obj, name, defaultValue) { michael@0: if (!(name in obj)) michael@0: return defaultValue; michael@0: michael@0: if (typeof obj[name] == "function") michael@0: return obj[name](this._targetElement); michael@0: michael@0: return obj[name]; michael@0: }, michael@0: michael@0: addAction: function(action) { michael@0: if (!action.id) michael@0: action.id = uuidgen.generateUUID().toString() michael@0: michael@0: if (this.actions[action.id]) michael@0: throw "Action with id " + action.id + " already added"; michael@0: michael@0: // Update actions list and actionbar UI if active. michael@0: this.actions[action.id] = action; michael@0: this._updateMenu(); michael@0: return action.id; michael@0: }, michael@0: michael@0: removeAction: function(id) { michael@0: // Update actions list and actionbar UI if active. michael@0: delete this.actions[id]; michael@0: this._updateMenu(); michael@0: }, michael@0: michael@0: _updateMenu: function() { michael@0: if (this._activeType == this.TYPE_NONE) { michael@0: return; michael@0: } michael@0: michael@0: // Update actionbar UI. michael@0: let actions = []; michael@0: for (let type in this.actions) { michael@0: let action = this.actions[type]; michael@0: if (action.selector.matches(this._targetElement)) { michael@0: let a = { michael@0: id: action.id, michael@0: label: this._getValue(action, "label", ""), michael@0: icon: this._getValue(action, "icon", "drawable://ic_status_logo"), michael@0: showAsAction: this._getValue(action, "showAsAction", true), michael@0: order: this._getValue(action, "order", 0) michael@0: }; michael@0: actions.push(a); michael@0: } michael@0: } michael@0: michael@0: actions.sort((a, b) => b.order - a.order); michael@0: michael@0: sendMessageToJava({ michael@0: type: "TextSelection:Update", michael@0: actions: actions michael@0: }); michael@0: }, michael@0: michael@0: /* michael@0: * Actionbar methods. michael@0: */ michael@0: actions: { michael@0: SELECT_ALL: { michael@0: label: Strings.browser.GetStringFromName("contextmenu.selectAll"), michael@0: id: "selectall_action", michael@0: icon: "drawable://ab_select_all", michael@0: action: function(aElement) { michael@0: SelectionHandler.startSelection(aElement); michael@0: UITelemetry.addEvent("action.1", "actionbar", null, "select_all"); michael@0: }, michael@0: order: 5, michael@0: selector: { michael@0: matches: function(aElement) { michael@0: return (aElement.textLength != 0); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: CUT: { michael@0: label: Strings.browser.GetStringFromName("contextmenu.cut"), michael@0: id: "cut_action", michael@0: icon: "drawable://ab_cut", michael@0: action: function(aElement) { michael@0: let start = aElement.selectionStart; michael@0: let end = aElement.selectionEnd; michael@0: michael@0: SelectionHandler.copySelection(); michael@0: aElement.value = aElement.value.substring(0, start) + aElement.value.substring(end) michael@0: michael@0: // copySelection closes the selection. Show a caret where we just cut the text. michael@0: SelectionHandler.attachCaret(aElement); michael@0: UITelemetry.addEvent("action.1", "actionbar", null, "cut"); michael@0: }, michael@0: order: 4, michael@0: selector: { michael@0: matches: function(aElement) { michael@0: return SelectionHandler.isElementEditableText(aElement) ? michael@0: SelectionHandler.isSelectionActive() : false; michael@0: } michael@0: } michael@0: }, michael@0: michael@0: COPY: { michael@0: label: Strings.browser.GetStringFromName("contextmenu.copy"), michael@0: id: "copy_action", michael@0: icon: "drawable://ab_copy", michael@0: action: function() { michael@0: SelectionHandler.copySelection(); michael@0: UITelemetry.addEvent("action.1", "actionbar", null, "copy"); michael@0: }, michael@0: order: 3, michael@0: selector: { michael@0: matches: function(aElement) { michael@0: // Don't include "copy" for password fields. michael@0: // mozIsTextField(true) tests for only non-password fields. michael@0: if (aElement instanceof Ci.nsIDOMHTMLInputElement && !aElement.mozIsTextField(true)) { michael@0: return false; michael@0: } michael@0: return SelectionHandler.isSelectionActive(); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: PASTE: { michael@0: label: Strings.browser.GetStringFromName("contextmenu.paste"), michael@0: id: "paste_action", michael@0: icon: "drawable://ab_paste", michael@0: action: function(aElement) { michael@0: if (aElement && (aElement instanceof Ci.nsIDOMNSEditableElement)) { michael@0: let target = aElement.QueryInterface(Ci.nsIDOMNSEditableElement); michael@0: target.editor.paste(Ci.nsIClipboard.kGlobalClipboard); michael@0: target.focus(); michael@0: SelectionHandler._closeSelection(); michael@0: UITelemetry.addEvent("action.1", "actionbar", null, "paste"); michael@0: } michael@0: }, michael@0: order: 2, michael@0: selector: { michael@0: matches: function(aElement) { michael@0: if (SelectionHandler.isElementEditableText(aElement)) { michael@0: let flavors = ["text/unicode"]; michael@0: return Services.clipboard.hasDataMatchingFlavors(flavors, flavors.length, Ci.nsIClipboard.kGlobalClipboard); michael@0: } michael@0: return false; michael@0: } michael@0: } michael@0: }, michael@0: michael@0: SHARE: { michael@0: label: Strings.browser.GetStringFromName("contextmenu.share"), michael@0: id: "share_action", michael@0: icon: "drawable://ic_menu_share", michael@0: action: function() { michael@0: SelectionHandler.shareSelection(); michael@0: UITelemetry.addEvent("action.1", "actionbar", null, "share"); michael@0: }, michael@0: selector: { michael@0: matches: function() { michael@0: return SelectionHandler.isSelectionActive(); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: SEARCH: { michael@0: label: function() { michael@0: return Strings.browser.formatStringFromName("contextmenu.search", [Services.search.defaultEngine.name], 1); michael@0: }, michael@0: id: "search_action", michael@0: icon: "drawable://ab_search", michael@0: action: function() { michael@0: SelectionHandler.searchSelection(); michael@0: SelectionHandler._closeSelection(); michael@0: UITelemetry.addEvent("action.1", "actionbar", null, "search"); michael@0: }, michael@0: order: 1, michael@0: selector: { michael@0: matches: function() { michael@0: return SelectionHandler.isSelectionActive(); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: CALL: { michael@0: label: Strings.browser.GetStringFromName("contextmenu.call"), michael@0: id: "call_action", michael@0: icon: "drawable://phone", michael@0: action: function() { michael@0: SelectionHandler.callSelection(); michael@0: UITelemetry.addEvent("action.1", "actionbar", null, "call"); michael@0: }, michael@0: order: 1, michael@0: selector: { michael@0: matches: function () { michael@0: return SelectionHandler._getSelectedPhoneNumber() != null; michael@0: } michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /* michael@0: * Called by BrowserEventHandler when the user taps in a form input. michael@0: * Initializes SelectionHandler and positions the caret handle. michael@0: * michael@0: * @param aX, aY tap location in client coordinates. michael@0: */ michael@0: attachCaret: function sh_attachCaret(aElement) { michael@0: // Ensure it isn't disabled, isn't handled by Android native dialog, and is editable text element michael@0: if (aElement.disabled || InputWidgetHelper.hasInputWidget(aElement) || !this.isElementEditableText(aElement)) { michael@0: return; michael@0: } michael@0: michael@0: this._initTargetInfo(aElement, this.TYPE_CURSOR); michael@0: michael@0: // Caret-specific observer/listeners michael@0: Services.obs.addObserver(this, "TextSelection:UpdateCaretPos", false); michael@0: BrowserApp.deck.addEventListener("keyup", this, false); michael@0: BrowserApp.deck.addEventListener("compositionupdate", this, false); michael@0: BrowserApp.deck.addEventListener("compositionend", this, false); michael@0: michael@0: this._activeType = this.TYPE_CURSOR; michael@0: michael@0: // Determine position and show caret, open actionbar michael@0: this._positionHandles(); michael@0: sendMessageToJava({ michael@0: type: "TextSelection:ShowHandles", michael@0: handles: [this.HANDLE_TYPE_MIDDLE] michael@0: }); michael@0: this._updateMenu(); michael@0: }, michael@0: michael@0: // Target initialization for both TYPE_CURSOR and TYPE_SELECTION michael@0: _initTargetInfo: function sh_initTargetInfo(aElement, aSelectionType) { michael@0: this._targetElement = aElement; michael@0: if (aElement instanceof Ci.nsIDOMNSEditableElement) { michael@0: if (aSelectionType === this.TYPE_SELECTION) { michael@0: // Blur the targetElement to force IME code to undo previous style compositions michael@0: // (visible underlines / etc generated by autoCorrection, autoSuggestion) michael@0: aElement.blur(); michael@0: } michael@0: // Ensure targetElement is now focused normally michael@0: aElement.focus(); michael@0: } michael@0: michael@0: this._stopDraggingHandles(); michael@0: this._contentWindow = aElement.ownerDocument.defaultView; michael@0: this._isRTL = (this._contentWindow.getComputedStyle(aElement, "").direction == "rtl"); michael@0: michael@0: this._addObservers(); michael@0: }, michael@0: michael@0: _getSelection: function sh_getSelection() { michael@0: if (this._targetElement instanceof Ci.nsIDOMNSEditableElement) michael@0: return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selection; michael@0: else michael@0: return this._contentWindow.getSelection(); michael@0: }, michael@0: michael@0: _getSelectedText: function sh_getSelectedText() { michael@0: if (!this._contentWindow) michael@0: return ""; michael@0: michael@0: let selection = this._getSelection(); michael@0: if (!selection) michael@0: return ""; michael@0: michael@0: if (this._targetElement instanceof Ci.nsIDOMHTMLTextAreaElement) { michael@0: return selection.QueryInterface(Ci.nsISelectionPrivate). michael@0: toStringWithFormat("text/plain", Ci.nsIDocumentEncoder.OutputPreformatted | Ci.nsIDocumentEncoder.OutputRaw, 0); michael@0: } michael@0: michael@0: return selection.toString().trim(); michael@0: }, michael@0: michael@0: _getSelectionController: function sh_getSelectionController() { michael@0: if (this._targetElement instanceof Ci.nsIDOMNSEditableElement) michael@0: return this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.selectionController; michael@0: else michael@0: return this._contentWindow.QueryInterface(Ci.nsIInterfaceRequestor). michael@0: getInterface(Ci.nsIWebNavigation). michael@0: QueryInterface(Ci.nsIInterfaceRequestor). michael@0: getInterface(Ci.nsISelectionDisplay). michael@0: QueryInterface(Ci.nsISelectionController); michael@0: }, michael@0: michael@0: // Used by the contextmenu "matches" functions in ClipboardHelper michael@0: isSelectionActive: function sh_isSelectionActive() { michael@0: return (this._activeType == this.TYPE_SELECTION); michael@0: }, michael@0: michael@0: isElementEditableText: function (aElement) { michael@0: return ((aElement instanceof HTMLInputElement && aElement.mozIsTextField(false)) || michael@0: (aElement instanceof HTMLTextAreaElement)); michael@0: }, michael@0: michael@0: /* michael@0: * Helper function for moving the selection inside an editable element. michael@0: * michael@0: * @param aAnchorX the stationary handle's x-coordinate in client coordinates michael@0: * @param aX the moved handle's x-coordinate in client coordinates michael@0: * @param aCaretPos the current position of the caret michael@0: */ michael@0: _moveSelectionInEditable: function sh_moveSelectionInEditable(aAnchorX, aX, aCaretPos) { michael@0: let anchorOffset = aX < aAnchorX ? this._targetElement.selectionEnd michael@0: : this._targetElement.selectionStart; michael@0: let newOffset = aCaretPos.offset; michael@0: let [start, end] = anchorOffset <= newOffset ? michael@0: [anchorOffset, newOffset] : michael@0: [newOffset, anchorOffset]; michael@0: this._targetElement.setSelectionRange(start, end); michael@0: }, michael@0: michael@0: /* michael@0: * Moves the selection as the user drags a selection handle. michael@0: * michael@0: * @param aIsStartHandle whether the user is moving the start handle (as opposed to the end handle) michael@0: * @param aX, aY selection point in client coordinates michael@0: */ michael@0: _moveSelection: function sh_moveSelection(aIsStartHandle, aX, aY) { michael@0: // XXX We should be smarter about the coordinates we pass to caretPositionFromPoint, especially michael@0: // in editable targets. We should factor out the logic that's currently in _sendMouseEvents. michael@0: let viewOffset = this._getViewOffset(); michael@0: let caretPos = this._contentWindow.document.caretPositionFromPoint(aX - viewOffset.x, aY - viewOffset.y); michael@0: if (!caretPos) { michael@0: // User moves handle offscreen while positioning michael@0: return; michael@0: } michael@0: michael@0: // Constrain text selection within editable elements. michael@0: let targetIsEditable = this._targetElement instanceof Ci.nsIDOMNSEditableElement; michael@0: if (targetIsEditable && (caretPos.offsetNode != this._targetElement)) { michael@0: return; michael@0: } michael@0: michael@0: // Update the cache as the handle is dragged (keep the cache in client coordinates). michael@0: if (aIsStartHandle) { michael@0: this._cache.start.x = aX; michael@0: this._cache.start.y = aY; michael@0: } else { michael@0: this._cache.end.x = aX; michael@0: this._cache.end.y = aY; michael@0: } michael@0: michael@0: let selection = this._getSelection(); michael@0: michael@0: // The handles work the same on both LTR and RTL pages, but the anchor/focus nodes michael@0: // are reversed, so we need to reverse the logic to extend the selection. michael@0: if ((aIsStartHandle && !this._isRTL) || (!aIsStartHandle && this._isRTL)) { michael@0: if (targetIsEditable) { michael@0: let anchorX = this._isRTL ? this._cache.start.x : this._cache.end.x; michael@0: this._moveSelectionInEditable(anchorX, aX, caretPos); michael@0: } else { michael@0: let focusNode = selection.focusNode; michael@0: let focusOffset = selection.focusOffset; michael@0: selection.collapse(caretPos.offsetNode, caretPos.offset); michael@0: selection.extend(focusNode, focusOffset); michael@0: } michael@0: } else { michael@0: if (targetIsEditable) { michael@0: let anchorX = this._isRTL ? this._cache.end.x : this._cache.start.x; michael@0: this._moveSelectionInEditable(anchorX, aX, caretPos); michael@0: } else { michael@0: selection.extend(caretPos.offsetNode, caretPos.offset); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _sendMouseEvents: function sh_sendMouseEvents(aX, aY, useShift) { michael@0: // If we're positioning a cursor in an input field, make sure the handle michael@0: // stays within the bounds of the field michael@0: if (this._activeType == this.TYPE_CURSOR) { michael@0: // Get rect of text inside element michael@0: let range = document.createRange(); michael@0: range.selectNodeContents(this._targetElement.QueryInterface(Ci.nsIDOMNSEditableElement).editor.rootElement); michael@0: let textBounds = range.getBoundingClientRect(); michael@0: michael@0: // Get rect of editor michael@0: let editorBounds = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_EDITOR_RECT, 0, 0, 0, 0, michael@0: this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK); michael@0: // the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so michael@0: // divide by the pixel ratio michael@0: let editorRect = new Rect(editorBounds.left / window.devicePixelRatio, michael@0: editorBounds.top / window.devicePixelRatio, michael@0: editorBounds.width / window.devicePixelRatio, michael@0: editorBounds.height / window.devicePixelRatio); michael@0: michael@0: // Use intersection of the text rect and the editor rect michael@0: let rect = new Rect(textBounds.left, textBounds.top, textBounds.width, textBounds.height); michael@0: rect.restrictTo(editorRect); michael@0: michael@0: // Clamp vertically and scroll if handle is at bounds. The top and bottom michael@0: // must be restricted by an additional pixel since clicking on the top michael@0: // edge of an input field moves the cursor to the beginning of that michael@0: // field's text (and clicking the bottom moves the cursor to the end). michael@0: if (aY < rect.y + 1) { michael@0: aY = rect.y + 1; michael@0: this._getSelectionController().scrollLine(false); michael@0: } else if (aY > rect.y + rect.height - 1) { michael@0: aY = rect.y + rect.height - 1; michael@0: this._getSelectionController().scrollLine(true); michael@0: } michael@0: michael@0: // Clamp horizontally and scroll if handle is at bounds michael@0: if (aX < rect.x) { michael@0: aX = rect.x; michael@0: this._getSelectionController().scrollCharacter(false); michael@0: } else if (aX > rect.x + rect.width) { michael@0: aX = rect.x + rect.width; michael@0: this._getSelectionController().scrollCharacter(true); michael@0: } michael@0: } else if (this._activeType == this.TYPE_SELECTION) { michael@0: // Send mouse event 1px too high to prevent selection from entering the line below where it should be michael@0: aY -= 1; michael@0: } michael@0: michael@0: this._domWinUtils.sendMouseEventToWindow("mousedown", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 0, true); michael@0: this._domWinUtils.sendMouseEventToWindow("mouseup", aX, aY, 0, 0, useShift ? Ci.nsIDOMNSEvent.SHIFT_MASK : 0, true); michael@0: }, michael@0: michael@0: copySelection: function sh_copySelection() { michael@0: let selectedText = this._getSelectedText(); michael@0: if (selectedText.length) { michael@0: let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper); michael@0: clipboard.copyString(selectedText, this._contentWindow.document); michael@0: NativeWindow.toast.show(Strings.browser.GetStringFromName("selectionHelper.textCopied"), "short"); michael@0: } michael@0: this._closeSelection(); michael@0: }, michael@0: michael@0: shareSelection: function sh_shareSelection() { michael@0: let selectedText = this._getSelectedText(); michael@0: if (selectedText.length) { michael@0: sendMessageToJava({ michael@0: type: "Share:Text", michael@0: text: selectedText michael@0: }); michael@0: } michael@0: this._closeSelection(); michael@0: }, michael@0: michael@0: searchSelection: function sh_searchSelection() { michael@0: let selectedText = this._getSelectedText(); michael@0: if (selectedText.length) { michael@0: let req = Services.search.defaultEngine.getSubmission(selectedText); michael@0: let parent = BrowserApp.selectedTab; michael@0: let isPrivate = PrivateBrowsingUtils.isWindowPrivate(parent.browser.contentWindow); michael@0: // Set current tab as parent of new tab, and set new tab as private if the parent is. michael@0: BrowserApp.addTab(req.uri.spec, {parentId: parent.id, michael@0: selected: true, michael@0: isPrivate: isPrivate}); michael@0: } michael@0: this._closeSelection(); michael@0: }, michael@0: michael@0: _phoneRegex: /^\+?[0-9\s,-.\(\)*#pw]{1,30}$/, michael@0: michael@0: _getSelectedPhoneNumber: function sh_getSelectedPhoneNumber() { michael@0: return this._isPhoneNumber(this._getSelectedText().trim()); michael@0: }, michael@0: michael@0: _isPhoneNumber: function sh_isPhoneNumber(selectedText) { michael@0: return (this._phoneRegex.test(selectedText) ? selectedText : null); michael@0: }, michael@0: michael@0: callSelection: function sh_callSelection() { michael@0: let selectedText = this._getSelectedPhoneNumber(); michael@0: if (selectedText) { michael@0: BrowserApp.loadURI("tel:" + selectedText); michael@0: } michael@0: this._closeSelection(); michael@0: }, michael@0: michael@0: /* michael@0: * Shuts SelectionHandler down. michael@0: */ michael@0: _closeSelection: function sh_closeSelection() { michael@0: // Bail if there's no active selection michael@0: if (this._activeType == this.TYPE_NONE) michael@0: return; michael@0: michael@0: if (this._activeType == this.TYPE_SELECTION) michael@0: this._clearSelection(); michael@0: michael@0: this._deactivate(); michael@0: }, michael@0: michael@0: _clearSelection: function sh_clearSelection() { michael@0: let selection = this._getSelection(); michael@0: if (selection) { michael@0: // Remove our listener before we clear the selection michael@0: selection.QueryInterface(Ci.nsISelectionPrivate).removeSelectionListener(this); michael@0: // Clear selection without clearing the anchorNode or focusNode michael@0: if (selection.rangeCount != 0) { michael@0: selection.collapseToStart(); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _deactivate: function sh_deactivate() { michael@0: this._stopDraggingHandles(); michael@0: // Hide handle/caret, close actionbar michael@0: sendMessageToJava({ type: "TextSelection:HideHandles" }); michael@0: michael@0: this._removeObservers(); michael@0: michael@0: // Only observed for caret positioning michael@0: if (this._activeType == this.TYPE_CURSOR) { michael@0: Services.obs.removeObserver(this, "TextSelection:UpdateCaretPos"); michael@0: BrowserApp.deck.removeEventListener("keyup", this); michael@0: BrowserApp.deck.removeEventListener("compositionupdate", this); michael@0: BrowserApp.deck.removeEventListener("compositionend", this); michael@0: } michael@0: michael@0: this._contentWindow = null; michael@0: this._targetElement = null; michael@0: this._isRTL = false; michael@0: this._cache = null; michael@0: this._ignoreCompositionChanges = false; michael@0: this._prevHandlePositions = []; michael@0: this._prevTargetElementHasText = null; michael@0: michael@0: this._activeType = this.TYPE_NONE; michael@0: }, michael@0: michael@0: _getViewOffset: function sh_getViewOffset() { michael@0: let offset = { x: 0, y: 0 }; michael@0: let win = this._contentWindow; michael@0: michael@0: // Recursively look through frames to compute the total position offset. michael@0: while (win.frameElement) { michael@0: let rect = win.frameElement.getBoundingClientRect(); michael@0: offset.x += rect.left; michael@0: offset.y += rect.top; michael@0: michael@0: win = win.parent; michael@0: } michael@0: michael@0: return offset; michael@0: }, michael@0: michael@0: _pointInSelection: function sh_pointInSelection(aX, aY) { michael@0: let offset = this._getViewOffset(); michael@0: let rangeRect = this._getSelection().getRangeAt(0).getBoundingClientRect(); michael@0: let radius = ElementTouchHelper.getTouchRadius(); michael@0: return (aX - offset.x > rangeRect.left - radius.left && michael@0: aX - offset.x < rangeRect.right + radius.right && michael@0: aY - offset.y > rangeRect.top - radius.top && michael@0: aY - offset.y < rangeRect.bottom + radius.bottom); michael@0: }, michael@0: michael@0: // Returns true if the selection has been reversed. Takes optional aIsStartHandle michael@0: // param to decide whether the selection has been reversed. michael@0: _updateCacheForSelection: function sh_updateCacheForSelection(aIsStartHandle) { michael@0: let rects = this._getSelection().getRangeAt(0).getClientRects(); michael@0: if (!rects[0]) { michael@0: // nsISelection object exists, but there's nothing actually selected michael@0: throw "Failed to update cache for invalid selection"; michael@0: } michael@0: michael@0: let start = { x: this._isRTL ? rects[0].right : rects[0].left, y: rects[0].bottom }; michael@0: let end = { x: this._isRTL ? rects[rects.length - 1].left : rects[rects.length - 1].right, y: rects[rects.length - 1].bottom }; michael@0: michael@0: let selectionReversed = false; michael@0: if (this._cache.start) { michael@0: // 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) michael@0: selectionReversed = (aIsStartHandle && (end.y > this._cache.end.y || (end.y == this._cache.end.y && end.x > this._cache.end.x))) || michael@0: (!aIsStartHandle && (start.y < this._cache.start.y || (start.y == this._cache.start.y && start.x < this._cache.start.x))); michael@0: } michael@0: michael@0: this._cache.start = start; michael@0: this._cache.end = end; michael@0: michael@0: return selectionReversed; michael@0: }, michael@0: michael@0: _getHandlePositions: function sh_getHandlePositions(scroll) { michael@0: // the checkHidden function tests to see if the given point is hidden inside an michael@0: // iframe/subdocument. this is so that if we select some text inside an iframe and michael@0: // scroll the iframe so the selection is out of view, we hide the handles rather michael@0: // than having them float on top of the main page content. michael@0: let checkHidden = function(x, y) { michael@0: return false; michael@0: }; michael@0: if (this._contentWindow.frameElement) { michael@0: let bounds = this._contentWindow.frameElement.getBoundingClientRect(); michael@0: checkHidden = function(x, y) { michael@0: return x < 0 || y < 0 || x > bounds.width || y > bounds.height; michael@0: }; michael@0: } michael@0: michael@0: let positions = null; michael@0: if (this._activeType == this.TYPE_CURSOR) { michael@0: // The left and top properties returned are relative to the client area michael@0: // of the window, so we don't need to account for a sub-frame offset. michael@0: let cursor = this._domWinUtils.sendQueryContentEvent(this._domWinUtils.QUERY_CARET_RECT, this._targetElement.selectionEnd, 0, 0, 0, michael@0: this._domWinUtils.QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK); michael@0: // the return value from sendQueryContentEvent is in LayoutDevice pixels and we want CSS pixels, so michael@0: // divide by the pixel ratio michael@0: let x = cursor.left / window.devicePixelRatio; michael@0: let y = (cursor.top + cursor.height) / window.devicePixelRatio; michael@0: return [{ handle: this.HANDLE_TYPE_MIDDLE, michael@0: left: x + scroll.X, michael@0: top: y + scroll.Y, michael@0: hidden: checkHidden(x, y) }]; michael@0: } else { michael@0: let sx = this._cache.start.x; michael@0: let sy = this._cache.start.y; michael@0: let ex = this._cache.end.x; michael@0: let ey = this._cache.end.y; michael@0: michael@0: // Translate coordinates to account for selections in sub-frames. We can't cache michael@0: // this because the top-level page may have scrolled since selection started. michael@0: let offset = this._getViewOffset(); michael@0: michael@0: return [{ handle: this.HANDLE_TYPE_START, michael@0: left: sx + offset.x + scroll.X, michael@0: top: sy + offset.y + scroll.Y, michael@0: hidden: checkHidden(sx, sy) }, michael@0: { handle: this.HANDLE_TYPE_END, michael@0: left: ex + offset.x + scroll.X, michael@0: top: ey + offset.y + scroll.Y, michael@0: hidden: checkHidden(ex, ey) }]; michael@0: } michael@0: }, michael@0: michael@0: // Position handles, but avoid superfluous re-positioning (helps during michael@0: // "TextSelection:LayerReflow", "scroll" of top-level document, etc). michael@0: _positionHandlesOnChange: function() { michael@0: // Helper function to compare position messages michael@0: let samePositions = function(aPrev, aCurr) { michael@0: if (aPrev.length != aCurr.length) { michael@0: return false; michael@0: } michael@0: for (let i = 0; i < aPrev.length; i++) { michael@0: if (aPrev[i].left != aCurr[i].left || michael@0: aPrev[i].top != aCurr[i].top || michael@0: aPrev[i].hidden != aCurr[i].hidden) { michael@0: return false; michael@0: } michael@0: } michael@0: return true; michael@0: } michael@0: michael@0: let positions = this._getHandlePositions(this._getScrollPos()); michael@0: if (!samePositions(this._prevHandlePositions, positions)) { michael@0: this._positionHandles(positions); michael@0: } michael@0: }, michael@0: michael@0: // Position handles, allow for re-position, in case user drags handle michael@0: // to invalid position, then releases, we can put it back where it started michael@0: // positions is an array of objects with data about handle positions, michael@0: // which we get from _getHandlePositions. michael@0: _positionHandles: function sh_positionHandles(positions) { michael@0: if (!positions) { michael@0: positions = this._getHandlePositions(this._getScrollPos()); michael@0: } michael@0: sendMessageToJava({ michael@0: type: "TextSelection:PositionHandles", michael@0: positions: positions, michael@0: rtl: this._isRTL michael@0: }); michael@0: this._prevHandlePositions = positions; michael@0: michael@0: // Text state transitions (text <--> no text) will affect selection context and actionbar display michael@0: let currTargetElementHasText = (this._targetElement.textLength > 0); michael@0: if (currTargetElementHasText != this._prevTargetElementHasText) { michael@0: this._prevTargetElementHasText = currTargetElementHasText; michael@0: this._updateMenu(); michael@0: } michael@0: }, michael@0: michael@0: subdocumentScrolled: function sh_subdocumentScrolled(aElement) { michael@0: if (this._activeType == this.TYPE_NONE) { michael@0: return; michael@0: } michael@0: let scrollView = aElement.ownerDocument.defaultView; michael@0: let view = this._contentWindow; michael@0: while (true) { michael@0: if (view == scrollView) { michael@0: // The selection is in a view (or sub-view) of the view that scrolled. michael@0: // So we need to reposition the handles. michael@0: if (this._activeType == this.TYPE_SELECTION) { michael@0: this._updateCacheForSelection(); michael@0: } michael@0: this._positionHandles(); michael@0: break; michael@0: } michael@0: if (view == view.parent) { michael@0: break; michael@0: } michael@0: view = view.parent; michael@0: } michael@0: } michael@0: };