diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index a1e0a82e61..b6434f8f50 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -58,14 +58,10 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { return (remoteObject as Protocol.Runtime.RemoteObject).subtype === 'node'; } - private _getBoxModel(handle: dom.ElementHandle): Promise { - return this._client.send('DOM.getBoxModel', { - objectId: toRemoteObject(handle).objectId - }).catch(error => debugError(error)); - } - async boundingBox(handle: dom.ElementHandle): Promise { - const result = await this._getBoxModel(handle); + const result = await this._client.send('DOM.getBoxModel', { + objectId: toRemoteObject(handle).objectId + }).catch(debugError); if (!result) return null; const quad = result.model.border; @@ -76,120 +72,30 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { return {x, y, width, height}; } + async contentQuads(handle: dom.ElementHandle): Promise { + const result = await this._client.send('DOM.getContentQuads', { + objectId: toRemoteObject(handle).objectId + }).catch(debugError); + if (!result) + return null; + return result.quads.map(quad => [ + { x: quad[0], y: quad[1] }, + { x: quad[2], y: quad[3] }, + { x: quad[4], y: quad[5] }, + { x: quad[6], y: quad[7] } + ]); + } + + async layoutViewport(): Promise<{ width: number, height: number }> { + const layoutMetrics = await this._client.send('Page.getLayoutMetrics'); + return { width: layoutMetrics.layoutViewport.clientWidth, height: layoutMetrics.layoutViewport.clientHeight }; + } + screenshot(handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise { const page = this._frameManager.page(); return page._screenshotter.screenshotElement(page, handle, options); } - async ensurePointerActionPoint(handle: dom.ElementHandle, relativePoint?: types.Point): Promise { - await handle._scrollIntoViewIfNeeded(); - if (!relativePoint) - return this._clickablePoint(handle); - let r = await this._viewportPointAndScroll(handle, relativePoint); - if (r.scrollX || r.scrollY) { - const error = await handle.evaluate((element, scrollX, scrollY) => { - if (!element.ownerDocument || !element.ownerDocument.defaultView) - return 'Node does not have a containing window'; - element.ownerDocument.defaultView.scrollBy(scrollX, scrollY); - return false; - }, r.scrollX, r.scrollY); - if (error) - throw new Error(error); - r = await this._viewportPointAndScroll(handle, relativePoint); - if (r.scrollX || r.scrollY) - throw new Error('Failed to scroll relative point into viewport'); - } - return r.point; - } - - private async _clickablePoint(handle: dom.ElementHandle): Promise { - const fromProtocolQuad = (quad: number[]): types.Point[] => { - return [ - {x: quad[0], y: quad[1]}, - {x: quad[2], y: quad[3]}, - {x: quad[4], y: quad[5]}, - {x: quad[6], y: quad[7]} - ]; - }; - - const intersectQuadWithViewport = (quad: types.Point[], width: number, height: number): types.Point[] => { - return quad.map(point => ({ - x: Math.min(Math.max(point.x, 0), width), - y: Math.min(Math.max(point.y, 0), height), - })); - }; - - const computeQuadArea = (quad: types.Point[]) => { - // Compute sum of all directed areas of adjacent triangles - // https://en.wikipedia.org/wiki/Polygon#Simple_polygons - let area = 0; - for (let i = 0; i < quad.length; ++i) { - const p1 = quad[i]; - const p2 = quad[(i + 1) % quad.length]; - area += (p1.x * p2.y - p2.x * p1.y) / 2; - } - return Math.abs(area); - }; - - const [result, layoutMetrics] = await Promise.all([ - this._client.send('DOM.getContentQuads', { - objectId: toRemoteObject(handle).objectId - }).catch(debugError), - this._client.send('Page.getLayoutMetrics'), - ]); - if (!result || !result.quads.length) - throw new Error('Node is either not visible or not an HTMLElement'); - // Filter out quads that have too small area to click into. - const { clientWidth, clientHeight } = layoutMetrics.layoutViewport; - const quads = result.quads.map(fromProtocolQuad) - .map(quad => intersectQuadWithViewport(quad, clientWidth, clientHeight)) - .filter(quad => computeQuadArea(quad) > 1); - if (!quads.length) - throw new Error('Node is either not visible or not an HTMLElement'); - // Return the middle point of the first quad. - const quad = quads[0]; - let x = 0; - let y = 0; - for (const point of quad) { - x += point.x; - y += point.y; - } - return { - x: x / 4, - y: y / 4 - }; - } - - async _viewportPointAndScroll(handle: dom.ElementHandle, relativePoint: types.Point): Promise<{point: types.Point, scrollX: number, scrollY: number}> { - const model = await this._getBoxModel(handle); - let point: types.Point; - if (!model) { - point = relativePoint; - } else { - // Use padding quad to be compatible with offsetX/offsetY properties. - const quad = model.model.padding; - const x = Math.min(quad[0], quad[2], quad[4], quad[6]); - const y = Math.min(quad[1], quad[3], quad[5], quad[7]); - point = { - x: x + relativePoint.x, - y: y + relativePoint.y, - }; - } - const metrics = await this._client.send('Page.getLayoutMetrics'); - // Give one extra pixel to avoid any issues on viewport edge. - let scrollX = 0; - if (point.x < 1) - scrollX = point.x - 1; - if (point.x > metrics.layoutViewport.clientWidth - 1) - scrollX = point.x - metrics.layoutViewport.clientWidth + 1; - let scrollY = 0; - if (point.y < 1) - scrollY = point.y - 1; - if (point.y > metrics.layoutViewport.clientHeight - 1) - scrollY = point.y - metrics.layoutViewport.clientHeight + 1; - return { point, scrollX, scrollY }; - } - async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { await handle.evaluate(input.setFileInputFunction, files); } diff --git a/src/dom.ts b/src/dom.ts index e4ada5e096..d26248e649 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -8,7 +8,7 @@ import * as types from './types'; import * as injectedSource from './generated/injectedSource'; import * as cssSelectorEngineSource from './generated/cssSelectorEngineSource'; import * as xpathSelectorEngineSource from './generated/xpathSelectorEngineSource'; -import { assert, helper } from './helper'; +import { assert, helper, debugError } from './helper'; import Injected from './injected/injected'; import { SelectorRoot } from './injected/selectorEngine'; @@ -19,9 +19,10 @@ export interface DOMWorldDelegate { isJavascriptEnabled(): boolean; isElement(remoteObject: any): boolean; contentFrame(handle: ElementHandle): Promise; + contentQuads(handle: ElementHandle): Promise; + layoutViewport(): Promise<{ width: number, height: number }>; boundingBox(handle: ElementHandle): Promise; screenshot(handle: ElementHandle, options?: any): Promise; - ensurePointerActionPoint(handle: ElementHandle, relativePoint?: types.Point): Promise; setInputFiles(handle: ElementHandle, files: input.FilePayload[]): Promise; adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise; } @@ -190,8 +191,101 @@ export class ElementHandle extends js.JSHandle { throw new Error(error); } + private async _ensurePointerActionPoint(relativePoint?: types.Point): Promise { + await this._scrollIntoViewIfNeeded(); + if (!relativePoint) + return this._clickablePoint(); + let r = await this._viewportPointAndScroll(relativePoint); + if (r.scrollX || r.scrollY) { + const error = await this.evaluate((element, scrollX, scrollY) => { + if (!element.ownerDocument || !element.ownerDocument.defaultView) + return 'Node does not have a containing window'; + element.ownerDocument.defaultView.scrollBy(scrollX, scrollY); + return false; + }, r.scrollX, r.scrollY); + if (error) + throw new Error(error); + r = await this._viewportPointAndScroll(relativePoint); + if (r.scrollX || r.scrollY) + throw new Error('Failed to scroll relative point into viewport'); + } + return r.point; + } + + private async _clickablePoint(): Promise { + const intersectQuadWithViewport = (quad: types.Quad): types.Quad => { + return quad.map(point => ({ + x: Math.min(Math.max(point.x, 0), metrics.width), + y: Math.min(Math.max(point.y, 0), metrics.height), + })) as types.Quad; + }; + + const computeQuadArea = (quad: types.Quad) => { + // Compute sum of all directed areas of adjacent triangles + // https://en.wikipedia.org/wiki/Polygon#Simple_polygons + let area = 0; + for (let i = 0; i < quad.length; ++i) { + const p1 = quad[i]; + const p2 = quad[(i + 1) % quad.length]; + area += (p1.x * p2.y - p2.x * p1.y) / 2; + } + return Math.abs(area); + }; + + const [quads, metrics] = await Promise.all([ + this._world.delegate.contentQuads(this), + this._world.delegate.layoutViewport(), + ]); + if (!quads || !quads.length) + throw new Error('Node is either not visible or not an HTMLElement'); + + const filtered = quads.map(quad => intersectQuadWithViewport(quad)).filter(quad => computeQuadArea(quad) > 1); + if (!filtered.length) + throw new Error('Node is either not visible or not an HTMLElement'); + // Return the middle point of the first quad. + const result = { x: 0, y: 0 }; + for (const point of filtered[0]) { + result.x += point.x / 4; + result.y += point.y / 4; + } + return result; + } + + private async _viewportPointAndScroll(relativePoint: types.Point): Promise<{point: types.Point, scrollX: number, scrollY: number}> { + const [box, border] = await Promise.all([ + this.boundingBox(), + this.evaluate((e: Element) => { + const style = e.ownerDocument.defaultView.getComputedStyle(e); + return { x: parseInt(style.borderLeftWidth, 10), y: parseInt(style.borderTopWidth, 10) }; + }).catch(debugError), + ]); + const point = { x: relativePoint.x, y: relativePoint.y }; + if (box) { + point.x += box.x; + point.y += box.y; + } + if (border) { + // Make point relative to the padding box to align with offsetX/offsetY. + point.x += border.x; + point.y += border.y; + } + const metrics = await this._world.delegate.layoutViewport(); + // Give one extra pixel to avoid any issues on viewport edge. + let scrollX = 0; + if (point.x < 1) + scrollX = point.x - 1; + if (point.x > metrics.width - 1) + scrollX = point.x - metrics.width + 1; + let scrollY = 0; + if (point.y < 1) + scrollY = point.y - 1; + if (point.y > metrics.height - 1) + scrollY = point.y - metrics.height + 1; + return { point, scrollX, scrollY }; + } + async _performPointerAction(action: (point: types.Point) => Promise, options?: input.PointerActionOptions): Promise { - const point = await this._world.delegate.ensurePointerActionPoint(this, options ? options.relativePoint : undefined); + const point = await this._ensurePointerActionPoint(options ? options.relativePoint : undefined); let restoreModifiers: input.Modifier[] | undefined; if (options && options.modifiers) restoreModifiers = await this._world.delegate.keyboard._ensureModifiers(options.modifiers); diff --git a/src/firefox/JSHandle.ts b/src/firefox/JSHandle.ts index 2f54d6564a..3a6843a101 100644 --- a/src/firefox/JSHandle.ts +++ b/src/firefox/JSHandle.ts @@ -60,10 +60,36 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { } async boundingBox(handle: dom.ElementHandle): Promise { - return await this._session.send('Page.getBoundingBox', { + const quads = await this.contentQuads(handle); + if (!quads || !quads.length) + return null; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const quad of quads) { + for (const point of quad) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + async contentQuads(handle: dom.ElementHandle): Promise { + const result = await this._session.send('Page.getContentQuads', { frameId: this._frameId, objectId: toRemoteObject(handle).objectId, - }); + }).catch(debugError); + if (!result) + return null; + return result.quads.map(quad => [ quad.p1, quad.p2, quad.p3, quad.p4 ]); + } + + async layoutViewport(): Promise<{ width: number, height: number }> { + return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight })); } async screenshot(handle: dom.ElementHandle, options: any = {}): Promise { @@ -87,53 +113,6 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { })); } - async ensurePointerActionPoint(handle: dom.ElementHandle, relativePoint?: types.Point): Promise { - await handle._scrollIntoViewIfNeeded(); - if (!relativePoint) - return this._clickablePoint(handle); - const box = await this.boundingBox(handle); - return { x: box.x + relativePoint.x, y: box.y + relativePoint.y }; - } - - private async _clickablePoint(handle: dom.ElementHandle): Promise { - type Quad = {p1: types.Point, p2: types.Point, p3: types.Point, p4: types.Point}; - - const computeQuadArea = (quad: Quad) => { - // Compute sum of all directed areas of adjacent triangles - // https://en.wikipedia.org/wiki/Polygon#Simple_polygons - let area = 0; - const points = [quad.p1, quad.p2, quad.p3, quad.p4]; - for (let i = 0; i < points.length; ++i) { - const p1 = points[i]; - const p2 = points[(i + 1) % points.length]; - area += (p1.x * p2.y - p2.x * p1.y) / 2; - } - return Math.abs(area); - }; - - const computeQuadCenter = (quad: Quad) => { - let x = 0, y = 0; - for (const point of [quad.p1, quad.p2, quad.p3, quad.p4]) { - x += point.x; - y += point.y; - } - return {x: x / 4, y: y / 4}; - }; - - const result = await this._session.send('Page.getContentQuads', { - frameId: this._frameId, - objectId: toRemoteObject(handle).objectId, - }).catch(debugError); - if (!result || !result.quads.length) - throw new Error('Node is either not visible or not an HTMLElement'); - // Filter out quads that have too small area to click into. - const quads = result.quads.filter(quad => computeQuadArea(quad) > 1); - if (!quads.length) - throw new Error('Node is either not visible or not an HTMLElement'); - // Return the middle point of the first quad. - return computeQuadCenter(quads[0]); - } - async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { await handle.evaluate(input.setFileInputFunction, files); } diff --git a/src/types.ts b/src/types.ts index ee1905c4b3..96e31ada28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export type EvaluateHandleOn = (pageFunction: PageFunctionOn export type Rect = { x: number, y: number, width: number, height: number }; export type Point = { x: number, y: number }; +export type Quad = [ Point, Point, Point, Point ]; export type TimeoutOptions = { timeout?: number }; diff --git a/src/webkit/JSHandle.ts b/src/webkit/JSHandle.ts index 30adda47ad..b3a363a0d0 100644 --- a/src/webkit/JSHandle.ts +++ b/src/webkit/JSHandle.ts @@ -55,7 +55,40 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { } async boundingBox(handle: dom.ElementHandle): Promise { - throw new Error('boundingBox() is not implemented'); + const quads = await this.contentQuads(handle); + if (!quads || !quads.length) + return null; + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const quad of quads) { + for (const point of quad) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + + async contentQuads(handle: dom.ElementHandle): Promise { + const result = await this._client.send('DOM.getContentQuads', { + objectId: toRemoteObject(handle).objectId + }).catch(debugError); + if (!result) + return null; + return result.quads.map(quad => [ + { x: quad[0], y: quad[1] }, + { x: quad[2], y: quad[3] }, + { x: quad[4], y: quad[5] }, + { x: quad[6], y: quad[7] } + ]); + } + + async layoutViewport(): Promise<{ width: number, height: number }> { + return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight })); } async screenshot(handle: dom.ElementHandle, options: any = {}): Promise { @@ -70,72 +103,6 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { return buffer; } - async ensurePointerActionPoint(handle: dom.ElementHandle, relativePoint?: types.Point): Promise { - await handle._scrollIntoViewIfNeeded(); - if (!relativePoint) - return this._clickablePoint(handle); - const box = await this.boundingBox(handle); - return { x: box.x + relativePoint.x, y: box.y + relativePoint.y }; - } - - private async _clickablePoint(handle: dom.ElementHandle): Promise { - const fromProtocolQuad = (quad: number[]): types.Point[] => { - return [ - {x: quad[0], y: quad[1]}, - {x: quad[2], y: quad[3]}, - {x: quad[4], y: quad[5]}, - {x: quad[6], y: quad[7]} - ]; - }; - - const intersectQuadWithViewport = (quad: types.Point[], width: number, height: number): types.Point[] => { - return quad.map(point => ({ - x: Math.min(Math.max(point.x, 0), width), - y: Math.min(Math.max(point.y, 0), height), - })); - }; - - const computeQuadArea = (quad: types.Point[]) => { - // Compute sum of all directed areas of adjacent triangles - // https://en.wikipedia.org/wiki/Polygon#Simple_polygons - let area = 0; - for (let i = 0; i < quad.length; ++i) { - const p1 = quad[i]; - const p2 = quad[(i + 1) % quad.length]; - area += (p1.x * p2.y - p2.x * p1.y) / 2; - } - return Math.abs(area); - }; - - const [result, viewport] = await Promise.all([ - this._client.send('DOM.getContentQuads', { - objectId: toRemoteObject(handle).objectId - }).catch(debugError), - handle.evaluate(() => ({ clientWidth: innerWidth, clientHeight: innerHeight })), - ]); - if (!result || !result.quads.length) - throw new Error('Node is either not visible or not an HTMLElement'); - // Filter out quads that have too small area to click into. - const {clientWidth, clientHeight} = viewport; - const quads = result.quads.map(fromProtocolQuad) - .map(quad => intersectQuadWithViewport(quad, clientWidth, clientHeight)) - .filter(quad => computeQuadArea(quad) > 1); - if (!quads.length) - throw new Error('Node is either not visible or not an HTMLElement'); - // Return the middle point of the first quad. - const quad = quads[0]; - let x = 0; - let y = 0; - for (const point of quad) { - x += point.x; - y += point.y; - } - return { - x: x / 4, - y: y / 4 - }; - } - async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { const objectId = toRemoteObject(handle).objectId; await this._client.send('DOM.setInputFiles', { objectId, files }); diff --git a/test/assets/frames/frame.html b/test/assets/frames/frame.html index 8f20d2da9f..d096087912 100644 --- a/test/assets/frames/frame.html +++ b/test/assets/frames/frame.html @@ -1,6 +1,10 @@