From 2a051cf2595791b3e4319c042a267ba40a7b12ac Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 16 Sep 2021 09:01:19 +0200 Subject: [PATCH] feat: add ability to specify offsets for JSHandle.click Until now, the click would be always sent to the middle point of the target element. With this change, one can define offsets relative to the border box of the elements and click different areas of an element. --- docs/api.md | 10 ++- src/common/JSHandle.ts | 49 ++++++++++++- test/jshandle.spec.ts | 91 +++++++++++++++++++++++++ utils/doclint/check_public_api/index.js | 7 ++ 4 files changed, 152 insertions(+), 5 deletions(-) diff --git a/docs/api.md b/docs/api.md index c996ff7c53c4f..cb065dd15e571 100644 --- a/docs/api.md +++ b/docs/api.md @@ -307,7 +307,7 @@ * [elementHandle.boundingBox()](#elementhandleboundingbox) * [elementHandle.boxModel()](#elementhandleboxmodel) * [elementHandle.click([options])](#elementhandleclickoptions) - * [elementHandle.clickablePoint()](#elementhandleclickablepoint) + * [elementHandle.clickablePoint([offset])](#elementhandleclickablepointoffset) * [elementHandle.contentFrame()](#elementhandlecontentframe) * [elementHandle.dispose()](#elementhandledispose) * [elementHandle.drag(target)](#elementhandledragtarget) @@ -4442,13 +4442,19 @@ This method returns boxes of the element, or `null` if the element is not visibl - `button` <"left"|"right"|"middle"> Defaults to `left`. - `clickCount` <[number]> defaults to 1. See [UIEvent.detail]. - `delay` <[number]> Time to wait between `mousedown` and `mouseup` in milliseconds. Defaults to 0. + - `offset` <[Object]> Offset in pixels relative to the border box of the element. + - `x` x-offset in pixels relative to the border box of the element. + - `y` y-offset in pixels relative to the border box of the element. - returns: <[Promise]> Promise which resolves when the element is successfully clicked. Promise gets rejected if the element is detached from DOM. This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to click in the center of the element. If the element is detached from DOM, the method throws an error. -#### elementHandle.clickablePoint() +#### elementHandle.clickablePoint([offset]) +- `offset` <[Object]> + - `x` x-offset in pixels relative to the border box of the element. + - `y` y-offset in pixels relative to the border box of the element. - returns: <[Promise<[Point]>]> Resolves to the x, y point that describes the element's position. #### elementHandle.contentFrame() diff --git a/src/common/JSHandle.ts b/src/common/JSHandle.ts index 2e138c514e821..cc1f2bdffaf23 100644 --- a/src/common/JSHandle.ts +++ b/src/common/JSHandle.ts @@ -411,7 +411,10 @@ export class ElementHandle< if (error) throw new Error(error); } - async clickablePoint(): Promise { + /** + * Returns the middle point within an element unless a specific offset is provided. + */ + async clickablePoint(offset?: Offset): Promise { const [result, layoutMetrics] = await Promise.all([ this._client .send('DOM.getContentQuads', { @@ -434,8 +437,30 @@ export class ElementHandle< .filter((quad) => computeQuadArea(quad) > 1); if (!quads.length) throw new Error('Node is either not clickable or not an HTMLElement'); - // Return the middle point of the first quad. const quad = quads[0]; + if (offset) { + // Return the point of the first quad identified by offset. + let minX = Number.MAX_SAFE_INTEGER; + let minY = Number.MAX_SAFE_INTEGER; + for (const point of quad) { + if (point.x < minX) { + minX = point.x; + } + if (point.y < minY) { + minY = point.y; + } + } + if ( + minX !== Number.MAX_SAFE_INTEGER && + minY !== Number.MAX_SAFE_INTEGER + ) { + return { + x: minX + offset.x, + y: minY + offset.y, + }; + } + } + // Return the middle point of the first quad. let x = 0; let y = 0; for (const point of quad) { @@ -495,7 +520,7 @@ export class ElementHandle< */ async click(options: ClickOptions = {}): Promise { await this._scrollIntoViewIfNeeded(); - const { x, y } = await this.clickablePoint(); + const { x, y } = await this.clickablePoint(options.offset); await this._page.mouse.click(x, y, options); } @@ -1011,6 +1036,20 @@ export class ElementHandle< } } +/** + * @public + */ +export interface Offset { + /** + * x-offset for the clickable point relative to the border box. + */ + x: number; + /** + * y-offset for the clickable point relative to the border box. + */ + y: number; +} + /** * @public */ @@ -1029,6 +1068,10 @@ export interface ClickOptions { * @defaultValue 1 */ clickCount?: number; + /** + * Offset for the clickable point relative to the border box. + */ + offset?: Offset; } /** diff --git a/test/jshandle.spec.ts b/test/jshandle.spec.ts index 7978d4acda5db..1efbf95bd5fc1 100644 --- a/test/jshandle.spec.ts +++ b/test/jshandle.spec.ts @@ -297,4 +297,95 @@ describe('JSHandle', function () { ); }); }); + + describe('JSHandle.clickablePoint', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.evaluate(() => { + document.body.style.padding = '0'; + document.body.style.margin = '0'; + document.body.innerHTML = ` +
+ `; + }); + + const divHandle = await page.$('div'); + expect(await divHandle.clickablePoint()).toEqual({ + x: 45 + 60, // margin + middle point offset + y: 45 + 30, // margin + middle point offset + }); + expect( + await divHandle.clickablePoint({ + x: 10, + y: 15, + }) + ).toEqual({ + x: 30 + 10, // margin + offset + y: 30 + 15, // margin + offset + }); + }); + + it('should work for iframes', async () => { + const { page } = getTestState(); + await page.evaluate(() => { + document.body.style.padding = '10px'; + document.body.style.margin = '10px'; + document.body.innerHTML = ` + + `; + }); + const frame = page.frames()[1]; + const divHandle = await frame.$('div'); + expect(await divHandle.clickablePoint()).toEqual({ + x: 20 + 45 + 60, // iframe pos + margin + middle point offset + y: 20 + 45 + 30, // iframe pos + margin + middle point offset + }); + expect( + await divHandle.clickablePoint({ + x: 10, + y: 15, + }) + ).toEqual({ + x: 20 + 30 + 10, // iframe pos + margin + offset + y: 20 + 30 + 15, // iframe pos + margin + offset + }); + }); + }); + + describe('JSHandle.click', function () { + it('should work', async () => { + const { page } = getTestState(); + + const clicks = []; + + await page.exposeFunction('reportClick', (x: number, y: number): void => { + clicks.push([x, y]); + }); + + await page.evaluate(() => { + document.body.style.padding = '0'; + document.body.style.margin = '0'; + document.body.innerHTML = ` +
+ `; + document.body.addEventListener('click', (e) => { + (window as any).reportClick(e.clientX, e.clientY); + }); + }); + + const divHandle = await page.$('div'); + await divHandle.click(); + await divHandle.click({ + offset: { + x: 10, + y: 15, + }, + }); + expect(clicks).toEqual([ + [45 + 60, 45 + 30], // margin + middle point offset + [30 + 10, 30 + 15], // margin + offset + ]); + }); + }); }); diff --git a/utils/doclint/check_public_api/index.js b/utils/doclint/check_public_api/index.js index 0fffb31f1628f..3e4e68a1c5d2e 100644 --- a/utils/doclint/check_public_api/index.js +++ b/utils/doclint/check_public_api/index.js @@ -371,6 +371,13 @@ function compareDocumentations(actual, expected) { expectedName: 'ClickOptions', }, ], + [ + 'Method ElementHandle.clickablePoint() offset', + { + actualName: 'Object', + expectedName: 'Offset', + }, + ], [ 'Method ElementHandle.press() options', {