Source code

Revision control

Copy as Markdown

Other Tools

// ownerGlobal isn't defined in content privileged windows.
/* eslint-disable mozilla/use-ownerGlobal */
// Utilities for synthesizing of native events.
async function getResolution() {
let resolution = -1; // bogus value in case DWU fails us
// Use window.top to get the root content window which is what has
// the resolution.
resolution = await SpecialPowers.spawn(window.top, [], () => {
return SpecialPowers.getDOMWindowUtils(content.window).getResolution();
});
return resolution;
}
function getPlatform() {
if (navigator.platform.indexOf("Win") == 0) {
return "windows";
}
if (navigator.platform.indexOf("Mac") == 0) {
return "mac";
}
// Check for Android before Linux
if (navigator.appVersion.includes("Android")) {
return "android";
}
if (navigator.platform.indexOf("Linux") == 0) {
return "linux";
}
return "unknown";
}
function nativeVerticalWheelEventMsg() {
switch (getPlatform()) {
case "windows":
return 0x020a; // WM_MOUSEWHEEL
case "mac":
var useWheelCodepath = SpecialPowers.getBoolPref(
"apz.test.mac.synth_wheel_input",
false
);
// Default to 1 (kCGScrollPhaseBegan) to trigger PanGestureInput events
// from widget code. Allow setting a pref to override this behaviour and
// trigger ScrollWheelInput events instead.
return useWheelCodepath ? 0 : 1;
case "linux":
return 4; // value is unused, pass GDK_SCROLL_SMOOTH anyway
}
throw new Error(
"Native wheel events not supported on platform " + getPlatform()
);
}
function nativeHorizontalWheelEventMsg() {
switch (getPlatform()) {
case "windows":
return 0x020e; // WM_MOUSEHWHEEL
case "mac":
return 0; // value is unused, can be anything
case "linux":
return 4; // value is unused, pass GDK_SCROLL_SMOOTH anyway
}
throw new Error(
"Native wheel events not supported on platform " + getPlatform()
);
}
function nativeArrowDownKey() {
switch (getPlatform()) {
case "windows":
return WIN_VK_DOWN;
case "mac":
return MAC_VK_DownArrow;
}
throw new Error(
"Native key events not supported on platform " + getPlatform()
);
}
function nativeArrowUpKey() {
switch (getPlatform()) {
case "windows":
return WIN_VK_UP;
case "mac":
return MAC_VK_UpArrow;
}
throw new Error(
"Native key events not supported on platform " + getPlatform()
);
}
function targetIsWindow(aTarget) {
return aTarget.Window && aTarget instanceof aTarget.Window;
}
function targetIsTopWindow(aTarget) {
if (!targetIsWindow(aTarget)) {
return false;
}
return aTarget == aTarget.top;
}
// Given an event target which may be a window or an element, get the associated window.
function windowForTarget(aTarget) {
if (targetIsWindow(aTarget)) {
return aTarget;
}
return aTarget.ownerDocument.defaultView;
}
// Given an event target which may be a window or an element, get the associated element.
function elementForTarget(aTarget) {
if (targetIsWindow(aTarget)) {
return aTarget.document.documentElement;
}
return aTarget;
}
// Given an event target which may be a window or an element, get the associatd nsIDOMWindowUtils.
function utilsForTarget(aTarget) {
return SpecialPowers.getDOMWindowUtils(windowForTarget(aTarget));
}
// Given a pixel scrolling delta, converts it to the platform's native units.
function nativeScrollUnits(aTarget, aDimen) {
switch (getPlatform()) {
case "linux": {
// GTK deltas are treated as line height divided by 3 by gecko.
var targetWindow = windowForTarget(aTarget);
var targetElement = elementForTarget(aTarget);
var lineHeight =
targetWindow.getComputedStyle(targetElement)["font-size"];
return aDimen / (parseInt(lineHeight) * 3);
}
}
return aDimen;
}
function parseNativeModifiers(aModifiers, aWindow = window) {
let modifiers = 0;
if (aModifiers.capsLockKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CAPS_LOCK;
}
if (aModifiers.numLockKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_NUM_LOCK;
}
if (aModifiers.shiftKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_SHIFT_LEFT;
}
if (aModifiers.shiftRightKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_SHIFT_RIGHT;
}
if (aModifiers.ctrlKey) {
modifiers |=
SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_LEFT;
}
if (aModifiers.ctrlRightKey) {
modifiers |=
SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_RIGHT;
}
if (aModifiers.altKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_LEFT;
}
if (aModifiers.altRightKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_RIGHT;
}
if (aModifiers.metaKey) {
modifiers |=
SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_LEFT;
}
if (aModifiers.metaRightKey) {
modifiers |=
SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_RIGHT;
}
if (aModifiers.helpKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_HELP;
}
if (aModifiers.fnKey) {
modifiers |= SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_FUNCTION;
}
if (aModifiers.numericKeyPadKey) {
modifiers |=
SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_NUMERIC_KEY_PAD;
}
if (aModifiers.accelKey) {
modifiers |= _EU_isMac(aWindow)
? SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_LEFT
: SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_LEFT;
}
if (aModifiers.accelRightKey) {
modifiers |= _EU_isMac(aWindow)
? SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_COMMAND_RIGHT
: SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_CONTROL_RIGHT;
}
if (aModifiers.altGrKey) {
modifiers |= _EU_isMac(aWindow)
? SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_LEFT
: SpecialPowers.Ci.nsIDOMWindowUtils.NATIVE_MODIFIER_ALT_GRAPH;
}
return modifiers;
}
// Several event sythesization functions below (and their helpers) take a "target"
// parameter which may be either an element or a window. For such functions,
// the target's "bounding rect" refers to the bounding client rect for an element,
// and the window's origin for a window.
// Not all functions have been "upgraded" to allow a window argument yet; feel
// free to upgrade others as necessary.
// Get the origin of |aTarget| relative to the root content document's
// visual viewport in CSS coordinates.
// |aTarget| may be an element (contained in the root content document or
// a subdocument) or, as a special case, the root content window.
// FIXME: Support iframe windows as targets.
function _getTargetRect(aTarget) {
let rect = { left: 0, top: 0, width: 0, height: 0 };
// If the target is the root content window, its origin relative
// to the visual viewport is (0, 0).
if (aTarget instanceof Window) {
return rect;
}
if (aTarget.Window && aTarget instanceof aTarget.Window) {
// iframe window
// FIXME: Compute proper rect against the root content window
return rect;
}
// Otherwise, we have an element. Start with the origin of
// its bounding client rect which is relative to the enclosing
// document's layout viewport. Note that for iframes, the
// layout viewport is also the visual viewport.
const boundingClientRect = aTarget.getBoundingClientRect();
rect.left = boundingClientRect.left;
rect.top = boundingClientRect.top;
rect.width = boundingClientRect.width;
rect.height = boundingClientRect.height;
// Iterate up the window hierarchy until we reach the root
// content window, adding the offsets of any iframe windows
// relative to their parent window.
while (aTarget.ownerDocument.defaultView.frameElement) {
const iframe = aTarget.ownerDocument.defaultView.frameElement;
// 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 style = iframe.ownerDocument.defaultView.getComputedStyle(iframe);
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
const borderTop = parseFloat(style.borderTopWidth) || 0;
const borderRight = parseFloat(style.borderRightWidth) || 0;
const borderBottom = parseFloat(style.borderBottomWidth) || 0;
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const paddingTop = parseFloat(style.paddingTop) || 0;
const paddingRight = parseFloat(style.paddingRight) || 0;
const paddingBottom = parseFloat(style.paddingBottom) || 0;
const iframeRect = iframe.getBoundingClientRect();
rect.left += iframeRect.left + borderLeft + paddingLeft;
rect.top += iframeRect.top + borderTop + paddingTop;
if (
rect.left + rect.width >
iframeRect.right - borderRight - paddingRight
) {
rect.width = Math.max(
iframeRect.right - borderRight - paddingRight - rect.left,
0
);
}
if (
rect.top + rect.height >
iframeRect.bottom - borderBottom - paddingBottom
) {
rect.height = Math.max(
iframeRect.bottom - borderBottom - paddingBottom - rect.top,
0
);
}
aTarget = iframe;
}
return rect;
}
// Returns the in-process root window for the given |aWindow|.
function getInProcessRootWindow(aWindow) {
let window = aWindow;
while (window.frameElement) {
window = window.frameElement.ownerDocument.defaultView;
}
return window;
}
// Convert (offsetX, offsetY) of target or center of it, in CSS pixels to device
// pixels relative to the screen.
// TODO: this function currently does not incorporate some CSS transforms on
// elements enclosing target, e.g. scale transforms.
async function coordinatesRelativeToScreen(aParams) {
const {
target, // The target element or window
offsetX, // X offset relative to `target`
offsetY, // Y offset relative to `target`
atCenter, // Instead of offsetX/offsetY, return center of `target`
} = aParams;
// Note that |window| might not be the root content window, for two
// possible reasons:
// 1. The mochitest that's calling into this function is not using a mechanism
// like runSubtestsSeriallyInFreshWindows() to load the test page in
// a top-level context, so it's loaded into an iframe by the mochitest
// harness.
// 2. The mochitest itself creates an iframe and calls this function from
// script running in the context of the iframe.
// Since the resolution applies to the top level content document, below we
// use the mozInnerScreen{X,Y} of the top level content window (window.top)
// only for the case where this function gets called in the top level content
// document. In other cases we use nsIDOMWindowUtils.toScreenRect().
// We do often specify `window` as the target, if it's the top level window,
// `nsIDOMWindowUtils.toScreenRect` isn't suitable because the function is
// supposed to be called with values in the document coords, so for example
// if desktop zoom is being applied, (0, 0) in the document coords might be
// outside of the visual viewport, i.e. it's going to be negative with the
// `toScreenRect` conversion, whereas the call sites with `window` of this
// function expect (0, 0) position should be the visual viport's offset. So
// in such cases we simply use mozInnerScreen{X,Y} to convert the given value
// to the screen coords.
if (target instanceof Window && window.parent == window) {
const resolution = await getResolution();
const deviceScale = window.devicePixelRatio;
return {
x:
window.mozInnerScreenX * deviceScale +
(atCenter ? 0 : offsetX) * resolution * deviceScale,
y:
window.mozInnerScreenY * deviceScale +
(atCenter ? 0 : offsetY) * resolution * deviceScale,
};
}
const rect = _getTargetRect(target);
const utils = SpecialPowers.getDOMWindowUtils(getInProcessRootWindow(window));
const positionInScreenCoords = utils.toScreenRect(
rect.left + (atCenter ? rect.width / 2 : offsetX),
rect.top + (atCenter ? rect.height / 2 : offsetY),
0,
0
);
return {
x: positionInScreenCoords.x,
y: positionInScreenCoords.y,
};
}
// Get the bounding box of aElement, and return it in device pixels
// relative to the screen.
// TODO: This function should probably take into account the resolution and
// the relative viewport rect like coordinatesRelativeToScreen() does.
function rectRelativeToScreen(aElement) {
var targetWindow = aElement.ownerDocument.defaultView;
var scale = targetWindow.devicePixelRatio;
var rect = aElement.getBoundingClientRect();
return {
x: (targetWindow.mozInnerScreenX + rect.left) * scale,
y: (targetWindow.mozInnerScreenY + rect.top) * scale,
width: rect.width * scale,
height: rect.height * scale,
};
}
// Synthesizes a native mousewheel event and returns immediately. This does not
// guarantee anything; you probably want to use one of the other functions below
// which actually wait for results.
// aX and aY are relative to the top-left of |aTarget|'s bounding rect.
// aDeltaX and aDeltaY are pixel deltas, and aObserver can be left undefined
// if not needed.
async function synthesizeNativeWheel(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY,
aObserver
) {
var pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aTarget,
});
if (aDeltaX && aDeltaY) {
throw new Error(
"Simultaneous wheeling of horizontal and vertical is not supported on all platforms."
);
}
aDeltaX = nativeScrollUnits(aTarget, aDeltaX);
aDeltaY = nativeScrollUnits(aTarget, aDeltaY);
var msg = aDeltaX
? nativeHorizontalWheelEventMsg()
: nativeVerticalWheelEventMsg();
var utils = utilsForTarget(aTarget);
var element = elementForTarget(aTarget);
utils.sendNativeMouseScrollEvent(
pt.x,
pt.y,
msg,
aDeltaX,
aDeltaY,
0,
0,
// Specify MOUSESCROLL_SCROLL_LINES if the test wants to run through wheel
// input code path on Mac since it's normal mouse wheel inputs.
SpecialPowers.getBoolPref("apz.test.mac.synth_wheel_input", false)
? SpecialPowers.DOMWindowUtils.MOUSESCROLL_SCROLL_LINES
: 0,
element,
aObserver
);
return true;
}
// Synthesizes a native pan gesture event and returns immediately.
// NOTE: This works only on Mac.
// You can specify kCGScrollPhaseBegan = 1, kCGScrollPhaseChanged = 2 and
// kCGScrollPhaseEnded = 4 for |aPhase|.
async function synthesizeNativePanGestureEvent(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY,
aPhase,
aObserver
) {
if (getPlatform() != "mac") {
throw new Error(
`synthesizeNativePanGestureEvent doesn't work on ${getPlatform()}`
);
}
var pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aTarget,
});
if (aDeltaX && aDeltaY) {
throw new Error(
"Simultaneous panning of horizontal and vertical is not supported."
);
}
aDeltaX = nativeScrollUnits(aTarget, aDeltaX);
aDeltaY = nativeScrollUnits(aTarget, aDeltaY);
var element = elementForTarget(aTarget);
var utils = utilsForTarget(aTarget);
utils.sendNativeMouseScrollEvent(
pt.x,
pt.y,
aPhase,
aDeltaX,
aDeltaY,
0 /* deltaZ */,
0 /* modifiers */,
0 /* scroll event unit pixel */,
element,
aObserver
);
return true;
}
// Sends a native touchpad pan event and resolve the returned promise once the
// request has been successfully made to the OS.
// NOTE: This works only on Windows and Linux.
// You can specify nsIDOMWindowUtils.PHASE_BEGIN, PHASE_UPDATE and PHASE_END
// for |aPhase|.
async function promiseNativeTouchpadPanEventAndWaitForObserver(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY,
aPhase
) {
if (getPlatform() != "windows" && getPlatform() != "linux") {
throw new Error(
`promiseNativeTouchpadPanEventAndWaitForObserver doesn't work on ${getPlatform()}`
);
}
let pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aTarget,
});
const utils = utilsForTarget(aTarget);
return new Promise(resolve => {
var observer = {
observe(aSubject, aTopic) {
if (aTopic == "touchpadpanevent") {
resolve();
}
},
};
utils.sendNativeTouchpadPan(
aPhase,
pt.x,
pt.y,
aDeltaX,
aDeltaY,
0,
observer
);
});
}
async function synthesizeSimpleGestureEvent(
aElement,
aType,
aX,
aY,
aDirection,
aDelta,
aModifiers,
aClickCount
) {
let pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aElement,
});
let utils = utilsForTarget(aElement);
utils.sendSimpleGestureEvent(
aType,
pt.x,
pt.y,
aDirection,
aDelta,
aModifiers,
aClickCount
);
}
// Synthesizes a native pan gesture event and resolve the returned promise once the
// request has been successfully made to the OS.
function promiseNativePanGestureEventAndWaitForObserver(
aElement,
aX,
aY,
aDeltaX,
aDeltaY,
aPhase
) {
return new Promise(resolve => {
var observer = {
observe(aSubject, aTopic) {
if (aTopic == "mousescrollevent") {
resolve();
}
},
};
synthesizeNativePanGestureEvent(
aElement,
aX,
aY,
aDeltaX,
aDeltaY,
aPhase,
observer
);
});
}
// Synthesizes a native mousewheel event and resolve the returned promise once the
// request has been successfully made to the OS. This does not necessarily
// guarantee that the OS generates the event we requested. See
// synthesizeNativeWheel for details on the parameters.
function promiseNativeWheelAndWaitForObserver(
aElement,
aX,
aY,
aDeltaX,
aDeltaY
) {
return new Promise(resolve => {
var observer = {
observe(aSubject, aTopic) {
if (aTopic == "mousescrollevent") {
resolve();
}
},
};
synthesizeNativeWheel(aElement, aX, aY, aDeltaX, aDeltaY, observer);
});
}
// Synthesizes a native mousewheel event and resolve the returned promise once the
// wheel event is dispatched to |aTarget|'s containing window. If the event
// targets content in a subdocument, |aTarget| should be inside the
// subdocument (or the subdocument's window). See synthesizeNativeWheel for
// details on the other parameters.
function promiseNativeWheelAndWaitForWheelEvent(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY
) {
return new Promise((resolve, reject) => {
var targetWindow = windowForTarget(aTarget);
targetWindow.addEventListener(
"wheel",
function () {
setTimeout(resolve, 0);
},
{ once: true }
);
try {
synthesizeNativeWheel(aTarget, aX, aY, aDeltaX, aDeltaY);
} catch (e) {
reject(e);
}
});
}
// Synthesizes a native mousewheel event and resolves the returned promise once the
// first resulting scroll event is dispatched to |aTarget|'s containing window.
// If the event targets content in a subdocument, |aTarget| should be inside
// the subdocument (or the subdocument's window). See synthesizeNativeWheel
// for details on the other parameters.
function promiseNativeWheelAndWaitForScrollEvent(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY
) {
return new Promise((resolve, reject) => {
var targetWindow = windowForTarget(aTarget);
targetWindow.addEventListener(
"scroll",
function () {
setTimeout(resolve, 0);
},
{ capture: true, once: true }
); // scroll events don't always bubble
try {
synthesizeNativeWheel(aTarget, aX, aY, aDeltaX, aDeltaY);
} catch (e) {
reject(e);
}
});
}
async function synthesizeTouchpadPinch(scales, focusX, focusY, options) {
var scalesAndFoci = [];
for (let i = 0; i < scales.length; i++) {
scalesAndFoci.push([scales[i], focusX, focusY]);
}
await synthesizeTouchpadGesture(scalesAndFoci, options);
}
// scalesAndFoci is an array of [scale, focusX, focuxY] tuples.
async function synthesizeTouchpadGesture(scalesAndFoci, options) {
// Check for options, fill in defaults if appropriate.
let waitForTransformEnd =
options.waitForTransformEnd !== undefined
? options.waitForTransformEnd
: true;
let waitForFrames =
options.waitForFrames !== undefined ? options.waitForFrames : false;
// Register the listener for the TransformEnd observer topic
let transformEndPromise = promiseTransformEnd();
var modifierFlags = 0;
var utils = utilsForTarget(document.body);
for (let i = 0; i < scalesAndFoci.length; i++) {
var pt = await coordinatesRelativeToScreen({
offsetX: scalesAndFoci[i][1],
offsetY: scalesAndFoci[i][2],
target: document.body,
});
var phase;
if (i === 0) {
phase = SpecialPowers.DOMWindowUtils.PHASE_BEGIN;
} else if (i === scalesAndFoci.length - 1) {
phase = SpecialPowers.DOMWindowUtils.PHASE_END;
} else {
phase = SpecialPowers.DOMWindowUtils.PHASE_UPDATE;
}
utils.sendNativeTouchpadPinch(
phase,
scalesAndFoci[i][0],
pt.x,
pt.y,
modifierFlags
);
if (waitForFrames) {
await promiseFrame();
}
}
// Wait for TransformEnd to fire.
if (waitForTransformEnd) {
await transformEndPromise;
}
}
async function synthesizeTouchpadPan(
focusX,
focusY,
deltaXs,
deltaYs,
options
) {
// Check for options, fill in defaults if appropriate.
let waitForTransformEnd =
options.waitForTransformEnd !== undefined
? options.waitForTransformEnd
: true;
let waitForFrames =
options.waitForFrames !== undefined ? options.waitForFrames : false;
// Register the listener for the TransformEnd observer topic
let transformEndPromise = promiseTransformEnd();
var modifierFlags = 0;
var pt = await coordinatesRelativeToScreen({
offsetX: focusX,
offsetY: focusY,
target: document.body,
});
var utils = utilsForTarget(document.body);
for (let i = 0; i < deltaXs.length; i++) {
var phase;
if (i === 0) {
phase = SpecialPowers.DOMWindowUtils.PHASE_BEGIN;
} else if (i === deltaXs.length - 1) {
phase = SpecialPowers.DOMWindowUtils.PHASE_END;
} else {
phase = SpecialPowers.DOMWindowUtils.PHASE_UPDATE;
}
utils.sendNativeTouchpadPan(
phase,
pt.x,
pt.y,
deltaXs[i],
deltaYs[i],
modifierFlags
);
if (waitForFrames) {
await promiseFrame();
}
}
// Wait for TransformEnd to fire.
if (waitForTransformEnd) {
await transformEndPromise;
}
}
// Synthesizes a native touch event and dispatches it. aX and aY in CSS pixels
// relative to the top-left of |aTarget|'s bounding rect.
async function synthesizeNativeTouch(
aTarget,
aX,
aY,
aType,
aObserver = null,
aTouchId = 0
) {
var pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aTarget,
});
var utils = utilsForTarget(aTarget);
utils.sendNativeTouchPoint(aTouchId, aType, pt.x, pt.y, 1, 90, aObserver);
return true;
}
function sendBasicNativePointerInput(
utils,
aId,
aPointerType,
aState,
aX,
aY,
aObserver,
{ pressure = 1, twist = 0, tiltX = 0, tiltY = 0, button = 0 } = {}
) {
switch (aPointerType) {
case "touch":
utils.sendNativeTouchPoint(aId, aState, aX, aY, pressure, 90, aObserver);
break;
case "pen":
utils.sendNativePenInput(
aId,
aState,
aX,
aY,
pressure,
twist,
tiltX,
tiltY,
button,
aObserver
);
break;
default:
throw new Error(`Not supported: ${aPointerType}`);
}
}
async function promiseNativePointerInput(
aTarget,
aPointerType,
aState,
aX,
aY,
options
) {
const pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aTarget,
});
const utils = utilsForTarget(aTarget);
return new Promise(resolve => {
sendBasicNativePointerInput(
utils,
options?.pointerId ?? 0,
aPointerType,
aState,
pt.x,
pt.y,
resolve,
options
);
});
}
/**
* Function to generate native pointer events as a sequence.
* @param aTarget is the element or window whose bounding rect the coordinates are
* relative to.
* @param aPointerType "touch" or "pen".
* @param aPositions is a 2D array of position data. It is indexed as [row][column],
* where advancing the row counter moves forward in time, and each column
* represents a single pointer. Each row must have exactly
* the same number of columns, and the number of columns must match the length
* of the aPointerIds parameter.
* For each row, each entry is either an object with x and y fields,
* or a null. A null value indicates that the pointer should be "lifted"
* (i.e. send a touchend for that touch input). A non-null value therefore
* indicates the position of the pointer input.
* This function takes care of the state tracking necessary to send
* pointerup/pointerdown inputs as necessary as the pointers go up and down.
* @param aObserver is the observer that will get registered on the very last
* native pointer synthesis call this function makes.
* @param aPointerIds is an array holding the pointer ID values.
*/
async function synthesizeNativePointerSequences(
aTarget,
aPointerType,
aPositions,
aObserver = null,
aPointerIds = [0],
options
) {
// We use lastNonNullValue to figure out which synthesizeNativeTouch call
// will be the last one we make, so that we can register aObserver on it.
var lastNonNullValue = -1;
for (let i = 0; i < aPositions.length; i++) {
if (aPositions[i] == null) {
throw new Error(`aPositions[${i}] was unexpectedly null`);
}
if (aPositions[i].length != aPointerIds.length) {
throw new Error(
`aPositions[${i}] did not have the expected number of positions; ` +
`expected ${aPointerIds.length} pointers but found ${aPositions[i].length}`
);
}
for (let j = 0; j < aPointerIds.length; j++) {
if (aPositions[i][j] != null) {
lastNonNullValue = i * aPointerIds.length + j;
// Do the conversion to screen space before actually synthesizing
// the events, otherwise the screen space may change as a result of
// the touch inputs and the conversion may not work as intended.
aPositions[i][j] = await coordinatesRelativeToScreen({
offsetX: aPositions[i][j].x,
offsetY: aPositions[i][j].y,
target: aTarget,
});
}
}
}
if (lastNonNullValue < 0) {
throw new Error("All values in positions array were null!");
}
// Insert a row of nulls at the end of aPositions, to ensure that all
// touches get removed. If the touches have already been removed this will
// just add an extra no-op iteration in the aPositions loop below.
var allNullRow = new Array(aPointerIds.length);
allNullRow.fill(null);
aPositions.push(allNullRow);
// The last sendNativeTouchPoint call will be the TOUCH_REMOVE which happens
// one iteration of aPosition after the last non-null value.
var lastSynthesizeCall = lastNonNullValue + aPointerIds.length;
// track which touches are down and which are up. start with all up
var currentPositions = new Array(aPointerIds.length);
currentPositions.fill(null);
var utils = utilsForTarget(aTarget);
// Iterate over the position data now, and generate the touches requested
for (let i = 0; i < aPositions.length; i++) {
for (let j = 0; j < aPointerIds.length; j++) {
if (aPositions[i][j] == null) {
// null means lift the finger
if (currentPositions[j] == null) {
// it's already lifted, do nothing
} else {
// synthesize the touch-up. If this is the last call we're going to
// make, pass the observer as well
var thisIndex = i * aPointerIds.length + j;
var observer = lastSynthesizeCall == thisIndex ? aObserver : null;
sendBasicNativePointerInput(
utils,
aPointerIds[j],
aPointerType,
SpecialPowers.DOMWindowUtils.TOUCH_REMOVE,
currentPositions[j].x,
currentPositions[j].y,
observer,
options
);
currentPositions[j] = null;
}
} else {
sendBasicNativePointerInput(
utils,
aPointerIds[j],
aPointerType,
SpecialPowers.DOMWindowUtils.TOUCH_CONTACT,
aPositions[i][j].x,
aPositions[i][j].y,
null,
options
);
currentPositions[j] = aPositions[i][j];
}
}
}
return true;
}
async function synthesizeNativeTouchSequences(
aTarget,
aPositions,
aObserver = null,
aTouchIds = [0]
) {
await synthesizeNativePointerSequences(
aTarget,
"touch",
aPositions,
aObserver,
aTouchIds
);
}
async function synthesizeNativePointerDrag(
aTarget,
aPointerType,
aX,
aY,
aDeltaX,
aDeltaY,
aObserver = null,
aPointerId = 0,
options
) {
var steps = Math.max(Math.abs(aDeltaX), Math.abs(aDeltaY));
var positions = [[{ x: aX, y: aY }]];
for (var i = 1; i < steps; i++) {
var dx = i * (aDeltaX / steps);
var dy = i * (aDeltaY / steps);
var pos = { x: aX + dx, y: aY + dy };
positions.push([pos]);
}
positions.push([{ x: aX + aDeltaX, y: aY + aDeltaY }]);
return synthesizeNativePointerSequences(
aTarget,
aPointerType,
positions,
aObserver,
[aPointerId],
options
);
}
// Note that when calling this function you'll want to make sure that the pref
// "apz.touch_start_tolerance" is set to 0, or some of the touchmove will get
// consumed to overcome the panning threshold.
async function synthesizeNativeTouchDrag(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY,
aObserver = null,
aTouchId = 0
) {
return synthesizeNativePointerDrag(
aTarget,
"touch",
aX,
aY,
aDeltaX,
aDeltaY,
aObserver,
aTouchId
);
}
function promiseNativePointerDrag(
aTarget,
aPointerType,
aX,
aY,
aDeltaX,
aDeltaY,
aPointerId = 0,
options
) {
return new Promise(resolve => {
synthesizeNativePointerDrag(
aTarget,
aPointerType,
aX,
aY,
aDeltaX,
aDeltaY,
resolve,
aPointerId,
options
);
});
}
// Promise-returning variant of synthesizeNativeTouchDrag
function promiseNativeTouchDrag(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY,
aTouchId = 0
) {
return new Promise(resolve => {
synthesizeNativeTouchDrag(
aTarget,
aX,
aY,
aDeltaX,
aDeltaY,
resolve,
aTouchId
);
});
}
// Tapping is essentially a dragging with no move
function promiseNativePointerTap(aTarget, aPointerType, aX, aY, options) {
return promiseNativePointerDrag(
aTarget,
aPointerType,
aX,
aY,
0,
0,
options?.pointerId ?? 0,
options
);
}
async function synthesizeNativeTap(aTarget, aX, aY, aObserver = null) {
var pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aTarget,
});
let utils = utilsForTarget(aTarget);
utils.sendNativeTouchTap(pt.x, pt.y, false, aObserver);
return true;
}
// only currently implemented on macOS
async function synthesizeNativeTouchpadDoubleTap(aTarget, aX, aY) {
ok(
getPlatform() == "mac",
"only implemented on mac. implement sendNativeTouchpadDoubleTap for this platform," +
" see bug 1696802 for how it was done on macOS"
);
let pt = await coordinatesRelativeToScreen({
offsetX: aX,
offsetY: aY,
target: aTarget,
});
let utils = utilsForTarget(aTarget);
utils.sendNativeTouchpadDoubleTap(pt.x, pt.y, 0);
return true;
}
// If the event targets content in a subdocument, |aTarget| should be inside the
// subdocument (or the subdocument window).
async function synthesizeNativeMouseEventWithAPZ(aParams, aObserver = null) {
if (aParams.win !== undefined) {
throw Error(
"Are you trying to use EventUtils' API? `win` won't be used with synthesizeNativeMouseClickWithAPZ."
);
}
if (aParams.scale !== undefined) {
throw Error(
"Are you trying to use EventUtils' API? `scale` won't be used with synthesizeNativeMouseClickWithAPZ."
);
}
if (aParams.elementOnWidget !== undefined) {
throw Error(
"Are you trying to use EventUtils' API? `elementOnWidget` won't be used with synthesizeNativeMouseClickWithAPZ."
);
}
const {
type, // "click", "mousedown", "mouseup" or "mousemove"
target, // Origin of offsetX and offsetY, must be an element
offsetX, // X offset in `target` in CSS Pixels
offsetY, // Y offset in `target` in CSS pixels
atCenter, // Instead of offsetX/Y, synthesize the event at center of `target`
screenX, // X offset in screen in device pixels, offsetX/Y nor atCenter must not be set if this is set
screenY, // Y offset in screen in device pixels, offsetX/Y nor atCenter must not be set if this is set
button = 0, // if "click", "mousedown", "mouseup", set same value as DOM MouseEvent.button
modifiers = {}, // Active modifiers, see `parseNativeModifiers`
} = aParams;
if (atCenter) {
if (offsetX != undefined || offsetY != undefined) {
throw Error(
`atCenter is specified, but offsetX (${offsetX}) and/or offsetY (${offsetY}) are also specified`
);
}
if (screenX != undefined || screenY != undefined) {
throw Error(
`atCenter is specified, but screenX (${screenX}) and/or screenY (${screenY}) are also specified`
);
}
} else if (offsetX != undefined && offsetY != undefined) {
if (screenX != undefined || screenY != undefined) {
throw Error(
`offsetX/Y are specified, but screenX (${screenX}) and/or screenY (${screenY}) are also specified`
);
}
} else if (screenX != undefined && screenY != undefined) {
if (offsetX != undefined || offsetY != undefined) {
throw Error(
`screenX/Y are specified, but offsetX (${offsetX}) and/or offsetY (${offsetY}) are also specified`
);
}
}
const pt = await (async () => {
if (screenX != undefined) {
return { x: screenX, y: screenY };
}
return coordinatesRelativeToScreen({
offsetX,
offsetY,
atCenter,
target,
});
})();
const utils = utilsForTarget(target);
const element = elementForTarget(target);
const modifierFlags = parseNativeModifiers(modifiers);
if (type === "click") {
utils.sendNativeMouseEvent(
pt.x,
pt.y,
utils.NATIVE_MOUSE_MESSAGE_BUTTON_DOWN,
button,
modifierFlags,
element,
function () {
utils.sendNativeMouseEvent(
pt.x,
pt.y,
utils.NATIVE_MOUSE_MESSAGE_BUTTON_UP,
button,
modifierFlags,
element,
aObserver
);
}
);
return;
}
utils.sendNativeMouseEvent(
pt.x,
pt.y,
(() => {
switch (type) {
case "mousedown":
return utils.NATIVE_MOUSE_MESSAGE_BUTTON_DOWN;
case "mouseup":
return utils.NATIVE_MOUSE_MESSAGE_BUTTON_UP;
case "mousemove":
return utils.NATIVE_MOUSE_MESSAGE_MOVE;
default:
throw Error(`Invalid type is specified: ${type}`);
}
})(),
button,
modifierFlags,
element,
aObserver
);
}
function promiseNativeMouseEventWithAPZ(aParams) {
return new Promise(resolve =>
synthesizeNativeMouseEventWithAPZ(aParams, resolve)
);
}
// See synthesizeNativeMouseEventWithAPZ for the detail of aParams.
function promiseNativeMouseEventWithAPZAndWaitForEvent(aParams) {
return new Promise(resolve => {
const targetWindow = windowForTarget(aParams.target);
const eventType = aParams.eventTypeToWait || aParams.type;
targetWindow.addEventListener(eventType, resolve, {
once: true,
});
synthesizeNativeMouseEventWithAPZ(aParams);
});
}
// Move the mouse to (dx, dy) relative to |target|, and scroll the wheel
// at that location.
// Moving the mouse is necessary to avoid wheel events from two consecutive
// promiseMoveMouseAndScrollWheelOver() calls on different elements being incorrectly
// considered as part of the same wheel transaction.
// We also wait for the mouse move event to be processed before sending the
// wheel event, otherwise there is a chance they might get reordered, and
// we have the transaction problem again.
// This function returns a promise that is resolved when the resulting wheel
// (if waitForScroll = false) or scroll (if waitForScroll = true) event is
// received.
function promiseMoveMouseAndScrollWheelOver(
target,
dx,
dy,
waitForScroll = true,
scrollDelta = 10
) {
let p = promiseNativeMouseEventWithAPZAndWaitForEvent({
type: "mousemove",
target,
offsetX: dx,
offsetY: dy,
});
if (waitForScroll) {
p = p.then(() => {
return promiseNativeWheelAndWaitForScrollEvent(
target,
dx,
dy,
0,
-scrollDelta
);
});
} else {
p = p.then(() => {
return promiseNativeWheelAndWaitForWheelEvent(
target,
dx,
dy,
0,
-scrollDelta
);
});
}
return p;
}
async function scrollbarDragStart(aTarget, aScaleFactor) {
var targetElement = elementForTarget(aTarget);
var w = {},
h = {};
utilsForTarget(aTarget).getScrollbarSizes(targetElement, w, h);
var verticalScrollbarWidth = w.value;
if (verticalScrollbarWidth == 0) {
return null;
}
var upArrowHeight = verticalScrollbarWidth; // assume square scrollbar buttons
var startX = targetElement.clientWidth + verticalScrollbarWidth / 2;
var startY = upArrowHeight + 5; // start dragging somewhere in the thumb
startX *= aScaleFactor;
startY *= aScaleFactor;
// targetElement.clientWidth is unaffected by the zoom, but if the target
// is the root content window, the distance from the window origin to the
// scrollbar in CSS pixels does decrease proportionally to the zoom,
// so the CSS coordinates we return need to be scaled accordingly.
if (targetIsTopWindow(aTarget)) {
var resolution = await getResolution();
startX /= resolution;
startY /= resolution;
}
return { x: startX, y: startY };
}
// Synthesizes events to drag |target|'s vertical scrollbar by the distance
// specified, synthesizing a mousemove for each increment as specified.
// Returns null if the element doesn't have a vertical scrollbar. Otherwise,
// returns an async function that should be invoked after the mousemoves have been
// processed by the widget code, to end the scrollbar drag. Mousemoves being
// processed by the widget code can be detected by listening for the mousemove
// events in the caller, or for some other event that is triggered by the
// mousemove, such as the scroll event resulting from the scrollbar drag.
// The aScaleFactor argument should be provided if the scrollframe has been
// scaled by an enclosing CSS transform. (TODO: this is a workaround for the
// fact that coordinatesRelativeToScreen is supposed to do this automatically
// but it currently does not).
// Note: helper_scrollbar_snap_bug1501062.html contains a copy of this code
// with modifications. Fixes here should be copied there if appropriate.
// |target| can be an element (for subframes) or a window (for root frames).
async function promiseVerticalScrollbarDrag(
aTarget,
aDistance = 20,
aIncrement = 5,
aScaleFactor = 1
) {
var startPoint = await scrollbarDragStart(aTarget, aScaleFactor);
var targetElement = elementForTarget(aTarget);
if (startPoint == null) {
return null;
}
dump(
"Starting drag at " +
startPoint.x +
", " +
startPoint.y +
" from top-left of #" +
targetElement.id +
"\n"
);
// Move the mouse to the scrollbar thumb and drag it down
await promiseNativeMouseEventWithAPZ({
target: aTarget,
offsetX: startPoint.x,
offsetY: startPoint.y,
type: "mousemove",
});
// mouse down
await promiseNativeMouseEventWithAPZ({
target: aTarget,
offsetX: startPoint.x,
offsetY: startPoint.y,
type: "mousedown",
});
// drag vertically by |aIncrement| until we reach the specified distance
for (var y = aIncrement; y < aDistance; y += aIncrement) {
await promiseNativeMouseEventWithAPZ({
target: aTarget,
offsetX: startPoint.x,
offsetY: startPoint.y + y,
type: "mousemove",
});
}
await promiseNativeMouseEventWithAPZ({
target: aTarget,
offsetX: startPoint.x,
offsetY: startPoint.y + aDistance,
type: "mousemove",
});
// and return an async function to call afterwards to finish up the drag
return async function () {
dump("Finishing drag of #" + targetElement.id + "\n");
await promiseNativeMouseEventWithAPZ({
target: aTarget,
offsetX: startPoint.x,
offsetY: startPoint.y + aDistance,
type: "mouseup",
});
};
}
// This is similar to promiseVerticalScrollbarDrag except this triggers
// the vertical scrollbar drag with a touch drag input. This function
// returns true if a scrollbar was present and false if no scrollbar
// was found for the given element.
async function promiseVerticalScrollbarTouchDrag(
aTarget,
aDistance = 20,
aScaleFactor = 1
) {
var startPoint = await scrollbarDragStart(aTarget, aScaleFactor);
var targetElement = elementForTarget(aTarget);
if (startPoint == null) {
return false;
}
dump(
"Starting touch drag at " +
startPoint.x +
", " +
startPoint.y +
" from top-left of #" +
targetElement.id +
"\n"
);
await promiseNativeTouchDrag(
aTarget,
startPoint.x,
startPoint.y,
0,
aDistance
);
return true;
}
// Synthesizes a native mouse drag, starting at offset (mouseX, mouseY) from
// the given target. The drag occurs in the given number of steps, to a final
// destination of (mouseX + distanceX, mouseY + distanceY) from the target.
// Returns a promise (wrapped in a function, so it doesn't execute immediately)
// that should be awaited after the mousemoves have been processed by the widget
// code, to end the drag. This is important otherwise the OS can sometimes
// reorder the events and the drag doesn't have the intended effect (see
// Example usage:
// let dragFinisher = await promiseNativeMouseDrag(myElement, 0, 0);
// await myIndicationThatDragHadAnEffect;
// await dragFinisher();
async function promiseNativeMouseDrag(
target,
mouseX,
mouseY,
distanceX = 20,
distanceY = 20,
steps = 20
) {
var targetElement = elementForTarget(target);
dump(
"Starting drag at " +
mouseX +
", " +
mouseY +
" from top-left of #" +
targetElement.id +
"\n"
);
// Move the mouse to the target position
await promiseNativeMouseEventWithAPZ({
target,
offsetX: mouseX,
offsetY: mouseY,
type: "mousemove",
});
// mouse down
await promiseNativeMouseEventWithAPZ({
target,
offsetX: mouseX,
offsetY: mouseY,
type: "mousedown",
});
// drag vertically by |increment| until we reach the specified distance
for (var s = 1; s <= steps; s++) {
let dx = distanceX * (s / steps);
let dy = distanceY * (s / steps);
dump(`Dragging to ${mouseX + dx}, ${mouseY + dy} from target\n`);
await promiseNativeMouseEventWithAPZ({
target,
offsetX: mouseX + dx,
offsetY: mouseY + dy,
type: "mousemove",
});
}
// and return a function-wrapped promise to call afterwards to finish the drag
return function () {
return promiseNativeMouseEventWithAPZ({
target,
offsetX: mouseX + distanceX,
offsetY: mouseY + distanceY,
type: "mouseup",
});
};
}
// Synthesizes a native touch sequence of events corresponding to a pinch-zoom-in
// at the given focus point. The focus point must be specified in CSS coordinates
// relative to the document body.
async function pinchZoomInTouchSequence(focusX, focusY) {
// prettier-ignore
var zoom_in = [
[ { x: focusX - 25, y: focusY - 50 }, { x: focusX + 25, y: focusY + 50 } ],
[ { x: focusX - 30, y: focusY - 80 }, { x: focusX + 30, y: focusY + 80 } ],
[ { x: focusX - 35, y: focusY - 110 }, { x: focusX + 40, y: focusY + 110 } ],
[ { x: focusX - 40, y: focusY - 140 }, { x: focusX + 45, y: focusY + 140 } ],
[ { x: focusX - 45, y: focusY - 170 }, { x: focusX + 50, y: focusY + 170 } ],
[ { x: focusX - 50, y: focusY - 200 }, { x: focusX + 55, y: focusY + 200 } ],
];
var touchIds = [0, 1];
return synthesizeNativeTouchSequences(document.body, zoom_in, null, touchIds);
}
// Returns a promise that is resolved when the observer service dispatches a
// message with the given topic.
function promiseTopic(aTopic) {
return new Promise((resolve, reject) => {
SpecialPowers.Services.obs.addObserver(function observer(
subject,
topic,
data
) {
try {
SpecialPowers.Services.obs.removeObserver(observer, topic);
resolve([subject, data]);
} catch (ex) {
SpecialPowers.Services.obs.removeObserver(observer, topic);
reject(ex);
}
},
aTopic);
});
}
// Returns a promise that is resolved when a APZ transform ends.
function promiseTransformEnd() {
return promiseTopic("APZ:TransformEnd");
}
function promiseScrollend(aTarget = window) {
return promiseOneEvent(aTarget, "scrollend");
}
// Returns a promise that resolves after the indicated number
// of touchend events have fired on the given target element.
function promiseTouchEnd(element, count = 1) {
return new Promise(resolve => {
var eventCount = 0;
var counterFunction = function () {
eventCount++;
if (eventCount == count) {
element.removeEventListener("touchend", counterFunction, {
passive: true,
});
resolve();
}
};
element.addEventListener("touchend", counterFunction, { passive: true });
});
}
// This generates a touch-based pinch zoom-in gesture that is expected
// to succeed. It returns after APZ has completed the zoom and reaches the end
// of the transform. The focus point is expected to be in CSS coordinates
// relative to the document body.
async function pinchZoomInWithTouch(focusX, focusY) {
// Register the listener for the TransformEnd observer topic
let transformEndPromise = promiseTopic("APZ:TransformEnd");
// Dispatch all the touch events
await pinchZoomInTouchSequence(focusX, focusY);
// Wait for TransformEnd to fire.
await transformEndPromise;
}
// This generates a touchpad pinch zoom-in gesture that is expected
// to succeed. It returns after APZ has completed the zoom and reaches the end
// of the transform. The focus point is expected to be in CSS coordinates
// relative to the document body.
async function pinchZoomInWithTouchpad(focusX, focusY, options = {}) {
var zoomIn = [
1.0, 1.019531, 1.035156, 1.037156, 1.039156, 1.054688, 1.056688, 1.070312,
1.072312, 1.089844, 1.091844, 1.109375, 1.128906, 1.144531, 1.160156,
1.175781, 1.191406, 1.207031, 1.222656, 1.234375, 1.246094, 1.261719,
1.273438, 1.285156, 1.296875, 1.3125, 1.328125, 1.347656, 1.363281,
1.382812, 1.402344, 1.421875, 1.0,
];
await synthesizeTouchpadPinch(zoomIn, focusX, focusY, options);
}
async function pinchZoomInAndPanWithTouchpad(options = {}) {
var x = 584;
var y = 347;
var scalesAndFoci = [];
// Zoom
for (var scale = 1.0; scale <= 2.0; scale += 0.2) {
scalesAndFoci.push([scale, x, y]);
}
// Pan (due to a limitation of the current implementation, events
// for which the scale doesn't change are dropped, so vary the
// scale slightly as well).
for (var i = 1; i <= 20; i++) {
x -= 4;
y -= 5;
scalesAndFoci.push([scale + 0.01 * i, x, y]);
}
await synthesizeTouchpadGesture(scalesAndFoci, options);
}
async function pinchZoomOutWithTouchpad(focusX, focusY, options = {}) {
// The last item equal one to indicate scale end
var zoomOut = [
1.0, 1.375, 1.359375, 1.339844, 1.316406, 1.296875, 1.277344, 1.257812,
1.238281, 1.21875, 1.199219, 1.175781, 1.15625, 1.132812, 1.101562,
1.078125, 1.054688, 1.03125, 1.011719, 0.992188, 0.972656, 0.953125,
0.933594, 1.0,
];
await synthesizeTouchpadPinch(zoomOut, focusX, focusY, options);
}
async function pinchZoomInOutWithTouchpad(focusX, focusY, options = {}) {
// Use the same scale for two events in a row to make sure the code handles this properly.
var zoomInOut = [
1.0, 1.082031, 1.089844, 1.097656, 1.101562, 1.109375, 1.121094, 1.128906,
1.128906, 1.125, 1.097656, 1.074219, 1.054688, 1.035156, 1.015625, 1.0, 1.0,
];
await synthesizeTouchpadPinch(zoomInOut, focusX, focusY, options);
}
// This generates a touch-based pinch gesture that is expected to succeed
// and trigger an APZ:TransformEnd observer notification.
// It returns after that notification has been dispatched.
// The coordinates of touch events in `touchSequence` are expected to be
// in CSS coordinates relative to the document body.
async function synthesizeNativeTouchAndWaitForTransformEnd(
touchSequence,
touchIds
) {
// Register the listener for the TransformEnd observer topic
let transformEndPromise = promiseTopic("APZ:TransformEnd");
// Dispatch all the touch events
await synthesizeNativeTouchSequences(
document.body,
touchSequence,
null,
touchIds
);
// Wait for TransformEnd to fire.
await transformEndPromise;
}
// Returns a touch sequence for a pinch-zoom-out operation in the center
// of the visual viewport. The touch sequence returned is in CSS coordinates
// relative to the document body.
function pinchZoomOutTouchSequenceAtCenter() {
// Divide the half of visual viewport size by 8, then cause touch events
// starting from the 7th furthest away from the center towards the center.
const deltaX = window.visualViewport.width / 16;
const deltaY = window.visualViewport.height / 16;
const centerX =
window.visualViewport.pageLeft + window.visualViewport.width / 2;
const centerY =
window.visualViewport.pageTop + window.visualViewport.height / 2;
// prettier-ignore
var zoom_out = [
[ { x: centerX - (deltaX * 6), y: centerY - (deltaY * 6) },
{ x: centerX + (deltaX * 6), y: centerY + (deltaY * 6) } ],
[ { x: centerX - (deltaX * 5), y: centerY - (deltaY * 5) },
{ x: centerX + (deltaX * 5), y: centerY + (deltaY * 5) } ],
[ { x: centerX - (deltaX * 4), y: centerY - (deltaY * 4) },
{ x: centerX + (deltaX * 4), y: centerY + (deltaY * 4) } ],
[ { x: centerX - (deltaX * 3), y: centerY - (deltaY * 3) },
{ x: centerX + (deltaX * 3), y: centerY + (deltaY * 3) } ],
[ { x: centerX - (deltaX * 2), y: centerY - (deltaY * 2) },
{ x: centerX + (deltaX * 2), y: centerY + (deltaY * 2) } ],
[ { x: centerX - (deltaX * 1), y: centerY - (deltaY * 1) },
{ x: centerX + (deltaX * 1), y: centerY + (deltaY * 1) } ],
];
return zoom_out;
}
// This generates a touch-based pinch zoom-out gesture that is expected
// to succeed. It returns after APZ has completed the zoom and reaches the end
// of the transform. The touch inputs are directed to the center of the
// current visual viewport.
async function pinchZoomOutWithTouchAtCenter() {
var zoom_out = pinchZoomOutTouchSequenceAtCenter();
var touchIds = [0, 1];
await synthesizeNativeTouchAndWaitForTransformEnd(zoom_out, touchIds);
}
// useTouchpad is only currently implemented on macOS
async function synthesizeDoubleTap(element, x, y, useTouchpad) {
if (useTouchpad) {
await synthesizeNativeTouchpadDoubleTap(element, x, y);
} else {
await synthesizeNativeTap(element, x, y);
await synthesizeNativeTap(element, x, y);
}
}
// useTouchpad is only currently implemented on macOS
async function doubleTapOn(element, x, y, useTouchpad) {
let transformEndPromise = promiseTransformEnd();
await synthesizeDoubleTap(element, x, y, useTouchpad);
// Wait for the APZ:TransformEnd to fire
await transformEndPromise;
// Flush state so we can query an accurate resolution
await promiseApzFlushedRepaints();
}
const NativePanHandlerForLinux = {
beginPhase: SpecialPowers.DOMWindowUtils.PHASE_BEGIN,
updatePhase: SpecialPowers.DOMWindowUtils.PHASE_UPDATE,
endPhase: SpecialPowers.DOMWindowUtils.PHASE_END,
promiseNativePanEvent: promiseNativeTouchpadPanEventAndWaitForObserver,
delta: -50,
};
const NativePanHandlerForWindows = {
beginPhase: SpecialPowers.DOMWindowUtils.PHASE_BEGIN,
updatePhase: SpecialPowers.DOMWindowUtils.PHASE_UPDATE,
endPhase: SpecialPowers.DOMWindowUtils.PHASE_END,
promiseNativePanEvent: promiseNativeTouchpadPanEventAndWaitForObserver,
delta: 50,
};
const NativePanHandlerForMac = {
beginPhase: 1, // kCGScrollPhaseBegan
updatePhase: 2, // kCGScrollPhaseChanged
endPhase: 4, // kCGScrollPhaseEnded
promiseNativePanEvent: promiseNativePanGestureEventAndWaitForObserver,
delta: -50,
};
const NativePanHandlerForHeadless = {
beginPhase: SpecialPowers.DOMWindowUtils.PHASE_BEGIN,
updatePhase: SpecialPowers.DOMWindowUtils.PHASE_UPDATE,
endPhase: SpecialPowers.DOMWindowUtils.PHASE_END,
promiseNativePanEvent: promiseNativeTouchpadPanEventAndWaitForObserver,
delta: 50,
};
function getPanHandler() {
if (SpecialPowers.isHeadless) {
return NativePanHandlerForHeadless;
}
switch (getPlatform()) {
case "linux":
return NativePanHandlerForLinux;
case "windows":
return NativePanHandlerForWindows;
case "mac":
return NativePanHandlerForMac;
default:
throw new Error(
"There's no native pan handler on platform " + getPlatform()
);
}
}
// Lazily get `NativePanHandler` to avoid an exception where we don't support
// native pan events (e.g. Android).
if (!window.hasOwnProperty("NativePanHandler")) {
Object.defineProperty(window, "NativePanHandler", {
get() {
return getPanHandler();
},
});
}
async function panRightToLeftBegin(aElement, aX, aY, aMultiplier) {
await NativePanHandler.promiseNativePanEvent(
aElement,
aX,
aY,
NativePanHandler.delta * aMultiplier,
0,
NativePanHandler.beginPhase
);
}
async function panRightToLeftUpdate(aElement, aX, aY, aMultiplier) {
await NativePanHandler.promiseNativePanEvent(
aElement,
aX,
aY,
NativePanHandler.delta * aMultiplier,
0,
NativePanHandler.updatePhase
);
}
async function panRightToLeftEnd(aElement, aX, aY) {
await NativePanHandler.promiseNativePanEvent(
aElement,
aX,
aY,
0,
0,
NativePanHandler.endPhase
);
}
async function panRightToLeft(aElement, aX, aY, aMultiplier) {
await panRightToLeftBegin(aElement, aX, aY, aMultiplier);
await panRightToLeftUpdate(aElement, aX, aY, aMultiplier);
await panRightToLeftEnd(aElement, aX, aY, aMultiplier);
}
async function panLeftToRight(aElement, aX, aY, aMultiplier) {
await panLeftToRightBegin(aElement, aX, aY, aMultiplier);
await panLeftToRightUpdate(aElement, aX, aY, aMultiplier);
await panLeftToRightEnd(aElement, aX, aY, aMultiplier);
}
async function panLeftToRightBegin(aElement, aX, aY, aMultiplier) {
await NativePanHandler.promiseNativePanEvent(
aElement,
aX,
aY,
-NativePanHandler.delta * aMultiplier,
0,
NativePanHandler.beginPhase
);
}
async function panLeftToRightUpdate(aElement, aX, aY, aMultiplier) {
await NativePanHandler.promiseNativePanEvent(
aElement,
aX,
aY,
-NativePanHandler.delta * aMultiplier,
0,
NativePanHandler.updatePhase
);
await NativePanHandler.promiseNativePanEvent(
aElement,
aX,
aY,
-NativePanHandler.delta * aMultiplier,
0,
NativePanHandler.updatePhase
);
}
async function panLeftToRightEnd(aElement, aX, aY) {
await NativePanHandler.promiseNativePanEvent(
aElement,
aX,
aY,
0,
0,
NativePanHandler.endPhase
);
}
// Close the context menu on desktop platforms.
// NOTE: This function doesn't work if the context menu isn't open.
async function closeContextMenu() {
if (getPlatform() == "android") {
return;
}
const contextmenuClosedPromise = SpecialPowers.spawnChrome([], async () => {
const menu = this.browsingContext.topChromeWindow.document.getElementById(
"contentAreaContextMenu"
);
ok(
menu.state == "open" || menu.state == "showing",
"This function is supposed to work only if the context menu is open or showing"
);
return new Promise(resolve => {
menu.addEventListener(
"popuphidden",
() => {
resolve();
},
{ once: true }
);
menu.hidePopup();
});
});
await contextmenuClosedPromise;
}
// Get a list of prefs which should be used for a subtest which wants to
// generate a smooth scroll animation using an input event. The smooth
// scroll animation is slowed down so the test can perform other actions
// while it's still in progress.
function getSmoothScrollPrefs(aInputType, aMsdPhysics) {
let result = [["apz.test.logging_enabled", true]];
// Some callers just want the default and don't pass in aMsdPhysics.
if (aMsdPhysics !== undefined) {
result.push(["general.smoothScroll.msdPhysics.enabled", aMsdPhysics]);
} else {
aMsdPhysics = SpecialPowers.getBoolPref(
"general.smoothScroll.msdPhysics.enabled"
);
}
if (aInputType == "wheel") {
// We want to test real wheel events rather than pan events.
result.push(["apz.test.mac.synth_wheel_input", true]);
} /* keyboard input */ else {
// The default verticalScrollDistance (which is 3) is too small for native
// keyboard scrolling, it sometimes produces same scroll offsets in the early
// stages of the smooth animation.
result.push(["toolkit.scrollbox.verticalScrollDistance", 5]);
}
// Use a longer animation duration to avoid the situation that the
// animation stops accidentally in between each arrow input event.
// If the situation happens, scroll offsets will not change at the moment.
if (aMsdPhysics) {
// Prefs for MSD physics (applicable to any input type).
result.push(
...[
["general.smoothScroll.msdPhysics.motionBeginSpringConstant", 20],
["general.smoothScroll.msdPhysics.regularSpringConstant", 20],
["general.smoothScroll.msdPhysics.slowdownMinDeltaRatio", 0.1],
["general.smoothScroll.msdPhysics.slowdownSpringConstant", 20],
]
);
} else if (aInputType == "wheel") {
// Prefs for Bezier physics with wheel input.
result.push(
...[
["general.smoothScroll.mouseWheel.durationMaxMS", 1500],
["general.smoothScroll.mouseWheel.durationMinMS", 1500],
]
);
} else {
// Prefs for Bezier physics with keyboard input.
result.push(
...[
["general.smoothScroll.lines.durationMaxMS", 1500],
["general.smoothScroll.lines.durationMinMS", 1500],
]
);
}
return result;
}
function buildRelativeScrollSmoothnessVariants(aInputType, aScrollMethods) {
let subtests = [];
for (let scrollMethod of aScrollMethods) {
subtests.push({
file: `helper_relative_scroll_smoothness.html?input-type=${aInputType}&scroll-method=${scrollMethod}&strict=true`,
prefs: getSmoothScrollPrefs(aInputType, /* Bezier physics */ false),
});
// For MSD physics, run the test with strict=false. The shape of the
// animation curve is highly timing dependent, and we can't guarantee
// that an animation will run long enough until the next input event
// arrives.
subtests.push({
file: `helper_relative_scroll_smoothness.html?input-type=${aInputType}&scroll-method=${scrollMethod}&strict=false`,
prefs: getSmoothScrollPrefs(aInputType, /* MSD physics */ true),
});
}
return subtests;
}