Skip to content

Add HTML-in-Canvas APIs - #11588

Open
foolip wants to merge 95 commits into
mainfrom
foolip/html-in-canvas
Open

Add HTML-in-Canvas APIs#11588
foolip wants to merge 95 commits into
mainfrom
foolip/html-in-canvas

Conversation

@foolip

@foolip foolip commented Aug 21, 2025

Copy link
Copy Markdown
Member

(See WHATWG Working Mode: Changes for more details.)


/browsers.html ( diff )
/canvas.html ( diff )
/dnd.html ( diff )
/dom.html ( diff )
/index.html ( diff )
/indices.html ( diff )
/infrastructure.html ( diff )
/interaction.html ( diff )
/popover.html ( diff )
/references.html ( diff )
/rendering.html ( diff )
/webappapis.html ( diff )

Handwavy things that need fleshing out are marked with 👋
@foolip
foolip marked this pull request as draft August 21, 2025 12:52

@Kaiido Kaiido left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Glad to see this being worked on, thanks.

Not quite sure how much discussion should be held at this stage. So to note, this doesn't seem to fully match the latest state of https://github.com/WICG/html-in-canvas. e.g. the rename to drawHTMLElement. The layoutsubtree attribute is also missing along with the implications to the existing fallback contents.
Still, thanks for making this move.

Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
@foolip foolip changed the title Stub out canvas.drawElement() Stub out canvas.drawHTMLElement() Aug 29, 2025
@foolip

foolip commented Aug 29, 2025

Copy link
Copy Markdown
Member Author

@Kaiido thank you for the review! I've fleshed things out more, renaming to drawHTMLElement() (because drawElement() will probably not be workable in WebGL) and adding the layoutsubtree attribute.

There's still some handwaving going on of course, in particular what causes the subtree to be laid out but not painted.

@foolip foolip mentioned this pull request Sep 2, 2025
Comment thread source Outdated
@foolip foolip changed the title Stub out canvas.drawHTMLElement() Stub out canvas.drawElementImage() Sep 16, 2025
@foolip foolip changed the title Stub out canvas.drawElementImage() Add HTML-in-Canvas APIs Sep 17, 2025
@foolip

foolip commented Sep 17, 2025

Copy link
Copy Markdown
Member Author

I've fleshed this out some more now, in particular the hit testing.

@Kaiido Kaiido left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

One common complain with the use of dictionaries in the Canvas2D API is that this makes GC kick in very often during animations which has a non-negligible performance cost.
This API shape makes a big use of such dictionaries with one for the wrapper CanvasElementHitTestRegion and then a nested one for the CanvasHitTestRect, and I guess there will be scenarios where multiple of these will need to be updated at every frame. Since the values are copied over from the passed objects to new internal objects, it's unclear if even a careful author, who would try to reuse the same objects, could avoid GC at all here.

On the other hand, I really like how this API shape enables future additions like using a Path2D, or even a bitmap mask, instead of a CanvasHitTestRect. (btw can we bikeshed on rect for that purpose?)

It's not my area of expertise, but would an actual exposed interface allow for non copy from JS, so that authors can just update the regions instead of setting new ones?

Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
@szager-chromium

szager-chromium commented May 16, 2026

Copy link
Copy Markdown

That makes no difference. captureElementImage() doesn't do anything special,

What? No, ElementImage and Element do behave differently: ElementImage does hold its snapshot whereas Element is just a key to the <canvas> live list of snapshots. It has to be this way, otherwise doing something like

ctx.drawElementImage(img, x, y);
ctx.drawElementImage(img, x, y);

in a Worker could use 2 different snapshots.

Well, the premise is faulty because you can never have an Element on a worker thread. But I think this is a moot point; what I really meant is that there is no difference between Element and ElementImage as it pertains to the issue of drawing into a different canvas from the one containing the Element.

@Kaiido

Kaiido commented May 19, 2026

Copy link
Copy Markdown
Member

@szager-chromium I tried to contact you by email to avoid flooding this PR, but I must have found the wrong address.

Well, the premise is faulty because you can never have an Element on a worker thread.

First, in my last example, like in the one before, img is an ElementImage, not an Element.
This does matter, because the ElementImage keeps its one and only snapshot. (in specs) This snapshot is supposed to be accessible for at least as long as either the ElementImage is garbage collected, or its close() method is called.
This means that an ElementImage can outlive the element it represents, and may definitely not represent the current state of the element at the time it’s drawn.

For an Element on the other hand, we only pick in the <canvas>’s map of available snapshots (in specs), if there is one for this Element at the time drawElementImage(element, x, y) (or getElementTransform) is called.

So in code that would give

canvas.addEventListener("paint", (evt) => {
    // First event firing
    // Here we have a snapshot created for the element, let’s call it snapshot_1
    const img = canvas.captureElementImage(element);
    element.style.color = red; // trigger a new paint
    canvas.addEventListener("paint", (evt) => {
        // Second event firing
        // Here we have another snapshot created for the element, snapshot_2
        ctx.drawElementImage(img, x, y); // uses the old snapshot_1, because it’s the one held by the `ElementImage`
        ctx.drawElementImage(element, x, y); // uses the new snapshot_2, available in the live canvas map of snapshots
    }, { once: true });
}, { once: true });

You can’t have ElementImage work as a key over the live map like Element does, because in a worker thread you can’t know when the map content in the main thread will change.

canvas.drawElementImage(img, x, y); // uses snapshot_1
// the map on the main thread is updated, but in the worker we're sync
canvas.drawElementImage(img, x, y); // uses snapshot_2

This shouldn’t happen.

Now, this means the “necessary information” from the canvas also needs to be accessible for as long as the ElementImage holds it; even if you do maintain a same-canvas policy.
You weren’t very explicit about this “necessary information”, but I suppose it would change when the canvas is resized, moved to another monitor or even detached entirely from the document.
The current solution provided in the PR, to prevent drawing the ElementImage in other canvases, does not solve this issue. You still must keep the “necessary information” along with the ElementImage or the snapshot.

So, if that information is held even for the same-canvas policy, why can’t it be used by another canvas? Why would it work between canvasA and canvasA-after-a-resize, but not between canvasA and canvasB?

@progers

progers commented May 19, 2026

Copy link
Copy Markdown

As I said in #11588 (comment) I still don't quite understand the rational to prevent using an ElementImage from another canvas. The sizing issue still does exist even with this limitation: A <canvas> can be resized after the ElementImage has been generated, the browser tab can be moved to a monitor with a different pixel density, the page can be zoomed in, the element can be detached from the document, etc. So there must be a solution for the sizing issues anyway, and it's really unclear to me why that solution couldn't also handle the case of drawing into another canvas.

When drawing an element from canvasA into canvasB, it is difficult to correctly update the DOM position (transform) of the element so that it matches the on-screen location, which is needed for web platform features like hit testing, accessibility, etc.

@Kaiido

Kaiido commented May 19, 2026

Copy link
Copy Markdown
Member

When drawing an element from canvasA into canvasB, it is difficult to correctly update the DOM position (transform) of the element so that it matches the on-screen location, which is needed for web platform features like hit testing, accessibility, etc.

But you face the same issues with an ElementImage that holds an out of date snapshot, whether it came from the same canvas or not. The content of the element in DOM may be completely different than what the old snapshot holds (I mean, the original element could even not exist in DOM anymore).
I personally think it's ok if this is broken, it still allows complex processing using multiple layers and vector sizing, and it's still possible to do the positioning in DOM yourself.

But if you believe it's a no-go, then there should probably be further limitation to ElementImage objects, because only preventing them from being drawn on a different canvas doesn't solve much.

@szager-chromium

Copy link
Copy Markdown

You can’t have ElementImage work as a key over the live map like Element does, because in a worker thread you can’t know when the map content in the main thread will change.

canvas.drawElementImage(img, x, y); // uses snapshot_1
// the map on the main thread is updated, but in the worker we're sync
canvas.drawElementImage(img, x, y); // uses snapshot_2

This shouldn’t happen.

This can never happen, for all kinds of reasons that have nothing to do with HTML-in-canvas. There is no concurrent access of this kind anywhere in JavaScript, with the exception of SharedArrayBuffer. But again -- and I'm having a hard time making myself understood on this point -- the "liveness" of Element compared to ElementImage is really besides the point...

Now, this means the “necessary information” from the canvas also needs to be accessible for as long as the ElementImage holds it; even if you do maintain a same-canvas policy. You weren’t very explicit about this “necessary information”, but I suppose it would change when the canvas is resized, moved to another monitor or even detached entirely from the document. The current solution provided in the PR, to prevent drawing the ElementImage in other canvases, does not solve this issue. You still must keep the “necessary information” along with the ElementImage or the snapshot.

So, if that information is held even for the same-canvas policy, why can’t it be used by another canvas? Why would it work between canvasA and canvasA-after-a-resize, but not between canvasA and canvasB?

Among the canvas information required to determine the correct raster scale for drawElementImage() is the canvas' width and height attributes, its CSS box size, and its CSS zoom level. All that information is computed for canvasA and included in element's snapshot because element is a child of canvasA. If you call canvasB.drawElementImage(element), we don't have the snapshotted width/height, CSS box size, and CSS zoom level of canvasB available. There are pretty much two options in this situation: do a forced layout to get the necessary information for canvasB; or rasterize using the snapshotted information from canvasA, which can give screwy results. Maybe you don't care if the raster scale is wrong and you can live with the screwy results; but if that's true then canvasA.drawElementImage(element); canvasB.drawImage(canvasA) will work just as well.

As mentioned before, allowing an ElementImage to be drawn into a different canvas creates some potentially very confusing situations with respect to hit testing and rasterization scale, and it's very likely to be negative for accessibility as well. It also creates a possible weird loophole for privacy-preserving paint behavior: what should the browser do if you postMessage an ElementImage to a cross-origin context and draw it into a canvas there?

In my opinion, for all these reasons and for other reasons that I can't even think of right now, your stated reasons for wanting this behavior do not outweight the complexity and potential for confusion.

@Kaiido

Kaiido commented May 19, 2026

Copy link
Copy Markdown
Member

This can never happen, ...

I might be wrong, but I'm pretty sure it's still something we defend against when writing cross-context specs.
Besides, remember we're talking about two JS contexts working in parallel and having access to the same non-JS resources.

Among the canvas information required to determine the correct raster scale for drawElementImage() is the canvas' width and height attributes, its CSS box size, and its CSS zoom level.

And that information is gone as soon as the snapshot held by the ElementImage object is outdated. So you still face the same issues, even if you allow only drawing on the same canvas.

It also creates a possible weird loophole for privacy-preserving paint behavior: what should the browser do if you postMessage an ElementImage to a cross-origin context and draw it into a canvas there?

You can also send an ImageBitmap, or an ImageData of that snapshot rendered on the canvas to that cross-origin context. You can't prevent a page from leaking its data to another page if it wants to do so. What needs to be prevented is the cross-origin doc being able to read that info without the page's consent.

canvasA.drawElementImage(element); canvasB.drawImage(canvasA) will work just as well.

Once again, no. This prevents doing complex compositing in a Worker while maintaining the visible canvas clean with the last generated frame for the time the processing is done. It forces you to display an half-baked frame.

your stated reasons for wanting this behavior do not outweight the complexity and potential for confusion.

My main point isn't that this should work just for the sake of it, but rather that this limitation doesn't solve anything for an ElementImage with an outdated snapshot.

@szager-chromium

Copy link
Copy Markdown

My main point isn't that this should work just for the sake of it, but rather that this limitation doesn't solve anything for an ElementImage with an outdated snapshot.

I sense that we have perhaps reached the limit of what can be resolved with this exchange. If you feel strongly on this point, I'd suggest you file a separate issue to bring it to the attention of the standards bodies. I don't think this PR should be blocked on resolving it.

@Kaiido

Kaiido commented May 26, 2026

Copy link
Copy Markdown
Member

I don't think this PR should be blocked on resolving it.

I think I disagree on this.
You came as the sole developer of this feature saying that the planned API had an issue. We have to trust you on this, and I do.
You proposed a solution that does bring some new limitations to the API. This goes against the UX > DX > impl > specs hierarchy, so as a reviewer, I think it's normal I challenge this proposed change. Given this is a whole new API and no actual user has voiced in favor of the particular use cases that would be prevented by this limitation, I do agree it would still be an acceptable change.

But, even with this limitation, the issue you identified still does exist in some other parts of the API as is shown in this fiddle (for the green text). This needs to be resolved too. We can't leave the API with a known broken path in it.
So please provide a solution to also fix the issue you've identified, but for the ElementImage objects that point to outdated snapshots,

From what I gather from your past comments, I am under the impression that it's mainly the magical 3 argument version of drawElementImage(element, x, y) that triggers the issue, and that the 5 argument version with explicit dw and dh wouldn't have the same problem. Is it so? Then maybe, this 3 argument version is the actual problem?

@szager-chromium

Copy link
Copy Markdown

I don't think this PR should be blocked on resolving it.

I think I disagree on this. You came as the sole developer of this feature saying that the planned API had an issue. We have to trust you on this, and I do. You proposed a solution that does bring some new limitations to the API. This goes against the UX > DX > impl > specs hierarchy, so as a reviewer, I think it's normal I challenge this proposed change. Given this is a whole new API and no actual user has voiced in favor of the particular use cases that would be prevented by this limitation, I do agree it would still be an acceptable change.

But, even with this limitation, the issue you identified still does exist in some other parts of the API as is shown in this fiddle (for the green text). This needs to be resolved too. We can't leave the API with a known broken path in it. So please provide a solution to also fix the issue you've identified, but for the ElementImage objects that point to outdated snapshots,

From what I gather from your past comments, I am under the impression that it's mainly the magical 3 argument version of drawElementImage(element, x, y) that triggers the issue, and that the 5 argument version with explicit dw and dh wouldn't have the same problem. Is it so? Then maybe, this 3 argument version is the actual problem?

This is a known issue with the feature as specified: if you change the canvas grid and draw an outdated ElementImage it will have distorted proportions. We went through a very lengthy process of considering alternatives and working with other browsers to find something we all can agree on, and this is it. If you would like to suggest a different approach to the ones already considered please file an issue in the project repository. We do not have any clever ideas for how to accomplish this.

In my opinion this is not a significant shortcoming. As a point of comparison: the canvas is already implicitly clear()-ed when the canvas grid changes. One could argue that this is also a form of broken-ness -- one which your demo side-steps without complaint by doing all its painting after the resize. As a practical matter developers don't seem to have a problem with it. As for HTML-in-canvas, I don't think it's too onerous to require developers to draw with new element paint snapshots after resizing the canvas grid if they want to avoid distortion. This could perhaps be emphasized in the spec, but I don't agree that this constitutes "broken" behavior.

Specifying dx/dy will avoid the behavior but may introduce fuzziness, even if no projection is applied when drawing. Only the three-argument version gives reliably pixel-perfect results when compared to the same content placed outside the canvas.

@mmocny

mmocny commented Jun 5, 2026

Copy link
Copy Markdown

(Drive by review; I haven't read the full patch or comment history)

Hey folks! I was working on some paint-timing spec cleanup (context: https://github.com/wicg/soft-navigations) and @shaseley pointed out that the "mark paint timing" steps are ambiguously expecting to start pre-paint but end post-paint (i.e., include paint time).

I think your PR nicely clarifies the update-the-rendering steps, but looks like it moves "mark paint time" to come after paint, and would AFAIK change the intent of the paintTime value that many specifications rely on for performance reporting.

See: w3c/paint-timing#126


I think the best solution would be to split "mark paint timing" into steps that come before paint, and steps that come after.

An alternative solution might be to mark the pre-paint timestamp in update-the-rendering and then pass that timestamp to "mark paint timing", similar to how we mark first, then pass unsafeStyleAndLayoutStartTime to "record rendering time".

@progers

progers commented Jun 9, 2026

Copy link
Copy Markdown

@foolip , to address @mmocny 's point, we can go ahead and land this "breaking" change that introduces a paint step, and then ask @mmocny to follow up with a change to split paint timing into a pre-paint and post-paint steps, and w3c/paint-timing#126 can track this.

@foolip

foolip commented Jun 11, 2026

Copy link
Copy Markdown
Member Author

@progers with the structure I'm proposing in #12554 there won't be a definition of "paint" in HTML. I hope that means we can leave this to w3c/paint-timing#126?

Comment thread source
<span>continue</span>.</p></li>

<li>
<p>Let <var>snapshot</var> be a new <span

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think this works for nested canvases. By creating the snapshots in here, before firing any event, the snapshot for the outer canvas would have the content of the inner canvas before it has handled its own onpaint event.
So, if we start empty, inner canvas would receive its snapshot with its content, draw it, and then outer will gets its snapshot that was created before inner's event fired, and thus with the inner canvas still empty.

Current Canary doesn't seem to do that and exposes inner's new content from outer's snapshot (though they weirdly fire 2 events on outer): https://jsfiddle.net/39am5of2/
And it seems the way they do it is by updating outer's snapshot synchronously during inner's onpaint event: https://jsfiddle.net/waqepx1b/

Comment thread source
Comment on lines +123062 to +123064
<p>For each descendant element <var>element</var> of <var>canvas</var> within the
<span>flat tree</span> that has the <code data-x="attr-drawable">drawable</code> attribute
specified, in reverse <span>tree order</span>:</p>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This would generate one snapshot per canvas nesting level, allowing drawing descendants of a canvas directly on its ancestor.
Current Canary doesn't allow that and throws claiming "The element can only be drawn into its nearest ancestor ".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

HTML-in-Canvas

10 participants