Bug 1880594 - Make PresShell::EventHandler dispatch mouse events as a default action of eTouchEnd if it's dispatched without APZ r=smaug

The mouse events for `eTouchEnd` is currently dispatched by
`APZCCallbackHelper` [1] and currently we don't support async event dispatching
in WPT (bug 1773393).  Therefore, tests of Pointer Events for touch won't work.
This blocks our further work to improve Pointer Events.  Therefore,
`PresShell::EventHandler` should have a fallback path for it.

1. https://searchfox.org/mozilla-central/rev/a7809ff8b0a6d98e6df3183d3ca99c77ef2f983e/gfx/layers/apz/util/APZCCallbackHelper.cpp#553,562-567

Differential Revision: https://phabricator.services.mozilla.com/D202376
This commit is contained in:
Masayuki Nakano 2024-02-27 01:25:51 +00:00
parent f8f56a700c
commit 008be0fde3
6 changed files with 474 additions and 5 deletions

View file

@ -897,6 +897,7 @@ nsresult nsDOMWindowUtils::SendTouchEventCommon(
return NS_ERROR_UNEXPECTED;
}
WidgetTouchEvent event(true, msg, widget);
event.mFlags.mIsSynthesizedForTests = true;
event.mModifiers = nsContentUtils::GetWidgetModifiers(aModifiers);
nsPresContext* presContext = GetPresContext();

View file

@ -9,11 +9,13 @@
#include "mozilla/PresShell.h"
#include "Units.h"
#include "mozilla/EventForwards.h"
#include "mozilla/RefPtr.h"
#include "mozilla/dom/AncestorIterator.h"
#include "mozilla/dom/FontFaceSet.h"
#include "mozilla/dom/ElementBinding.h"
#include "mozilla/dom/LargestContentfulPaint.h"
#include "mozilla/dom/MouseEventBinding.h"
#include "mozilla/dom/PerformanceMainThread.h"
#include "mozilla/dom/HTMLAreaElement.h"
#include "mozilla/ArrayUtils.h"
@ -45,6 +47,7 @@
#include "mozilla/StaticPrefs_font.h"
#include "mozilla/StaticPrefs_image.h"
#include "mozilla/StaticPrefs_layout.h"
#include "mozilla/StaticPrefs_test.h"
#include "mozilla/StaticPrefs_toolkit.h"
#include "mozilla/Try.h"
#include "mozilla/TextEvents.h"
@ -7267,7 +7270,17 @@ nsresult PresShell::EventHandler::HandleEventUsingCoordinates(
nsresult rv = eventHandler.HandleEventWithCurrentEventInfo(
aGUIEvent, aEventStatus, true,
MOZ_KnownLive(eventTargetData.mOverrideClickTarget));
return rv;
if (NS_FAILED(rv) ||
MOZ_UNLIKELY(eventTargetData.mPresShell->IsDestroying())) {
return rv;
}
if (aGUIEvent->mMessage == eTouchEnd) {
MaybeSynthesizeCompatMouseEventsForTouchEnd(aGUIEvent->AsTouchEvent(),
aEventStatus);
}
return NS_OK;
}
bool PresShell::EventHandler::MaybeFlushPendingNotifications(
@ -7586,6 +7599,60 @@ bool PresShell::EventHandler::MaybeHandleEventWithAccessibleCaret(
return true;
}
void PresShell::EventHandler::MaybeSynthesizeCompatMouseEventsForTouchEnd(
const WidgetTouchEvent* aTouchEndEvent,
const nsEventStatus* aStatus) const {
MOZ_ASSERT(aTouchEndEvent->mMessage == eTouchEnd);
// If the eTouchEnd event is dispatched via APZ, APZCCallbackHelper dispatches
// a set of mouse events with better handling. Therefore, we don't need to
// handle that here.
if (!aTouchEndEvent->mFlags.mIsSynthesizedForTests ||
StaticPrefs::test_events_async_enabled()) {
return;
}
// If the tap was consumed or 2 or more touches occurred, we don't need the
// compatibility mouse events.
if (*aStatus == nsEventStatus_eConsumeNoDefault ||
!TouchManager::IsSingleTapEndToDoDefault(aTouchEndEvent)) {
return;
}
if (NS_WARN_IF(!aTouchEndEvent->mWidget)) {
return;
}
nsCOMPtr<nsIWidget> widget = aTouchEndEvent->mWidget;
// NOTE: I think that we don't need to implement a double click here becase
// WebDriver does not support a way to synthesize a double click and Chrome
// does not fire "dblclick" even if doing `pointerDown().pointerUp()` twice.
// FIXME: Currently we don't support long tap.
RefPtr<PresShell> presShell = mPresShell;
for (const EventMessage message : {eMouseMove, eMouseDown, eMouseUp}) {
if (MOZ_UNLIKELY(presShell->IsDestroying())) {
break;
}
nsIFrame* frameForPresShell = GetNearestFrameContainingPresShell(presShell);
if (!frameForPresShell) {
break;
}
WidgetMouseEvent event(true, message, widget, WidgetMouseEvent::eReal,
WidgetMouseEvent::eNormal);
event.mRefPoint = aTouchEndEvent->mTouches[0]->mRefPoint;
event.mButton = MouseButton::ePrimary;
event.mButtons = message == eMouseDown ? MouseButtonsFlag::ePrimaryFlag
: MouseButtonsFlag::eNoButtons;
event.mInputSource = MouseEvent_Binding::MOZ_SOURCE_TOUCH;
event.mClickCount = message == eMouseMove ? 0 : 1;
event.mModifiers = aTouchEndEvent->mModifiers;
event.convertToPointer = false;
nsEventStatus mouseEventStatus = nsEventStatus_eIgnore;
presShell->HandleEvent(frameForPresShell, &event, false, &mouseEventStatus);
}
}
bool PresShell::EventHandler::MaybeDiscardEvent(WidgetGUIEvent* aGUIEvent) {
MOZ_ASSERT(aGUIEvent);
@ -8360,7 +8427,7 @@ nsresult PresShell::EventHandler::HandleEventWithCurrentEventInfo(
manager->TryToFlushPendingNotificationsToIME();
}
FinalizeHandlingEvent(aEvent);
FinalizeHandlingEvent(aEvent, aEventStatus);
RecordEventHandlingResponsePerformance(aEvent);
@ -8512,7 +8579,8 @@ bool PresShell::EventHandler::PrepareToDispatchEvent(
}
}
void PresShell::EventHandler::FinalizeHandlingEvent(WidgetEvent* aEvent) {
void PresShell::EventHandler::FinalizeHandlingEvent(
WidgetEvent* aEvent, const nsEventStatus* aStatus) {
switch (aEvent->mMessage) {
case eKeyPress:
case eKeyDown:
@ -8562,6 +8630,16 @@ void PresShell::EventHandler::FinalizeHandlingEvent(WidgetEvent* aEvent) {
}
return;
}
case eTouchStart:
case eTouchMove:
case eTouchEnd:
case eTouchCancel:
case eTouchPointerCancel:
case eMouseLongTap:
case eContextMenu: {
mPresShell->mTouchManager.PostHandleEvent(aEvent, aStatus);
break;
}
default:
return;
}

View file

@ -2439,6 +2439,14 @@ class PresShell final : public nsStubDocumentObserver,
WidgetGUIEvent* aGUIEvent,
nsEventStatus* aEventStatus);
/**
* Maybe dispatch mouse events for aTouchEnd. This should be called after
* aTouchEndEvent is dispatched into the DOM.
*/
MOZ_CAN_RUN_SCRIPT void MaybeSynthesizeCompatMouseEventsForTouchEnd(
const WidgetTouchEvent* aTouchEndEvent,
const nsEventStatus* aStatus) const;
/**
* MaybeDiscardOrDelayKeyboardEvent() may discared or put aGUIEvent into
* the delayed event queue if it's a keyboard event and if we should do so.
@ -2821,8 +2829,10 @@ class PresShell final : public nsStubDocumentObserver,
* and then, this cleans up the state of mPresShell and aEvent.
*
* @param aEvent The handled event.
* @param aStatus The status of aEvent. Must not be nullptr.
*/
MOZ_CAN_RUN_SCRIPT void FinalizeHandlingEvent(WidgetEvent* aEvent);
MOZ_CAN_RUN_SCRIPT void FinalizeHandlingEvent(WidgetEvent* aEvent,
const nsEventStatus* aStatus);
/**
* AutoCurrentEventInfoSetter() pushes and pops current event info of

View file

@ -7,9 +7,13 @@
#include "TouchManager.h"
#include "Units.h"
#include "mozilla/EventForwards.h"
#include "mozilla/PresShell.h"
#include "mozilla/StaticPrefs_test.h"
#include "mozilla/TimeStamp.h"
#include "mozilla/dom/Document.h"
#include "mozilla/dom/EventTarget.h"
#include "mozilla/PresShell.h"
#include "mozilla/layers/InputAPZContext.h"
#include "nsIContent.h"
#include "nsIFrame.h"
@ -24,6 +28,8 @@ namespace mozilla {
StaticAutoPtr<nsTHashMap<nsUint32HashKey, TouchManager::TouchInfo>>
TouchManager::sCaptureTouchList;
layers::LayersId TouchManager::sCaptureTouchLayersId;
TimeStamp TouchManager::sSingleTouchStartTimeStamp;
LayoutDeviceIntPoint TouchManager::sSingleTouchStartPoint;
/*static*/
void TouchManager::InitializeStatics() {
@ -236,8 +242,11 @@ bool TouchManager::PreHandleEvent(WidgetEvent* aEvent, nsEventStatus* aStatus,
// touch event associated to. We cache layers id of the first touchstart
// event, all subsequent touch events will use the same layers id.
sCaptureTouchLayersId = aEvent->mLayersId;
sSingleTouchStartTimeStamp = aEvent->mTimeStamp;
sSingleTouchStartPoint = aEvent->AsTouchEvent()->mTouches[0]->mRefPoint;
} else {
touchEvent->mLayersId = sCaptureTouchLayersId;
sSingleTouchStartTimeStamp = TimeStamp();
}
// Add any new touches to the queue
WidgetTouchEvent::TouchArray& touches = touchEvent->mTouches;
@ -404,6 +413,60 @@ bool TouchManager::PreHandleEvent(WidgetEvent* aEvent, nsEventStatus* aStatus,
return true;
}
void TouchManager::PostHandleEvent(const WidgetEvent* aEvent,
const nsEventStatus* aStatus) {
switch (aEvent->mMessage) {
case eTouchMove: {
if (sSingleTouchStartTimeStamp.IsNull()) {
break;
}
if (*aStatus == nsEventStatus_eConsumeNoDefault) {
sSingleTouchStartTimeStamp = TimeStamp();
break;
}
const WidgetTouchEvent* touchEvent = aEvent->AsTouchEvent();
if (touchEvent->mTouches.Length() > 1) {
sSingleTouchStartTimeStamp = TimeStamp();
break;
}
if (touchEvent->mTouches.Length() == 1) {
// If the touch moved too far from the start point, don't treat the
// touch as a tap.
const float distance =
static_cast<float>((sSingleTouchStartPoint -
aEvent->AsTouchEvent()->mTouches[0]->mRefPoint)
.Length());
const float maxDistance =
StaticPrefs::apz_touch_start_tolerance() *
(MOZ_LIKELY(touchEvent->mWidget) ? touchEvent->mWidget->GetDPI()
: 96.0f);
if (distance > maxDistance) {
sSingleTouchStartTimeStamp = TimeStamp();
}
}
break;
}
case eTouchStart:
case eTouchEnd:
if (*aStatus == nsEventStatus_eConsumeNoDefault &&
!sSingleTouchStartTimeStamp.IsNull()) {
sSingleTouchStartTimeStamp = TimeStamp();
}
break;
case eTouchCancel:
case eTouchPointerCancel:
case eMouseLongTap:
case eContextMenu: {
if (!sSingleTouchStartTimeStamp.IsNull()) {
sSingleTouchStartTimeStamp = TimeStamp();
}
break;
}
default:
break;
}
}
/*static*/
already_AddRefed<nsIContent> TouchManager::GetAnyCapturedTouchTarget() {
nsCOMPtr<nsIContent> result = nullptr;
@ -473,4 +536,25 @@ bool TouchManager::ShouldConvertTouchToPointer(const Touch* aTouch,
return true;
}
/* static */
bool TouchManager::IsSingleTapEndToDoDefault(
const WidgetTouchEvent* aTouchEndEvent) {
MOZ_ASSERT(aTouchEndEvent);
MOZ_ASSERT(aTouchEndEvent->mFlags.mIsSynthesizedForTests);
MOZ_ASSERT(!StaticPrefs::test_events_async_enabled());
if (sSingleTouchStartTimeStamp.IsNull() ||
aTouchEndEvent->mTouches.Length() != 1) {
return false;
}
// If it's pressed long time, we should not treat it as a single tap because
// a long press should cause opening context menu by default.
if ((aTouchEndEvent->mTimeStamp - sSingleTouchStartTimeStamp)
.ToMilliseconds() > StaticPrefs::apz_max_tap_time()) {
return false;
}
NS_WARNING_ASSERTION(aTouchEndEvent->mTouches[0]->mChanged,
"The single tap end should be changed");
return true;
}
} // namespace mozilla

View file

@ -12,6 +12,7 @@
#ifndef TouchManager_h_
#define TouchManager_h_
#include "Units.h"
#include "mozilla/BasicEvents.h"
#include "mozilla/dom/Touch.h"
#include "mozilla/StaticPtr.h"
@ -20,6 +21,7 @@
namespace mozilla {
class PresShell;
class TimeStamp;
class TouchManager {
public:
@ -52,6 +54,8 @@ class TouchManager {
bool PreHandleEvent(mozilla::WidgetEvent* aEvent, nsEventStatus* aStatus,
bool& aTouchIsNew,
nsCOMPtr<nsIContent>& aCurrentEventContent);
void PostHandleEvent(const mozilla::WidgetEvent* aEvent,
const nsEventStatus* aStatus);
static already_AddRefed<nsIContent> GetAnyCapturedTouchTarget();
static bool HasCapturedTouch(int32_t aId);
@ -59,6 +63,12 @@ class TouchManager {
static bool ShouldConvertTouchToPointer(const dom::Touch* aTouch,
const WidgetTouchEvent* aEvent);
// This should be called after PostHandleEvent() is called. Note that this
// cannot check touches outside this process. So, this should not be used for
// actual user input handling. This is designed for a fallback path to
// dispatch mouse events for touch events synthesized without APZ.
static bool IsSingleTapEndToDoDefault(const WidgetTouchEvent* aTouchEndEvent);
private:
void EvictTouches(dom::Document* aLimitToDocument = nullptr);
static void EvictTouchPoint(RefPtr<dom::Touch>& aTouch,
@ -77,6 +87,12 @@ class TouchManager {
static StaticAutoPtr<nsTHashMap<nsUint32HashKey, TouchInfo>>
sCaptureTouchList;
static layers::LayersId sCaptureTouchLayersId;
// The last start of a single tap. This will be set to "Null" if the tap is
// consumed or becomes not a single tap.
static TimeStamp sSingleTouchStartTimeStamp;
// The last start point of the single tap tracked with
// sSingleTouchStartTimeStamp.
static LayoutDeviceIntPoint sSingleTouchStartPoint;
};
} // namespace mozilla

View file

@ -0,0 +1,280 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="timeout" content="long">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mouse events for compatibility after a tap</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-actions.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<style>
#parent, #child {
width: 300px;
height: 64px;
padding: 16px;
}
#parent {
background-color: black;
}
#child {
background-color: gray;
}
</style>
<script>
"use strict";
addEventListener("load", t => {
let events = [];
for (const type of ["mousemove",
"mousedown",
"mouseup",
"click",
"dblclick",
"contextmenu",
"touchend"]) {
if (type == "touchend") {
addEventListener(type, event => {
events.push({type: type, target: event.target});
}, {capture: true});
} else {
addEventListener(type, event => {
events.push({
type: event.type,
target: event.target,
detail: event.detail,
button: event.button,
buttons: event.buttons,
});
}, {capture: true});
}
}
function stringifyEvents(arrayOfEvents) {
if (!arrayOfEvents.length) {
return "[]";
}
function stringifyEvent(event) {
return `{ type: ${event.type}, target: ${
event.target.id || event.target.nodeName
}${
event.detail !== undefined ? `, detail: ${event.detail}` : ""
}${
event.button !== undefined ? `, button: ${event.button}` : ""
}${
event.buttons !== undefined ? `, buttons: ${event.buttons}` : ""
} }`;
}
let ret = "";
for (const event of arrayOfEvents) {
if (ret === "") {
ret = "[ ";
} else {
ret += ", ";
}
ret += stringifyEvent(event);
}
return ret + " ]";
}
const child = document.getElementById("child");
const parent = child.parentNode;
function promiseInitPointer() {
return new test_driver.Actions()
.addPointer("touchPointer", "touch")
.pointerMove(0, 0, {origin: document.body})
.send();
}
promise_test(async () => {
await promiseInitPointer();
events = [];
await new test_driver.Actions()
.addPointer("touchPointer", "touch")
.pointerMove(5, 5, {origin: child})
.pointerDown()
.pointerUp()
.send();
assert_equals(
stringifyEvents(events),
stringifyEvents([
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
])
);
}, "Single tap should cause a click");
promise_test(async () => {
await promiseInitPointer();
events = [];
child.addEventListener("touchstart", event => {
event.preventDefault();
}, {once: true});
await new test_driver.Actions()
.addPointer("touchPointer", "touch")
.pointerMove(105, 5, {origin: child})
.pointerDown()
.pointerUp()
.send();
assert_equals(
stringifyEvents(events),
stringifyEvents([
{ type: "touchend", target: child },
])
);
}, "Single tap whose touchstart is consumed should not cause a click");
promise_test(async () => {
await promiseInitPointer();
events = [];
child.addEventListener("touchend", event => {
event.preventDefault();
}, {once: true});
await new test_driver.Actions()
.addPointer("touchPointer", "touch")
.pointerMove(105, 5, {origin: child})
.pointerDown()
.pointerUp()
.send();
assert_equals(
stringifyEvents(events),
stringifyEvents([
{ type: "touchend", target: child },
])
);
}, "Single tap whose touchend is consumed should not cause a click");
promise_test(async () => {
await promiseInitPointer();
events = [];
await new test_driver.Actions()
.addPointer("touchPointer", "touch")
.pointerMove(5, 5, {origin: child})
.pointerDown()
.pointerUp()
.pointerDown()
.pointerUp()
.send();
assert_in_array(
stringifyEvents(events),
[
// Currently, WebDriver does not have a strict way to synthesize a
// double click, therefore, it's fine either single click twice or
// a set of a double-click.
stringifyEvents([
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
]),
stringifyEvents([
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 2, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 2, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 2, button: 0, buttons: 0 },
{ type: "dblclick", target: child, detail: 2, button: 0, buttons: 0 },
]),
],
);
}, "Double tap should cause single-click twice or a double-click");
promise_test(async () => {
await promiseInitPointer();
events = [];
await new test_driver.Actions()
.addPointer("touchPointer", "touch")
.pointerMove(105, 5, {origin: child})
.pointerDown()
.pointerUp()
.pause(1000)
.pointerDown()
.pointerUp()
.send();
assert_equals(
stringifyEvents(events),
stringifyEvents([
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
])
);
}, "Tapping twice slowly should not cause a dblclick");
promise_test(async () => {
await promiseInitPointer();
events = [];
await new test_driver.Actions()
.addPointer("touchPointer", "touch")
.pointerMove(5, 5, {origin: child})
.pointerDown()
.pointerUp()
.pointerMove(100, 5, {origin: child})
.pointerDown()
.pointerUp()
.send();
assert_equals(
stringifyEvents(events),
stringifyEvents([
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "touchend", target: child },
{ type: "mousemove", target: child, detail: 0, button: 0, buttons: 0 },
{ type: "mousedown", target: child, detail: 1, button: 0, buttons: 1 },
{ type: "mouseup", target: child, detail: 1, button: 0, buttons: 0 },
{ type: "click", target: child, detail: 1, button: 0, buttons: 0 },
])
);
}, "Tapping too far points should not cause a dblclick");
promise_test(async () => {
await promiseInitPointer();
events = [];
await new test_driver.Actions()
.addPointer("touchPointer", "touch")
.addPointer("touchPointer2", "touch")
.pointerMove(5, 5, {origin: child, sourceName: "touchPointer"})
.pointerMove(25, 25, {origin: child, sourceName: "touchPointer2"})
.pointerDown({sourceName: "touchPointer"})
.pointerDown({sourceName: "touchPointer2"})
.pointerUp({sourceName: "touchPointer"})
.pointerUp({sourceName: "touchPointer2"})
.send();
assert_equals(
stringifyEvents(events),
stringifyEvents([
{ type: "touchend", target: child },
{ type: "touchend", target: child },
])
);
}, "Multi tap should not cause mouse events");
}, {once: true});
</script>
</head>
<body><div id="parent"><div id="child"></div></div></body>
</html>