// ownerGlobal isn't defined in content privileged windows. /* eslint-disable mozilla/use-ownerGlobal */ // Utilities for synthesizing of native events. 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 = SpecialPowers.getDOMWindowUtils(window.top).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() ); } // Given an event target which may be a window or an element, get the associated window. function windowForTarget(aTarget) { if (aTarget instanceof Window) { 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 (aTarget instanceof Window) { 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 nativeMouseDownEventMsg() { switch (getPlatform()) { case "windows": return 2; // MOUSEEVENTF_LEFTDOWN case "mac": return 1; // NSEventTypeLeftMouseDown case "linux": return 4; // GDK_BUTTON_PRESS case "android": return 5; // ACTION_POINTER_DOWN } throw new Error( "Native mouse-down events not supported on platform " + getPlatform() ); } function nativeMouseMoveEventMsg() { switch (getPlatform()) { case "windows": return 1; // MOUSEEVENTF_MOVE case "mac": return 5; // NSEventTypeMouseMoved case "linux": return 3; // GDK_MOTION_NOTIFY case "android": return 7; // ACTION_HOVER_MOVE } throw new Error( "Native mouse-move events not supported on platform " + getPlatform() ); } function nativeMouseUpEventMsg() { switch (getPlatform()) { case "windows": return 4; // MOUSEEVENTF_LEFTUP case "mac": return 2; // NSEventTypeLeftMouseUp case "linux": return 7; // GDK_BUTTON_RELEASE case "android": return 6; // ACTION_POINTER_UP } throw new Error( "Native mouse-up events not supported on platform " + getPlatform() ); } function getBoundingClientRectRelativeToVisualViewport(aElement) { let utils = SpecialPowers.getDOMWindowUtils(window); var rect = aElement.getBoundingClientRect(); var offsetX = {}, offsetY = {}; // TODO: Audit whether these offset values are correct or not for // position:fixed elements especially in the case where the visual viewport // offset is not 0. utils.getVisualViewportOffsetRelativeToLayoutViewport(offsetX, offsetY); rect.x -= offsetX.value; rect.y -= offsetY.value; return rect; } // 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 getTargetOrigin(aTarget) { let origin = { left: 0, top: 0 }; // If the target is the root content window, its origin relative // to the visual viewport is (0, 0). if (aTarget instanceof Window) { // FIXME: Assert that it's not an iframe window. return origin; } // 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. let rect = aTarget.getBoundingClientRect(); origin.left += rect.left; origin.top += rect.top; // 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) { let 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. let style = iframe.ownerDocument.defaultView.getComputedStyle(iframe); let borderLeft = parseFloat(style.borderLeftWidth) || 0; let borderTop = parseFloat(style.borderTopWidth) || 0; let paddingLeft = parseFloat(style.paddingLeft) || 0; let paddingTop = parseFloat(style.paddingTop) || 0; rect = iframe.getBoundingClientRect(); origin.left += rect.left + borderLeft + paddingLeft; origin.top += rect.top + borderTop + paddingTop; aTarget = iframe; } // 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 = {}, offsetY = {}; let rootUtils = SpecialPowers.getDOMWindowUtils(window.top); rootUtils.getVisualViewportOffsetRelativeToLayoutViewport(offsetX, offsetY); origin.left -= offsetX.value; origin.top -= offsetY.value; return origin; } // Convert (aX, aY), in CSS pixels relative to aTarget's bounding rect // to device pixels relative to the screen. // TODO: this function currently does not incorporate some CSS transforms on // elements enclosing aTarget, e.g. scale transforms. function coordinatesRelativeToScreen(aX, aY, aTarget) { // 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 root content document, below we use // the mozInnerScreen{X,Y} of the root content window (window.top) only, // and factor any offsets between iframe windows and the root content window // into |origin|. var utils = SpecialPowers.getDOMWindowUtils(window); var deviceScale = utils.screenPixelsPerCSSPixel; var deviceScaleNoOverride = utils.screenPixelsPerCSSPixelNoOverride; var resolution = getResolution(); var origin = getTargetOrigin(aTarget); // moxInnerScreen{X,Y} are in CSS coordinates of the browser chrome. // The device scale applies to them, but the resolution only zooms the content. // In addition, if we're inside RDM, RDM overrides the device scale; // the overridden scale only applies to the content inside the RDM // document, not to mozInnerScreen{X,Y}. return { x: window.top.mozInnerScreenX * deviceScaleNoOverride + (origin.left + aX) * resolution * deviceScale, y: window.top.mozInnerScreenY * deviceScaleNoOverride + (origin.top + aY) * resolution * deviceScale, }; } // 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 use getBoundingClientRectRelativeToVisualViewport() // 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, w: rect.width * scale, h: 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. function synthesizeNativeWheel(aTarget, aX, aY, aDeltaX, aDeltaY, aObserver) { var pt = coordinatesRelativeToScreen(aX, aY, 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, 0, element, aObserver ); return true; } // Synthesizes a native mousewheel event and invokes the callback 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 synthesizeNativeWheelAndWaitForObserver( aElement, aX, aY, aDeltaX, aDeltaY, aCallback ) { var observer = { observe(aSubject, aTopic, aData) { if (aCallback && aTopic == "mousescrollevent") { setTimeout(aCallback, 0); } }, }; return synthesizeNativeWheel(aElement, aX, aY, aDeltaX, aDeltaY, observer); } // Synthesizes a native mousewheel event and invokes the callback 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 synthesizeNativeWheelAndWaitForWheelEvent( aTarget, aX, aY, aDeltaX, aDeltaY, aCallback ) { let p = promiseNativeWheelAndWaitForWheelEvent( aTarget, aX, aY, aDeltaX, aDeltaY ); if (aCallback) { p.then(aCallback); } return true; } // Same as synthesizeNativeWheelAndWaitForWheelEvent, except returns a promise // instead of taking a callback function promiseNativeWheelAndWaitForWheelEvent( aTarget, aX, aY, aDeltaX, aDeltaY ) { return new Promise((resolve, reject) => { var targetWindow = windowForTarget(aTarget); targetWindow.addEventListener( "wheel", function(e) { setTimeout(resolve, 0); }, { once: true } ); try { synthesizeNativeWheel(aTarget, aX, aY, aDeltaX, aDeltaY); } catch (e) { reject(); } }); } // Synthesizes a native mousewheel event and invokes the callback 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 synthesizeNativeWheelAndWaitForScrollEvent( aTarget, aX, aY, aDeltaX, aDeltaY, aCallback ) { promiseNativeWheelAndWaitForScrollEvent( aTarget, aX, aY, aDeltaX, aDeltaY ).then(aCallback); return true; } // Same as synthesizeNativeWheelAndWaitForScrollEvent, but returns a promise // instead of taking a callback 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(); } }); } // Synthesizes a native mouse move event and returns immediately. // aX and aY are relative to the top-left of |aTarget|'s bounding rect. function synthesizeNativeMouseMove(aTarget, aX, aY) { var pt = coordinatesRelativeToScreen(aX, aY, aTarget); var utils = utilsForTarget(aTarget); var element = elementForTarget(aTarget); utils.sendNativeMouseEvent(pt.x, pt.y, nativeMouseMoveEventMsg(), 0, element); return true; } // Synthesizes a native mouse move event and invokes the callback once the // mouse move 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 window). See synthesizeNativeMouseMove for // details on the other parameters. function synthesizeNativeMouseMoveAndWaitForMoveEvent( aTarget, aX, aY, aCallback ) { promiseNativeMouseMoveAndWaitForMoveEvent(aTarget, aX, aY).then(aCallback); return true; } // Same as synthesizeNativeMouseMoveAndWaitForMoveEvent but returns a promise // instead of taking a callback. function promiseNativeMouseMoveAndWaitForMoveEvent(aTarget, aX, aY) { return new Promise((resolve, reject) => { var targetWindow = windowForTarget(aTarget); targetWindow.addEventListener( "mousemove", function(e) { setTimeout(resolve, 0); }, { once: true } ); try { synthesizeNativeMouseMove(aTarget, aX, aY); } catch (e) { reject(); } }); } // Synthesizes a native touch event and dispatches it. aX and aY in CSS pixels // relative to the top-left of |aTarget|'s bounding rect. function synthesizeNativeTouch( aTarget, aX, aY, aType, aObserver = null, aTouchId = 0 ) { var pt = coordinatesRelativeToScreen(aX, aY, aTarget); var utils = utilsForTarget(aTarget); utils.sendNativeTouchPoint(aTouchId, aType, pt.x, pt.y, 1, 90, aObserver); return true; } // Function to generate native touch events for a multi-touch sequence. // aTarget is the element or window whose bounding rect the coordinates are relative to. // 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 "finger" (or touch input). Each row must have exactly // the same number of columns, and the number of columns must match the length // of the aTouchIds parameter. // For each row, each entry is either an object with x and y fields, // or a null. A null value indicates that the "finger" should be "lifted" // (i.e. send a touchend for that touch input). A non-null value therefore // indicates the position of the touch input. // This function takes care of the state tracking necessary to send // touchstart/touchend inputs as necessary as the fingers go up and down. // aObserver is the observer that will get registered on the very last // synthesizeNativeTouch call this function makes. // aTouchIds is an array holding the touch ID values of each "finger". function synthesizeNativeTouchSequences( aTarget, aPositions, aObserver = null, aTouchIds = [0] ) { // 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 != aTouchIds.length) { throw new Error( `aPositions[${i}] did not have the expected number of positions; ` + `expected ${aTouchIds.length} touch points but found ${aPositions[i].length}` ); } for (let j = 0; j < aTouchIds.length; j++) { if (aPositions[i][j] != null) { lastNonNullValue = i * aTouchIds.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] = coordinatesRelativeToScreen( aPositions[i][j].x, aPositions[i][j].y, 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(aTouchIds.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 + aTouchIds.length; // track which touches are down and which are up. start with all up var currentPositions = new Array(aTouchIds.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 < aTouchIds.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 * aTouchIds.length + j; var observer = lastSynthesizeCall == thisIndex ? aObserver : null; utils.sendNativeTouchPoint( aTouchIds[j], SpecialPowers.DOMWindowUtils.TOUCH_REMOVE, currentPositions[j].x, currentPositions[j].y, 1, 90, observer ); currentPositions[j] = null; } } else { utils.sendNativeTouchPoint( aTouchIds[j], SpecialPowers.DOMWindowUtils.TOUCH_CONTACT, aPositions[i][j].x, aPositions[i][j].y, 1, 90, null ); currentPositions[j] = aPositions[i][j]; } } } return true; } // 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. function synthesizeNativeTouchDrag( aTarget, aX, aY, aDeltaX, aDeltaY, aObserver = null, aTouchId = 0 ) { 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 synthesizeNativeTouchSequences(aTarget, positions, aObserver, [ aTouchId, ]); } // 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 ); }); } function synthesizeNativeTap(aElement, aX, aY, aObserver = null) { var pt = coordinatesRelativeToScreen(aX, aY, aElement); var utils = SpecialPowers.getDOMWindowUtils( aElement.ownerDocument.defaultView ); utils.sendNativeTouchTap(pt.x, pt.y, false, aObserver); return true; } function synthesizeNativeMouseEvent(aTarget, aX, aY, aType, aObserver = null) { var pt = coordinatesRelativeToScreen(aX, aY, aTarget); var utils = utilsForTarget(aTarget); var element = elementForTarget(aTarget); utils.sendNativeMouseEvent(pt.x, pt.y, aType, 0, element, aObserver); return true; } // Promise-returning variant of synthesizeNativeMouseEvent function promiseNativeMouseEvent(aTarget, aX, aY, aType) { return new Promise(resolve => { synthesizeNativeMouseEvent(aTarget, aX, aY, aType, resolve); }); } function synthesizeNativeClick(aElement, aX, aY, aObserver = null) { var pt = coordinatesRelativeToScreen(aX, aY, aElement); var utils = SpecialPowers.getDOMWindowUtils( aElement.ownerDocument.defaultView ); utils.sendNativeMouseEvent( pt.x, pt.y, nativeMouseDownEventMsg(), 0, aElement, function() { utils.sendNativeMouseEvent( pt.x, pt.y, nativeMouseUpEventMsg(), 0, aElement, aObserver ); } ); return true; } // Promise-returning variant of synthesizeNativeClick. function promiseNativeClick(aElement, aX, aY) { return new Promise(resolve => { synthesizeNativeClick(aElement, aX, aY, resolve); }); } function synthesizeNativeClickAndWaitForClickEvent( aElement, aX, aY, aCallback ) { var targetWindow = windowForTarget(aElement); targetWindow.addEventListener( "click", function(e) { setTimeout(aCallback, 0); }, { capture: true, once: true } ); return synthesizeNativeClick(aElement, aX, aY); } // Promise-returning variant of synthesizeNativeClickAndWaitForClickEvent function promiseNativeClickAndClickEvent(aElement, aX, aY) { return new Promise(resolve => { synthesizeNativeClickAndWaitForClickEvent(aElement, aX, aY, resolve); }); } // 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 // moveMouseAndScrollWheelOver() 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. function moveMouseAndScrollWheelOver( target, dx, dy, testDriver, waitForScroll = true, scrollDelta = 10 ) { promiseMoveMouseAndScrollWheelOver( target, dx, dy, waitForScroll, scrollDelta ).then(testDriver); return true; } // Same as moveMouseAndScrollWheelOver, but returns a promise instead of taking // a callback function. function promiseMoveMouseAndScrollWheelOver( target, dx, dy, waitForScroll = true, scrollDelta = 10 ) { let p = promiseNativeMouseMoveAndWaitForMoveEvent(target, dx, dy); if (waitForScroll) { p = p.then(() => promiseNativeWheelAndWaitForScrollEvent(target, dx, dy, 0, -scrollDelta) ); } else { p = p.then(() => promiseNativeWheelAndWaitForWheelEvent(target, dx, dy, 0, -scrollDelta) ); } return p; } // 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 scaleFactor 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( target, distance = 20, increment = 5, scaleFactor = 1 ) { var targetElement = elementForTarget(target); var w = {}, h = {}; utilsForTarget(target).getScrollbarSizes(targetElement, w, h); var verticalScrollbarWidth = w.value; if (verticalScrollbarWidth == 0) { return null; } var upArrowHeight = verticalScrollbarWidth; // assume square scrollbar buttons var mouseX = targetElement.clientWidth + verticalScrollbarWidth / 2; var mouseY = upArrowHeight + 5; // start dragging somewhere in the thumb mouseX *= scaleFactor; mouseY *= scaleFactor; dump( "Starting drag at " + mouseX + ", " + mouseY + " from top-left of #" + targetElement.id + "\n" ); // Move the mouse to the scrollbar thumb and drag it down await promiseNativeMouseEvent( target, mouseX, mouseY, nativeMouseMoveEventMsg() ); // mouse down await promiseNativeMouseEvent( target, mouseX, mouseY, nativeMouseDownEventMsg() ); // drag vertically by |increment| until we reach the specified distance for (var y = increment; y < distance; y += increment) { await promiseNativeMouseEvent( target, mouseX, mouseY + y, nativeMouseMoveEventMsg() ); } await promiseNativeMouseEvent( target, mouseX, mouseY + distance, nativeMouseMoveEventMsg() ); // and return an async function to call afterwards to finish up the drag return async function() { dump("Finishing drag of #" + targetElement.id + "\n"); await promiseNativeMouseEvent( target, mouseX, mouseY + distance, nativeMouseUpEventMsg() ); }; } // 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 // bug 1368603). // 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 promiseNativeMouseEvent( target, mouseX, mouseY, nativeMouseMoveEventMsg() ); // mouse down await promiseNativeMouseEvent( target, mouseX, mouseY, nativeMouseDownEventMsg() ); // 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 promiseNativeMouseEvent( target, mouseX + dx, mouseY + dy, nativeMouseMoveEventMsg() ); } // and return a function-wrapped promise to call afterwards to finish the drag return function() { return promiseNativeMouseEvent( target, mouseX + distanceX, mouseY + distanceY, nativeMouseUpEventMsg() ); }; } // 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. 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"); } // 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(e) { 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 pinchZoomInTouchSequence(focusX, focusY); // Wait for TransformEnd to fire. await transformEndPromise; } // 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 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); }