fix(click): better compensate for integer click coordinates in firefox (#33467)

This commit is contained in:
Dmitry Gozman 2024-11-07 00:01:00 -08:00 коммит произвёл GitHub
Родитель 50775698ae
Коммит 43e4ba9f2f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
2 изменённых файлов: 87 добавлений и 26 удалений

Просмотреть файл

@ -266,14 +266,22 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const filtered = quads.map(quad => intersectQuadWithViewport(quad)).filter(quad => computeQuadArea(quad) > 0.99); const filtered = quads.map(quad => intersectQuadWithViewport(quad)).filter(quad => computeQuadArea(quad) > 0.99);
if (!filtered.length) if (!filtered.length)
return 'error:notinviewport'; return 'error:notinviewport';
// Return the middle point of the first quad. if (this._page._browserContext._browser.options.name === 'firefox') {
const result = { x: 0, y: 0 }; // Firefox internally uses integer coordinates, so 8.x is converted to 8 or 9 when clicking.
for (const point of filtered[0]) { //
result.x += point.x / 4; // This does not work nicely for small elements. For example, 1x1 square with corners
result.y += point.y / 4; // (8;8) and (9;9) is targeted when clicking at (8;8) but not when clicking at (9;9).
// So, clicking at (8.x;8.y) will sometimes click at (9;9) and miss the target.
//
// Therefore, we try to find an integer point within a quad to make sure we click inside the element.
for (const quad of filtered) {
const integerPoint = findIntegerPointInsideQuad(quad);
if (integerPoint)
return integerPoint;
}
} }
compensateHalfIntegerRoundingError(result); // Return the middle point of the first quad.
return result; return quadMiddlePoint(filtered[0]);
} }
private async _offsetPoint(offset: types.Point): Promise<types.Point | 'error:notvisible' | 'error:notconnected'> { private async _offsetPoint(offset: types.Point): Promise<types.Point | 'error:notvisible' | 'error:notconnected'> {
@ -920,24 +928,47 @@ function roundPoint(point: types.Point): types.Point {
}; };
} }
function compensateHalfIntegerRoundingError(point: types.Point) { function quadMiddlePoint(quad: types.Quad): types.Point {
// Firefox internally uses integer coordinates, so 8.5 is converted to 9 when clicking. const result = { x: 0, y: 0 };
// for (const point of quad) {
// This does not work nicely for small elements. For example, 1x1 square with corners result.x += point.x / 4;
// (8;8) and (9;9) is targeted when clicking at (8;8) but not when clicking at (9;9). result.y += point.y / 4;
// So, clicking at (8.5;8.5) will effectively click at (9;9) and miss the target. }
// return result;
// Therefore, we skew half-integer values from the interval (8.49, 8.51) towards }
// (8.47, 8.49) that is rounded towards 8. This means clicking at (8.5;8.5) will
// be replaced with (8.48;8.48) and will effectively click at (8;8). function triangleArea(p1: types.Point, p2: types.Point, p3: types.Point): number {
// return Math.abs(p1.x * (p2.y - p3.y) + p2.x * (p3.y - p1.y) + p3.x * (p1.y - p2.y)) / 2;
// Other browsers use float coordinates, so this change should not matter. }
const remainderX = point.x - Math.floor(point.x);
if (remainderX > 0.49 && remainderX < 0.51) function isPointInsideQuad(point: types.Point, quad: types.Quad): boolean {
point.x -= 0.02; const area1 = triangleArea(point, quad[0], quad[1]) + triangleArea(point, quad[1], quad[2]) + triangleArea(point, quad[2], quad[3]) + triangleArea(point, quad[3], quad[0]);
const remainderY = point.y - Math.floor(point.y); const area2 = triangleArea(quad[0], quad[1], quad[2]) + triangleArea(quad[1], quad[2], quad[3]);
if (remainderY > 0.49 && remainderY < 0.51) // Check that point is inside the quad.
point.y -= 0.02; if (Math.abs(area1 - area2) > 0.1)
return false;
// Check that point is not on the right/bottom edge, because clicking
// there does not actually click the element.
return point.x < Math.max(quad[0].x, quad[1].x, quad[2].x, quad[3].x) &&
point.y < Math.max(quad[0].y, quad[1].y, quad[2].y, quad[3].y);
}
function findIntegerPointInsideQuad(quad: types.Quad): types.Point | undefined {
// Try all four rounding directions of the middle point.
const point = quadMiddlePoint(quad);
point.x = Math.floor(point.x);
point.y = Math.floor(point.y);
if (isPointInsideQuad(point, quad))
return point;
point.x += 1;
if (isPointInsideQuad(point, quad))
return point;
point.y += 1;
if (isPointInsideQuad(point, quad))
return point;
point.x -= 1;
if (isPointInsideQuad(point, quad))
return point;
} }
export const kUnableToAdoptErrorMessage = 'Unable to adopt element handle from a different document'; export const kUnableToAdoptErrorMessage = 'Unable to adopt element handle from a different document';

Просмотреть файл

@ -95,12 +95,42 @@ it('should not throw UnhandledPromiseRejection when page closes', async ({ page,
]).catch(e => {}); ]).catch(e => {});
}); });
it('should click the 1x1 div', async ({ page }) => { it('should click the aligned 1x1 div', async ({ page }) => {
await page.setContent(`<div style="width: 1px; height: 1px;" onclick="window.__clicked = true"></div>`); await page.setContent(`<div style="width: 1px; height: 1px;" onclick="window.__clicked = true"></div>`);
await page.click('div'); await page.click('div');
expect(await page.evaluate('window.__clicked')).toBe(true); expect(await page.evaluate('window.__clicked')).toBe(true);
}); });
it('should click the half-aligned 1x1 div', async ({ page }) => {
await page.setContent(`<div style="margin-left: 20.5px; margin-top: 11.5px; width: 1px; height: 1px;" onclick="window.__clicked = true"></div>`);
await page.click('div');
expect(await page.evaluate('window.__clicked')).toBe(true);
});
it('should click the unaligned 1x1 div v1', async ({ page }) => {
await page.setContent(`<div style="margin-left: 20.23px; margin-top: 11.65px; width: 1px; height: 1px;" onclick="window.__clicked = true"></div>`);
await page.click('div');
expect(await page.evaluate('window.__clicked')).toBe(true);
});
it('should click the unaligned 1x1 div v2', async ({ page }) => {
await page.setContent(`<div style="margin-left: 20.68px; margin-top: 11.13px; width: 1px; height: 1px;" onclick="window.__clicked = true"></div>`);
await page.click('div');
expect(await page.evaluate('window.__clicked')).toBe(true);
});
it('should click the unaligned 1x1 div v3', async ({ page }) => {
await page.setContent(`<div style="margin-left: 20.68px; margin-top: 11.52px; width: 1px; height: 1px;" onclick="window.__clicked = true"></div>`);
await page.click('div');
expect(await page.evaluate('window.__clicked')).toBe(true);
});
it('should click the unaligned 1x1 div v4', async ({ page }) => {
await page.setContent(`<div style="margin-left: 20.15px; margin-top: 11.24px; width: 1px; height: 1px;" onclick="window.__clicked = true"></div>`);
await page.click('div');
expect(await page.evaluate('window.__clicked')).toBe(true);
});
it('should click the button after navigation ', async ({ page, server }) => { it('should click the button after navigation ', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/button.html'); await page.goto(server.PREFIX + '/input/button.html');
await page.click('button'); await page.click('button');