Bug 1909020 - Make createDragEventObject() in EventUtils.js aware of HiDPI environments r=smaug

`synthesizePlainDragAndDrop()` sends `drop` event [1] via `PresShell` [2].
Then, the `screenX` and `screenY` values are set to
`WidgetEvent::mRefPoint` [3].  Then, the `drop` event's position is
recorded before dispatch [4].  So, `drop` event is fired on the target
but the `mRefPoint` may be outside the target.  Finally, synthesized
`eMouseMove` after `eDrop` will be fired on the wrong element which is
different from `eDrop`'s target.  This caused the failures of
`test_synthmousemove_after_dnd.html` and `test_dragdrop.html` on Android.

Perhaps, we should improve `nsDOMWindowUtils` or something lower layer
later.  Instead, this patch fixes in `EventUtils.js` level.  This makes
the `createDragEventObject()`.

1. https://searchfox.org/mozilla-central/rev/6a72a6d20eeb1b20b93862a79166938d6ce794a0/testing/mochitest/tests/SimpleTest/EventUtils.js#3893-3900
2. https://searchfox.org/mozilla-central/rev/6a72a6d20eeb1b20b93862a79166938d6ce794a0/testing/mochitest/tests/SimpleTest/EventUtils.js#376-393,400
3. https://searchfox.org/mozilla-central/rev/6a72a6d20eeb1b20b93862a79166938d6ce794a0/dom/events/MouseEvent.cpp#81-84
4. https://searchfox.org/mozilla-central/rev/6a72a6d20eeb1b20b93862a79166938d6ce794a0/layout/base/PresShell.cpp#8987

Differential Revision: https://phabricator.services.mozilla.com/D251091
This commit is contained in:
Masayuki Nakano
2025-05-24 23:34:17 +00:00
committed by masayuki@d-toybox.com
parent 842f61a7ac
commit 735e6414f0
4 changed files with 123 additions and 109 deletions

View File

@@ -939,9 +939,6 @@ async function doTest() {
})(); })();
// -------- Test dragging contenteditable to same contenteditable // -------- Test dragging contenteditable to same contenteditable
// Bug 1904272: Android Non-XOrigin incorrectly inserts after the 3rd M
// instead of after the 2nd M in some of the following tests.
const isAndroidException = AppConstants.platform === "android" && !isXOrigin;
await (async function test_dragging_from_contenteditable_to_itself() { await (async function test_dragging_from_contenteditable_to_itself() {
const description = "dragging text in contenteditable to same contenteditable"; const description = "dragging text in contenteditable to same contenteditable";
@@ -972,16 +969,8 @@ async function doTest() {
} }
) )
) { ) {
const kExpectedOffsets = isAndroidException ? [3,3] : [2,2]; is(contenteditable.innerHTML, "<b>bd</b> <span>MM</span><b>ol</b><span>MM</span>",
if (isAndroidException) { `${description}: dragged range should be removed from contenteditable`);
todo_is(contenteditable.innerHTML, "<b>bd</b> <span>MM</span><b>ol</b><span>MM</span>",
`${description}: dragged range should be removed from contenteditable`);
isnot(contenteditable.innerHTML, "<b>bd</b> <span>MMMM</span><b>ol</b>",
`${description}: dragged range should be removed from contenteditable`);
} else {
is(contenteditable.innerHTML, "<b>bd</b> <span>MM</span><b>ol</b><span>MM</span>",
`${description}: dragged range should be removed from contenteditable`);
}
is(beforeinputEvents.length, 2, is(beforeinputEvents.length, 2,
`${description}: 2 "beforeinput" events should be fired on contenteditable`); `${description}: 2 "beforeinput" events should be fired on contenteditable`);
checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null, checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null,
@@ -991,8 +980,8 @@ async function doTest() {
checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null, checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null,
[{type: "text/html", data: "<b>ol</b>"}, [{type: "text/html", data: "<b>ol</b>"},
{type: "text/plain", data: "ol"}], {type: "text/plain", data: "ol"}],
[{startContainer: lastTextNode, startOffset: kExpectedOffsets[0], [{startContainer: lastTextNode, startOffset: 2,
endContainer: lastTextNode, endOffset: kExpectedOffsets[1]}], endContainer: lastTextNode, endOffset: 2}],
description); description);
is(inputEvents.length, 2, is(inputEvents.length, 2,
`${description}: 2 "input" events should be fired on contenteditable`); `${description}: 2 "input" events should be fired on contenteditable`);
@@ -1039,16 +1028,8 @@ async function doTest() {
} }
) )
) { ) {
const kExpectedOffsets = isAndroidException ? [3,3] : [2,2]; is(contenteditable.innerHTML, "<b>bold</b> <span>MM</span><b>ol</b><span>MM</span>",
if (isAndroidException) { `${description}: dragged range shouldn't be removed from contenteditable`);
todo_is(contenteditable.innerHTML, "<b>bold</b> <span>MM</span><b>ol</b><span>MM</span>",
`${description}: dragged range shouldn't be removed from contenteditable`);
isnot(contenteditable.innerHTML, "<b>bold</b> <span>MMMM</span><b>ol</b>",
`${description}: dragged range shouldn't be removed from contenteditable`);
} else {
is(contenteditable.innerHTML, "<b>bold</b> <span>MM</span><b>ol</b><span>MM</span>",
`${description}: dragged range shouldn't be removed from contenteditable`);
}
is(beforeinputEvents.length, 2, is(beforeinputEvents.length, 2,
`${description}: 2 "beforeinput" events should be fired on contenteditable`); `${description}: 2 "beforeinput" events should be fired on contenteditable`);
checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null, checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null,
@@ -1058,8 +1039,8 @@ async function doTest() {
checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null, checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null,
[{type: "text/html", data: "<b>ol</b>"}, [{type: "text/html", data: "<b>ol</b>"},
{type: "text/plain", data: "ol"}], {type: "text/plain", data: "ol"}],
[{startContainer: lastTextNode, startOffset: kExpectedOffsets[0], [{startContainer: lastTextNode, startOffset: 2,
endContainer: lastTextNode, endOffset: kExpectedOffsets[1]}], endContainer: lastTextNode, endOffset: 2}],
description); description);
is(inputEvents.length, 1, is(inputEvents.length, 1,
`${description}: only one "input" event should be fired on contenteditable`); `${description}: only one "input" event should be fired on contenteditable`);
@@ -1114,12 +1095,11 @@ async function doTest() {
[{startContainer: selectionContainers[0], startOffset: 1, [{startContainer: selectionContainers[0], startOffset: 1,
endContainer: selectionContainers[1], endOffset: 3}], endContainer: selectionContainers[1], endOffset: 3}],
description); description);
const kExpectedOffsets = isAndroidException ? [3,3] : [2,2];
checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null, checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null,
[{type: "text/html", data: "<b>ol</b>"}, [{type: "text/html", data: "<b>ol</b>"},
{type: "text/plain", data: "ol"}], {type: "text/plain", data: "ol"}],
[{startContainer: lastTextNode, startOffset: kExpectedOffsets[0], [{startContainer: lastTextNode, startOffset: 2,
endContainer: lastTextNode, endOffset: kExpectedOffsets[1]}], endContainer: lastTextNode, endOffset: 2}],
description); description);
is(inputEvents.length, 1, is(inputEvents.length, 1,
`${description}: only one "input" event should be fired on contenteditable`); `${description}: only one "input" event should be fired on contenteditable`);
@@ -1162,23 +1142,15 @@ async function doTest() {
} }
) )
) { ) {
const kExpectedOffsets = isAndroidException ? [3,3] : [2,2]; is(contenteditable.innerHTML, "<b>bold</b> <span>MM</span><b>ol</b><span>MM</span>",
if (isAndroidException) { `${description}: dragged range shouldn't be removed from contenteditable`);
todo_is(contenteditable.innerHTML, "<b>bold</b> <span>MM</span><b>ol</b><span>MM</span>",
`${description}: dragged range shouldn't be removed from contenteditable`);
isnot(contenteditable.innerHTML, "<b>bold</b> <span>MMMM</span><b>ol</b>",
`${description}: dragged range shouldn't be removed from contenteditable`);
} else {
is(contenteditable.innerHTML, "<b>bold</b> <span>MM</span><b>ol</b><span>MM</span>",
`${description}: dragged range shouldn't be removed from contenteditable`);
}
is(beforeinputEvents.length, 1, is(beforeinputEvents.length, 1,
`${description}: only 1 "beforeinput" events should be fired on contenteditable`); `${description}: only 1 "beforeinput" events should be fired on contenteditable`);
checkInputEvent(beforeinputEvents[0], contenteditable, "insertFromDrop", null, checkInputEvent(beforeinputEvents[0], contenteditable, "insertFromDrop", null,
[{type: "text/html", data: "<b>ol</b>"}, [{type: "text/html", data: "<b>ol</b>"},
{type: "text/plain", data: "ol"}], {type: "text/plain", data: "ol"}],
[{startContainer: lastTextNode, startOffset: kExpectedOffsets[0], [{startContainer: lastTextNode, startOffset: 2,
endContainer: lastTextNode, endOffset: kExpectedOffsets[1]}], endContainer: lastTextNode, endOffset: 2}],
description); description);
is(inputEvents.length, 1, is(inputEvents.length, 1,
`${description}: only 1 "input" events should be fired on contenteditable`); `${description}: only 1 "input" events should be fired on contenteditable`);
@@ -3548,16 +3520,8 @@ async function doTest() {
} }
) )
) { ) {
const kExpectedOffsets = isAndroidException ? [4,4] : [2,2]; is(contenteditable.innerHTML, "!!<span>MM</span>dragme<span>MM</span>",
if (isAndroidException) { `${description}: dragged range should be moved in inline contenteditable`);
todo_is(contenteditable.innerHTML, "!!<span>MM</span>dragme<span>MM</span>",
`${description}: dragged range should be moved in inline contenteditable`);
is(contenteditable.innerHTML, "!!<span>MMMM</span>dragme",
`${description}: dragged range should be moved in inline contenteditable`);
} else {
is(contenteditable.innerHTML, "!!<span>MM</span>dragme<span>MM</span>",
`${description}: dragged range should be moved in inline contenteditable`);
}
is(beforeinputEvents.length, 2, is(beforeinputEvents.length, 2,
`${description}: 2 "beforeinput" events should be fired on inline contenteditable`); `${description}: 2 "beforeinput" events should be fired on inline contenteditable`);
checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null, checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null,
@@ -3567,8 +3531,8 @@ async function doTest() {
checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null, checkInputEvent(beforeinputEvents[1], contenteditable, "insertFromDrop", null,
[{type: "text/html", data: "dragme"}, [{type: "text/html", data: "dragme"},
{type: "text/plain", data: "dragme"}], {type: "text/plain", data: "dragme"}],
[{startContainer: span.firstChild, startOffset: kExpectedOffsets[0], [{startContainer: span.firstChild, startOffset: 2,
endContainer: span.firstChild, endOffset: kExpectedOffsets[1]}], endContainer: span.firstChild, endOffset: 2}],
description); description);
is(inputEvents.length, 2, is(inputEvents.length, 2,
`${description}: 2 "input" events should be fired on inline contenteditable`); `${description}: 2 "input" events should be fired on inline contenteditable`);
@@ -3611,18 +3575,10 @@ async function doTest() {
} }
) )
) { ) {
const kExpectedOffsets = isAndroidException ? [2,2] : [1,1];
is(contenteditable.innerHTML, "!!", is(contenteditable.innerHTML, "!!",
`${description}: dragged range should be removed from inline contenteditable`); `${description}: dragged range should be removed from inline contenteditable`);
if (isAndroidException) { is(otherContenteditable.innerHTML, "MdragmeM",
todo_is(otherContenteditable.innerHTML, "MdragmeM", `${description}: dragged content should be inserted into other inline contenteditable`);
`${description}: dragged content should be inserted into other inline contenteditable`);
is(otherContenteditable.innerHTML, "MMdragme",
`${description}: dragged content should be inserted into other inline contenteditable`);
} else {
is(otherContenteditable.innerHTML, "MdragmeM",
`${description}: dragged content should be inserted into other inline contenteditable`);
}
is(beforeinputEvents.length, 2, is(beforeinputEvents.length, 2,
`${description}: 2 "beforeinput" events should be fired on inline contenteditable`); `${description}: 2 "beforeinput" events should be fired on inline contenteditable`);
checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null, checkInputEvent(beforeinputEvents[0], contenteditable, "deleteByDrag", null, null,
@@ -3632,8 +3588,8 @@ async function doTest() {
checkInputEvent(beforeinputEvents[1], otherContenteditable, "insertFromDrop", null, checkInputEvent(beforeinputEvents[1], otherContenteditable, "insertFromDrop", null,
[{type: "text/html", data: "dragme"}, [{type: "text/html", data: "dragme"},
{type: "text/plain", data: "dragme"}], {type: "text/plain", data: "dragme"}],
[{startContainer: otherContenteditable.firstChild, startOffset: kExpectedOffsets[0], [{startContainer: otherContenteditable.firstChild, startOffset: 1,
endContainer: otherContenteditable.firstChild, endOffset: kExpectedOffsets[1]}], endContainer: otherContenteditable.firstChild, endOffset: 1}],
description); description);
is(inputEvents.length, 2, is(inputEvents.length, 2,
`${description}: 2 "input" events should be fired on inline contenteditable`); `${description}: 2 "input" events should be fired on inline contenteditable`);

View File

@@ -597,7 +597,6 @@ support-files = [
] ]
["test_synthmousemove_after_dnd.html"] ["test_synthmousemove_after_dnd.html"]
skip-if = ["os == 'android'"] # Bug 1909020
["test_transformed_scrolling_repaints.html"] ["test_transformed_scrolling_repaints.html"]

View File

@@ -32,9 +32,19 @@ SimpleTest.waitForFocus(async () => {
const promiseDrop = new Promise(resolve => { const promiseDrop = new Promise(resolve => {
target.addEventListener("drop", event => { target.addEventListener("drop", event => {
event.preventDefault(); event.preventDefault();
target.addEventListener("mouseover", resolve, {once: true}); isnot(
document.querySelector("span:hover"),
target,
"The target should not have hover state during the drop event propagation"
);
info("Waiting for mouseover event after drop event...");
target.addEventListener("mouseover", () => {
info("Got mouseover event");
resolve();
}, {once: true});
}, { once: true }); }, { once: true });
}); });
info("Dragging selection is the source element and drop it to the target...");
getSelection().selectAllChildren(source); getSelection().selectAllChildren(source);
synthesizePlainDragAndDrop({ synthesizePlainDragAndDrop({
srcSelection: getSelection(), srcSelection: getSelection(),

View File

@@ -1465,13 +1465,7 @@ function synthesizeNativeMouseEvent(aParams, aCallback = null) {
} }
const rect = target?.getBoundingClientRect(); const rect = target?.getBoundingClientRect();
let resolution = 1.0; const resolution = _getTopWindowResolution(win);
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 = (() => { const scaleValue = (() => {
if (scale === "inScreenPixels") { if (scale === "inScreenPixels") {
return 1.0; return 1.0;
@@ -1490,15 +1484,7 @@ function synthesizeNativeMouseEvent(aParams, aCallback = null) {
if (screenX != undefined) { if (screenX != undefined) {
return screenX * scaleValue; return screenX * scaleValue;
} }
let winInnerOffsetX = win.mozInnerScreenX; const winInnerOffsetX = _getScreenXInUnscaledCSSPixels(win);
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 ( return (
(((atCenter ? rect.width / 2 : offsetX) + rect.left) * resolution + (((atCenter ? rect.width / 2 : offsetX) + rect.left) * resolution +
winInnerOffsetX) * winInnerOffsetX) *
@@ -1511,15 +1497,7 @@ function synthesizeNativeMouseEvent(aParams, aCallback = null) {
if (screenY != undefined) { if (screenY != undefined) {
return screenY * scaleValue; return screenY * scaleValue;
} }
let winInnerOffsetY = win.mozInnerScreenY; const winInnerOffsetY = _getScreenYInUnscaledCSSPixels(win);
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 ( return (
(((atCenter ? rect.height / 2 : offsetY) + rect.top) * resolution + (((atCenter ? rect.height / 2 : offsetY) + rect.top) * resolution +
winInnerOffsetY) * winInnerOffsetY) *
@@ -2249,6 +2227,67 @@ function _getDOMWindowUtils(aWindow = window) {
return aWindow.windowUtils; return aWindow.windowUtils;
} }
/**
* @param {Window} aWindow The window.
* @returns The scaling value applied to the top window.
*/
function _getTopWindowResolution(aWindow) {
let resolution = 1.0;
try {
resolution = _getDOMWindowUtils(aWindow.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?
}
return resolution;
}
/**
* @param {Window} aWindow The window which you want to get its x-offset in the
* screen.
* @returns The screenX of aWindow in the unscaled CSS pixels.
*/
function _getScreenXInUnscaledCSSPixels(aWindow) {
// 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.
let winInnerOffsetX = aWindow.mozInnerScreenX;
try {
winInnerOffsetX =
aWindow.top.mozInnerScreenX +
(aWindow.mozInnerScreenX - aWindow.top.mozInnerScreenX) *
_getTopWindowResolution(aWindow);
} catch (e) {
// XXX fission+xorigin test throws permission denied since win.top is
// cross-origin.
}
return winInnerOffsetX;
}
/**
* @param {Window} aWindow The window which you want to get its y-offset in the
* screen.
* @returns The screenY of aWindow in the unscaled CSS pixels.
*/
function _getScreenYInUnscaledCSSPixels(aWindow) {
// 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.
let winInnerOffsetY = aWindow.mozInnerScreenY;
try {
winInnerOffsetY =
aWindow.top.mozInnerScreenY +
(aWindow.mozInnerScreenY - aWindow.top.mozInnerScreenY) *
_getTopWindowResolution(aWindow);
} catch (e) {
// XXX fission+xorigin test throws permission denied since win.top is
// cross-origin.
}
return winInnerOffsetY;
}
function _defineConstant(name, value) { function _defineConstant(name, value) {
Object.defineProperty(this, name, { Object.defineProperty(this, name, {
value, value,
@@ -3127,17 +3166,28 @@ function createDragEventObject(
aDataTransfer, aDataTransfer,
aDragEvent aDragEvent
) { ) {
var destRect = aDestElement.getBoundingClientRect(); const resolution = _getTopWindowResolution(aDestWindow.top);
var destClientX = destRect.left + destRect.width / 2; const destRect = aDestElement.getBoundingClientRect();
var destClientY = destRect.top + destRect.height / 2; // If clientX and/or clientY are specified, we should use them. Otherwise,
var destScreenX = aDestWindow.mozInnerScreenX + destClientX; // use the center of the dest element.
var destScreenY = aDestWindow.mozInnerScreenY + destClientY; const destClientXInCSSPixels =
if ("clientX" in aDragEvent && !("screenX" in aDragEvent)) { "clientX" in aDragEvent && !("screenX" in aDragEvent)
destScreenX = aDestWindow.mozInnerScreenX + aDragEvent.clientX; ? aDragEvent.clientX
} : destRect.left + destRect.width / 2;
if ("clientY" in aDragEvent && !("screenY" in aDragEvent)) { const destClientYInCSSPixels =
destScreenY = aDestWindow.mozInnerScreenY + aDragEvent.clientY; "clientY" in aDragEvent && !("screenY" in aDragEvent)
} ? aDragEvent.clientY
: destRect.top + destRect.height / 2;
const devicePixelRatio = aDestWindow.devicePixelRatio;
const destScreenXInDevicePixels =
(_getScreenXInUnscaledCSSPixels(aDestWindow) +
destClientXInCSSPixels * resolution) *
devicePixelRatio;
const destScreenYInDevicePixels =
(_getScreenYInUnscaledCSSPixels(aDestWindow) +
destClientYInCSSPixels * resolution) *
devicePixelRatio;
// Wrap only in plain mochitests // Wrap only in plain mochitests
let dataTransfer; let dataTransfer;
@@ -3151,14 +3201,13 @@ function createDragEventObject(
// nsContentUtils::SetDataTransferInEvent for actual impl). // nsContentUtils::SetDataTransferInEvent for actual impl).
dataTransfer.dropEffect = aDataTransfer.dropEffect; dataTransfer.dropEffect = aDataTransfer.dropEffect;
} }
return Object.assign( return Object.assign(
{ {
type: aType, type: aType,
screenX: destScreenX, screenX: _EU_roundDevicePixels(destScreenXInDevicePixels),
screenY: destScreenY, screenY: _EU_roundDevicePixels(destScreenYInDevicePixels),
clientX: destClientX, clientX: _EU_roundDevicePixels(destClientXInCSSPixels),
clientY: destClientY, clientY: _EU_roundDevicePixels(destClientYInCSSPixels),
dataTransfer, dataTransfer,
_domDispatchOnly: aDragEvent._domDispatchOnly, _domDispatchOnly: aDragEvent._domDispatchOnly,
}, },