-
Notifications
You must be signed in to change notification settings - Fork 9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add get bounding box to ElementHandle for easier element screenshot #445
Add get bounding box to ElementHandle for easier element screenshot #445
Conversation
That's nice. Let's make |
The challenge is that all of the screenshot code is written on the |
We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for the commit author(s). If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA (login here to double check)? If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google. |
I have tried to resolve the cla check by adding an alternative email of mine... |
CLAs look good, thanks! |
@elisherer ah, I see. Let's do the follwing:
|
@aslushnikov, how about we put the screenshot taking logic into one common place. |
@elisherer this approach would work. However, I don't like it because it touches quite a bit of code with no good excuse. I see the // Polyfill for elementHandle.screenshot
async function screenshotElement(page, selector, options) {
const handle = await page.$(selector);
const box = await getElementCoordinates(handle); // roughly return element's bounding box
options.clip = box;
return await page.screenshot(options);
} Convenience methods should be implemented atop of core, with as little footprint as possible. This will minimize the maintenance cost.
The |
…an element (unit tests and documentation, with example, were updated)
test/test.js
Outdated
it('should work', SX(async function() { | ||
await page.setViewport({width: 500, height: 500}); | ||
await page.goto(PREFIX + '/grid.html'); | ||
const fourtySecondBoxElement = await page.$('.box:nth-of-type(42)'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: fortySecondBoxElement
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needs explaining here... You don't like the long variable name?
lib/ElementHandle.js
Outdated
* @return {!Object} | ||
*/ | ||
async boundingBox() { | ||
const boxModel = await this._client.send('DOM.getBoxModel', { objectId: this._remoteObject.objectId }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The bounding box of the element could be partially off-screen. The element might also not be scrolled into view. something to consider when taking screenshots. If you look above at _visibleCenter
, we use scrollIntoViewIfNeeded
and clip based on the window bounds. Because we are already in JavaScript, we use getBoundingClientRect and avoid introducing DOM.getBoxModel
.
I don't expect element.boundingBox()
to scroll the element into view, but I can imagine that it would be intended if boundingBox is used to take screenshots.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would you recommend documenting this consideration?
lib/ElementHandle.js
Outdated
* @return {!Object} | ||
*/ | ||
async boundingBox() { | ||
const boxModel = await this._client.send('DOM.getBoxModel', { objectId: this._remoteObject.objectId }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Values in DOM.getBoxModel
are affected by the box-sizing
css property. AFAIR they also include page scale (@wwwillchen might remember better).
getBoundingClientRect
might work better for this use case.
Would you recommend documenting this consideration?
Once this lands, #452 should work as a good-enough documentation.
test/test.js
Outdated
@@ -1116,6 +1116,16 @@ describe('Page', function() { | |||
})); | |||
}); | |||
|
|||
describe('ElementHandle.boundingBox', function() { | |||
fit('should work', SX(async function() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's do it
instead of fit
.
lib/ElementHandle.js
Outdated
height: rect.bottom - rect.top | ||
}; | ||
}); | ||
if (!box) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's return nullable box
without throwing
lib/ElementHandle.js
Outdated
@@ -73,7 +73,7 @@ class ElementHandle { | |||
}; | |||
}); | |||
if (!center) | |||
throw new Error('No node found for selector: ' + selector); | |||
throw new Error('No node found'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice catch!
Could you please change this into a more elaborate message while we're here:
throw new Error('Element is detached from DOM');
lib/ElementHandle.js
Outdated
return { | ||
x: rect.left, | ||
y: rect.top, | ||
width: rect.right - rect.left, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any reason why you don't use the rect.width
and rect.height
here?
lib/ElementHandle.js
Outdated
const box = await this.evaluate(element => { | ||
if (!element.ownerDocument.contains(element)) | ||
return null; | ||
const rect = element.getBoundingClientRect(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This returns bounding box in viewport, whereas screenshot
accepts coordinates relative to the main frame. To make this usable with page.screenshot
, you need to convert coordinates to the window.
That's how we do this in DevTools front-end: DOMExtension.js
Let's add a test that verifies screenshots with clipping, something along the lines:
describe('Page.screenshot', function() {
...
it('should work with elementHandle.boundingBox', SX(async function() {
await page.setViewport({width: 500, height: 500});
await page.goto(PREFIX + '/grid.html');
await page.evaluate(() => window.scrollBy(0, 100));
const box = await page.$('div:nth-child(3)');
const screenshot = await page.screenshot({clip: await box.boundingBox()});
expect(screenshot).toBeGolden('screenshot-element-bounding-box.png');
}));
...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added the test above, but I ended up using the code from document-offset for getting the absolute offset.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding a test!
but I ended up using the code from document-offset for getting the absolute offset.
In case of nested iframes, this will return the bounding box inside the iframe's document, not the main frame document. (can we have a test for this too?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we are running JavaScript we are are bound to CORS rules, accessing parent frame and getting its position might not be possible (it even happens when ran from the file system).
So the DevTools approach might be better to accomplish this. I'm trying to get this to run:
fit('should handle nested frames', SX(async function() {
await page.setViewport({width: 500, height: 500});
await page.goto(PREFIX + '/frames/nested-frames.html');
const nestedFrame = page.frames()[0].childFrames()[1];
const elementHandle = await nestedFrame.$('div');
const box = await elementHandle.boundingBox();
expect(box).toEqual({ x: 28, y: 253, width: 284, height: 18 });
}));
Currently errors with:
Message:
Expected $.x = 8 to equal 28.
Expected $.y = 8 to equal 253.
That being said, I'm guessing that click
and hover
won't work with iframes either...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UPDATE: I tried again with DOM.getBoxModel and the content quad returned is the position of the box relative to the main frame. I will update once both tests are running (scroll on main frame and nested in iframe)
docs/api.md
Outdated
- width <[number]> the width of the element in pixels. | ||
- height <[number]> the height of the element in pixels. | ||
|
||
This method returns the bounding box of the element (relative to the main frame). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(relative to the main frame), or null
if element is detached from dom.
…ent, add unit test for being used together with screenshot when not visible
…me), unit test added for nested frames.
lib/ElementHandle.js
Outdated
*/ | ||
async boundingBox() { | ||
const layoutMetrics = await this._client.send('Page.getLayoutMetrics'); | ||
const boxModel = await this._client.send('DOM.getBoxModel', { objectId: this._remoteObject.objectId }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's not use DOM.getBoxModel
- it misbehaves with page zoom. It's also an experimental protocol method (and we try to minimize our use of experimental methods so that it would be easier to stabilize them in future).
The logic here is implementable with DOM API, e.g. like this. Do you have any concerns about this approach?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, as you pointed out, we need to consider elements inside iframes and this logic you suggest applies only to the frame containing the element. Using a while loop to get the parent frame for offsets might not work because of CORS policy which makes the method inconsistant.
On the other hand using the DevTools API gives us the ability to inspect the window without browser JavaScript constraints.
So it's a matter of deciding if we want to support nested element bounding boxes or not (or we do but only for same domain frames).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using a while loop to get the parent frame for offsets might not work because of CORS policy which makes the method inconsistant.
Yes, you're right.
We're thinking about introducing the elementHandle.ownerFrame()
method in #433. This would allow you to iterate frame structure avoiding CORS and use bullet-proof element.getBoundingClientRect
instead of DOM.getBoxModel
.
If this solves your concerns, I'd like to wait for the ownerFrame
to land first and then follow-up with this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, will do.
@elisherer any update? looks like a few people eager for this. |
@paulirish this is stuck on my implementation of |
# Conflicts: # lib/ElementHandle.js
…x inside the page. Also fixes the visible center used by mouse and touchscreen.
@aslushnikov, It now supports iframes by using upwards traversal using the DOM api (describeNode) and getting the position inside the window using JavaScript. |
why is the cla/google "waiting for status to be reported" is waiting for a couple of days? shouldn't it be instant? |
Thanks @elisherer. I've though about this a bit more and doing multiple hops into page to resolve bounding box seems to be fragile - page might've changed during the process. This would be terrible to debug. I now tend to agree with you on the matter of using
No worries, we can ignore the bot. I believe it's not very reliable. |
@elisherer: @JoelEinbinder looked into that and it turned out that:
To sum up: we can use Also, @JoelEinbinder has a PR to fix clicking in iframes: #971 |
…to feature/screenshot-by-selector # Conflicts: # docs/api.md # lib/ElementHandle.js
@aslushnikov, OK, I'm done bringing back the code of getBoxModel while merging to the new changes in ElementHandle. |
lib/ElementHandle.js
Outdated
return 'Node is detached from document'; | ||
if (element.nodeType !== HTMLElement.ELEMENT_NODE) | ||
return 'Node is not of type HTMLElement'; | ||
element.scrollIntoViewIfNeeded(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't expect element.boundingBox()
to cause side effects like scrolling page. Can we drop it from here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't it the same side effects as keyboard and touchscreen operations (no expecatations there either)?
Maybe it should be optional?
i.e.
element.boundingBox({ scrollIntoView: true })
(it will return a different bounding box if scroll was needed)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't it the same side effects as keyboard and touchscreen operations (no expecatations there either)?
This only happens for mouse/touch operations. The reason is that we can't click to the off-screen location, it is (somewhat) understandable that element should be on screen to be clicked.
Whereas The boundingBox
method is a getter, and getters are generally perceived as operations without side-effects.
(it will return a different bounding box if scroll was needed)
Thanks, I see why you need it. I'd make an internal method _innerBoundingBox(scrollIntoView)
, that would be used in both _visibleCenter
and boundingBox()
:
async _visibleCenter() {
const box = this._innerBoundingBox(true /* scrollIntoView */);
// .. compute center
}
async boundingBox() {
return this._innerBoundingBox(false /* scrollIntoView */);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree about the above, but how do you suggest solve the problem of "off-frame" objects needing to be captured by screenshot.
I would want the behavior of scrolling before calling boundingBox()
.
element.scrollIntoViewIfNeeded()
like in focus()
?
… a new method of element handle, fix executionContext jsdoc
@aslushnikov , as my last commit states, I added a scrollIntoViewIfNeeded method and used is as part of the visualCenter, and in the unit test of taking a screenshot with a framed scrolled, I called it just before calling boundingBox to ensure the box is visible. looks good by me. |
@elisherer Even with |
# Conflicts: # lib/ElementHandle.js Also add /test/test-user-data-dir* to gitignore
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work, thank you.
if the elementhandle in a frame, screenshot method , does it work ? |
This will add the ability to capture a screenshot based on a selector (as opposed to using the clip object).The implementation is on top of the clip option to make things simple:await page.screenshot({clip: '.box:nth-of-type(43)'});*The example above is from the unit test (see output image below in the affected files).EDIT: Changed to adding a method:
elementHandle.boundingBox()
which will return the rectangle needed for screenshot's clip option.