diff --git a/README.md b/README.md index 2d0cee6..594ad87 100644 --- a/README.md +++ b/README.md @@ -164,11 +164,14 @@ Scrolls the element into view. If already in view, no scroll occurs. - `scrollDelay (number):` Time to wait after scrolling (when scrolling occurs). Default is `200`. - `inViewportMargin (number):` Margin (in px) to add around the element when ensuring it is in the viewport. Default is `0`. -#### `scrollTo: (destination: Partial | 'top' | 'bottom' | 'left' | 'right', options?: ScrollOptions) => Promise` +#### `scrollTo: (destination: Partial | 'top' | 'bottom' | 'left' | 'right' | ElementHandle, options?: ScrollOptions) => Promise` Scrolls to the specified destination point. -- **destination:** An object with `x` and `y` coordinates representing the target position. For example, `{ x: 500, y: 300 }`. Can also be `"top"` or `"bottom"`. +- **destination:** Can be any of: + - An object with `x` and `y` coordinates representing the target position. For example, `{ x: 500, y: 300 }`. + - `'top'` or `'bottom'` or `'left'` or `'right'`. + - An ElementHandle (scrolls to the element's bounding box x/y coordinates). - **options (optional):** Additional options for scrolling. **Extends the `options` of the `scroll` function (below)** #### `scroll: (delta: Partial, options?: ScrollOptions) => Promise` diff --git a/src/__test__/spoof.spec.ts b/src/__test__/spoof.spec.ts index 30ef36c..210062f 100644 --- a/src/__test__/spoof.spec.ts +++ b/src/__test__/spoof.spec.ts @@ -110,6 +110,20 @@ describe('Mouse movements', () => { await cursor.scrollTo({ y: 200, x: 400 }) expect(await getScrollPosition()).toEqual({ top: 200, left: 400 }) }) + + it('Should scroll to an ElementHandle correctly from a non-zero scroll position', async () => { + const box2 = await page.waitForSelector('#box2') as ElementHandle | null + if (box2 == null) throw new Error('#box2 not found') + + // Start from a non-zero scroll position so that boundingBox() returns + // viewport-relative coords that differ from document-relative coords. + await cursor.scrollTo({ y: 1000 }) + expect(await getScrollPosition()).toEqual({ top: 1000, left: 0 }) + expect(await box2.isIntersectingViewport()).toBeFalsy() + + await cursor.scrollTo(box2) + expect(await box2.isIntersectingViewport()).toBeTruthy() + }) }) jest.setTimeout(15_000) diff --git a/src/spoof.ts b/src/spoof.ts index ea6d4dc..682f19f 100644 --- a/src/spoof.ts +++ b/src/spoof.ts @@ -47,6 +47,9 @@ export interface GetElementOptions { readonly waitForSelector?: number } +type PagePosition = 'top' | 'bottom' | 'left' | 'right' +export type ScrollToDestination = Partial | PagePosition | ElementHandle + export interface ScrollOptions { /** * Scroll speed. 0 to 100. 100 is instant. @@ -159,8 +162,6 @@ export interface MoveToOptions extends PathOptions, Pick | 'top' | 'bottom' | 'left' | 'right' - export type MouseButtonOptions = Pick /** @@ -205,6 +206,9 @@ const delay = async (ms: number): Promise => { return await new Promise((resolve) => setTimeout(resolve, ms)) } +const isElementHandle = (item: any): item is ElementHandle => + item !== null && item !== undefined && typeof item.asElement === 'function' + /** * Calculate the amount of time needed to move from (x1, y1) to (x2, y2) * given the width of the element being clicked on @@ -484,6 +488,24 @@ export class GhostCursor { this.removeMouseHelperFn = undefined } + private async getPageState (): Promise<{ + viewportWidth: number + viewportHeight: number + docHeight: number + docWidth: number + scrollPositionTop: number + scrollPositionLeft: number + }> { + return await this.page.evaluate(() => ({ + viewportWidth: document.body.clientWidth, + viewportHeight: document.body.clientHeight, + docHeight: document.body.scrollHeight, + docWidth: document.body.scrollWidth, + scrollPositionTop: window.scrollY, + scrollPositionLeft: window.scrollX + })) + } + /** Move the mouse to a point, getting the vectors via `path(previous, newLocation, options)` */ private async moveMouse ( newLocation: BoundingBox | Vector, @@ -748,16 +770,7 @@ export class GhostCursor { docWidth, scrollPositionTop, scrollPositionLeft - } = await this.page.evaluate(() => ( - { - viewportWidth: document.body.clientWidth, - viewportHeight: document.body.clientHeight, - docHeight: document.body.scrollHeight, - docWidth: document.body.scrollWidth, - scrollPositionTop: window.scrollY, - scrollPositionLeft: window.scrollX - } - )) + } = await this.getPageState() const elemBoundingBox = await getElementBox(this.page, elem) // is relative to viewport const elemBox = { @@ -929,16 +942,9 @@ export class GhostCursor { docWidth, scrollPositionTop, scrollPositionLeft - } = await this.page.evaluate(() => ( - { - docHeight: document.body.scrollHeight, - docWidth: document.body.scrollWidth, - scrollPositionTop: window.scrollY, - scrollPositionLeft: window.scrollX - } - )) + } = await this.getPageState() - const to = ((): Partial => { + const to: Partial = await (async (): Promise> => { switch (destination) { case 'top': return { y: 0 } @@ -948,8 +954,22 @@ export class GhostCursor { return { x: 0 } case 'right': return { x: docWidth } - default: + default: { + if (isElementHandle(destination)) { + const box = await destination.boundingBox() + if (box == null) { + throw new Error('no boundingBox') + } + // boundingBox() is viewport-relative; convert to document-relative + return { + x: box.x + scrollPositionLeft, + y: box.y + scrollPositionTop + } + } + + // Partial return destination + } } })()