diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /testing/mochitest/tests/SimpleTest/EventUtils.js | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/mochitest/tests/SimpleTest/EventUtils.js')
-rw-r--r-- | testing/mochitest/tests/SimpleTest/EventUtils.js | 3665 |
1 files changed, 3665 insertions, 0 deletions
diff --git a/testing/mochitest/tests/SimpleTest/EventUtils.js b/testing/mochitest/tests/SimpleTest/EventUtils.js new file mode 100644 index 0000000000..d4600e3107 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/EventUtils.js @@ -0,0 +1,3665 @@ +/** + * EventUtils provides some utility methods for creating and sending DOM events. + * + * When adding methods to this file, please add a performance test for it. + */ + +// Certain functions assume this is loaded into browser window scope. +// This is modifiable because certain chrome tests create their own gBrowser. +/* global gBrowser:true */ + +// This file is used both in privileged and unprivileged contexts, so we have to +// be careful about our access to Components.interfaces. We also want to avoid +// naming collisions with anything that might be defined in the scope that imports +// this script. +// +// Even if the real |Components| doesn't exist, we might shim in a simple JS +// placebo for compat. An easy way to differentiate this from the real thing +// is whether the property is read-only or not. The real |Components| property +// is read-only. +/* global _EU_Ci, _EU_Cc, _EU_Cu, _EU_ChromeUtils, _EU_OS */ +window.__defineGetter__("_EU_Ci", function() { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? Ci : SpecialPowers.Ci; +}); + +window.__defineGetter__("_EU_Cc", function() { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? Cc : SpecialPowers.Cc; +}); + +window.__defineGetter__("_EU_Cu", function() { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? Cu : SpecialPowers.Cu; +}); + +window.__defineGetter__("_EU_ChromeUtils", function() { + var c = Object.getOwnPropertyDescriptor(window, "ChromeUtils"); + return c && c.value && !c.writable ? ChromeUtils : SpecialPowers.ChromeUtils; +}); + +window.__defineGetter__("_EU_OS", function() { + delete this._EU_OS; + try { + this._EU_OS = _EU_ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ).platform; + } catch (ex) { + this._EU_OS = null; + } + return this._EU_OS; +}); + +function _EU_isMac(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "macosx"; + } + if (aWindow) { + try { + return aWindow.navigator.platform.indexOf("Mac") > -1; + } catch (ex) {} + } + return navigator.platform.indexOf("Mac") > -1; +} + +function _EU_isWin(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "win"; + } + if (aWindow) { + try { + return aWindow.navigator.platform.indexOf("Win") > -1; + } catch (ex) {} + } + return navigator.platform.indexOf("Win") > -1; +} + +function _EU_isLinux(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "linux"; + } + if (aWindow) { + try { + return aWindow.navigator.platform.startsWith("Linux"); + } catch (ex) {} + } + return navigator.platform.startsWith("Linux"); +} + +function _EU_isAndroid(aWindow = window) { + if (window._EU_OS) { + return window._EU_OS == "android"; + } + if (aWindow) { + try { + return aWindow.navigator.userAgent.includes("Android"); + } catch (ex) {} + } + return navigator.userAgent.includes("Android"); +} + +function _EU_maybeWrap(o) { + // We're used in some contexts where there is no SpecialPowers and also in + // some where it exists but has no wrap() method. And this is somewhat + // independent of whether window.Components is a thing... + var haveWrap = false; + try { + haveWrap = SpecialPowers.wrap != undefined; + } catch (e) { + // Just leave it false. + } + if (!haveWrap) { + // Not much we can do here. + return o; + } + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? o : SpecialPowers.wrap(o); +} + +function _EU_maybeUnwrap(o) { + var c = Object.getOwnPropertyDescriptor(window, "Components"); + return c && c.value && !c.writable ? o : SpecialPowers.unwrap(o); +} + +function _EU_getPlatform() { + if (_EU_isWin()) { + return "windows"; + } + if (_EU_isMac()) { + return "mac"; + } + if (_EU_isAndroid()) { + return "android"; + } + if (_EU_isLinux()) { + return "linux"; + } + return "unknown"; +} + +/** + * promiseElementReadyForUserInput() dispatches mousemove events to aElement + * and waits one of them for a while. Then, returns "resolved" state when it's + * successfully received. Otherwise, if it couldn't receive mousemove event on + * it, this throws an exception. So, aElement must be an element which is + * assumed non-collapsed visible element in the window. + * + * This is useful if you need to synthesize mouse events via the main process + * but your test cannot check whether the element is now in APZ to deliver + * a user input event. + */ +async function promiseElementReadyForUserInput( + aElement, + aWindow = window, + aLogFunc = null +) { + if (typeof aElement == "string") { + aElement = aWindow.document.getElementById(aElement); + } + + function waitForMouseMoveForHittest() { + return new Promise(resolve => { + let timeout; + const onHit = () => { + if (aLogFunc) { + aLogFunc("mousemove received"); + } + aWindow.clearInterval(timeout); + resolve(true); + }; + aElement.addEventListener("mousemove", onHit, { + capture: true, + once: true, + }); + synthesizeMouseAtCenter(aElement, { type: "mousemove" }, aWindow); + timeout = aWindow.setInterval(() => { + if (aLogFunc) { + aLogFunc("mousemove not received in this 300ms"); + } + aElement.removeEventListener("mousemove", onHit, { + capture: true, + }); + resolve(false); + }, 300); + }); + } + for (let i = 0; i < 20; i++) { + if (await waitForMouseMoveForHittest()) { + return Promise.resolve(); + } + } + throw new Error("The element or the window did not become interactive"); +} + +function getElement(id) { + return typeof id == "string" ? document.getElementById(id) : id; +} + +this.$ = this.getElement; + +function computeButton(aEvent) { + if (typeof aEvent.button != "undefined") { + return aEvent.button; + } + return aEvent.type == "contextmenu" ? 2 : 0; +} + +function computeButtons(aEvent, utils) { + if (typeof aEvent.buttons != "undefined") { + return aEvent.buttons; + } + + if (typeof aEvent.button != "undefined") { + return utils.MOUSE_BUTTONS_NOT_SPECIFIED; + } + + if (typeof aEvent.type != "undefined" && aEvent.type != "mousedown") { + return utils.MOUSE_BUTTONS_NO_BUTTON; + } + + return utils.MOUSE_BUTTONS_NOT_SPECIFIED; +} + +/** + * Send a mouse event to the node aTarget (aTarget can be an id, or an + * actual node) . The "event" passed in to aEvent is just a JavaScript + * object with the properties set that the real mouse event object should + * have. This includes the type of the mouse event. Pretty much all those + * properties are optional. + * E.g. to send an click event to the node with id 'node' you might do this: + * + * ``sendMouseEvent({type:'click'}, 'node');`` + */ +function sendMouseEvent(aEvent, aTarget, aWindow) { + if ( + ![ + "click", + "contextmenu", + "dblclick", + "mousedown", + "mouseup", + "mouseover", + "mouseout", + ].includes(aEvent.type) + ) { + throw new Error( + "sendMouseEvent doesn't know about event type '" + aEvent.type + "'" + ); + } + + if (!aWindow) { + aWindow = window; + } + + if (typeof aTarget == "string") { + aTarget = aWindow.document.getElementById(aTarget); + } + + if (aEvent.type === "click" && this.AccessibilityUtils) { + this.AccessibilityUtils.assertCanBeClicked(aTarget); + } + + var event = aWindow.document.createEvent("MouseEvent"); + + var typeArg = aEvent.type; + var canBubbleArg = true; + var cancelableArg = true; + var viewArg = aWindow; + var detailArg = + aEvent.detail || + // eslint-disable-next-line no-nested-ternary + (aEvent.type == "click" || + aEvent.type == "mousedown" || + aEvent.type == "mouseup" + ? 1 + : aEvent.type == "dblclick" + ? 2 + : 0); + var screenXArg = aEvent.screenX || 0; + var screenYArg = aEvent.screenY || 0; + var clientXArg = aEvent.clientX || 0; + var clientYArg = aEvent.clientY || 0; + var ctrlKeyArg = aEvent.ctrlKey || false; + var altKeyArg = aEvent.altKey || false; + var shiftKeyArg = aEvent.shiftKey || false; + var metaKeyArg = aEvent.metaKey || false; + var buttonArg = computeButton(aEvent); + var relatedTargetArg = aEvent.relatedTarget || null; + + event.initMouseEvent( + typeArg, + canBubbleArg, + cancelableArg, + viewArg, + detailArg, + screenXArg, + screenYArg, + clientXArg, + clientYArg, + ctrlKeyArg, + altKeyArg, + shiftKeyArg, + metaKeyArg, + buttonArg, + relatedTargetArg + ); + + // If documentURIObject exists or `window` is a stub object, we're in + // a chrome scope, so don't bother trying to go through SpecialPowers. + if (!window.document || window.document.documentURIObject) { + return aTarget.dispatchEvent(event); + } + return SpecialPowers.dispatchEvent(aWindow, aTarget, event); +} + +function isHidden(aElement) { + var box = aElement.getBoundingClientRect(); + return box.width == 0 && box.height == 0; +} + +/** + * Send a drag event to the node aTarget (aTarget can be an id, or an + * actual node) . The "event" passed in to aEvent is just a JavaScript + * object with the properties set that the real drag event object should + * have. This includes the type of the drag event. + */ +function sendDragEvent(aEvent, aTarget, aWindow = window) { + if ( + ![ + "drag", + "dragstart", + "dragend", + "dragover", + "dragenter", + "dragleave", + "drop", + ].includes(aEvent.type) + ) { + throw new Error( + "sendDragEvent doesn't know about event type '" + aEvent.type + "'" + ); + } + + if (typeof aTarget == "string") { + aTarget = aWindow.document.getElementById(aTarget); + } + + /* + * Drag event cannot be performed if the element is hidden, except 'dragend' + * event where the element can becomes hidden after start dragging. + */ + if (aEvent.type != "dragend" && isHidden(aTarget)) { + var targetName = aTarget.nodeName; + if ("id" in aTarget && aTarget.id) { + targetName += "#" + aTarget.id; + } + throw new Error(`${aEvent.type} event target ${targetName} is hidden`); + } + + var event = aWindow.document.createEvent("DragEvent"); + + var typeArg = aEvent.type; + var canBubbleArg = true; + var cancelableArg = true; + var viewArg = aWindow; + var detailArg = aEvent.detail || 0; + var screenXArg = aEvent.screenX || 0; + var screenYArg = aEvent.screenY || 0; + var clientXArg = aEvent.clientX || 0; + var clientYArg = aEvent.clientY || 0; + var ctrlKeyArg = aEvent.ctrlKey || false; + var altKeyArg = aEvent.altKey || false; + var shiftKeyArg = aEvent.shiftKey || false; + var metaKeyArg = aEvent.metaKey || false; + var buttonArg = computeButton(aEvent); + var relatedTargetArg = aEvent.relatedTarget || null; + var dataTransfer = aEvent.dataTransfer || null; + + event.initDragEvent( + typeArg, + canBubbleArg, + cancelableArg, + viewArg, + detailArg, + screenXArg, + screenYArg, + clientXArg, + clientYArg, + ctrlKeyArg, + altKeyArg, + shiftKeyArg, + metaKeyArg, + buttonArg, + relatedTargetArg, + dataTransfer + ); + + if (aEvent._domDispatchOnly) { + return aTarget.dispatchEvent(event); + } + + var utils = _getDOMWindowUtils(aWindow); + return utils.dispatchDOMEventViaPresShellForTesting(aTarget, event); +} + +/** + * Send the char aChar to the focused element. This method handles casing of + * chars (sends the right charcode, and sends a shift key for uppercase chars). + * No other modifiers are handled at this point. + * + * For now this method only works for ASCII characters and emulates the shift + * key state on US keyboard layout. + */ +function sendChar(aChar, aWindow) { + var hasShift; + // Emulate US keyboard layout for the shiftKey state. + switch (aChar) { + case "!": + case "@": + case "#": + case "$": + case "%": + case "^": + case "&": + case "*": + case "(": + case ")": + case "_": + case "+": + case "{": + case "}": + case ":": + case '"': + case "|": + case "<": + case ">": + case "?": + hasShift = true; + break; + default: + hasShift = + aChar.toLowerCase() != aChar.toUpperCase() && + aChar == aChar.toUpperCase(); + break; + } + synthesizeKey(aChar, { shiftKey: hasShift }, aWindow); +} + +/** + * Send the string aStr to the focused element. + * + * For now this method only works for ASCII characters and emulates the shift + * key state on US keyboard layout. + */ +function sendString(aStr, aWindow) { + for (var i = 0; i < aStr.length; ++i) { + sendChar(aStr.charAt(i), aWindow); + } +} + +/** + * Send the non-character key aKey to the focused node. + * The name of the key should be the part that comes after ``DOM_VK_`` in the + * KeyEvent constant name for this key. + * No modifiers are handled at this point. + */ +function sendKey(aKey, aWindow) { + var keyName = "VK_" + aKey.toUpperCase(); + synthesizeKey(keyName, { shiftKey: false }, aWindow); +} + +/** + * Parse the key modifier flags from aEvent. Used to share code between + * synthesizeMouse and synthesizeKey. + */ +function _parseModifiers(aEvent, aWindow = window) { + var nsIDOMWindowUtils = _EU_Ci.nsIDOMWindowUtils; + var mval = 0; + if (aEvent.shiftKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SHIFT; + } + if (aEvent.ctrlKey) { + mval |= nsIDOMWindowUtils.MODIFIER_CONTROL; + } + if (aEvent.altKey) { + mval |= nsIDOMWindowUtils.MODIFIER_ALT; + } + if (aEvent.metaKey) { + mval |= nsIDOMWindowUtils.MODIFIER_META; + } + if (aEvent.accelKey) { + mval |= _EU_isMac(aWindow) + ? nsIDOMWindowUtils.MODIFIER_META + : nsIDOMWindowUtils.MODIFIER_CONTROL; + } + if (aEvent.altGrKey) { + mval |= nsIDOMWindowUtils.MODIFIER_ALTGRAPH; + } + if (aEvent.capsLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_CAPSLOCK; + } + if (aEvent.fnKey) { + mval |= nsIDOMWindowUtils.MODIFIER_FN; + } + if (aEvent.fnLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_FNLOCK; + } + if (aEvent.numLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_NUMLOCK; + } + if (aEvent.scrollLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SCROLLLOCK; + } + if (aEvent.symbolKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SYMBOL; + } + if (aEvent.symbolLockKey) { + mval |= nsIDOMWindowUtils.MODIFIER_SYMBOLLOCK; + } + if (aEvent.osKey) { + mval |= nsIDOMWindowUtils.MODIFIER_OS; + } + + return mval; +} + +/** + * Synthesize a mouse event on a target. The actual client point is determined + * by taking the aTarget's client box and offseting it by aOffsetX and + * aOffsetY. This allows mouse clicks to be simulated by calling this method. + * + * aEvent is an object which may contain the properties: + * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`, + * `button`, `type`. + * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`. + * + * If the type is specified, an mouse event of that type is fired. Otherwise, + * a mousedown followed by a mouseup is performed. + * + * aWindow is optional, and defaults to the current window object. + * + * Returns whether the event had preventDefault() called on it. + */ +function synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + return synthesizeMouseAtPoint( + rect.left + aOffsetX, + rect.top + aOffsetY, + aEvent, + aWindow + ); +} +function synthesizeTouch(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + return synthesizeTouchAtPoint( + rect.left + aOffsetX, + rect.top + aOffsetY, + aEvent, + aWindow + ); +} + +/* + * Synthesize a mouse event at a particular point in aWindow. + * + * aEvent is an object which may contain the properties: + * `shiftKey`, `ctrlKey`, `altKey`, `metaKey`, `accessKey`, `clickCount`, + * `button`, `type`. + * For valid `type`s see nsIDOMWindowUtils' `sendMouseEvent`. + * + * If the type is specified, an mouse event of that type is fired. Otherwise, + * a mousedown followed by a mouseup is performed. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeMouseAtPoint(left, top, aEvent, aWindow = window) { + var utils = _getDOMWindowUtils(aWindow); + var defaultPrevented = false; + + if (utils) { + var button = computeButton(aEvent); + var clickCount = aEvent.clickCount || 1; + var modifiers = _parseModifiers(aEvent, aWindow); + var pressure = "pressure" in aEvent ? aEvent.pressure : 0; + + // aWindow might be cross-origin from us. + var MouseEvent = _EU_maybeWrap(aWindow).MouseEvent; + + // Default source to mouse. + var inputSource = + "inputSource" in aEvent + ? aEvent.inputSource + : MouseEvent.MOZ_SOURCE_MOUSE; + // Compute a pointerId if needed. + var id; + if ("id" in aEvent) { + id = aEvent.id; + } else { + var isFromPen = inputSource === MouseEvent.MOZ_SOURCE_PEN; + id = isFromPen + ? utils.DEFAULT_PEN_POINTER_ID + : utils.DEFAULT_MOUSE_POINTER_ID; + } + + var isDOMEventSynthesized = + "isSynthesized" in aEvent ? aEvent.isSynthesized : true; + var isWidgetEventSynthesized = + "isWidgetEventSynthesized" in aEvent + ? aEvent.isWidgetEventSynthesized + : false; + if ("type" in aEvent && aEvent.type) { + defaultPrevented = utils.sendMouseEvent( + aEvent.type, + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + computeButtons(aEvent, utils), + id + ); + } else { + utils.sendMouseEvent( + "mousedown", + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + computeButtons(Object.assign({ type: "mousedown" }, aEvent), utils), + id + ); + utils.sendMouseEvent( + "mouseup", + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + computeButtons(Object.assign({ type: "mouseup" }, aEvent), utils), + id + ); + } + } + + return defaultPrevented; +} + +function synthesizeTouchAtPoint(left, top, aEvent, aWindow = window) { + var utils = _getDOMWindowUtils(aWindow); + let defaultPrevented = false; + + if (utils) { + var id = aEvent.id || utils.DEFAULT_TOUCH_POINTER_ID; + var rx = aEvent.rx || 1; + var ry = aEvent.ry || 1; + var angle = aEvent.angle || 0; + var force = aEvent.force || (aEvent.type === "touchend" ? 0 : 1); + var tiltX = aEvent.tiltX || 0; + var tiltY = aEvent.tiltY || 0; + var twist = aEvent.twist || 0; + var modifiers = _parseModifiers(aEvent, aWindow); + + if ("type" in aEvent && aEvent.type) { + defaultPrevented = utils.sendTouchEvent( + aEvent.type, + [id], + [left], + [top], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + modifiers + ); + } else { + utils.sendTouchEvent( + "touchstart", + [id], + [left], + [top], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + modifiers + ); + utils.sendTouchEvent( + "touchend", + [id], + [left], + [top], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + modifiers + ); + } + } + return defaultPrevented; +} + +// Call synthesizeMouse with coordinates at the center of aTarget. +function synthesizeMouseAtCenter(aTarget, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + return synthesizeMouse( + aTarget, + rect.width / 2, + rect.height / 2, + aEvent, + aWindow + ); +} +function synthesizeTouchAtCenter(aTarget, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + synthesizeTouchAtPoint( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + aEvent, + aWindow + ); +} + +/** + * Synthesize a wheel event without flush layout at a particular point in + * aWindow. + * + * aEvent is an object which may contain the properties: + * shiftKey, ctrlKey, altKey, metaKey, accessKey, deltaX, deltaY, deltaZ, + * deltaMode, lineOrPageDeltaX, lineOrPageDeltaY, isMomentum, + * isNoLineOrPageDelta, isCustomizedByPrefs, expectedOverflowDeltaX, + * expectedOverflowDeltaY + * + * deltaMode must be defined, others are ok even if undefined. + * + * expectedOverflowDeltaX and expectedOverflowDeltaY take integer value. The + * value is just checked as 0 or positive or negative. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeWheelAtPoint(aLeft, aTop, aEvent, aWindow = window) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return; + } + + var modifiers = _parseModifiers(aEvent, aWindow); + var options = 0; + if (aEvent.isNoLineOrPageDelta) { + options |= utils.WHEEL_EVENT_CAUSED_BY_NO_LINE_OR_PAGE_DELTA_DEVICE; + } + if (aEvent.isMomentum) { + options |= utils.WHEEL_EVENT_CAUSED_BY_MOMENTUM; + } + if (aEvent.isCustomizedByPrefs) { + options |= utils.WHEEL_EVENT_CUSTOMIZED_BY_USER_PREFS; + } + if (typeof aEvent.expectedOverflowDeltaX !== "undefined") { + if (aEvent.expectedOverflowDeltaX === 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_ZERO; + } else if (aEvent.expectedOverflowDeltaX > 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_POSITIVE; + } else { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_X_NEGATIVE; + } + } + if (typeof aEvent.expectedOverflowDeltaY !== "undefined") { + if (aEvent.expectedOverflowDeltaY === 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_ZERO; + } else if (aEvent.expectedOverflowDeltaY > 0) { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_POSITIVE; + } else { + options |= utils.WHEEL_EVENT_EXPECTED_OVERFLOW_DELTA_Y_NEGATIVE; + } + } + + // Avoid the JS warnings "reference to undefined property" + if (!aEvent.deltaX) { + aEvent.deltaX = 0; + } + if (!aEvent.deltaY) { + aEvent.deltaY = 0; + } + if (!aEvent.deltaZ) { + aEvent.deltaZ = 0; + } + + var lineOrPageDeltaX = + // eslint-disable-next-line no-nested-ternary + aEvent.lineOrPageDeltaX != null + ? aEvent.lineOrPageDeltaX + : aEvent.deltaX > 0 + ? Math.floor(aEvent.deltaX) + : Math.ceil(aEvent.deltaX); + var lineOrPageDeltaY = + // eslint-disable-next-line no-nested-ternary + aEvent.lineOrPageDeltaY != null + ? aEvent.lineOrPageDeltaY + : aEvent.deltaY > 0 + ? Math.floor(aEvent.deltaY) + : Math.ceil(aEvent.deltaY); + utils.sendWheelEvent( + aLeft, + aTop, + aEvent.deltaX, + aEvent.deltaY, + aEvent.deltaZ, + aEvent.deltaMode, + modifiers, + lineOrPageDeltaX, + lineOrPageDeltaY, + options + ); +} + +/** + * Synthesize a wheel event on a target. The actual client point is determined + * by taking the aTarget's client box and offseting it by aOffsetX and + * aOffsetY. + * + * aEvent is an object which may contain the properties: + * shiftKey, ctrlKey, altKey, metaKey, accessKey, deltaX, deltaY, deltaZ, + * deltaMode, lineOrPageDeltaX, lineOrPageDeltaY, isMomentum, + * isNoLineOrPageDelta, isCustomizedByPrefs, expectedOverflowDeltaX, + * expectedOverflowDeltaY + * + * deltaMode must be defined, others are ok even if undefined. + * + * expectedOverflowDeltaX and expectedOverflowDeltaY take integer value. The + * value is just checked as 0 or positive or negative. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeWheel(aTarget, aOffsetX, aOffsetY, aEvent, aWindow) { + var rect = aTarget.getBoundingClientRect(); + synthesizeWheelAtPoint( + rect.left + aOffsetX, + rect.top + aOffsetY, + aEvent, + aWindow + ); +} + +const _FlushModes = { + FLUSH: 0, + NOFLUSH: 1, +}; + +function _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aFlushMode = _FlushModes.FLUSH, + aWindow = window +) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return; + } + + if (utils.isMozAfterPaintPending) { + // If a paint is pending, then APZ may be waiting for a scroll acknowledgement + // from the content thread. If we send a wheel event now, it could be ignored + // by APZ (or its scroll offset could be overridden). To avoid problems we + // just wait for the paint to complete. + aWindow.waitForAllPaintsFlushed(function() { + _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aFlushMode, + aWindow + ); + }); + return; + } + + var onwheel = function() { + SpecialPowers.removeSystemEventListener(window, "wheel", onwheel); + + // Wait one frame since the wheel event has not caused a refresh observer + // to be added yet. + setTimeout(function() { + utils.advanceTimeAndRefresh(1000); + + if (!aCallback) { + utils.advanceTimeAndRefresh(0); + return; + } + + var waitForPaints = function() { + SpecialPowers.Services.obs.removeObserver( + waitForPaints, + "apz-repaints-flushed" + ); + aWindow.waitForAllPaintsFlushed(function() { + utils.restoreNormalRefresh(); + aCallback(); + }); + }; + + SpecialPowers.Services.obs.addObserver( + waitForPaints, + "apz-repaints-flushed" + ); + if (!utils.flushApzRepaints(aWindow)) { + waitForPaints(); + } + }, 0); + }; + + // Listen for the system wheel event, because it happens after all of + // the other wheel events, including legacy events. + SpecialPowers.addSystemEventListener(aWindow, "wheel", onwheel); + if (aFlushMode === _FlushModes.FLUSH) { + synthesizeWheel(aTarget, aOffsetX, aOffsetY, aEvent, aWindow); + } else { + synthesizeWheelAtPoint(aOffsetX, aOffsetY, aEvent, aWindow); + } +} + +/** + * This is a wrapper around synthesizeWheel that waits for the wheel event + * to be dispatched and for the subsequent layout/paints to be flushed. + * + * This requires including paint_listener.js. Tests must call + * DOMWindowUtils.restoreNormalRefresh() before finishing, if they use this + * function. + * + * If no callback is provided, the caller is assumed to have its own method of + * determining scroll completion and the refresh driver is not automatically + * restored. + */ +function sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aWindow = window +) { + _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + _FlushModes.FLUSH, + aWindow + ); +} + +/** + * Similar to sendWheelAndPaint but without flushing layout for obtaining + * ``aTarget`` position in ``aWindow`` before sending the wheel event. + * ``aOffsetX`` and ``aOffsetY`` should be offsets against aWindow. + */ +function sendWheelAndPaintNoFlush( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + aWindow = window +) { + _sendWheelAndPaint( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aCallback, + _FlushModes.NOFLUSH, + aWindow + ); +} + +function synthesizeNativeTapAtCenter( + aTarget, + aLongTap = false, + aCallback = null, + aWindow = window +) { + let rect = aTarget.getBoundingClientRect(); + return synthesizeNativeTap( + aTarget, + rect.width / 2, + rect.height / 2, + aLongTap, + aCallback, + aWindow + ); +} + +function synthesizeNativeTap( + aTarget, + aOffsetX, + aOffsetY, + aLongTap = false, + aCallback = null, + aWindow = window +) { + let utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return; + } + + let scale = aWindow.devicePixelRatio; + let rect = aTarget.getBoundingClientRect(); + let x = (aWindow.mozInnerScreenX + rect.left + aOffsetX) * scale; + let y = (aWindow.mozInnerScreenY + rect.top + aOffsetY) * scale; + + let observer = { + observe: (subject, topic, data) => { + if (aCallback && topic == "mouseevent") { + aCallback(data); + } + }, + }; + utils.sendNativeTouchTap(x, y, aLongTap, observer); +} + +/** + * Similar to synthesizeMouse but generates a native widget level event + * (so will actually move the "real" mouse cursor etc. Be careful because + * this can impact later code as well! (e.g. with hover states etc.) + * + * @description There are 3 mutually exclusive ways of indicating the location of the + * mouse event: set ``atCenter``, or pass ``offsetX`` and ``offsetY``, + * or pass ``screenX`` and ``screenY``. Do not attempt to mix these. + * + * @param {object} aParams + * @param {string} aParams.type "click", "mousedown", "mouseup" or "mousemove" + * @param {Element} aParams.target Origin of offsetX and offsetY, must be an element + * @param {Boolean} [aParams.atCenter] + * Instead of offsetX/Y, synthesize the event at center of `target`. + * @param {Number} [aParams.offsetX] + * X offset in `target` (in CSS pixels if `scale` is "screenPixelsPerCSSPixel") + * @param {Number} [aParams.offsetY] + * Y offset in `target` (in CSS pixels if `scale` is "screenPixelsPerCSSPixel") + * @param {Number} [aParams.screenX] + * X offset in screen (in CSS pixels if `scale` is "screenPixelsPerCSSPixel"), + * Neither offsetX/Y nor atCenter must be set if this is set. + * @param {Number} [aParams.screenY] + * Y offset in screen (in CSS pixels if `scale` is "screenPixelsPerCSSPixel"), + * Neither offsetX/Y nor atCenter must be set if this is set. + * @param {String} [aParams.scale="screenPixelsPerCSSPixel"] + * If scale is "screenPixelsPerCSSPixel", devicePixelRatio will be used. + * If scale is "inScreenPixels", clientX/Y nor scaleX/Y are not adjusted with screenPixelsPerCSSPixel. + * @param {Number} [aParams.button=0] + * Defaults to 0, if "click", "mousedown", "mouseup", set same value as DOM MouseEvent.button + * @param {Object} [aParams.modifiers={}] + * Active modifiers, see `_parseNativeModifiers` + * @param {Window} [aParams.win=window] + * The window to use its utils. Defaults to the window in which EventUtils.js is running. + * @param {Element} [aParams.elementOnWidget=target] + * Defaults to target. If element under the point is in another widget from target's widget, + * e.g., when it's in a XUL <panel>, specify this. + */ +function synthesizeNativeMouseEvent(aParams, aCallback = null) { + const { + type, + target, + offsetX, + offsetY, + atCenter, + screenX, + screenY, + scale = "screenPixelsPerCSSPixel", + button = 0, + modifiers = {}, + win = window, + elementOnWidget = target, + } = 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` + ); + } + if (!target) { + throw Error("atCenter is specified, but target is not 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` + ); + } + if (!target) { + throw Error( + "offsetX and offsetY are specified, but target is not 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 utils = _getDOMWindowUtils(win); + if (!utils) { + return; + } + + const rect = target?.getBoundingClientRect(); + let resolution = 1.0; + try { + resolution = _getDOMWindowUtils(win.top).getResolution(); + } catch (e) { + // XXX How to get mobile viewport scale on Fission+xorigin since + // window.top access isn't allowed due to cross-origin? + } + const scaleValue = (() => { + if (scale === "inScreenPixels") { + return 1.0; + } + if (scale === "screenPixelsPerCSSPixel") { + return win.devicePixelRatio; + } + throw Error(`invalid scale value (${scale}) is specified`); + })(); + // XXX mozInnerScreen might be invalid value on mobile viewport (Bug 1701546), + // so use window.top's mozInnerScreen. But this won't work fission+xorigin + // with mobile viewport until mozInnerScreen returns valid value with + // scale. + const x = (() => { + if (screenX != undefined) { + return screenX * scaleValue; + } + let winInnerOffsetX = win.mozInnerScreenX; + try { + winInnerOffsetX = + win.top.mozInnerScreenX + + (win.mozInnerScreenX - win.top.mozInnerScreenX) * resolution; + } catch (e) { + // XXX fission+xorigin test throws permission denied since win.top is + // cross-origin. + } + return ( + (((atCenter ? rect.width / 2 : offsetX) + rect.left) * resolution + + winInnerOffsetX) * + scaleValue + ); + })(); + const y = (() => { + if (screenY != undefined) { + return screenY * scaleValue; + } + let winInnerOffsetY = win.mozInnerScreenY; + try { + winInnerOffsetY = + win.top.mozInnerScreenY + + (win.mozInnerScreenY - win.top.mozInnerScreenY) * resolution; + } catch (e) { + // XXX fission+xorigin test throws permission denied since win.top is + // cross-origin. + } + return ( + (((atCenter ? rect.height / 2 : offsetY) + rect.top) * resolution + + winInnerOffsetY) * + scaleValue + ); + })(); + const modifierFlags = _parseNativeModifiers(modifiers); + + const observer = { + observe: (subject, topic, data) => { + if (aCallback && topic == "mouseevent") { + aCallback(data); + } + }, + }; + if (type === "click") { + utils.sendNativeMouseEvent( + x, + y, + utils.NATIVE_MOUSE_MESSAGE_BUTTON_DOWN, + button, + modifierFlags, + elementOnWidget, + function() { + utils.sendNativeMouseEvent( + x, + y, + utils.NATIVE_MOUSE_MESSAGE_BUTTON_UP, + button, + modifierFlags, + elementOnWidget, + observer + ); + } + ); + return; + } + utils.sendNativeMouseEvent( + x, + 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, + elementOnWidget, + observer + ); +} + +function promiseNativeMouseEvent(aParams) { + return new Promise(resolve => synthesizeNativeMouseEvent(aParams, resolve)); +} + +function synthesizeNativeMouseEventAndWaitForEvent(aParams, aCallback) { + const listener = aParams.eventTargetToListen || aParams.target; + const eventType = aParams.eventTypeToWait || aParams.type; + listener.addEventListener(eventType, aCallback, { + capture: true, + once: true, + }); + synthesizeNativeMouseEvent(aParams); +} + +function promiseNativeMouseEventAndWaitForEvent(aParams) { + return new Promise(resolve => + synthesizeNativeMouseEventAndWaitForEvent(aParams, resolve) + ); +} + +/** + * This is a wrapper around synthesizeNativeMouseEvent that waits for the mouse + * event to be dispatched to the target content. + * + * This API is supposed to be used in those test cases that synthesize some + * input events to chrome process and have some checks in content. + */ +function synthesizeAndWaitNativeMouseMove( + aTarget, + aOffsetX, + aOffsetY, + aCallback, + aWindow = window +) { + let browser = gBrowser.selectedTab.linkedBrowser; + let mm = browser.messageManager; + let { ContentTask } = _EU_ChromeUtils.importESModule( + "resource://testing-common/ContentTask.sys.mjs" + ); + + let eventRegisteredPromise = new Promise(resolve => { + mm.addMessageListener("Test:MouseMoveRegistered", function processed( + message + ) { + mm.removeMessageListener("Test:MouseMoveRegistered", processed); + resolve(); + }); + }); + let eventReceivedPromise = ContentTask.spawn( + browser, + [aOffsetX, aOffsetY], + ([clientX, clientY]) => { + return new Promise(resolve => { + addEventListener("mousemove", function onMouseMoveEvent(e) { + if (e.clientX == clientX && e.clientY == clientY) { + removeEventListener("mousemove", onMouseMoveEvent); + resolve(); + } + }); + sendAsyncMessage("Test:MouseMoveRegistered"); + }); + } + ); + eventRegisteredPromise.then(() => { + synthesizeNativeMouseEvent({ + type: "mousemove", + target: aTarget, + offsetX: aOffsetX, + offsetY: aOffsetY, + win: aWindow, + }); + }); + return eventReceivedPromise; +} + +/** + * Synthesize a key event. It is targeted at whatever would be targeted by an + * actual keypress by the user, typically the focused element. + * + * @param {String} aKey + * Should be either: + * + * - key value (recommended). If you specify a non-printable key name, + * prepend the ``KEY_`` prefix. Otherwise, specifying a printable key, the + * key value should be specified. + * + * - keyCode name starting with ``VK_`` (e.g., ``VK_RETURN``). This is available + * only for compatibility with legacy API. Don't use this with new tests. + * + * @param {Object} [aEvent] + * Optional event object with more specifics about the key event to + * synthesize. + * @param {String} [aEvent.code] + * If you don't specify this explicitly, it'll be guessed from aKey + * of US keyboard layout. Note that this value may be different + * between browsers. For example, "Insert" is never set only on + * macOS since actual key operation won't cause this code value. + * In such case, the value becomes empty string. + * If you need to emulate non-US keyboard layout or virtual keyboard + * which doesn't emulate hardware key input, you should set this value + * to empty string explicitly. + * @param {Number} [aEvent.repeat] + * If you emulate auto-repeat, you should set the count of repeat. + * This method will automatically synthesize keydown (and keypress). + * @param {*} aEvent.location + * If you want to specify this, you can specify this explicitly. + * However, if you don't specify this value, it will be computed + * from code value. + * @param {String} aEvent.type + * Basically, you shouldn't specify this. Then, this function will + * synthesize keydown (, keypress) and keyup. + * If keydown is specified, this only fires keydown (and keypress if + * it should be fired). + * If keyup is specified, this only fires keyup. + * @param {Number} aEvent.keyCode + * Must be 0 - 255 (0xFF). If this is specified explicitly, + * .keyCode value is initialized with this value. + * @param {Window} aWindow + * Is optional and defaults to the current window object. + * @param {Function} aCallback + * Is optional and can be used to receive notifications from TIP. + * + * @description + * ``accelKey``, ``altKey``, ``altGraphKey``, ``ctrlKey``, ``capsLockKey``, + * ``fnKey``, ``fnLockKey``, ``numLockKey``, ``metaKey``, ``osKey``, + * ``scrollLockKey``, ``shiftKey``, ``symbolKey``, ``symbolLockKey`` + * Basically, you shouldn't use these attributes. nsITextInputProcessor + * manages modifier key state when you synthesize modifier key events. + * However, if some of these attributes are true, this function activates + * the modifiers only during dispatching the key events. + * Note that if some of these values are false, they are ignored (i.e., + * not inactivated with this function). + * + */ +function synthesizeKey(aKey, aEvent = undefined, aWindow = window, aCallback) { + var event = aEvent === undefined || aEvent === null ? {} : aEvent; + + var TIP = _getTIP(aWindow, aCallback); + if (!TIP) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + var modifiers = _emulateToActivateModifiers(TIP, event, aWindow); + var keyEventDict = _createKeyboardEventDictionary(aKey, event, TIP, aWindow); + var keyEvent = new KeyboardEvent("", keyEventDict.dictionary); + var dispatchKeydown = + !("type" in event) || event.type === "keydown" || !event.type; + var dispatchKeyup = + !("type" in event) || event.type === "keyup" || !event.type; + + try { + if (dispatchKeydown) { + TIP.keydown(keyEvent, keyEventDict.flags); + if ("repeat" in event && event.repeat > 1) { + keyEventDict.dictionary.repeat = true; + var repeatedKeyEvent = new KeyboardEvent("", keyEventDict.dictionary); + for (var i = 1; i < event.repeat; i++) { + TIP.keydown(repeatedKeyEvent, keyEventDict.flags); + } + } + } + if (dispatchKeyup) { + TIP.keyup(keyEvent, keyEventDict.flags); + } + } finally { + _emulateToInactivateModifiers(TIP, modifiers, aWindow); + } +} + +/** + * This is a wrapper around synthesizeKey that waits for the key event to be + * dispatched to the target content. It returns a promise which is resolved + * when the content receives the key event. + * + * This API is supposed to be used in those test cases that synthesize some + * input events to chrome process and have some checks in content. + */ +function synthesizeAndWaitKey( + aKey, + aEvent, + aWindow = window, + checkBeforeSynthesize, + checkAfterSynthesize +) { + let browser = gBrowser.selectedTab.linkedBrowser; + let mm = browser.messageManager; + let keyCode = _createKeyboardEventDictionary(aKey, aEvent, null, aWindow) + .dictionary.keyCode; + let { ContentTask } = _EU_ChromeUtils.importESModule( + "resource://testing-common/ContentTask.sys.mjs" + ); + + let keyRegisteredPromise = new Promise(resolve => { + mm.addMessageListener("Test:KeyRegistered", function processed(message) { + mm.removeMessageListener("Test:KeyRegistered", processed); + resolve(); + }); + }); + // eslint-disable-next-line no-shadow + let keyReceivedPromise = ContentTask.spawn(browser, keyCode, keyCode => { + return new Promise(resolve => { + addEventListener("keyup", function onKeyEvent(e) { + if (e.keyCode == keyCode) { + removeEventListener("keyup", onKeyEvent); + resolve(); + } + }); + sendAsyncMessage("Test:KeyRegistered"); + }); + }); + keyRegisteredPromise.then(() => { + if (checkBeforeSynthesize) { + checkBeforeSynthesize(); + } + synthesizeKey(aKey, aEvent, aWindow); + if (checkAfterSynthesize) { + checkAfterSynthesize(); + } + }); + return keyReceivedPromise; +} + +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; +} + +// Mac: Any unused number is okay for adding new keyboard layout. +// When you add new keyboard layout here, you need to modify +// TISInputSourceWrapper::InitByLayoutID(). +// Win: These constants can be found by inspecting registry keys under +// HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Keyboard Layouts + +const KEYBOARD_LAYOUT_ARABIC = { + name: "Arabic", + Mac: 6, + Win: 0x00000401, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_ARABIC_PC = { + name: "Arabic - PC", + Mac: 7, + Win: null, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_BRAZILIAN_ABNT = { + name: "Brazilian ABNT", + Mac: null, + Win: 0x00000416, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_DVORAK_QWERTY = { + name: "Dvorak-QWERTY", + Mac: 4, + Win: null, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_EN_US = { + name: "US", + Mac: 0, + Win: 0x00000409, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_FRENCH = { + name: "French", + Mac: 8, + Win: 0x0000040c, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_GREEK = { + name: "Greek", + Mac: 1, + Win: 0x00000408, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_GERMAN = { + name: "German", + Mac: 2, + Win: 0x00000407, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_HEBREW = { + name: "Hebrew", + Mac: 9, + Win: 0x0000040d, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_JAPANESE = { + name: "Japanese", + Mac: null, + Win: 0x00000411, + hasAltGrOnWin: false, +}; +const KEYBOARD_LAYOUT_KHMER = { + name: "Khmer", + Mac: null, + Win: 0x00000453, + hasAltGrOnWin: true, +}; // available on Win7 or later. +const KEYBOARD_LAYOUT_LITHUANIAN = { + name: "Lithuanian", + Mac: 10, + Win: 0x00010427, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_NORWEGIAN = { + name: "Norwegian", + Mac: 11, + Win: 0x00000414, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_RUSSIAN_MNEMONIC = { + name: "Russian - Mnemonic", + Mac: null, + Win: 0x00020419, + hasAltGrOnWin: true, +}; // available on Win8 or later. +const KEYBOARD_LAYOUT_SPANISH = { + name: "Spanish", + Mac: 12, + Win: 0x0000040a, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_SWEDISH = { + name: "Swedish", + Mac: 3, + Win: 0x0000041d, + hasAltGrOnWin: true, +}; +const KEYBOARD_LAYOUT_THAI = { + name: "Thai", + Mac: 5, + Win: 0x0002041e, + hasAltGrOnWin: false, +}; + +/** + * synthesizeNativeKey() dispatches native key event on active window. + * This is implemented only on Windows and Mac. Note that this function + * dispatches the key event asynchronously and returns immediately. If a + * callback function is provided, the callback will be called upon + * completion of the key dispatch. + * + * @param aKeyboardLayout One of KEYBOARD_LAYOUT_* defined above. + * @param aNativeKeyCode A native keycode value defined in + * NativeKeyCodes.js. + * @param aModifiers Modifier keys. If no modifire key is pressed, + * this must be {}. Otherwise, one or more items + * referred in _parseNativeModifiers() must be + * true. + * @param aChars Specify characters which should be generated + * by the key event. + * @param aUnmodifiedChars Specify characters of unmodified (except Shift) + * aChar value. + * @param aCallback If provided, this callback will be invoked + * once the native keys have been processed + * by Gecko. Will never be called if this + * function returns false. + * @return True if this function succeed dispatching + * native key event. Otherwise, false. + */ + +function synthesizeNativeKey( + aKeyboardLayout, + aNativeKeyCode, + aModifiers, + aChars, + aUnmodifiedChars, + aCallback, + aWindow = window +) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return false; + } + var nativeKeyboardLayout = null; + if (_EU_isMac(aWindow)) { + nativeKeyboardLayout = aKeyboardLayout.Mac; + } else if (_EU_isWin(aWindow)) { + nativeKeyboardLayout = aKeyboardLayout.Win; + } + if (nativeKeyboardLayout === null) { + return false; + } + + var observer = { + observe(aSubject, aTopic, aData) { + if (aCallback && aTopic == "keyevent") { + aCallback(aData); + } + }, + }; + utils.sendNativeKeyEvent( + nativeKeyboardLayout, + aNativeKeyCode, + _parseNativeModifiers(aModifiers, aWindow), + aChars, + aUnmodifiedChars, + observer + ); + return true; +} + +var _gSeenEvent = false; + +/** + * Indicate that an event with an original target of aExpectedTarget and + * a type of aExpectedEvent is expected to be fired, or not expected to + * be fired. + */ +function _expectEvent(aExpectedTarget, aExpectedEvent, aTestName) { + if (!aExpectedTarget || !aExpectedEvent) { + return null; + } + + _gSeenEvent = false; + + var type = + aExpectedEvent.charAt(0) == "!" + ? aExpectedEvent.substring(1) + : aExpectedEvent; + var eventHandler = function(event) { + var epassed = + !_gSeenEvent && + event.originalTarget == aExpectedTarget && + event.type == type; + is( + epassed, + true, + aTestName + " " + type + " event target " + (_gSeenEvent ? "twice" : "") + ); + _gSeenEvent = true; + }; + + aExpectedTarget.addEventListener(type, eventHandler); + return eventHandler; +} + +/** + * Check if the event was fired or not. The event handler aEventHandler + * will be removed. + */ +function _checkExpectedEvent( + aExpectedTarget, + aExpectedEvent, + aEventHandler, + aTestName +) { + if (aEventHandler) { + var expectEvent = aExpectedEvent.charAt(0) != "!"; + var type = expectEvent ? aExpectedEvent : aExpectedEvent.substring(1); + aExpectedTarget.removeEventListener(type, aEventHandler); + var desc = type + " event"; + if (!expectEvent) { + desc += " not"; + } + is(_gSeenEvent, expectEvent, aTestName + " " + desc + " fired"); + } + + _gSeenEvent = false; +} + +/** + * Similar to synthesizeMouse except that a test is performed to see if an + * event is fired at the right target as a result. + * + * aExpectedTarget - the expected originalTarget of the event. + * aExpectedEvent - the expected type of the event, such as 'select'. + * aTestName - the test name when outputing results + * + * To test that an event is not fired, use an expected type preceded by an + * exclamation mark, such as '!select'. This might be used to test that a + * click on a disabled element doesn't fire certain events for instance. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeMouseExpectEvent( + aTarget, + aOffsetX, + aOffsetY, + aEvent, + aExpectedTarget, + aExpectedEvent, + aTestName, + aWindow +) { + var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName); + synthesizeMouse(aTarget, aOffsetX, aOffsetY, aEvent, aWindow); + _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName); +} + +/** + * Similar to synthesizeKey except that a test is performed to see if an + * event is fired at the right target as a result. + * + * aExpectedTarget - the expected originalTarget of the event. + * aExpectedEvent - the expected type of the event, such as 'select'. + * aTestName - the test name when outputing results + * + * To test that an event is not fired, use an expected type preceded by an + * exclamation mark, such as '!select'. + * + * aWindow is optional, and defaults to the current window object. + */ +function synthesizeKeyExpectEvent( + key, + aEvent, + aExpectedTarget, + aExpectedEvent, + aTestName, + aWindow +) { + var eventHandler = _expectEvent(aExpectedTarget, aExpectedEvent, aTestName); + synthesizeKey(key, aEvent, aWindow); + _checkExpectedEvent(aExpectedTarget, aExpectedEvent, eventHandler, aTestName); +} + +function disableNonTestMouseEvents(aDisable) { + var domutils = _getDOMWindowUtils(); + domutils.disableNonTestMouseEvents(aDisable); +} + +function _getDOMWindowUtils(aWindow = window) { + // Leave this here as something, somewhere, passes a falsy argument + // to this, causing the |window| default argument not to get picked up. + if (!aWindow) { + aWindow = window; + } + + // If documentURIObject exists or `window` is a stub object, we're in + // a chrome scope, so don't bother trying to go through SpecialPowers. + if (!window.document || window.document.documentURIObject) { + return aWindow.windowUtils; + } + + // we need parent.SpecialPowers for: + // layout/base/tests/test_reftests_with_caret.html + // chrome: toolkit/content/tests/chrome/test_findbar.xul + // chrome: toolkit/content/tests/chrome/test_popup_anchor.xul + if ("SpecialPowers" in window && window.SpecialPowers != undefined) { + return SpecialPowers.getDOMWindowUtils(aWindow); + } + if ("SpecialPowers" in parent && parent.SpecialPowers != undefined) { + return parent.SpecialPowers.getDOMWindowUtils(aWindow); + } + + // TODO: this is assuming we are in chrome space + return aWindow.windowUtils; +} + +function _defineConstant(name, value) { + Object.defineProperty(this, name, { + value, + enumerable: true, + writable: false, + }); +} + +const COMPOSITION_ATTR_RAW_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE; +_defineConstant("COMPOSITION_ATTR_RAW_CLAUSE", COMPOSITION_ATTR_RAW_CLAUSE); +const COMPOSITION_ATTR_SELECTED_RAW_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_SELECTED_RAW_CLAUSE; +_defineConstant( + "COMPOSITION_ATTR_SELECTED_RAW_CLAUSE", + COMPOSITION_ATTR_SELECTED_RAW_CLAUSE +); +const COMPOSITION_ATTR_CONVERTED_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_CONVERTED_CLAUSE; +_defineConstant( + "COMPOSITION_ATTR_CONVERTED_CLAUSE", + COMPOSITION_ATTR_CONVERTED_CLAUSE +); +const COMPOSITION_ATTR_SELECTED_CLAUSE = + _EU_Ci.nsITextInputProcessor.ATTR_SELECTED_CLAUSE; +_defineConstant( + "COMPOSITION_ATTR_SELECTED_CLAUSE", + COMPOSITION_ATTR_SELECTED_CLAUSE +); + +var TIPMap = new WeakMap(); + +function _getTIP(aWindow, aCallback) { + if (!aWindow) { + aWindow = window; + } + var tip; + if (TIPMap.has(aWindow)) { + tip = TIPMap.get(aWindow); + } else { + tip = _EU_Cc["@mozilla.org/text-input-processor;1"].createInstance( + _EU_Ci.nsITextInputProcessor + ); + TIPMap.set(aWindow, tip); + } + if (!tip.beginInputTransactionForTests(aWindow, aCallback)) { + tip = null; + TIPMap.delete(aWindow); + } + return tip; +} + +function _getKeyboardEvent(aWindow = window) { + if (typeof KeyboardEvent != "undefined") { + try { + // See if the object can be instantiated; sometimes this yields + // 'TypeError: can't access dead object' or 'KeyboardEvent is not a constructor'. + new KeyboardEvent("", {}); + return KeyboardEvent; + } catch (ex) {} + } + if (typeof content != "undefined" && "KeyboardEvent" in content) { + return content.KeyboardEvent; + } + return aWindow.KeyboardEvent; +} + +// eslint-disable-next-line complexity +function _guessKeyNameFromKeyCode(aKeyCode, aWindow = window) { + var KeyboardEvent = _getKeyboardEvent(aWindow); + switch (aKeyCode) { + case KeyboardEvent.DOM_VK_CANCEL: + return "Cancel"; + case KeyboardEvent.DOM_VK_HELP: + return "Help"; + case KeyboardEvent.DOM_VK_BACK_SPACE: + return "Backspace"; + case KeyboardEvent.DOM_VK_TAB: + return "Tab"; + case KeyboardEvent.DOM_VK_CLEAR: + return "Clear"; + case KeyboardEvent.DOM_VK_RETURN: + return "Enter"; + case KeyboardEvent.DOM_VK_SHIFT: + return "Shift"; + case KeyboardEvent.DOM_VK_CONTROL: + return "Control"; + case KeyboardEvent.DOM_VK_ALT: + return "Alt"; + case KeyboardEvent.DOM_VK_PAUSE: + return "Pause"; + case KeyboardEvent.DOM_VK_EISU: + return "Eisu"; + case KeyboardEvent.DOM_VK_ESCAPE: + return "Escape"; + case KeyboardEvent.DOM_VK_CONVERT: + return "Convert"; + case KeyboardEvent.DOM_VK_NONCONVERT: + return "NonConvert"; + case KeyboardEvent.DOM_VK_ACCEPT: + return "Accept"; + case KeyboardEvent.DOM_VK_MODECHANGE: + return "ModeChange"; + case KeyboardEvent.DOM_VK_PAGE_UP: + return "PageUp"; + case KeyboardEvent.DOM_VK_PAGE_DOWN: + return "PageDown"; + case KeyboardEvent.DOM_VK_END: + return "End"; + case KeyboardEvent.DOM_VK_HOME: + return "Home"; + case KeyboardEvent.DOM_VK_LEFT: + return "ArrowLeft"; + case KeyboardEvent.DOM_VK_UP: + return "ArrowUp"; + case KeyboardEvent.DOM_VK_RIGHT: + return "ArrowRight"; + case KeyboardEvent.DOM_VK_DOWN: + return "ArrowDown"; + case KeyboardEvent.DOM_VK_SELECT: + return "Select"; + case KeyboardEvent.DOM_VK_PRINT: + return "Print"; + case KeyboardEvent.DOM_VK_EXECUTE: + return "Execute"; + case KeyboardEvent.DOM_VK_PRINTSCREEN: + return "PrintScreen"; + case KeyboardEvent.DOM_VK_INSERT: + return "Insert"; + case KeyboardEvent.DOM_VK_DELETE: + return "Delete"; + case KeyboardEvent.DOM_VK_WIN: + return "OS"; + case KeyboardEvent.DOM_VK_CONTEXT_MENU: + return "ContextMenu"; + case KeyboardEvent.DOM_VK_SLEEP: + return "Standby"; + case KeyboardEvent.DOM_VK_F1: + return "F1"; + case KeyboardEvent.DOM_VK_F2: + return "F2"; + case KeyboardEvent.DOM_VK_F3: + return "F3"; + case KeyboardEvent.DOM_VK_F4: + return "F4"; + case KeyboardEvent.DOM_VK_F5: + return "F5"; + case KeyboardEvent.DOM_VK_F6: + return "F6"; + case KeyboardEvent.DOM_VK_F7: + return "F7"; + case KeyboardEvent.DOM_VK_F8: + return "F8"; + case KeyboardEvent.DOM_VK_F9: + return "F9"; + case KeyboardEvent.DOM_VK_F10: + return "F10"; + case KeyboardEvent.DOM_VK_F11: + return "F11"; + case KeyboardEvent.DOM_VK_F12: + return "F12"; + case KeyboardEvent.DOM_VK_F13: + return "F13"; + case KeyboardEvent.DOM_VK_F14: + return "F14"; + case KeyboardEvent.DOM_VK_F15: + return "F15"; + case KeyboardEvent.DOM_VK_F16: + return "F16"; + case KeyboardEvent.DOM_VK_F17: + return "F17"; + case KeyboardEvent.DOM_VK_F18: + return "F18"; + case KeyboardEvent.DOM_VK_F19: + return "F19"; + case KeyboardEvent.DOM_VK_F20: + return "F20"; + case KeyboardEvent.DOM_VK_F21: + return "F21"; + case KeyboardEvent.DOM_VK_F22: + return "F22"; + case KeyboardEvent.DOM_VK_F23: + return "F23"; + case KeyboardEvent.DOM_VK_F24: + return "F24"; + case KeyboardEvent.DOM_VK_NUM_LOCK: + return "NumLock"; + case KeyboardEvent.DOM_VK_SCROLL_LOCK: + return "ScrollLock"; + case KeyboardEvent.DOM_VK_VOLUME_MUTE: + return "AudioVolumeMute"; + case KeyboardEvent.DOM_VK_VOLUME_DOWN: + return "AudioVolumeDown"; + case KeyboardEvent.DOM_VK_VOLUME_UP: + return "AudioVolumeUp"; + case KeyboardEvent.DOM_VK_META: + return "Meta"; + case KeyboardEvent.DOM_VK_ALTGR: + return "AltGraph"; + case KeyboardEvent.DOM_VK_PROCESSKEY: + return "Process"; + case KeyboardEvent.DOM_VK_ATTN: + return "Attn"; + case KeyboardEvent.DOM_VK_CRSEL: + return "CrSel"; + case KeyboardEvent.DOM_VK_EXSEL: + return "ExSel"; + case KeyboardEvent.DOM_VK_EREOF: + return "EraseEof"; + case KeyboardEvent.DOM_VK_PLAY: + return "Play"; + default: + return "Unidentified"; + } +} + +function _createKeyboardEventDictionary( + aKey, + aKeyEvent, + aTIP = null, + aWindow = window +) { + var result = { dictionary: null, flags: 0 }; + var keyCodeIsDefined = "keyCode" in aKeyEvent; + var keyCode = + keyCodeIsDefined && aKeyEvent.keyCode >= 0 && aKeyEvent.keyCode <= 255 + ? aKeyEvent.keyCode + : 0; + var keyName = "Unidentified"; + var code = aKeyEvent.code; + if (!aTIP) { + aTIP = _getTIP(aWindow); + } + if (aKey.indexOf("KEY_") == 0) { + keyName = aKey.substr("KEY_".length); + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY; + if (code === undefined) { + code = aTIP.computeCodeValueOfNonPrintableKey( + keyName, + aKeyEvent.location + ); + } + } else if (aKey.indexOf("VK_") == 0) { + keyCode = _getKeyboardEvent(aWindow)["DOM_" + aKey]; + if (!keyCode) { + throw new Error("Unknown key: " + aKey); + } + keyName = _guessKeyNameFromKeyCode(keyCode, aWindow); + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY; + if (code === undefined) { + code = aTIP.computeCodeValueOfNonPrintableKey( + keyName, + aKeyEvent.location + ); + } + } else if (aKey != "") { + keyName = aKey; + if (!keyCodeIsDefined) { + keyCode = aTIP.guessKeyCodeValueOfPrintableKeyInUSEnglishKeyboardLayout( + aKey, + aKeyEvent.location + ); + } + if (!keyCode) { + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_KEEP_KEYCODE_ZERO; + } + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY; + if (code === undefined) { + code = aTIP.guessCodeValueOfPrintableKeyInUSEnglishKeyboardLayout( + keyName, + aKeyEvent.location + ); + } + } + var locationIsDefined = "location" in aKeyEvent; + if (locationIsDefined && aKeyEvent.location === 0) { + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_KEEP_KEY_LOCATION_STANDARD; + } + if (aKeyEvent.doNotMarkKeydownAsProcessed) { + result.flags |= + _EU_Ci.nsITextInputProcessor.KEY_DONT_MARK_KEYDOWN_AS_PROCESSED; + } + if (aKeyEvent.markKeyupAsProcessed) { + result.flags |= _EU_Ci.nsITextInputProcessor.KEY_MARK_KEYUP_AS_PROCESSED; + } + result.dictionary = { + key: keyName, + code, + location: locationIsDefined ? aKeyEvent.location : 0, + repeat: "repeat" in aKeyEvent ? aKeyEvent.repeat === true : false, + keyCode, + }; + return result; +} + +function _emulateToActivateModifiers(aTIP, aKeyEvent, aWindow = window) { + if (!aKeyEvent) { + return null; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + + var modifiers = { + normal: [ + { key: "Alt", attr: "altKey" }, + { key: "AltGraph", attr: "altGraphKey" }, + { key: "Control", attr: "ctrlKey" }, + { key: "Fn", attr: "fnKey" }, + { key: "Meta", attr: "metaKey" }, + { key: "OS", attr: "osKey" }, + { key: "Shift", attr: "shiftKey" }, + { key: "Symbol", attr: "symbolKey" }, + { key: _EU_isMac(aWindow) ? "Meta" : "Control", attr: "accelKey" }, + ], + lockable: [ + { key: "CapsLock", attr: "capsLockKey" }, + { key: "FnLock", attr: "fnLockKey" }, + { key: "NumLock", attr: "numLockKey" }, + { key: "ScrollLock", attr: "scrollLockKey" }, + { key: "SymbolLock", attr: "symbolLockKey" }, + ], + }; + + for (let i = 0; i < modifiers.normal.length; i++) { + if (!aKeyEvent[modifiers.normal[i].attr]) { + continue; + } + if (aTIP.getModifierState(modifiers.normal[i].key)) { + continue; // already activated. + } + let event = new KeyboardEvent("", { key: modifiers.normal[i].key }); + aTIP.keydown( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + modifiers.normal[i].activated = true; + } + for (let i = 0; i < modifiers.lockable.length; i++) { + if (!aKeyEvent[modifiers.lockable[i].attr]) { + continue; + } + if (aTIP.getModifierState(modifiers.lockable[i].key)) { + continue; // already activated. + } + let event = new KeyboardEvent("", { key: modifiers.lockable[i].key }); + aTIP.keydown( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + aTIP.keyup( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + modifiers.lockable[i].activated = true; + } + return modifiers; +} + +function _emulateToInactivateModifiers(aTIP, aModifiers, aWindow = window) { + if (!aModifiers) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + for (let i = 0; i < aModifiers.normal.length; i++) { + if (!aModifiers.normal[i].activated) { + continue; + } + let event = new KeyboardEvent("", { key: aModifiers.normal[i].key }); + aTIP.keyup( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + } + for (let i = 0; i < aModifiers.lockable.length; i++) { + if (!aModifiers.lockable[i].activated) { + continue; + } + if (!aTIP.getModifierState(aModifiers.lockable[i].key)) { + continue; // who already inactivated this? + } + let event = new KeyboardEvent("", { key: aModifiers.lockable[i].key }); + aTIP.keydown( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + aTIP.keyup( + event, + aTIP.KEY_NON_PRINTABLE_KEY | aTIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + } +} + +/** + * Synthesize a composition event and keydown event and keyup events unless + * you prevent to dispatch them explicitly (see aEvent.key's explanation). + * + * Note that you shouldn't call this with "compositionstart" unless you need to + * test compositionstart event which is NOT followed by compositionupdate + * event immediately. Typically, native IME starts composition with + * a pair of keydown and keyup event and dispatch compositionstart and + * compositionupdate (and non-standard text event) between them. So, in most + * cases, you should call synthesizeCompositionChange() directly. + * If you call this with compositionstart, keyup event will be fired + * immediately after compositionstart. In other words, you should use + * "compositionstart" only when you need to emulate IME which just starts + * composition with compositionstart event but does not send composing text to + * us until committing the composition. This is behavior of some Chinese IMEs. + * + * @param aEvent The composition event information. This must + * have |type| member. The value must be + * "compositionstart", "compositionend", + * "compositioncommitasis" or "compositioncommit". + * + * And also this may have |data| and |locale| which + * would be used for the value of each property of + * the composition event. Note that the |data| is + * ignored if the event type is "compositionstart" + * or "compositioncommitasis". + * + * If |key| is undefined, "keydown" and "keyup" + * events which are marked as "processed by IME" + * are dispatched. If |key| is not null, "keydown" + * and/or "keyup" events are dispatched (if the + * |key.type| is specified as "keydown", only + * "keydown" event is dispatched). Otherwise, + * i.e., if |key| is null, neither "keydown" nor + * "keyup" event is dispatched. + * + * If |key.doNotMarkKeydownAsProcessed| is not true, + * key value and keyCode value of "keydown" event + * will be set to "Process" and DOM_VK_PROCESSKEY. + * If |key.markKeyupAsProcessed| is true, + * key value and keyCode value of "keyup" event + * will be set to "Process" and DOM_VK_PROCESSKEY. + * @param aWindow Optional (If null, current |window| will be used) + * @param aCallback Optional (If non-null, use the callback for + * receiving notifications to IME) + */ +function synthesizeComposition(aEvent, aWindow = window, aCallback) { + var TIP = _getTIP(aWindow, aCallback); + if (!TIP) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + var modifiers = _emulateToActivateModifiers(TIP, aEvent.key, aWindow); + var keyEventDict = { dictionary: null, flags: 0 }; + var keyEvent = null; + if (aEvent.key && typeof aEvent.key.key === "string") { + keyEventDict = _createKeyboardEventDictionary( + aEvent.key.key, + aEvent.key, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent( + // eslint-disable-next-line no-nested-ternary + aEvent.key.type === "keydown" + ? "keydown" + : aEvent.key.type === "keyup" + ? "keyup" + : "", + keyEventDict.dictionary + ); + } else if (aEvent.key === undefined) { + keyEventDict = _createKeyboardEventDictionary( + "KEY_Process", + {}, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent("", keyEventDict.dictionary); + } + try { + switch (aEvent.type) { + case "compositionstart": + TIP.startComposition(keyEvent, keyEventDict.flags); + break; + case "compositioncommitasis": + TIP.commitComposition(keyEvent, keyEventDict.flags); + break; + case "compositioncommit": + TIP.commitCompositionWith(aEvent.data, keyEvent, keyEventDict.flags); + break; + } + } finally { + _emulateToInactivateModifiers(TIP, modifiers, aWindow); + } +} +/** + * Synthesize eCompositionChange event which causes a DOM text event, may + * cause compositionupdate event, and causes keydown event and keyup event + * unless you prevent to dispatch them explicitly (see aEvent.key's + * explanation). + * + * Note that if you call this when there is no composition, compositionstart + * event will be fired automatically. This is better than you use + * synthesizeComposition("compositionstart") in most cases. See the + * explanation of synthesizeComposition(). + * + * @param aEvent The compositionchange event's information, this has + * |composition| and |caret| members. |composition| has + * |string| and |clauses| members. |clauses| must be array + * object. Each object has |length| and |attr|. And |caret| + * has |start| and |length|. See the following tree image. + * + * aEvent + * +-- composition + * | +-- string + * | +-- clauses[] + * | +-- length + * | +-- attr + * +-- caret + * | +-- start + * | +-- length + * +-- key + * + * Set the composition string to |composition.string|. Set its + * clauses information to the |clauses| array. + * + * When it's composing, set the each clauses' length to the + * |composition.clauses[n].length|. The sum of the all length + * values must be same as the length of |composition.string|. + * Set nsICompositionStringSynthesizer.ATTR_* to the + * |composition.clauses[n].attr|. + * + * When it's not composing, set 0 to the + * |composition.clauses[0].length| and + * |composition.clauses[0].attr|. + * + * Set caret position to the |caret.start|. It's offset from + * the start of the composition string. Set caret length to + * |caret.length|. If it's larger than 0, it should be wide + * caret. However, current nsEditor doesn't support wide + * caret, therefore, you should always set 0 now. + * + * If |key| is undefined, "keydown" and "keyup" events which + * are marked as "processed by IME" are dispatched. If |key| + * is not null, "keydown" and/or "keyup" events are dispatched + * (if the |key.type| is specified as "keydown", only "keydown" + * event is dispatched). Otherwise, i.e., if |key| is null, + * neither "keydown" nor "keyup" event is dispatched. + * If |key.doNotMarkKeydownAsProcessed| is not true, key value + * and keyCode value of "keydown" event will be set to + * "Process" and DOM_VK_PROCESSKEY. + * If |key.markKeyupAsProcessed| is true key value and keyCode + * value of "keyup" event will be set to "Process" and + * DOM_VK_PROCESSKEY. + * + * @param aWindow Optional (If null, current |window| will be used) + * @param aCallback Optional (If non-null, use the callback for receiving + * notifications to IME) + */ +function synthesizeCompositionChange(aEvent, aWindow = window, aCallback) { + var TIP = _getTIP(aWindow, aCallback); + if (!TIP) { + return; + } + var KeyboardEvent = _getKeyboardEvent(aWindow); + + if ( + !aEvent.composition || + !aEvent.composition.clauses || + !aEvent.composition.clauses[0] + ) { + return; + } + + TIP.setPendingCompositionString(aEvent.composition.string); + if (aEvent.composition.clauses[0].length) { + for (var i = 0; i < aEvent.composition.clauses.length; i++) { + switch (aEvent.composition.clauses[i].attr) { + case TIP.ATTR_RAW_CLAUSE: + case TIP.ATTR_SELECTED_RAW_CLAUSE: + case TIP.ATTR_CONVERTED_CLAUSE: + case TIP.ATTR_SELECTED_CLAUSE: + TIP.appendClauseToPendingComposition( + aEvent.composition.clauses[i].length, + aEvent.composition.clauses[i].attr + ); + break; + case 0: + // Ignore dummy clause for the argument. + break; + default: + throw new Error("invalid clause attribute specified"); + } + } + } + + if (aEvent.caret) { + TIP.setCaretInPendingComposition(aEvent.caret.start); + } + + var modifiers = _emulateToActivateModifiers(TIP, aEvent.key, aWindow); + try { + var keyEventDict = { dictionary: null, flags: 0 }; + var keyEvent = null; + if (aEvent.key && typeof aEvent.key.key === "string") { + keyEventDict = _createKeyboardEventDictionary( + aEvent.key.key, + aEvent.key, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent( + // eslint-disable-next-line no-nested-ternary + aEvent.key.type === "keydown" + ? "keydown" + : aEvent.key.type === "keyup" + ? "keyup" + : "", + keyEventDict.dictionary + ); + } else if (aEvent.key === undefined) { + keyEventDict = _createKeyboardEventDictionary( + "KEY_Process", + {}, + TIP, + aWindow + ); + keyEvent = new KeyboardEvent("", keyEventDict.dictionary); + } + TIP.flushPendingComposition(keyEvent, keyEventDict.flags); + } finally { + _emulateToInactivateModifiers(TIP, modifiers, aWindow); + } +} + +// Must be synchronized with nsIDOMWindowUtils. +const QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK = 0x0000; +const QUERY_CONTENT_FLAG_USE_XP_LINE_BREAK = 0x0001; + +const QUERY_CONTENT_FLAG_SELECTION_NORMAL = 0x0000; +const QUERY_CONTENT_FLAG_SELECTION_SPELLCHECK = 0x0002; +const QUERY_CONTENT_FLAG_SELECTION_IME_RAWINPUT = 0x0004; +const QUERY_CONTENT_FLAG_SELECTION_IME_SELECTEDRAWTEXT = 0x0008; +const QUERY_CONTENT_FLAG_SELECTION_IME_CONVERTEDTEXT = 0x0010; +const QUERY_CONTENT_FLAG_SELECTION_IME_SELECTEDCONVERTEDTEXT = 0x0020; +const QUERY_CONTENT_FLAG_SELECTION_ACCESSIBILITY = 0x0040; +const QUERY_CONTENT_FLAG_SELECTION_FIND = 0x0080; +const QUERY_CONTENT_FLAG_SELECTION_URLSECONDARY = 0x0100; +const QUERY_CONTENT_FLAG_SELECTION_URLSTRIKEOUT = 0x0200; + +const QUERY_CONTENT_FLAG_OFFSET_RELATIVE_TO_INSERTION_POINT = 0x0400; + +const SELECTION_SET_FLAG_USE_NATIVE_LINE_BREAK = 0x0000; +const SELECTION_SET_FLAG_USE_XP_LINE_BREAK = 0x0001; +const SELECTION_SET_FLAG_REVERSE = 0x0002; + +/** + * Synthesize a query text content event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of getting text. If the length is too long, + * the extra length is ignored. + * @param aIsRelative Optional (If true, aOffset is relative to start of + * composition if there is, or start of selection.) + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryTextContent(aOffset, aLength, aIsRelative, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return null; + } + var flags = QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK; + if (aIsRelative === true) { + flags |= QUERY_CONTENT_FLAG_OFFSET_RELATIVE_TO_INSERTION_POINT; + } + return utils.sendQueryContentEvent( + utils.QUERY_TEXT_CONTENT, + aOffset, + aLength, + 0, + 0, + flags + ); +} + +/** + * Synthesize a query selected text event. + * + * @param aSelectionType Optional, one of QUERY_CONTENT_FLAG_SELECTION_*. + * If null, QUERY_CONTENT_FLAG_SELECTION_NORMAL will + * be used. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQuerySelectedText(aSelectionType, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + var flags = QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK; + if (aSelectionType) { + flags |= aSelectionType; + } + + return utils.sendQueryContentEvent( + utils.QUERY_SELECTED_TEXT, + 0, + 0, + 0, + 0, + flags + ); +} + +/** + * Synthesize a query caret rect event. + * + * @param aOffset The caret offset. 0 means left side of the first character + * in the selection root. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryCaretRect(aOffset, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return null; + } + return utils.sendQueryContentEvent( + utils.QUERY_CARET_RECT, + aOffset, + 0, + 0, + 0, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * Synthesize a selection set event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of the text. If the length is too long, + * the extra length is ignored. + * @param aReverse If true, the selection is from |aOffset + aLength| to + * |aOffset|. Otherwise, from |aOffset| to |aOffset + aLength|. + * @param aWindow Optional (If null, current |window| will be used) + * @return True, if succeeded. Otherwise false. + */ +function synthesizeSelectionSet(aOffset, aLength, aReverse, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + if (!utils) { + return false; + } + var flags = aReverse ? SELECTION_SET_FLAG_REVERSE : 0; + return utils.sendSelectionSetEvent(aOffset, aLength, flags); +} + +/** + * Synthesize a query text rect event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of the text. If the length is too long, + * the extra length is ignored. + * @param aIsRelative Optional (If true, aOffset is relative to start of + * composition if there is, or start of selection.) + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryTextRect(aOffset, aLength, aIsRelative, aWindow) { + if (aIsRelative !== undefined && typeof aIsRelative !== "boolean") { + throw new Error( + "Maybe, you set Window object to the 3rd argument, but it should be a boolean value" + ); + } + var utils = _getDOMWindowUtils(aWindow); + let flags = QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK; + if (aIsRelative === true) { + flags |= QUERY_CONTENT_FLAG_OFFSET_RELATIVE_TO_INSERTION_POINT; + } + return utils.sendQueryContentEvent( + utils.QUERY_TEXT_RECT, + aOffset, + aLength, + 0, + 0, + flags + ); +} + +/** + * Synthesize a query text rect array event. + * + * @param aOffset The character offset. 0 means the first character in the + * selection root. + * @param aLength The length of the text. If the length is too long, + * the extra length is ignored. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryTextRectArray(aOffset, aLength, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + return utils.sendQueryContentEvent( + utils.QUERY_TEXT_RECT_ARRAY, + aOffset, + aLength, + 0, + 0, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * Synthesize a query editor rect event. + * + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeQueryEditorRect(aWindow) { + var utils = _getDOMWindowUtils(aWindow); + return utils.sendQueryContentEvent( + utils.QUERY_EDITOR_RECT, + 0, + 0, + 0, + 0, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * Synthesize a character at point event. + * + * @param aX, aY The offset in the client area of the DOM window. + * @param aWindow Optional (If null, current |window| will be used) + * @return An nsIQueryContentEventResult object. If this failed, + * the result might be null. + */ +function synthesizeCharAtPoint(aX, aY, aWindow) { + var utils = _getDOMWindowUtils(aWindow); + return utils.sendQueryContentEvent( + utils.QUERY_CHARACTER_AT_POINT, + 0, + 0, + aX, + aY, + QUERY_CONTENT_FLAG_USE_NATIVE_LINE_BREAK + ); +} + +/** + * INTERNAL USE ONLY + * Create an event object to pass to sendDragEvent. + * + * @param aType The string represents drag event type. + * @param aDestElement The element to fire the drag event, used to calculate + * screenX/Y and clientX/Y. + * @param aDestWindow Optional; Defaults to the current window object. + * @param aDataTransfer dataTransfer for current drag session. + * @param aDragEvent The object contains properties to override the event + * object + * @return An object to pass to sendDragEvent. + */ +function createDragEventObject( + aType, + aDestElement, + aDestWindow, + aDataTransfer, + aDragEvent +) { + var destRect = aDestElement.getBoundingClientRect(); + var destClientX = destRect.left + destRect.width / 2; + var destClientY = destRect.top + destRect.height / 2; + var destScreenX = aDestWindow.mozInnerScreenX + destClientX; + var destScreenY = aDestWindow.mozInnerScreenY + destClientY; + if ("clientX" in aDragEvent && !("screenX" in aDragEvent)) { + aDragEvent.screenX = aDestWindow.mozInnerScreenX + aDragEvent.clientX; + } + if ("clientY" in aDragEvent && !("screenY" in aDragEvent)) { + aDragEvent.screenY = aDestWindow.mozInnerScreenY + aDragEvent.clientY; + } + + // Wrap only in plain mochitests + let dataTransfer; + if (aDataTransfer) { + dataTransfer = _EU_maybeUnwrap( + _EU_maybeWrap(aDataTransfer).mozCloneForEvent(aType) + ); + + // Copy over the drop effect. This isn't copied over by Clone, as it uses + // more complex logic in the actual implementation (see + // nsContentUtils::SetDataTransferInEvent for actual impl). + dataTransfer.dropEffect = aDataTransfer.dropEffect; + } + + return Object.assign( + { + type: aType, + screenX: destScreenX, + screenY: destScreenY, + clientX: destClientX, + clientY: destClientY, + dataTransfer, + _domDispatchOnly: aDragEvent._domDispatchOnly, + }, + aDragEvent + ); +} + +/** + * Emulate a event sequence of dragstart, dragenter, and dragover. + * + * @param {Element} aSrcElement + * The element to use to start the drag. + * @param {Element} aDestElement + * The element to fire the dragover, dragenter events + * @param {Array} aDragData + * The data to supply for the data transfer. + * This data is in the format: + * + * [ + * [ + * {"type": value, "data": value }, + * ..., + * ], + * ... + * ] + * + * Pass null to avoid modifying dataTransfer. + * @param {String} [aDropEffect="move"] + * The drop effect to set during the dragstart event, or 'move' if omitted. + * @param {Window} [aWindow=window] + * The window in which the drag happens. Defaults to the window in which + * EventUtils.js is loaded. + * @param {Window} [aDestWindow=aWindow] + * Used when aDestElement is in a different window than aSrcElement. + * Default is to match ``aWindow``. + * @param {Object} [aDragEvent={}] + * Defaults to empty object. Overwrites an object passed to sendDragEvent. + * @return {Array} + * A two element array, where the first element is the value returned + * from sendDragEvent for dragover event, and the second element is the + * dataTransfer for the current drag session. + */ +function synthesizeDragOver( + aSrcElement, + aDestElement, + aDragData, + aDropEffect, + aWindow, + aDestWindow, + aDragEvent = {} +) { + if (!aWindow) { + aWindow = window; + } + if (!aDestWindow) { + aDestWindow = aWindow; + } + + // eslint-disable-next-line mozilla/use-services + const obs = _EU_Cc["@mozilla.org/observer-service;1"].getService( + _EU_Ci.nsIObserverService + ); + const ds = _EU_Cc["@mozilla.org/widget/dragservice;1"].getService( + _EU_Ci.nsIDragService + ); + var sess = ds.getCurrentSession(); + + // This method runs before other callbacks, and acts as a way to inject the + // initial drag data into the DataTransfer. + function fillDrag(event) { + if (aDragData) { + for (var i = 0; i < aDragData.length; i++) { + var item = aDragData[i]; + for (var j = 0; j < item.length; j++) { + _EU_maybeWrap(event.dataTransfer).mozSetDataAt( + item[j].type, + item[j].data, + i + ); + } + } + } + event.dataTransfer.dropEffect = aDropEffect || "move"; + event.preventDefault(); + } + + function trapDrag(subject, topic) { + if (topic == "on-datatransfer-available") { + sess.dataTransfer = _EU_maybeUnwrap( + _EU_maybeWrap(subject).mozCloneForEvent("drop") + ); + sess.dataTransfer.dropEffect = subject.dropEffect; + } + } + + // need to use real mouse action + aWindow.addEventListener("dragstart", fillDrag, true); + obs.addObserver(trapDrag, "on-datatransfer-available"); + synthesizeMouseAtCenter(aSrcElement, { type: "mousedown" }, aWindow); + + var rect = aSrcElement.getBoundingClientRect(); + var x = rect.width / 2; + var y = rect.height / 2; + synthesizeMouse(aSrcElement, x, y, { type: "mousemove" }, aWindow); + synthesizeMouse(aSrcElement, x + 10, y + 10, { type: "mousemove" }, aWindow); + aWindow.removeEventListener("dragstart", fillDrag, true); + obs.removeObserver(trapDrag, "on-datatransfer-available"); + + var dataTransfer = sess.dataTransfer; + if (!dataTransfer) { + throw new Error("No data transfer object after synthesizing the mouse!"); + } + + // The EventStateManager will fire our dragenter event if it needs to. + var event = createDragEventObject( + "dragover", + aDestElement, + aDestWindow, + dataTransfer, + aDragEvent + ); + var result = sendDragEvent(event, aDestElement, aDestWindow); + + return [result, dataTransfer]; +} + +/** + * Emulate the drop event and mouseup event. + * This should be called after synthesizeDragOver. + * + * @param {*} aResult + * The first element of the array returned from ``synthesizeDragOver``. + * @param {DataTransfer} aDataTransfer + * The second element of the array returned from ``synthesizeDragOver``. + * @param {Element} aDestElement + * The element on which to fire the drop event. + * @param {Window} [aDestWindow=window] + * The window in which the drop happens. Defaults to the window in which + * EventUtils.js is loaded. + * @param {Object} [aDragEvent={}] + * Defaults to empty object. Overwrites an object passed to sendDragEvent. + * @return {String} + * "none" if aResult is true, ``aDataTransfer.dropEffect`` otherwise. + */ +function synthesizeDropAfterDragOver( + aResult, + aDataTransfer, + aDestElement, + aDestWindow, + aDragEvent = {} +) { + if (!aDestWindow) { + aDestWindow = window; + } + + var effect = aDataTransfer.dropEffect; + var event; + + if (aResult) { + effect = "none"; + } else if (effect != "none") { + event = createDragEventObject( + "drop", + aDestElement, + aDestWindow, + aDataTransfer, + aDragEvent + ); + sendDragEvent(event, aDestElement, aDestWindow); + } + synthesizeMouse(aDestElement, 2, 2, { type: "mouseup" }, aDestWindow); + + return effect; +} + +/** + * Emulate a drag and drop by emulating a dragstart and firing events dragenter, + * dragover, and drop. + * + * @param {Element} aSrcElement + * The element to use to start the drag. + * @param {Element} aDestElement + * The element to fire the dragover, dragenter events + * @param {Array} aDragData + * The data to supply for the data transfer. + * This data is in the format: + * + * [ + * [ + * {"type": value, "data": value }, + * ..., + * ], + * ... + * ] + * + * Pass null to avoid modifying dataTransfer. + * @param {String} [aDropEffect="move"] + * The drop effect to set during the dragstart event, or 'move' if omitted.. + * @param {Window} [aWindow=window] + * The window in which the drag happens. Defaults to the window in which + * EventUtils.js is loaded. + * @param {Window} [aDestWindow=aWindow] + * Used when aDestElement is in a different window than aSrcElement. + * Default is to match ``aWindow``. + * @param {Object} [aDragEvent={}] + * Defaults to empty object. Overwrites an object passed to sendDragEvent. + * @return {String} + * The drop effect that was desired. + */ +function synthesizeDrop( + aSrcElement, + aDestElement, + aDragData, + aDropEffect, + aWindow, + aDestWindow, + aDragEvent = {} +) { + if (!aWindow) { + aWindow = window; + } + if (!aDestWindow) { + aDestWindow = aWindow; + } + + var ds = _EU_Cc["@mozilla.org/widget/dragservice;1"].getService( + _EU_Ci.nsIDragService + ); + + let dropAction; + switch (aDropEffect) { + case null: + case undefined: + case "move": + dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_MOVE; + break; + case "copy": + dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_COPY; + break; + case "link": + dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_LINK; + break; + default: + throw new Error(`${aDropEffect} is an invalid drop effect value`); + } + + ds.startDragSessionForTests(dropAction); + + try { + var [result, dataTransfer] = synthesizeDragOver( + aSrcElement, + aDestElement, + aDragData, + aDropEffect, + aWindow, + aDestWindow, + aDragEvent + ); + return synthesizeDropAfterDragOver( + result, + dataTransfer, + aDestElement, + aDestWindow, + aDragEvent + ); + } finally { + ds.endDragSession(true, _parseModifiers(aDragEvent)); + } +} + +function _computeSrcElementFromSrcSelection(aSrcSelection) { + let srcElement = aSrcSelection.focusNode; + while (_EU_maybeWrap(srcElement).isNativeAnonymous) { + srcElement = _EU_maybeUnwrap( + _EU_maybeWrap(srcElement).flattenedTreeParentNode + ); + } + if (srcElement.nodeType !== Node.NODE_TYPE_ELEMENT) { + srcElement = srcElement.parentElement; + } + return srcElement; +} + +/** + * Emulate a drag and drop by emulating a dragstart by mousedown and mousemove, + * and firing events dragenter, dragover, drop, and dragend. + * This does not modify dataTransfer and tries to emulate the plain drag and + * drop as much as possible, compared to synthesizeDrop. + * Note that if synthesized dragstart is canceled, this throws an exception + * because in such case, Gecko does not start drag session. + * + * @param {Object} aParams + * @param {Event} aParams.dragEvent + * The DnD events will be generated with modifiers specified with this. + * @param {Element} aParams.srcElement + * The element to start dragging. If srcSelection is + * set, this is computed for element at focus node. + * @param {Selection|nil} aParams.srcSelection + * The selection to start to drag, set null if srcElement is set. + * @param {Element|nil} aParams.destElement + * The element to drop on. Pass null to emulate a drop on an invalid target. + * @param {Number} aParams.srcX + * The initial x coordinate inside srcElement or ignored if srcSelection is set. + * @param {Number} aParams.srcY + * The initial y coordinate inside srcElement or ignored if srcSelection is set. + * @param {Number} aParams.stepX + * The x-axis step for mousemove inside srcElement + * @param {Number} aParams.stepY + * The y-axis step for mousemove inside srcElement + * @param {Number} aParams.finalX + * The final x coordinate inside srcElement + * @param {Number} aParams.finalY + * The final x coordinate inside srcElement + * @param {Any} aParams.id + * The pointer event id + * @param {Window} aParams.srcWindow + * The window for dispatching event on srcElement, defaults to the current window object. + * @param {Window} aParams.destWindow + * The window for dispatching event on destElement, defaults to the current window object. + * @param {Boolean} aParams.expectCancelDragStart + * Set to true if the test cancels "dragstart" + * @param {Boolean} aParams.expectSrcElementDisconnected + * Set to true if srcElement will be disconnected and + * "dragend" event won't be fired. + * @param {Function} aParams.logFunc + * Set function which takes one argument if you need to log rect of target. E.g., `console.log`. + */ +// eslint-disable-next-line complexity +async function synthesizePlainDragAndDrop(aParams) { + let { + dragEvent = {}, + srcElement, + srcSelection, + destElement, + srcX = 2, + srcY = 2, + stepX = 9, + stepY = 9, + finalX = srcX + stepX * 2, + finalY = srcY + stepY * 2, + id = _getDOMWindowUtils(window).DEFAULT_MOUSE_POINTER_ID, + srcWindow = window, + destWindow = window, + expectCancelDragStart = false, + expectSrcElementDisconnected = false, + logFunc, + } = aParams; + // Don't modify given dragEvent object because we modify dragEvent below and + // callers may use the object multiple times so that callers must not assume + // that it'll be modified. + if (aParams.dragEvent !== undefined) { + dragEvent = Object.assign({}, aParams.dragEvent); + } + + function rectToString(aRect) { + return `left: ${aRect.left}, top: ${aRect.top}, right: ${aRect.right}, bottom: ${aRect.bottom}`; + } + + if (logFunc) { + logFunc("synthesizePlainDragAndDrop() -- START"); + } + + if (srcSelection) { + srcElement = _computeSrcElementFromSrcSelection(srcSelection); + let srcElementRect = srcElement.getBoundingClientRect(); + if (logFunc) { + logFunc( + `srcElement.getBoundingClientRect(): ${rectToString(srcElementRect)}` + ); + } + // Use last selection client rect because nsIDragSession.sourceNode is + // initialized from focus node which is usually in last rect. + let selectionRectList = srcSelection.getRangeAt(0).getClientRects(); + let lastSelectionRect = selectionRectList[selectionRectList.length - 1]; + if (logFunc) { + logFunc( + `srcSelection.getRangeAt(0).getClientRects()[${selectionRectList.length - + 1}]: ${rectToString(lastSelectionRect)}` + ); + } + // Click at center of last selection rect. + srcX = Math.floor(lastSelectionRect.left + lastSelectionRect.width / 2); + srcY = Math.floor(lastSelectionRect.top + lastSelectionRect.height / 2); + // Then, adjust srcX and srcY for making them offset relative to + // srcElementRect because they will be used when we call synthesizeMouse() + // with srcElement. + srcX = Math.floor(srcX - srcElementRect.left); + srcY = Math.floor(srcY - srcElementRect.top); + // Finally, recalculate finalX and finalY with new srcX and srcY if they + // are not specified by the caller. + if (aParams.finalX === undefined) { + finalX = srcX + stepX * 2; + } + if (aParams.finalY === undefined) { + finalY = srcY + stepY * 2; + } + } else if (logFunc) { + logFunc( + `srcElement.getBoundingClientRect(): ${rectToString( + srcElement.getBoundingClientRect() + )}` + ); + } + + const ds = _EU_Cc["@mozilla.org/widget/dragservice;1"].getService( + _EU_Ci.nsIDragService + ); + + try { + _getDOMWindowUtils(srcWindow).disableNonTestMouseEvents(true); + + await new Promise(r => setTimeout(r, 0)); + + let mouseDownEvent; + function onMouseDown(aEvent) { + mouseDownEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !srcElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + // If srcX and srcY does not point in one of rects in srcElement, + // "mousedown" target is not in srcElement. Such case must not + // be expected by this API users so that we should throw an exception + // for making debugging easier. + throw new Error( + 'event target of "mousedown" is not srcElement nor its descendant' + ); + } + } + try { + srcWindow.addEventListener("mousedown", onMouseDown, { capture: true }); + synthesizeMouse( + srcElement, + srcX, + srcY, + { type: "mousedown", id }, + srcWindow + ); + if (logFunc) { + logFunc(`mousedown at ${srcX}, ${srcY}`); + } + if (!mouseDownEvent) { + throw new Error('"mousedown" event is not fired'); + } + } finally { + srcWindow.removeEventListener("mousedown", onMouseDown, { + capture: true, + }); + } + + let dragStartEvent; + function onDragStart(aEvent) { + dragStartEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !srcElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + // If srcX and srcY does not point in one of rects in srcElement, + // "dragstart" target is not in srcElement. Such case must not + // be expected by this API users so that we should throw an exception + // for making debugging easier. + throw new Error( + 'event target of "dragstart" is not srcElement nor its descendant' + ); + } + } + let dragEnterEvent; + function onDragEnterGenerated(aEvent) { + dragEnterEvent = aEvent; + } + srcWindow.addEventListener("dragstart", onDragStart, { capture: true }); + srcWindow.addEventListener("dragenter", onDragEnterGenerated, { + capture: true, + }); + try { + // Wait for the next event tick after each event dispatch, so that UI + // elements (e.g. menu) work like the real user input. + await new Promise(r => setTimeout(r, 0)); + + srcX += stepX; + srcY += stepY; + synthesizeMouse( + srcElement, + srcX, + srcY, + { type: "mousemove", id }, + srcWindow + ); + if (logFunc) { + logFunc(`first mousemove at ${srcX}, ${srcY}`); + } + + await new Promise(r => setTimeout(r, 0)); + + srcX += stepX; + srcY += stepY; + synthesizeMouse( + srcElement, + srcX, + srcY, + { type: "mousemove", id }, + srcWindow + ); + if (logFunc) { + logFunc(`second mousemove at ${srcX}, ${srcY}`); + } + + await new Promise(r => setTimeout(r, 0)); + + if (!dragStartEvent) { + throw new Error('"dragstart" event is not fired'); + } + } finally { + srcWindow.removeEventListener("dragstart", onDragStart, { + capture: true, + }); + srcWindow.removeEventListener("dragenter", onDragEnterGenerated, { + capture: true, + }); + } + + let session = ds.getCurrentSession(); + if (!session) { + if (expectCancelDragStart) { + synthesizeMouse( + srcElement, + finalX, + finalY, + { type: "mouseup", id }, + srcWindow + ); + return; + } + throw new Error("drag hasn't been started by the operation"); + } else if (expectCancelDragStart) { + throw new Error("drag has been started by the operation"); + } + + if (destElement) { + if ( + (srcElement != destElement && !dragEnterEvent) || + destElement != dragEnterEvent.target + ) { + if (logFunc) { + logFunc( + `destElement.getBoundingClientRect(): ${rectToString( + destElement.getBoundingClientRect() + )}` + ); + } + + function onDragEnter(aEvent) { + dragEnterEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if (aEvent.target != destElement) { + throw new Error('event target of "dragenter" is not destElement'); + } + } + destWindow.addEventListener("dragenter", onDragEnter, { + capture: true, + }); + try { + let event = createDragEventObject( + "dragenter", + destElement, + destWindow, + null, + dragEvent + ); + sendDragEvent(event, destElement, destWindow); + if (!dragEnterEvent && !destElement.disabled) { + throw new Error('"dragenter" event is not fired'); + } + if (dragEnterEvent && destElement.disabled) { + throw new Error( + '"dragenter" event should not be fired on disable element' + ); + } + } finally { + destWindow.removeEventListener("dragenter", onDragEnter, { + capture: true, + }); + } + } + + let dragOverEvent; + function onDragOver(aEvent) { + dragOverEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if (aEvent.target != destElement) { + throw new Error('event target of "dragover" is not destElement'); + } + } + destWindow.addEventListener("dragover", onDragOver, { capture: true }); + try { + // dragover and drop are only fired to a valid drop target. If the + // destElement parameter is null, this function is being used to + // simulate a drag'n'drop over an invalid drop target. + let event = createDragEventObject( + "dragover", + destElement, + destWindow, + null, + dragEvent + ); + sendDragEvent(event, destElement, destWindow); + if (!dragOverEvent && !destElement.disabled) { + throw new Error('"dragover" event is not fired'); + } + if (dragEnterEvent && destElement.disabled) { + throw new Error( + '"dragover" event should not be fired on disable element' + ); + } + } finally { + destWindow.removeEventListener("dragover", onDragOver, { + capture: true, + }); + } + + await new Promise(r => setTimeout(r, 0)); + + // If there is not accept to drop the data, "drop" event shouldn't be + // fired. + // XXX nsIDragSession.canDrop is different only on Linux. It must be + // a bug of gtk/nsDragService since it manages `mCanDrop` by itself. + // Thus, we should use nsIDragSession.dragAction instead. + if (session.dragAction != _EU_Ci.nsIDragService.DRAGDROP_ACTION_NONE) { + let dropEvent; + function onDrop(aEvent) { + dropEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !destElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + throw new Error( + 'event target of "drop" is not destElement nor its descendant' + ); + } + } + destWindow.addEventListener("drop", onDrop, { capture: true }); + try { + let event = createDragEventObject( + "drop", + destElement, + destWindow, + null, + dragEvent + ); + sendDragEvent(event, destElement, destWindow); + if (!dropEvent && session.canDrop) { + throw new Error('"drop" event is not fired'); + } + } finally { + destWindow.removeEventListener("drop", onDrop, { capture: true }); + } + return; + } + } + + // Since we don't synthesize drop event, we need to set drag end point + // explicitly for "dragEnd" event which will be fired by + // endDragSession(). + dragEvent.clientX = finalX; + dragEvent.clientY = finalY; + let event = createDragEventObject( + "dragend", + destElement || srcElement, + destElement ? srcWindow : destWindow, + null, + dragEvent + ); + session.setDragEndPointForTests(event.screenX, event.screenY); + } finally { + await new Promise(r => setTimeout(r, 0)); + + if (ds.getCurrentSession()) { + const sourceNode = ds.sourceNode; + let dragEndEvent; + function onDragEnd(aEvent) { + dragEndEvent = aEvent; + if (logFunc) { + logFunc(`"${aEvent.type}" event is fired`); + } + if ( + !srcElement.contains( + _EU_maybeUnwrap(_EU_maybeWrap(aEvent).composedTarget) + ) + ) { + throw new Error( + 'event target of "dragend" is not srcElement nor its descendant' + ); + } + if (expectSrcElementDisconnected) { + throw new Error( + `"dragend" event shouldn't be fired when the source node is disconnected (the source node is ${ + sourceNode?.isConnected ? "connected" : "null or disconnected" + })` + ); + } + } + srcWindow.addEventListener("dragend", onDragEnd, { capture: true }); + try { + ds.endDragSession(true, _parseModifiers(dragEvent)); + if (!expectSrcElementDisconnected && !dragEndEvent) { + // eslint-disable-next-line no-unsafe-finally + throw new Error( + `"dragend" event is not fired by nsIDragService.endDragSession()${ + ds.sourceNode && !ds.sourceNode.isConnected + ? "(sourceNode was disconnected)" + : "" + }` + ); + } + } finally { + srcWindow.removeEventListener("dragend", onDragEnd, { capture: true }); + } + } + _getDOMWindowUtils(srcWindow).disableNonTestMouseEvents(false); + if (logFunc) { + logFunc("synthesizePlainDragAndDrop() -- END"); + } + } +} + +function _checkDataTransferItems(aDataTransfer, aExpectedDragData) { + try { + // We must wrap only in plain mochitests, not chrome + let dataTransfer = _EU_maybeWrap(aDataTransfer); + if (!dataTransfer) { + return null; + } + if ( + aExpectedDragData == null || + dataTransfer.mozItemCount != aExpectedDragData.length + ) { + return dataTransfer; + } + for (let i = 0; i < dataTransfer.mozItemCount; i++) { + let dtTypes = dataTransfer.mozTypesAt(i); + if (dtTypes.length != aExpectedDragData[i].length) { + return dataTransfer; + } + for (let j = 0; j < dtTypes.length; j++) { + if (dtTypes[j] != aExpectedDragData[i][j].type) { + return dataTransfer; + } + let dtData = dataTransfer.mozGetDataAt(dtTypes[j], i); + if (aExpectedDragData[i][j].eqTest) { + if ( + !aExpectedDragData[i][j].eqTest( + dtData, + aExpectedDragData[i][j].data + ) + ) { + return dataTransfer; + } + } else if (aExpectedDragData[i][j].data != dtData) { + return dataTransfer; + } + } + } + } catch (ex) { + return ex; + } + return true; +} + +/** + * This callback type is used with ``synthesizePlainDragAndCancel()``. + * It should compare ``actualData`` and ``expectedData`` and return + * true if the two should be considered equal, false otherwise. + * + * @callback eqTest + * @param {*} actualData + * @param {*} expectedData + * @return {boolean} + */ + +/** + * synthesizePlainDragAndCancel() synthesizes drag start with + * synthesizePlainDragAndDrop(), but always cancel it with preventing default + * of "dragstart". Additionally, this checks whether the dataTransfer of + * "dragstart" event has only expected items. + * + * @param {Object} aParams + * The params which is set to the argument of ``synthesizePlainDragAndDrop()``. + * @param {Array} aExpectedDataTransferItems + * All expected dataTransfer items. + * This data is in the format: + * + * [ + * [ + * {"type": value, "data": value, eqTest: function} + * ..., + * ], + * ... + * ] + * + * This can also be null. + * You can optionally provide ``eqTest`` {@type eqTest} if the + * comparison to the expected data transfer items can't be done + * with x == y; + * @return {boolean} + * true if aExpectedDataTransferItems matches with + * DragEvent.dataTransfer of "dragstart" event. + * Otherwise, the dataTransfer object (may be null) or + * thrown exception, NOT false. Therefore, you shouldn't + * use. + */ +async function synthesizePlainDragAndCancel( + aParams, + aExpectedDataTransferItems +) { + let srcElement = aParams.srcSelection + ? _computeSrcElementFromSrcSelection(aParams.srcSelection) + : aParams.srcElement; + let result; + function onDragStart(aEvent) { + aEvent.preventDefault(); + result = _checkDataTransferItems( + aEvent.dataTransfer, + aExpectedDataTransferItems + ); + } + SpecialPowers.addSystemEventListener( + srcElement.ownerDocument, + "dragstart", + onDragStart, + { capture: true } + ); + try { + aParams.expectCancelDragStart = true; + await synthesizePlainDragAndDrop(aParams); + } finally { + SpecialPowers.removeSystemEventListener( + srcElement.ownerDocument, + "dragstart", + onDragStart, + { capture: true } + ); + } + return result; +} + +class EventCounter { + constructor(aTarget, aType, aOptions = {}) { + this.target = aTarget; + this.type = aType; + this.options = aOptions; + + this.eventCount = 0; + // Bug 1512817: + // SpecialPowers is picky and needs to be passed an explicit reference to + // the function to be called. To avoid having to bind "this", we therefore + // define the method this way, via a property. + this.handleEvent = aEvent => { + this.eventCount++; + }; + + if (aOptions.mozSystemGroup) { + SpecialPowers.addSystemEventListener( + aTarget, + aType, + this.handleEvent, + aOptions.capture + ); + } else { + aTarget.addEventListener(aType, this, aOptions); + } + } + + unregister() { + if (this.options.mozSystemGroup) { + SpecialPowers.removeSystemEventListener( + this.target, + this.type, + this.handleEvent, + this.options.capture + ); + } else { + this.target.removeEventListener(this.type, this, this.options); + } + } + + get count() { + return this.eventCount; + } +} |