Skip to content
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

Merged
merged 17 commits into from
Oct 10, 2017

Conversation

elisherer
Copy link
Contributor

@elisherer elisherer commented Aug 21, 2017

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.

@ebidel
Copy link
Contributor

ebidel commented Aug 21, 2017

FWIW, I posted a pr that shows how to do this in user-code. It's just a few lines but gets even smaller after #382 lands.

That said, this might be common enough that it's something we should support directly in the API.

@aslushnikov
Copy link
Contributor

That's nice. Let's make elementHandle.screenshot(options) method instead to keep Page lean.

@elisherer
Copy link
Contributor Author

The challenge is that all of the screenshot code is written on the Page class (together with a screenshotTaskQueue).
Also, ElementHandle does not have a reference to the creating page (maybe it should?)
Is elementHandle.screenshot(page, options) acceptable?

@googlebot
Copy link

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.
In order to pass this check, please resolve this problem and have the pull request author add another comment and the bot will run again.

@elisherer
Copy link
Contributor Author

I have tried to resolve the cla check by adding an alternative email of mine...

@googlebot
Copy link

CLAs look good, thanks!

@aslushnikov
Copy link
Contributor

@elisherer ah, I see. Let's do the follwing:

  • we'll introduce the elementHandle.ownerFrame() shortly (there's some work upstream to make it possible)
  • then you'll be able to add a frame.page() and use page.screenshot to drive element screenshots.

@elisherer
Copy link
Contributor Author

@aslushnikov, how about we put the screenshot taking logic into one common place.
I noticed the page is only important for using the fullPage option (usint its viewport), which elements' screenshots don't need.
Also, clipping should not be possible for elements.
So each of the cases have different "predefined" clip providing options.
In addition, the screenshot task queue is stored on the browser and passed to each of the pages. In the same way it could be passed to each element as a handle for screenshot capturing.
WDYT?

@aslushnikov
Copy link
Contributor

@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 elementHandle.screenshot as a convenience method for page.screenshot: the feature doesn't add any new capabilities and It's easy to polyfill with current puppeteer API:

// 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.

In addition, the screenshot task queue is stored on the browser and passed to each of the pages. In the same way it could be passed to each element as a handle for screenshot capturing.

The screenshotTaskQueue is a shabby detail of screenshot machinery we have today, it's unfortunate to have it spreading further into subsystems.

…an element

(unit tests and documentation, with example, were updated)
@elisherer elisherer changed the title Add an option to take a screenshot based on a selector Add get bounding box to ElementHandle for easier element screenshot Aug 22, 2017
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)');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: fortySecondBoxElement

Copy link
Contributor Author

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?

* @return {!Object}
*/
async boundingBox() {
const boxModel = await this._client.send('DOM.getBoxModel', { objectId: this._remoteObject.objectId });
Copy link
Collaborator

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.

Copy link
Contributor Author

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?

* @return {!Object}
*/
async boundingBox() {
const boxModel = await this._client.send('DOM.getBoxModel', { objectId: this._remoteObject.objectId });
Copy link
Contributor

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() {
Copy link
Contributor

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.

height: rect.bottom - rect.top
};
});
if (!box)
Copy link
Contributor

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

@@ -73,7 +73,7 @@ class ElementHandle {
};
});
if (!center)
throw new Error('No node found for selector: ' + selector);
throw new Error('No node found');
Copy link
Contributor

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');

return {
x: rect.left,
y: rect.top,
width: rect.right - rect.left,
Copy link
Contributor

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?

const box = await this.evaluate(element => {
if (!element.ownerDocument.contains(element))
return null;
const rect = element.getBoundingClientRect();
Copy link
Contributor

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');
  }));
  ...

Copy link
Contributor Author

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.

Copy link
Contributor

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?)

Copy link
Contributor Author

@elisherer elisherer Aug 23, 2017

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...

Copy link
Contributor Author

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).
Copy link
Contributor

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.

elisherer and others added 2 commits August 23, 2017 00:29
…ent, add unit test for being used together with screenshot when not visible
*/
async boundingBox() {
const layoutMetrics = await this._client.send('Page.getLayoutMetrics');
const boxModel = await this._client.send('DOM.getBoxModel', { objectId: this._remoteObject.objectId });
Copy link
Contributor

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?

Copy link
Contributor Author

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).

Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, will do.

@paulirish
Copy link
Collaborator

@elisherer any update? looks like a few people eager for this.

@aslushnikov
Copy link
Contributor

@paulirish this is stuck on my implementation of elementHandle.ownerFrame() method (#433).

# Conflicts:
#	lib/ElementHandle.js
@ebidel ebidel added the P1 label Sep 21, 2017
Eli Sherer and others added 2 commits September 28, 2017 15:38
@elisherer
Copy link
Contributor Author

@aslushnikov, It now supports iframes by using upwards traversal using the DOM api (describeNode) and getting the position inside the window using JavaScript.
(I used my own implementation of "ownerFrame", hope it's ok)

@elisherer
Copy link
Contributor Author

why is the cla/google "waiting for status to be reported" is waiting for a couple of days? shouldn't it be instant?

@aslushnikov
Copy link
Contributor

@aslushnikov, It now supports iframes by using upwards traversal using the DOM api (describeNode) and getting the position inside the window using JavaScript.

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 DOM.getBoxModel method. I'll look into it shortly to see how it could be fixed.

why is the cla/google "waiting for status to be reported" is waiting for a couple of days? shouldn't it be instant?

No worries, we can ignore the bot. I believe it's not very reliable.

@aslushnikov
Copy link
Contributor

@elisherer: @JoelEinbinder looked into that and it turned out that:

  • getBoxModel is wrong with non-default page's scale
  • we consider page's scale to be always default in pptr

To sum up: we can use getBoxModel in pptr. Please, go ahead with this patch!
I'm sorry for making you wait for so long.

Also, @JoelEinbinder has a PR to fix clicking in iframes: #971
It would be great if you can base your work atop of that one.

@elisherer
Copy link
Contributor Author

@aslushnikov, OK, I'm done bringing back the code of getBoxModel while merging to the new changes in ElementHandle.
I did base my code on @JoelEinbinder PR #971, so it kind of already fixes the problem there with visible center (because I made visible center call boundingBox; no need for 2 separate identical calls).

return 'Node is detached from document';
if (element.nodeType !== HTMLElement.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
element.scrollIntoViewIfNeeded();
Copy link
Contributor

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?

Copy link
Contributor Author

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)

Copy link
Contributor

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 */);
  }

Copy link
Contributor Author

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
@elisherer
Copy link
Contributor Author

@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.

@aslushnikov
Copy link
Contributor

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 element.scrollInfoViewIfNeeded would not be used for anything other then screenshots. In this case, we can just have the elementHandle.screenshot. Conveniently, the ElementHandle now has a reference to the page: https://github.com/GoogleChrome/puppeteer/blob/f1aa18af4e713abfb170e1f9f1d3a3d378b32987/lib/ElementHandle.js#L31

Even with elementHandle.screenshot, we should keep the ElementHandle.boundingBox: it comes handy in multiple places.

Eli Sherer added 2 commits October 10, 2017 08:53
# Conflicts:
#	lib/ElementHandle.js

Also add /test/test-user-data-dir* to gitignore
Copy link
Contributor

@aslushnikov aslushnikov left a 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.

@aslushnikov aslushnikov merged commit 7e28dba into puppeteer:master Oct 10, 2017
@elisherer elisherer deleted the feature/screenshot-by-selector branch October 10, 2017 06:19
@zhongjiewang
Copy link

if the elementhandle in a frame, screenshot method , does it work ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants