diff options
Diffstat (limited to '')
-rw-r--r-- | mobile/android/actors/SelectionActionDelegateChild.jsm | 310 |
1 files changed, 310 insertions, 0 deletions
diff --git a/mobile/android/actors/SelectionActionDelegateChild.jsm b/mobile/android/actors/SelectionActionDelegateChild.jsm new file mode 100644 index 0000000000..82a7b23a3e --- /dev/null +++ b/mobile/android/actors/SelectionActionDelegateChild.jsm @@ -0,0 +1,310 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { GeckoViewActorChild } = ChromeUtils.import( + "resource://gre/modules/GeckoViewActorChild.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const EXPORTED_SYMBOLS = ["SelectionActionDelegateChild"]; + +// Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to +// the GeckoSession on accessible caret changes. +class SelectionActionDelegateChild extends GeckoViewActorChild { + constructor(aModuleName, aMessageManager) { + super(aModuleName, aMessageManager); + + this._seqNo = 0; + this._isActive = false; + this._previousMessage = ""; + } + + _actions = [ + { + id: "org.mozilla.geckoview.HIDE", + predicate: _ => true, + perform: _ => this.handleEvent({ type: "pagehide" }), + }, + { + id: "org.mozilla.geckoview.CUT", + predicate: e => + !e.collapsed && e.selectionEditable && !this._isPasswordField(e), + perform: _ => this.docShell.doCommand("cmd_cut"), + }, + { + id: "org.mozilla.geckoview.COPY", + predicate: e => !e.collapsed && !this._isPasswordField(e), + perform: _ => this.docShell.doCommand("cmd_copy"), + }, + { + id: "org.mozilla.geckoview.PASTE", + predicate: e => + e.selectionEditable && + Services.clipboard.hasDataMatchingFlavors( + ["text/unicode"], + Ci.nsIClipboard.kGlobalClipboard + ), + perform: _ => this._performPaste(), + }, + { + id: "org.mozilla.geckoview.DELETE", + predicate: e => !e.collapsed && e.selectionEditable, + perform: _ => this.docShell.doCommand("cmd_delete"), + }, + { + id: "org.mozilla.geckoview.COLLAPSE_TO_START", + predicate: e => !e.collapsed && e.selectionEditable, + perform: e => this.docShell.doCommand("cmd_moveLeft"), + }, + { + id: "org.mozilla.geckoview.COLLAPSE_TO_END", + predicate: e => !e.collapsed && e.selectionEditable, + perform: e => this.docShell.doCommand("cmd_moveRight"), + }, + { + id: "org.mozilla.geckoview.UNSELECT", + predicate: e => !e.collapsed && !e.selectionEditable, + perform: e => this.docShell.doCommand("cmd_selectNone"), + }, + { + id: "org.mozilla.geckoview.SELECT_ALL", + predicate: e => { + if (e.reason === "longpressonemptycontent") { + return false; + } + if (e.selectionEditable && e.target && e.target.activeElement) { + const element = e.target.activeElement; + let value = ""; + if (element.value) { + value = element.value; + } else if ( + element.isContentEditable || + e.target.designMode === "on" + ) { + value = element.innerText; + } + // Do not show SELECT_ALL if the editable is empty + // or all the editable text is already selected. + return value !== "" && value !== e.selectedTextContent; + } + return true; + }, + perform: e => this.docShell.doCommand("cmd_selectAll"), + }, + ]; + + _performPaste() { + this.handleEvent({ type: "pagehide" }); + this.docShell.doCommand("cmd_paste"); + } + + _isPasswordField(aEvent) { + if (!aEvent.selectionEditable) { + return false; + } + + const win = aEvent.target.defaultView; + const focus = aEvent.target.activeElement; + return ( + win && + win.HTMLInputElement && + focus instanceof win.HTMLInputElement && + !focus.mozIsTextField(/* excludePassword */ true) + ); + } + + _getFrameOffset(aEvent) { + // Get correct offset in case of nested iframe. + const offset = { + left: 0, + top: 0, + }; + + let currentWindow = aEvent.target.defaultView; + while (currentWindow.realFrameElement) { + const frameElement = currentWindow.realFrameElement; + currentWindow = frameElement.ownerGlobal; + + // The offset of the iframe window relative to the parent window + // includes the iframe's border, and the iframe's origin in its + // containing document. + const currentRect = frameElement.getBoundingClientRect(); + const style = currentWindow.getComputedStyle(frameElement); + const borderLeft = parseFloat(style.borderLeftWidth) || 0; + const borderTop = parseFloat(style.borderTopWidth) || 0; + const paddingLeft = parseFloat(style.paddingLeft) || 0; + const paddingTop = parseFloat(style.paddingTop) || 0; + + offset.left += currentRect.left + borderLeft + paddingLeft; + offset.top += currentRect.top + borderTop + paddingTop; + + const targetDocShell = currentWindow.docShell; + if (targetDocShell.isMozBrowser) { + break; + } + } + + // Now we have coordinates relative to the root content document's + // layout viewport. Subtract the offset of the visual viewport + // relative to the layout viewport, to get coordinates relative to + // the visual viewport. + var offsetX = {}; + var offsetY = {}; + currentWindow.windowUtils.getVisualViewportOffsetRelativeToLayoutViewport( + offsetX, + offsetY + ); + offset.left -= offsetX.value; + offset.top -= offsetY.value; + + return offset; + } + + /** + * Receive and act on AccessibleCarets caret state-change + * (mozcaretstatechanged and pagehide) events. + */ + handleEvent(aEvent) { + if (aEvent.type === "pagehide" || aEvent.type === "deactivate") { + // Hide any selection actions on page hide or deactivate. + aEvent = { + reason: "visibilitychange", + caretVisibile: false, + selectionVisible: false, + collapsed: true, + selectionEditable: false, + }; + } + + let reason = aEvent.reason; + + if (this._isActive && !aEvent.caretVisible) { + // For mozcaretstatechanged, "visibilitychange" means the caret is hidden. + reason = "visibilitychange"; + } else if (!aEvent.collapsed && !aEvent.selectionVisible) { + reason = "invisibleselection"; + } else if ( + !this._isActive && + aEvent.selectionEditable && + aEvent.collapsed && + reason !== "longpressonemptycontent" && + reason !== "taponcaret" && + !Services.prefs.getBoolPref( + "geckoview.selection_action.show_on_focus", + false + ) + ) { + // Don't show selection actions when merely focusing on an editor or + // repositioning the cursor. Wait until long press or the caret is tapped + // in order to match Android behavior. + reason = "visibilitychange"; + } + + debug`handleEvent: ${reason}`; + + if ( + [ + "longpressonemptycontent", + "releasecaret", + "taponcaret", + "updateposition", + ].includes(reason) + ) { + const actions = this._actions.filter(action => + action.predicate.call(this, aEvent) + ); + + const offset = this._getFrameOffset(aEvent); + const password = this._isPasswordField(aEvent); + + const msg = { + type: "GeckoView:ShowSelectionAction", + seqNo: this._seqNo, + collapsed: aEvent.collapsed, + editable: aEvent.selectionEditable, + password, + selection: password ? "" : aEvent.selectedTextContent, + clientRect: !aEvent.boundingClientRect + ? null + : { + left: aEvent.boundingClientRect.left + offset.left, + top: aEvent.boundingClientRect.top + offset.top, + right: aEvent.boundingClientRect.right + offset.left, + bottom: aEvent.boundingClientRect.bottom + offset.top, + }, + actions: actions.map(action => action.id), + }; + + try { + if (msg.clientRect) { + msg.clientRect.bottom += parseFloat( + Services.prefs.getCharPref("layout.accessiblecaret.height", "0") + ); + } + } catch (e) {} + + if (this._isActive && JSON.stringify(msg) === this._previousMessage) { + // Don't call again if we're already active and things haven't changed. + return; + } + + msg.seqNo = ++this._seqNo; + this._isActive = true; + this._previousMessage = JSON.stringify(msg); + + this.eventDispatcher.sendRequest(msg, { + onSuccess: response => { + if (response.seqNo !== this._seqNo) { + // Stale action. + warn`Stale response ${response.id}`; + return; + } + const action = actions.find(action => action.id === response.id); + if (action) { + debug`Performing ${response.id}`; + action.perform.call(this, aEvent, response); + } else { + warn`Invalid action ${response.id}`; + } + }, + onError: _ => { + // Do nothing; we can get here if the delegate was just unregistered. + }, + }); + } else if ( + [ + "invisibleselection", + "presscaret", + "scroll", + "visibilitychange", + ].includes(reason) + ) { + if (!this._isActive) { + return; + } + + this._isActive = false; + + // Mark previous actions as stale. Don't do this for "invisibleselection" + // or "scroll" because previous actions should still be valid even after + // these events occur. + if (reason !== "invisibleselection" && reason !== "scroll") { + this._seqNo++; + } + + this.eventDispatcher.sendRequest({ + type: "GeckoView:HideSelectionAction", + reason, + }); + } else { + warn`Unknown reason: ${reason}`; + } + } +} + +const { debug, warn } = SelectionActionDelegateChild.initLogging( + "SelectionActionDelegate" +); |