442 lines
13 KiB
JavaScript
442 lines
13 KiB
JavaScript
/* 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/. */
|
|
|
|
import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
|
|
});
|
|
|
|
const MAGNIFIER_PREF = "layout.accessiblecaret.magnifier.enabled";
|
|
const ACCESSIBLECARET_HEIGHT_PREF = "layout.accessiblecaret.height";
|
|
const PREFS = [MAGNIFIER_PREF, ACCESSIBLECARET_HEIGHT_PREF];
|
|
|
|
// Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to
|
|
// the GeckoSession on accessible caret changes.
|
|
export class SelectionActionDelegateChild extends GeckoViewActorChild {
|
|
constructor(aModuleName, aMessageManager) {
|
|
super(aModuleName, aMessageManager);
|
|
|
|
this._actionCallback = () => {};
|
|
this._isActive = false;
|
|
this._previousMessage = "";
|
|
|
|
// Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
|
|
// directly, so we create a new function here instead to act as our
|
|
// nsIObserver, which forwards the notification to the observe method.
|
|
this._observerFunction = (subject, topic, data) => {
|
|
this.observe(subject, topic, data);
|
|
};
|
|
for (const pref of PREFS) {
|
|
Services.prefs.addObserver(pref, this._observerFunction);
|
|
}
|
|
|
|
this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF);
|
|
this._accessiblecaretHeight = parseFloat(
|
|
Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0")
|
|
);
|
|
}
|
|
|
|
didDestroy() {
|
|
for (const pref of PREFS) {
|
|
Services.prefs.removeObserver(pref, this._observerFunction);
|
|
}
|
|
}
|
|
|
|
_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 =>
|
|
(this._isContentHtmlEditable(e) &&
|
|
Services.clipboard.hasDataMatchingFlavors(
|
|
/* The following image types are considered by editor */
|
|
["image/gif", "image/jpeg", "image/png"],
|
|
Ci.nsIClipboard.kGlobalClipboard
|
|
)) ||
|
|
(e.selectionEditable &&
|
|
Services.clipboard.hasDataMatchingFlavors(
|
|
["text/plain"],
|
|
Ci.nsIClipboard.kGlobalClipboard
|
|
)),
|
|
perform: _ => this._performPaste(),
|
|
},
|
|
{
|
|
id: "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT",
|
|
predicate: e =>
|
|
this._isContentHtmlEditable(e) &&
|
|
Services.clipboard.hasDataMatchingFlavors(
|
|
["text/html"],
|
|
Ci.nsIClipboard.kGlobalClipboard
|
|
),
|
|
perform: _ => this._performPasteAsPlainText(),
|
|
},
|
|
{
|
|
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: () => this.docShell.doCommand("cmd_moveLeft"),
|
|
},
|
|
{
|
|
id: "org.mozilla.geckoview.COLLAPSE_TO_END",
|
|
predicate: e => !e.collapsed && e.selectionEditable,
|
|
perform: () => this.docShell.doCommand("cmd_moveRight"),
|
|
},
|
|
{
|
|
id: "org.mozilla.geckoview.UNSELECT",
|
|
predicate: e => !e.collapsed && !e.selectionEditable,
|
|
perform: () => this.docShell.doCommand("cmd_selectNone"),
|
|
},
|
|
{
|
|
id: "org.mozilla.geckoview.SELECT_ALL",
|
|
predicate: e => {
|
|
if (e.reason === "longpressonemptycontent") {
|
|
return false;
|
|
}
|
|
// When on design mode, focusedElement will be null.
|
|
const element =
|
|
Services.focus.focusedElement || e.target?.activeElement;
|
|
if (e.selectionEditable && e.target && element) {
|
|
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: () => this.docShell.doCommand("cmd_selectAll"),
|
|
},
|
|
];
|
|
|
|
receiveMessage({ name, data }) {
|
|
debug`receiveMessage ${name}`;
|
|
|
|
switch (name) {
|
|
case "ExecuteSelectionAction": {
|
|
this._actionCallback(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
_performPaste() {
|
|
this.handleEvent({ type: "pagehide" });
|
|
this.docShell.doCommand("cmd_paste");
|
|
}
|
|
|
|
_performPasteAsPlainText() {
|
|
this.handleEvent({ type: "pagehide" });
|
|
this.docShell.doCommand("cmd_pasteNoFormatting");
|
|
}
|
|
|
|
_isPasswordField(aEvent) {
|
|
if (!aEvent.selectionEditable) {
|
|
return false;
|
|
}
|
|
|
|
const win = aEvent.target.defaultView;
|
|
const focus = aEvent.target.activeElement;
|
|
return (
|
|
win &&
|
|
win.HTMLInputElement &&
|
|
win.HTMLInputElement.isInstance(focus) &&
|
|
!focus.mozIsTextField(/* excludePassword */ true)
|
|
);
|
|
}
|
|
|
|
_isContentHtmlEditable(aEvent) {
|
|
if (!aEvent.selectionEditable) {
|
|
return false;
|
|
}
|
|
|
|
if (aEvent.target.designMode == "on") {
|
|
return true;
|
|
}
|
|
|
|
// focused element isn't <input> nor <textarea>
|
|
const win = aEvent.target.defaultView;
|
|
const focus = Services.focus.focusedElement;
|
|
return (
|
|
win &&
|
|
win.HTMLInputElement &&
|
|
win.HTMLTextAreaElement &&
|
|
!win.HTMLInputElement.isInstance(focus) &&
|
|
!win.HTMLTextAreaElement.isInstance(focus)
|
|
);
|
|
}
|
|
|
|
_getDefaultMagnifierPoint(aEvent) {
|
|
const rect = lazy.LayoutUtils.rectToScreenRect(aEvent.target.ownerGlobal, {
|
|
left: aEvent.clientX,
|
|
top: aEvent.clientY - this._accessiblecaretHeight,
|
|
width: 0,
|
|
height: 0,
|
|
});
|
|
return { x: rect.left, y: rect.top };
|
|
}
|
|
|
|
_getBetterMagnifierPoint(aEvent) {
|
|
const win = aEvent.target.defaultView;
|
|
if (!win) {
|
|
return this._getDefaultMagnifierPoint(aEvent);
|
|
}
|
|
|
|
const focus = aEvent.target.activeElement;
|
|
if (
|
|
win.HTMLInputElement?.isInstance(focus) &&
|
|
focus.mozIsTextField(false)
|
|
) {
|
|
// <input> element. Use vertical center position of input element.
|
|
const bounds = focus.getBoundingClientRect();
|
|
const rect = lazy.LayoutUtils.rectToScreenRect(
|
|
aEvent.target.ownerGlobal,
|
|
{
|
|
left: aEvent.clientX,
|
|
top: bounds.top,
|
|
width: 0,
|
|
height: bounds.height,
|
|
}
|
|
);
|
|
return { x: rect.left, y: rect.top + rect.height / 2 };
|
|
}
|
|
|
|
if (win.HTMLTextAreaElement?.isInstance(focus)) {
|
|
// TODO:
|
|
// <textarea> element. How to get better selection bounds?
|
|
return this._getDefaultMagnifierPoint(aEvent);
|
|
}
|
|
|
|
const selection = win.getSelection();
|
|
if (selection.rangeCount != 1) {
|
|
// When selecting text using accessible caret, selection count will be 1.
|
|
// This situation means that current selection isn't into text.
|
|
return this._getDefaultMagnifierPoint(aEvent);
|
|
}
|
|
|
|
// We are looking for better selection bounds, then use it.
|
|
const bounds = (() => {
|
|
const range = selection.getRangeAt(0);
|
|
let distance = Number.MAX_SAFE_INTEGER;
|
|
let y = aEvent.clientY;
|
|
const rectList = range.getClientRects();
|
|
for (const rect of rectList) {
|
|
const newDistance = Math.abs(aEvent.clientY - rect.bottom);
|
|
if (distance > newDistance) {
|
|
y = rect.top + rect.height / 2;
|
|
distance = newDistance;
|
|
}
|
|
}
|
|
return { left: aEvent.clientX, top: y, width: 0, height: 0 };
|
|
})();
|
|
|
|
const rect = lazy.LayoutUtils.rectToScreenRect(
|
|
aEvent.target.ownerGlobal,
|
|
bounds
|
|
);
|
|
return { x: rect.left, y: rect.top };
|
|
}
|
|
|
|
_handleMagnifier(aEvent) {
|
|
if (["presscaret", "dragcaret"].includes(aEvent.reason)) {
|
|
debug`_handleMagnifier: ${aEvent.reason}`;
|
|
const screenPoint = this._getBetterMagnifierPoint(aEvent);
|
|
this.eventDispatcher.sendRequest({
|
|
type: "GeckoView:ShowMagnifier",
|
|
screenPoint,
|
|
});
|
|
} else if (aEvent.reason == "releasecaret") {
|
|
debug`_handleMagnifier: ${aEvent.reason}`;
|
|
this.eventDispatcher.sendRequest({
|
|
type: "GeckoView:HideMagnifier",
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (this._magnifierEnabled) {
|
|
this._handleMagnifier(aEvent);
|
|
}
|
|
|
|
if (
|
|
[
|
|
"longpressonemptycontent",
|
|
"releasecaret",
|
|
"taponcaret",
|
|
"updateposition",
|
|
].includes(reason)
|
|
) {
|
|
const actions = this._actions.filter(action =>
|
|
action.predicate.call(this, aEvent)
|
|
);
|
|
|
|
const screenRect = (() => {
|
|
const boundingRect = aEvent.boundingClientRect;
|
|
if (!boundingRect) {
|
|
return null;
|
|
}
|
|
const rect = lazy.LayoutUtils.rectToScreenRect(
|
|
aEvent.target.ownerGlobal,
|
|
boundingRect
|
|
);
|
|
return {
|
|
left: rect.left,
|
|
top: rect.top,
|
|
right: rect.right,
|
|
bottom: rect.bottom + this._accessiblecaretHeight,
|
|
};
|
|
})();
|
|
|
|
const password = this._isPasswordField(aEvent);
|
|
|
|
const msg = {
|
|
collapsed: aEvent.collapsed,
|
|
editable: aEvent.selectionEditable,
|
|
password,
|
|
selection: password ? "" : aEvent.selectedTextContent,
|
|
screenRect,
|
|
actions: actions.map(action => action.id),
|
|
};
|
|
|
|
if (this._isActive && JSON.stringify(msg) === this._previousMessage) {
|
|
// Don't call again if we're already active and things haven't changed.
|
|
return;
|
|
}
|
|
|
|
this._isActive = true;
|
|
this._previousMessage = JSON.stringify(msg);
|
|
|
|
// We can't just listen to the response of the message because we accept
|
|
// multiple callbacks.
|
|
this._actionCallback = data => {
|
|
const action = actions.find(action => action.id === data.id);
|
|
if (action) {
|
|
debug`Performing ${data.id}`;
|
|
action.perform.call(this, aEvent);
|
|
} else {
|
|
warn`Invalid action ${data.id}`;
|
|
}
|
|
};
|
|
this.sendAsyncMessage("ShowSelectionAction", msg);
|
|
} 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.sendAsyncMessage("HideSelectionAction", { reason });
|
|
} else if (reason == "dragcaret") {
|
|
// nothing for selection action
|
|
} else {
|
|
warn`Unknown reason: ${reason}`;
|
|
}
|
|
}
|
|
|
|
observe(aSubject, aTopic, aData) {
|
|
if (aTopic != "nsPref:changed") {
|
|
return;
|
|
}
|
|
|
|
switch (aData) {
|
|
case ACCESSIBLECARET_HEIGHT_PREF:
|
|
this._accessiblecaretHeight = parseFloat(
|
|
Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0")
|
|
);
|
|
break;
|
|
case MAGNIFIER_PREF:
|
|
this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF);
|
|
break;
|
|
}
|
|
// Reset magnifier
|
|
this.eventDispatcher.sendRequest({
|
|
type: "GeckoView:HideMagnifier",
|
|
});
|
|
}
|
|
}
|
|
|
|
const { debug, warn } = SelectionActionDelegateChild.initLogging(
|
|
"SelectionActionDelegate"
|
|
);
|